Container: - Dockerfile → Containerfile; drop gosu, entrypoint, PUID/PGID user-switching - HOME=/config so Path.home()/.aws resolves to runtime-mounted credentials - docker-compose.yml → compose.yml with userns_mode: keep-id for Podman rootless - .dockerignore → .containerignore - boto3 unpinned from 1.34.0 to >=1.34.0 CI: - Remove Woodpecker (.woodpecker.yml, .woodpecker/) - Add Forgejo Actions (.forgejo/workflows/ci.yml, publish.yml) - CI: syntax check, security scan, container lint (hadolint), build test - Publish: build and push to Quay.io on main push and version tags Cleanup: - Remove entrypoint.sh (no longer needed) - Remove scripts/build-and-push.sh and PUBLISHING.md (superseded by CI) - All docker → podman command references updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1836 lines
91 KiB
HTML
1836 lines
91 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 • <a href="https://codeberg.org/edfig/SGO" target="_blank" style="color: inherit; opacity: 0.7; text-decoration: none;">Source Code</a></p>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem;">
|
|
<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="clearAndRefresh()" id="clearRefreshBtn" style="padding: 0.5rem 1rem; background: #ea580c; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;" title="Clear all cached data and re-fetch from AWS">
|
|
Clear & Refresh
|
|
</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 id="sessionExpiration" style="font-size: 0.75rem; color: #94a3b8; display: none;">
|
|
Expire time: <span id="expirationText">-</span>
|
|
</div>
|
|
</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" checked>
|
|
<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>
|
|
<button class="filter-btn" data-filter="ip-search" style="background: #8b5cf6; color: white;">IP Search</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="ip-search-view" id="ipSearchView" style="display: none;">
|
|
<div class="ip-search-container">
|
|
<div style="background: white; border-radius: 0.5rem; padding: 1.5rem; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); margin-bottom: 1rem;">
|
|
<h3 style="margin: 0 0 1rem 0; color: #1e293b;">Search by IP Address with Filters</h3>
|
|
<p style="color: #64748b; margin-bottom: 1rem;">Search for an IP address or CIDR block across security group rules and EC2 instances. Combine with text filters for precise results.</p>
|
|
|
|
<div style="display: grid; grid-template-columns: 2fr 1.5fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem;">
|
|
<input
|
|
type="text"
|
|
id="ipSearchInput"
|
|
class="search-input"
|
|
placeholder="IP address (e.g., 10.0.1.5)"
|
|
style="margin: 0;"
|
|
>
|
|
<input
|
|
type="text"
|
|
id="ipTextFilter"
|
|
class="search-input"
|
|
placeholder="AND text (e.g., arit)"
|
|
style="margin: 0;"
|
|
>
|
|
<input
|
|
type="text"
|
|
id="ipPortFilter"
|
|
class="search-input"
|
|
placeholder="AND port (e.g., 443)"
|
|
style="margin: 0;"
|
|
>
|
|
<button onclick="performIPSearch()" style="padding: 0.5rem 1rem; background: #8b5cf6; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-weight: 500; white-space: nowrap;">Search</button>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 1rem; align-items: center; justify-content: space-between;">
|
|
<div style="display: flex; gap: 1rem; align-items: center;">
|
|
<label style="font-size: 0.875rem; color: #64748b; font-weight: 500;">Resource Type:</label>
|
|
<label style="display: flex; align-items: center; gap: 0.375rem; font-size: 0.875rem; cursor: pointer;">
|
|
<input type="radio" name="ipResourceType" value="all" checked style="cursor: pointer;">
|
|
<span>All Resources</span>
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 0.375rem; font-size: 0.875rem; cursor: pointer;">
|
|
<input type="radio" name="ipResourceType" value="ec2" style="cursor: pointer;">
|
|
<span>EC2 Only</span>
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 0.375rem; font-size: 0.875rem; cursor: pointer;">
|
|
<input type="radio" name="ipResourceType" value="sg" style="cursor: pointer;">
|
|
<span>Security Groups Only</span>
|
|
</label>
|
|
</div>
|
|
<div class="view-toggle" style="margin: 0;">
|
|
<button class="ip-view-toggle-btn active" data-ip-view="cards" onclick="toggleIPView('cards')">Cards</button>
|
|
<button class="ip-view-toggle-btn" data-ip-view="table" onclick="toggleIPView('table')">Table</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="ipSearchResults"></div>
|
|
<div class="results-table-view" id="ipResultsTableView">
|
|
<div class="results-table-container">
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortIPTable('type')" style="cursor: pointer;">Type <span id="ip-sort-type"></span></th>
|
|
<th onclick="sortIPTable('name')" style="cursor: pointer;">Name <span id="ip-sort-name"></span></th>
|
|
<th onclick="sortIPTable('id')" style="cursor: pointer;">ID <span id="ip-sort-id"></span></th>
|
|
<th onclick="sortIPTable('account_name')" style="cursor: pointer;">Account <span id="ip-sort-account_name"></span></th>
|
|
<th onclick="sortIPTable('ip')" style="cursor: pointer;">IP Address <span id="ip-sort-ip"></span></th>
|
|
<th onclick="sortIPTable('direction')" style="cursor: pointer;">Direction <span id="ip-sort-direction"></span></th>
|
|
<th onclick="sortIPTable('protocol')" style="cursor: pointer;">Protocol <span id="ip-sort-protocol"></span></th>
|
|
<th onclick="sortIPTable('port_range')" style="cursor: pointer;">Port <span id="ip-sort-port_range"></span></th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ipTableResultsBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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 currentIPView = 'cards';
|
|
let searchTimeout = null;
|
|
let currentResults = [];
|
|
let currentIPResults = [];
|
|
let sortColumn = null;
|
|
let sortDirection = 'asc';
|
|
let ipSortColumn = null;
|
|
let ipSortDirection = '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 getAccountStyle(accountName) {
|
|
if (!accountName) return '';
|
|
return `color: ${getColorFromName(accountName)}; font-weight: 600;`;
|
|
}
|
|
|
|
// Load stats and tags on page load
|
|
loadStats();
|
|
loadTags();
|
|
updateSessionExpiration();
|
|
|
|
// 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();
|
|
|
|
// Hide details view when search is empty
|
|
if (query === '') {
|
|
hideDetails();
|
|
}
|
|
|
|
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;
|
|
|
|
if (currentFilter === 'ip-search') {
|
|
// Show IP search view, hide others
|
|
document.getElementById('searchResults').style.display = 'none';
|
|
document.getElementById('resultsTableView').classList.remove('active');
|
|
document.getElementById('ipSearchView').style.display = 'block';
|
|
} else {
|
|
// Show regular search results
|
|
document.getElementById('ipSearchView').style.display = 'none';
|
|
const query = document.getElementById('searchInput').value.trim();
|
|
performSearch(query);
|
|
}
|
|
});
|
|
});
|
|
|
|
// View toggle handlers
|
|
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
if (!this.dataset.view) return; // skip Export and other non-toggle buttons
|
|
document.querySelectorAll('.view-toggle-btn[data-view]').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 updateSessionExpiration() {
|
|
try {
|
|
const response = await fetch('/api/session-expiration');
|
|
const data = await response.json();
|
|
|
|
const expirationDiv = document.getElementById('sessionExpiration');
|
|
const expirationText = document.getElementById('expirationText');
|
|
|
|
if (!data.has_session) {
|
|
expirationDiv.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const secondsRemaining = data.seconds_remaining;
|
|
|
|
let displayText;
|
|
let color = '#94a3b8';
|
|
let fontWeight = '400';
|
|
|
|
if (secondsRemaining <= 0) {
|
|
displayText = 'EXPIRED';
|
|
color = '#ef4444';
|
|
fontWeight = '600';
|
|
} else if (secondsRemaining < 60) {
|
|
displayText = `${secondsRemaining}s`;
|
|
color = '#ef4444';
|
|
fontWeight = '600';
|
|
} else if (secondsRemaining < 600) {
|
|
// Under 10 minutes — show MM:SS
|
|
const m = Math.floor(secondsRemaining / 60);
|
|
const s = secondsRemaining % 60;
|
|
displayText = `${m}:${String(s).padStart(2, '0')}`;
|
|
color = '#f59e0b';
|
|
fontWeight = '600';
|
|
} else {
|
|
const hours = Math.floor(secondsRemaining / 3600);
|
|
const minutes = Math.floor((secondsRemaining % 3600) / 60);
|
|
displayText = hours > 0
|
|
? (minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`)
|
|
: `${minutes}m`;
|
|
}
|
|
|
|
expirationText.textContent = displayText;
|
|
expirationText.style.color = color;
|
|
expirationText.style.fontWeight = fontWeight;
|
|
expirationDiv.style.display = 'block';
|
|
|
|
// Update every 10 seconds
|
|
setTimeout(updateSessionExpiration, 10000);
|
|
} catch (error) {
|
|
console.error('Error updating session expiration:', error);
|
|
// Retry in 10 seconds
|
|
setTimeout(updateSessionExpiration, 10000);
|
|
}
|
|
}
|
|
|
|
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();
|
|
updateSessionExpiration();
|
|
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;
|
|
}
|
|
|
|
async function clearAndRefresh() {
|
|
if (!confirm('Clear all cached data and re-fetch from AWS?\n\nThis will wipe the database before refreshing.')) return;
|
|
|
|
const btn = document.getElementById('clearRefreshBtn');
|
|
const refreshBtn = document.getElementById('refreshBtn');
|
|
btn.disabled = true;
|
|
refreshBtn.disabled = true;
|
|
btn.textContent = 'Clearing...';
|
|
|
|
try {
|
|
const response = await fetch('/api/clear-db', { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
alert('Failed to clear database: ' + (data.error || 'Unknown error'));
|
|
return;
|
|
}
|
|
|
|
// Update stats to show the cleared state
|
|
await loadStats();
|
|
|
|
// Now re-fetch from AWS using cached sessions
|
|
await refreshData();
|
|
} catch (error) {
|
|
console.error('Clear and refresh error:', error);
|
|
alert('Failed to clear database.');
|
|
} finally {
|
|
btn.textContent = 'Clear & Refresh';
|
|
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}®ex=${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');
|
|
}
|
|
}
|
|
|
|
// IP Search Functions
|
|
async function performIPSearch() {
|
|
const ipInput = document.getElementById('ipSearchInput').value.trim();
|
|
const textFilter = document.getElementById('ipTextFilter').value.trim();
|
|
const portFilter = document.getElementById('ipPortFilter').value.trim();
|
|
const resourceType = document.querySelector('input[name="ipResourceType"]:checked').value;
|
|
const resultsDiv = document.getElementById('ipSearchResults');
|
|
|
|
if (!ipInput) {
|
|
resultsDiv.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<p>Please enter an IP address or CIDR block</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
resultsDiv.innerHTML = '<div class="loading"><div class="spinner"></div>Searching...</div>';
|
|
|
|
try {
|
|
let url = `/api/search-ip?ip=${encodeURIComponent(ipInput)}&type=${resourceType}`;
|
|
if (textFilter) {
|
|
url += `&text=${encodeURIComponent(textFilter)}`;
|
|
}
|
|
if (portFilter) {
|
|
url += `&port=${encodeURIComponent(portFilter)}`;
|
|
}
|
|
|
|
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>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
if (data.count === 0) {
|
|
const filterText = textFilter ? ` with "${textFilter}"` : '';
|
|
resultsDiv.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔍</div>
|
|
<p>No results found for IP "${ipInput}"${filterText}</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
renderIPSearchResults(data.results, resultsDiv, ipInput, textFilter, resourceType);
|
|
} catch (error) {
|
|
console.error('Error searching IP:', error);
|
|
resultsDiv.innerHTML = '<div class="empty-state">Error performing IP search</div>';
|
|
}
|
|
}
|
|
|
|
function toggleIPView(view) {
|
|
currentIPView = view;
|
|
const resultsDiv = document.getElementById('ipSearchResults');
|
|
const tableDiv = document.getElementById('ipResultsTableView');
|
|
|
|
// Update button states - only for IP search view toggle buttons
|
|
document.querySelectorAll('.ip-view-toggle-btn').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (btn.dataset.ipView === view) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
|
|
if (view === 'table') {
|
|
resultsDiv.style.display = 'none';
|
|
tableDiv.classList.add('active');
|
|
} else {
|
|
resultsDiv.style.display = 'block';
|
|
tableDiv.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
function sortIPTable(column) {
|
|
if (ipSortColumn === column) {
|
|
ipSortDirection = ipSortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
ipSortColumn = column;
|
|
ipSortDirection = 'asc';
|
|
}
|
|
|
|
currentIPResults.sort((a, b) => {
|
|
let aVal = a[column] || '';
|
|
let bVal = b[column] || '';
|
|
|
|
if (typeof aVal === 'string') {
|
|
aVal = aVal.toLowerCase();
|
|
bVal = bVal.toLowerCase();
|
|
}
|
|
|
|
if (ipSortDirection === 'asc') {
|
|
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
|
} else {
|
|
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0;
|
|
}
|
|
});
|
|
|
|
// Update sort indicators
|
|
document.querySelectorAll('[id^="ip-sort-"]').forEach(el => el.textContent = '');
|
|
const indicator = document.getElementById(`ip-sort-${column}`);
|
|
if (indicator) {
|
|
indicator.textContent = ipSortDirection === 'asc' ? ' ▲' : ' ▼';
|
|
}
|
|
|
|
renderIPTableView(currentIPResults, document.getElementById('ipTableResultsBody'));
|
|
}
|
|
|
|
function renderIPSearchResults(results, resultsDiv, ipQuery, textFilter, resourceType) {
|
|
const sgRules = results.sg_rules || [];
|
|
const ec2Instances = results.ec2_instances || [];
|
|
|
|
// Flatten results for table view
|
|
const flatResults = [];
|
|
|
|
// Add EC2 instances to flat results
|
|
ec2Instances.forEach(ec2 => {
|
|
flatResults.push({
|
|
type: 'ec2',
|
|
name: ec2.tag_name || ec2.instance_id,
|
|
id: ec2.instance_id,
|
|
account_name: ec2.account_name,
|
|
account_id: ec2.account_id,
|
|
ip: ec2.private_ip_address,
|
|
direction: '-',
|
|
protocol: '-',
|
|
port_range: '-',
|
|
source: '-',
|
|
state: ec2.state,
|
|
tag_git_repo: ec2.tag_git_repo,
|
|
tag_git_org: ec2.tag_git_org,
|
|
tag_git_file: ec2.tag_git_file,
|
|
security_groups_count: ec2.security_groups_id_list ? ec2.security_groups_id_list.split(';').filter(id => id.trim()).length : 0
|
|
});
|
|
});
|
|
|
|
// Add SG rules to flat results
|
|
sgRules.forEach(rule => {
|
|
flatResults.push({
|
|
type: 'sg',
|
|
name: rule.tag_name || rule.group_name,
|
|
id: rule.group_id,
|
|
account_name: rule.account_name,
|
|
account_id: rule.account_id,
|
|
ip: rule.source,
|
|
direction: rule.direction,
|
|
protocol: rule.protocol,
|
|
port_range: rule.port_range,
|
|
source: rule.source,
|
|
description: rule.description,
|
|
tag_wave: rule.tag_wave,
|
|
tag_git_repo: rule.tag_git_repo,
|
|
tag_git_org: rule.tag_git_org,
|
|
tag_git_file: rule.tag_git_file
|
|
});
|
|
});
|
|
|
|
// Store results for sorting
|
|
currentIPResults = flatResults;
|
|
|
|
// Render table view
|
|
renderIPTableView(flatResults, document.getElementById('ipTableResultsBody'));
|
|
|
|
// Render cards view
|
|
let html = `
|
|
<div style="background: white; border-radius: 0.5rem; padding: 1rem; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); margin-bottom: 1rem;">
|
|
<h3 style="margin: 0; color: #1e293b;">
|
|
Found ${ec2Instances.length} EC2 instance(s) and ${sgRules.length} security group rule(s)
|
|
</h3>
|
|
</div>
|
|
`;
|
|
|
|
// Render EC2 instances if any
|
|
if (ec2Instances.length > 0) {
|
|
html += `
|
|
<div style="background: white; border-radius: 0.5rem; padding: 1.5rem; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); margin-bottom: 1rem;">
|
|
<h3 style="margin: 0 0 1rem 0; color: #1e293b; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem;">
|
|
<span style="background: #dbeafe; color: #1e40af; padding: 0.25rem 0.75rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; margin-right: 0.5rem;">EC2</span>
|
|
EC2 Instances (${ec2Instances.length})
|
|
</h3>
|
|
<div style="display: grid; gap: 0.75rem;">
|
|
`;
|
|
|
|
ec2Instances.forEach(ec2 => {
|
|
const githubUrl = buildGitHubUrl(ec2.tag_git_org, ec2.tag_git_repo, ec2.tag_git_file);
|
|
const sourceLink = githubUrl
|
|
? `<a href="${githubUrl}" target="_blank" style="color: var(--primary-color); text-decoration: underline;">${ec2.tag_git_repo || 'GitHub'}</a>`
|
|
: (ec2.tag_git_repo || 'N/A');
|
|
|
|
const accountStyle = getAccountStyle(ec2.account_name);
|
|
const sgCount = ec2.security_groups_id_list ? ec2.security_groups_id_list.split(';').filter(id => id.trim()).length : 0;
|
|
|
|
html += `
|
|
<div style="border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 1rem; cursor: pointer; transition: all 0.2s;" onclick="showDetails('ec2', '${ec2.instance_id}')" onmouseover="this.style.borderColor='#8b5cf6'; this.style.backgroundColor='#faf5ff'" onmouseout="this.style.borderColor='#e2e8f0'; this.style.backgroundColor='white'">
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
|
<div style="font-weight: 600; color: #1e293b;">${ec2.tag_name || ec2.instance_id}</div>
|
|
<span class="status-badge ${ec2.state}" style="font-size: 0.75rem;">${ec2.state}</span>
|
|
</div>
|
|
<div style="color: #64748b; font-size: 0.875rem;">
|
|
<div><strong>Instance ID:</strong> <span style="font-family: monospace;">${ec2.instance_id}</span></div>
|
|
<div><strong>Private IP:</strong> <span style="font-family: monospace; font-weight: 600; color: #8b5cf6;">${ec2.private_ip_address}</span></div>
|
|
<div><strong>Account:</strong> <span style="${accountStyle}">${ec2.account_name}</span></div>
|
|
<div><strong>Security Groups:</strong> ${sgCount} | <strong>Source:</strong> ${sourceLink}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render Security Group rules if any
|
|
if (sgRules.length > 0) {
|
|
const groupedResults = {};
|
|
|
|
// Group results by security group
|
|
sgRules.forEach(rule => {
|
|
const key = rule.group_id;
|
|
if (!groupedResults[key]) {
|
|
groupedResults[key] = {
|
|
group_id: rule.group_id,
|
|
group_name: rule.group_name,
|
|
tag_name: rule.tag_name,
|
|
account_name: rule.account_name,
|
|
account_id: rule.account_id,
|
|
tag_wave: rule.tag_wave,
|
|
tag_git_repo: rule.tag_git_repo,
|
|
tag_git_org: rule.tag_git_org,
|
|
tag_git_file: rule.tag_git_file,
|
|
rules: []
|
|
};
|
|
}
|
|
groupedResults[key].rules.push(rule);
|
|
});
|
|
|
|
html += `
|
|
<div style="background: white; border-radius: 0.5rem; padding: 1.5rem; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); margin-bottom: 1rem;">
|
|
<h3 style="margin: 0 0 1rem 0; color: #1e293b; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem;">
|
|
<span style="background: #fce7f3; color: #9f1239; padding: 0.25rem 0.75rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; margin-right: 0.5rem;">SG</span>
|
|
Security Group Rules (${Object.keys(groupedResults).length} group(s), ${sgRules.length} rule(s))
|
|
</h3>
|
|
`;
|
|
|
|
Object.values(groupedResults).forEach(sg => {
|
|
const githubUrl = buildGitHubUrl(sg.tag_git_org, sg.tag_git_repo, sg.tag_git_file);
|
|
const sourceLink = githubUrl
|
|
? `<a href="${githubUrl}" target="_blank" style="color: var(--primary-color); text-decoration: underline;">${sg.tag_git_repo || 'GitHub'}</a>`
|
|
: (sg.tag_git_repo || 'N/A');
|
|
|
|
const accountStyle = getAccountStyle(sg.account_name);
|
|
|
|
html += `
|
|
<div style="border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 1rem; margin-bottom: 1rem;">
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
|
|
<div>
|
|
<h4 style="margin: 0 0 0.5rem 0; color: #1e293b; cursor: pointer;" onclick="showDetails('sg', '${sg.group_id}')">${sg.tag_name || sg.group_name}</h4>
|
|
<div style="color: #64748b; font-size: 0.875rem;">
|
|
<strong>Group ID:</strong> <span style="font-family: monospace;">${sg.group_id}</span> |
|
|
<strong>Account:</strong> <span style="${accountStyle}">${sg.account_name}</span> |
|
|
<strong>Wave:</strong> ${sg.tag_wave || 'N/A'} |
|
|
<strong>Source:</strong> ${sourceLink}
|
|
</div>
|
|
</div>
|
|
<button onclick="event.stopPropagation(); showDetails('sg', '${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;">View Details</button>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
|
|
<thead>
|
|
<tr style="background: #f8fafc; border-bottom: 2px solid #e2e8f0;">
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Direction</th>
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Protocol</th>
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Port Range</th>
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Source Type</th>
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Source</th>
|
|
<th style="padding: 0.75rem; text-align: left; font-weight: 600; color: #475569;">Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
sg.rules.forEach(rule => {
|
|
const directionBadge = rule.direction === 'ingress'
|
|
? '<span style="display: inline-block; padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-weight: 500;">Ingress</span>'
|
|
: '<span style="display: inline-block; padding: 0.125rem 0.5rem; background: #fce7f3; color: #9f1239; border-radius: 0.25rem; font-weight: 500;">Egress</span>';
|
|
|
|
html += `
|
|
<tr style="border-bottom: 1px solid #e2e8f0;">
|
|
<td style="padding: 0.75rem;">${directionBadge}</td>
|
|
<td style="padding: 0.75rem; font-family: monospace;">${rule.protocol}</td>
|
|
<td style="padding: 0.75rem; font-family: monospace;">${rule.port_range}</td>
|
|
<td style="padding: 0.75rem;">${rule.source_type}</td>
|
|
<td style="padding: 0.75rem; font-family: monospace; font-weight: 600; color: #8b5cf6;">${rule.source}</td>
|
|
<td style="padding: 0.75rem; color: #64748b;">${rule.description || '-'}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
resultsDiv.innerHTML = html;
|
|
}
|
|
|
|
function renderIPTableView(flatResults, tableBody) {
|
|
if (!flatResults || flatResults.length === 0) {
|
|
tableBody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 2rem;">No results found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = flatResults.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);
|
|
|
|
const directionBadge = item.direction === 'ingress'
|
|
? '<span style="display: inline-block; padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-weight: 500; font-size: 0.75rem;">Ingress</span>'
|
|
: item.direction === 'egress'
|
|
? '<span style="display: inline-block; padding: 0.125rem 0.5rem; background: #fce7f3; color: #9f1239; border-radius: 0.25rem; font-weight: 500; font-size: 0.75rem;">Egress</span>'
|
|
: '-';
|
|
|
|
return `
|
|
<tr onclick="showDetails('${item.type}', '${item.id}')" style="cursor: pointer;">
|
|
<td><span class="table-type-badge ${item.type}">${item.type.toUpperCase()}</span></td>
|
|
<td>${item.name}</td>
|
|
<td class="table-cell-mono">${item.id}</td>
|
|
<td><span style="${accountStyle}">${item.account_name}</span></td>
|
|
<td class="table-cell-mono" style="font-weight: 600; color: #8b5cf6;">${item.ip || '-'}</td>
|
|
<td>${directionBadge}</td>
|
|
<td class="table-cell-mono">${item.protocol}</td>
|
|
<td class="table-cell-mono">${item.port_range}</td>
|
|
<td class="table-cell-secondary">${sourceCell}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Enable Enter key for IP search
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const ipSearchInput = document.getElementById('ipSearchInput');
|
|
const ipTextFilter = document.getElementById('ipTextFilter');
|
|
const ipPortFilter = document.getElementById('ipPortFilter');
|
|
|
|
if (ipSearchInput) {
|
|
ipSearchInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
performIPSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (ipTextFilter) {
|
|
ipTextFilter.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
performIPSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (ipPortFilter) {
|
|
ipPortFilter.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
performIPSearch();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<footer style="text-align: center; padding: 2rem 1rem; margin-top: 4rem; border-top: 1px solid var(--border-color); color: #64748b; font-size: 0.875rem;">
|
|
<p style="margin: 0;">
|
|
SGO - Security Groups Observatory •
|
|
<a href="https://codeberg.org/edfig/SGO" target="_blank" style="color: var(--primary-color); text-decoration: none;">View Source</a> •
|
|
<a href="https://codeberg.org/edfig/SGO/issues" target="_blank" style="color: var(--primary-color); text-decoration: none;">Report Issue</a>
|
|
</p>
|
|
</footer>
|
|
</body>
|
|
</html>
|