Phase 3 Complete: Web Interface MVP

Major Achievements:
-  Full web interface (1,520+ lines of frontend code)
-  Interactive Leaflet.js map with marker clustering
-  Drag-and-drop upload system with GPS input
-  Search & filter UI with multi-criteria
-  Statistics dashboard with Chart.js
-  Responsive mobile-friendly design

Backend:
-  FastAPI static file serving
-  Simplified server mode (main_simple.py)
-  Improved startup script with port auto-selection
-  PostgreSQL schema ready (requires setup)

Database:
-  SQLite populated with 85 Flipper Zero signatures
-  Device matching system operational
-  Frequency-based search working

Documentation:
-  PHASE_3_COMPLETE.md - Technical summary
-  WEB_INTERFACE_README.md - User guide
-  WEBAPP_STARTUP_GUIDE.md - Troubleshooting
-  POSTGRESQL_SETUP_EXPLANATION.md - DB setup guide
-  DATABASE_POPULATION_SUCCESS.md - Import report
-  DEVICE_IDENTIFICATION_REPORT.md - Matching analysis

Files Created:
- templates/index.html (260 lines)
- static/css/main.css (500 lines)
- static/js/*.js (760 lines total)
- src/api/main_simple.py (simplified server)
- start_web.sh (auto port selection)

Status: Production MVP Ready
Next: Phase 4 - API & Integration

🛰️ Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-12 18:21:11 -08:00
parent bba30ad2da
commit 48fcb00241
39 changed files with 10138 additions and 12 deletions
+134
View File
@@ -0,0 +1,134 @@
// GigLez - Main Application Logic
// Navigation
document.addEventListener('DOMContentLoaded', () => {
setupNavigation();
initializeApp();
});
function setupNavigation() {
const navLinks = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('.section');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href').substring(1);
// Don't navigate for external links
if (link.hasAttribute('target')) {
window.open(link.getAttribute('href'), '_blank');
return;
}
// Update active nav link
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Show target section
sections.forEach(s => s.classList.remove('active'));
const targetSection = document.getElementById(`${target}-section`);
if (targetSection) {
targetSection.classList.add('active');
// Trigger section-specific actions
onSectionChange(target);
}
});
});
}
function onSectionChange(sectionName) {
switch (sectionName) {
case 'map':
// Refresh map tiles
if (window.map) {
setTimeout(() => window.map.invalidateSize(), 100);
}
break;
case 'stats':
// Load statistics
if (typeof loadStatistics === 'function') {
loadStatistics();
}
break;
}
}
function initializeApp() {
// Check API connection
checkAPIConnection();
// Load initial data
loadInitialData();
// Set up periodic refresh
setInterval(refreshData, 60000); // Refresh every minute
}
async function checkAPIConnection() {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.status === 'healthy') {
console.log('✅ API connection healthy');
} else {
console.warn('⚠️ API connection unhealthy:', data);
}
} catch (error) {
console.error('❌ API connection failed:', error);
}
}
async function loadInitialData() {
// Load captures for map
if (typeof loadCaptures === 'function') {
await loadCaptures();
}
}
async function refreshData() {
// Refresh map data if on map view
const mapSection = document.getElementById('map-section');
if (mapSection.classList.contains('active')) {
if (typeof window.reloadMapData === 'function') {
window.reloadMapData();
}
}
}
// Utility functions
function showNotification(message, type = 'info') {
// Simple notification system
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function formatFrequency(hz) {
return (hz / 1e6).toFixed(2) + ' MHz';
}
function formatDate(dateString) {
return new Date(dateString).toLocaleString();
}
function formatCoordinates(lat, lon) {
return `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
}
// Export utilities
window.showNotification = showNotification;
window.formatFrequency = formatFrequency;
window.formatDate = formatDate;
window.formatCoordinates = formatCoordinates;
+205
View File
@@ -0,0 +1,205 @@
// GigLez - Map Visualization
let map;
let markerLayer;
let markerClusterGroup;
let capturesData = [];
// Frequency color mapping
const FREQUENCY_COLORS = {
315: '#10b981', // Green
433: '#3b82f6', // Blue
868: '#f59e0b', // Orange
915: '#ef4444', // Red
default: '#6b7280' // Gray
};
// Initialize map
document.addEventListener('DOMContentLoaded', () => {
initMap();
loadCaptures();
setupMapControls();
});
function initMap() {
// Create map centered on US
map = L.map('map').setView([39.8283, -98.5795], 4);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Create marker cluster group
markerClusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true
});
// Create regular marker layer
markerLayer = L.layerGroup();
// Add cluster group by default
map.addLayer(markerClusterGroup);
}
function setupMapControls() {
// Cluster toggle
document.getElementById('cluster-toggle').addEventListener('change', (e) => {
if (e.target.checked) {
map.removeLayer(markerLayer);
map.addLayer(markerClusterGroup);
} else {
map.removeLayer(markerClusterGroup);
map.addLayer(markerLayer);
}
renderMarkers();
});
// Frequency filter
document.getElementById('frequency-filter').addEventListener('change', () => {
renderMarkers();
});
// Heatmap toggle (placeholder)
document.getElementById('heatmap-toggle').addEventListener('change', (e) => {
if (e.target.checked) {
alert('Heatmap view coming soon!');
e.target.checked = false;
}
});
}
async function loadCaptures() {
try {
const response = await fetch('/api/v1/query/captures?limit=1000');
if (!response.ok) {
console.error('Failed to load captures');
return;
}
const data = await response.json();
capturesData = data.captures || [];
renderMarkers();
updateStats();
} catch (error) {
console.error('Error loading captures:', error);
}
}
function renderMarkers() {
// Clear existing markers
markerClusterGroup.clearLayers();
markerLayer.clearLayers();
// Get frequency filter
const frequencyFilter = document.getElementById('frequency-filter').value;
// Filter captures
let filtered = capturesData;
if (frequencyFilter) {
const targetFreq = parseInt(frequencyFilter) * 1e6;
filtered = capturesData.filter(c => {
const freq = c.frequency / 1e6;
return Math.abs(freq - parseInt(frequencyFilter)) < 50;
});
}
// Create markers
filtered.forEach(capture => {
const marker = createMarker(capture);
// Add to both layers (only one will be visible)
markerClusterGroup.addLayer(marker);
markerLayer.addLayer(marker);
});
// Update count
document.getElementById('total-captures').textContent = filtered.length;
}
function createMarker(capture) {
// Determine color based on frequency
const freqMHz = Math.round(capture.frequency / 1e6);
let color = FREQUENCY_COLORS.default;
for (const [freq, col] of Object.entries(FREQUENCY_COLORS)) {
if (freq === 'default') continue;
if (Math.abs(freqMHz - parseInt(freq)) < 50) {
color = col;
break;
}
}
// Create custom icon
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6]
});
// Create marker
const marker = L.marker([capture.latitude, capture.longitude], { icon });
// Create popup
const popupContent = createPopupContent(capture);
marker.bindPopup(popupContent);
return marker;
}
function createPopupContent(capture) {
const freqMHz = (capture.frequency / 1e6).toFixed(2);
const date = new Date(capture.captured_at).toLocaleDateString();
let deviceInfo = 'Unknown Device';
if (capture.device_name) {
deviceInfo = `<strong>${capture.device_name}</strong>`;
if (capture.match_confidence) {
deviceInfo += ` (${(capture.match_confidence * 100).toFixed(0)}% confidence)`;
}
}
return `
<div class="marker-popup">
<h4>${deviceInfo}</h4>
<p><strong>Frequency:</strong> ${freqMHz} MHz</p>
<p><strong>Protocol:</strong> ${capture.protocol || 'RAW'}</p>
<p><strong>Captured:</strong> ${date}</p>
<p><strong>Location:</strong> ${capture.latitude.toFixed(6)}, ${capture.longitude.toFixed(6)}</p>
${capture.accuracy ? `<p><strong>Accuracy:</strong> ±${capture.accuracy.toFixed(1)}m</p>` : ''}
<button onclick="viewCaptureDetails('${capture.file_hash}')" class="btn btn-primary" style="margin-top: 0.5rem;">
View Details
</button>
</div>
`;
}
function updateStats() {
// Total captures
document.getElementById('total-captures').textContent = capturesData.length;
// Unique devices
const uniqueDevices = new Set(
capturesData
.filter(c => c.device_name)
.map(c => c.device_name)
).size;
document.getElementById('unique-devices').textContent = uniqueDevices;
}
function viewCaptureDetails(fileHash) {
// Navigate to detail page (to be implemented)
alert(`Viewing details for capture: ${fileHash}`);
}
// Export for external use
window.reloadMapData = loadCaptures;
+96
View File
@@ -0,0 +1,96 @@
// GigLez - Search Functionality
async function searchCaptures() {
const query = document.getElementById('search-query').value;
const frequency = document.getElementById('search-frequency').value;
const protocol = document.getElementById('search-protocol').value;
const dateStart = document.getElementById('search-date-start').value;
const dateEnd = document.getElementById('search-date-end').value;
const lat = document.getElementById('search-lat').value;
const lon = document.getElementById('search-lon').value;
const radius = document.getElementById('search-radius').value;
// Build query parameters
const params = new URLSearchParams();
if (query) params.append('q', query);
if (frequency) params.append('frequency', parseFloat(frequency) * 1e6);
if (protocol) params.append('protocol', protocol);
if (dateStart) params.append('date_start', dateStart);
if (dateEnd) params.append('date_end', dateEnd);
// Geographic search
if (lat && lon && radius) {
params.append('latitude', lat);
params.append('longitude', lon);
params.append('radius_km', radius);
}
try {
const response = await fetch(`/api/v1/query/captures?${params.toString()}`);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
displaySearchResults(data.captures || []);
} catch (error) {
console.error('Search error:', error);
alert(`Search failed: ${error.message}`);
}
}
function displaySearchResults(captures) {
const resultsDiv = document.getElementById('search-results');
const resultsList = document.getElementById('results-list');
resultsDiv.style.display = 'block';
if (captures.length === 0) {
resultsList.innerHTML = '<p>No results found. Try adjusting your search criteria.</p>';
return;
}
resultsList.innerHTML = captures.map(capture => createResultCard(capture)).join('');
}
function createResultCard(capture) {
const freqMHz = (capture.frequency / 1e6).toFixed(2);
const date = new Date(capture.captured_at).toLocaleString();
const deviceName = capture.device_name || 'Unknown Device';
const protocol = capture.protocol || 'RAW';
const confidence = capture.match_confidence
? `${(capture.match_confidence * 100).toFixed(0)}%`
: 'N/A';
return `
<div class="result-card" onclick="viewCaptureDetails('${capture.file_hash}')">
<div class="result-header">
<div class="result-title">${deviceName}</div>
<div class="result-frequency">${freqMHz} MHz</div>
</div>
<div class="result-details">
<div>
<strong>Protocol:</strong> ${protocol}
</div>
<div>
<strong>Confidence:</strong> ${confidence}
</div>
<div>
<strong>Captured:</strong> ${date}
</div>
<div>
<strong>Location:</strong> ${capture.latitude.toFixed(4)}, ${capture.longitude.toFixed(4)}
</div>
</div>
</div>
`;
}
function viewCaptureDetails(fileHash) {
// Show detail modal or navigate to detail page
alert(`Viewing details for: ${fileHash}\n\nDetail view coming soon!`);
}
+178
View File
@@ -0,0 +1,178 @@
// GigLez - Statistics Dashboard
let frequencyChart;
let timelineChart;
document.addEventListener('DOMContentLoaded', () => {
// Load stats when stats section becomes active
const statsSection = document.getElementById('stats-section');
const observer = new MutationObserver(() => {
if (statsSection.classList.contains('active')) {
loadStatistics();
}
});
observer.observe(statsSection, { attributes: true });
});
async function loadStatistics() {
try {
const response = await fetch('/api/v1/stats/summary');
if (!response.ok) {
console.error('Failed to load statistics');
return;
}
const stats = await response.json();
displayStatistics(stats);
} catch (error) {
console.error('Error loading statistics:', error);
displayDefaultStats();
}
}
function displayStatistics(stats) {
// Update stat cards
document.getElementById('stat-captures').textContent = formatNumber(stats.total_captures || 0);
document.getElementById('stat-devices').textContent = formatNumber(stats.unique_devices || 0);
document.getElementById('stat-coverage').textContent = formatCoverage(stats.coverage_area_km2 || 0);
document.getElementById('stat-contributors').textContent = formatNumber(stats.total_contributors || 0);
// Update charts
updateFrequencyChart(stats.frequency_distribution || {});
updateTimelineChart(stats.captures_timeline || []);
}
function displayDefaultStats() {
// Display placeholder stats
document.getElementById('stat-captures').textContent = '-';
document.getElementById('stat-devices').textContent = '-';
document.getElementById('stat-coverage').textContent = '-';
document.getElementById('stat-contributors').textContent = '-';
}
function updateFrequencyChart(distribution) {
const ctx = document.getElementById('frequency-chart').getContext('2d');
// Prepare data
const labels = Object.keys(distribution).map(freq => `${(freq / 1e6).toFixed(0)} MHz`);
const data = Object.values(distribution);
// Destroy existing chart
if (frequencyChart) {
frequencyChart.destroy();
}
// Create chart
frequencyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Number of Captures',
data: data,
backgroundColor: [
'rgba(16, 185, 129, 0.7)', // 315 MHz - Green
'rgba(59, 130, 246, 0.7)', // 433 MHz - Blue
'rgba(245, 158, 11, 0.7)', // 868 MHz - Orange
'rgba(239, 68, 68, 0.7)', // 915 MHz - Red
],
borderColor: [
'rgb(16, 185, 129)',
'rgb(59, 130, 246)',
'rgb(245, 158, 11)',
'rgb(239, 68, 68)',
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
}
function updateTimelineChart(timeline) {
const ctx = document.getElementById('timeline-chart').getContext('2d');
// Prepare data
const labels = timeline.map(item => new Date(item.date).toLocaleDateString());
const data = timeline.map(item => item.count);
// Destroy existing chart
if (timelineChart) {
timelineChart.destroy();
}
// Create chart
timelineChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Captures per Day',
data: data,
borderColor: 'rgb(37, 99, 235)',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
}
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
function formatCoverage(km2) {
if (km2 === 0) {
return 'N/A';
}
if (km2 >= 10000) {
return (km2 / 10000).toFixed(1) + ' x 10K km²';
}
if (km2 >= 1000) {
return (km2 / 1000).toFixed(1) + 'K km²';
}
return km2.toFixed(0) + ' km²';
}
+275
View File
@@ -0,0 +1,275 @@
// GigLez - Upload Functionality
let selectedFiles = [];
// Initialize upload functionality
document.addEventListener('DOMContentLoaded', () => {
setupDropZone();
setupFileInput();
});
function setupDropZone() {
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('click', () => {
document.getElementById('file-input').click();
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.sub'));
addFiles(files);
});
}
function setupFileInput() {
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
addFiles(files);
});
}
function addFiles(files) {
if (files.length === 0) {
alert('Please select .sub files');
return;
}
// Add new files
selectedFiles.push(...files);
// Show file list
document.getElementById('file-list').style.display = 'block';
// Render file list
renderFileList();
}
function renderFileList() {
const container = document.getElementById('files-container');
container.innerHTML = selectedFiles.map((file, index) => `
<div class="file-item" data-index="${index}">
<div class="file-info">
<div class="file-name">${file.name}</div>
<div class="file-meta">${formatFileSize(file.size)}</div>
</div>
<div class="file-actions">
<button type="button" class="btn btn-secondary" onclick="removeFile(${index})">Remove</button>
</div>
</div>
`).join('');
}
function removeFile(index) {
selectedFiles.splice(index, 1);
if (selectedFiles.length === 0) {
document.getElementById('file-list').style.display = 'none';
} else {
renderFileList();
}
}
function clearFiles() {
selectedFiles = [];
document.getElementById('file-list').style.display = 'none';
document.getElementById('file-input').value = '';
}
async function uploadFiles() {
if (selectedFiles.length === 0) {
alert('Please select files to upload');
return;
}
// Validate GPS coordinates
const lat = parseFloat(document.getElementById('default-lat').value);
const lon = parseFloat(document.getElementById('default-lon').value);
if (isNaN(lat) || isNaN(lon)) {
alert('Please provide valid GPS coordinates');
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
alert('GPS coordinates out of range');
return;
}
// Hide file list, show progress
document.getElementById('file-list').style.display = 'none';
document.getElementById('upload-progress').style.display = 'block';
try {
// Build manifest
const sessionUuid = document.getElementById('session-uuid').value || generateUUID();
const accuracy = parseFloat(document.getElementById('default-accuracy').value) || 5.0;
const altitude = parseFloat(document.getElementById('default-altitude').value) || null;
const manifest = {
session_uuid: sessionUuid,
captures: selectedFiles.map(file => ({
filename: file.name,
latitude: lat,
longitude: lon,
accuracy: accuracy,
altitude: altitude,
timestamp: new Date().toISOString()
}))
};
// Build FormData
const formData = new FormData();
formData.append('manifest', JSON.stringify(manifest));
selectedFiles.forEach(file => {
formData.append('files', file);
});
// Upload
updateProgress(0, 'Uploading files...');
const response = await fetch('/api/v1/captures/upload', {
method: 'POST',
body: formData
});
updateProgress(100, 'Processing...');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const result = await response.json();
// Show results
showUploadResults(result);
} catch (error) {
console.error('Upload error:', error);
alert(`Upload failed: ${error.message}`);
// Reset
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('file-list').style.display = 'block';
}
}
function updateProgress(percent, text) {
document.getElementById('progress-fill').style.width = `${percent}%`;
document.getElementById('progress-text').textContent = text;
}
function showUploadResults(result) {
// Hide progress
document.getElementById('upload-progress').style.display = 'none';
// Show results
const resultsDiv = document.getElementById('upload-results');
const container = document.getElementById('results-container');
resultsDiv.style.display = 'block';
const successCount = result.successful?.length || 0;
const errorCount = result.failed?.length || 0;
let html = `
<div class="result-summary">
<p>Successfully uploaded: <strong>${successCount}</strong> files</p>
${errorCount > 0 ? `<p>Failed: <strong>${errorCount}</strong> files</p>` : ''}
</div>
`;
if (result.successful && result.successful.length > 0) {
html += '<h4>Successful Uploads:</h4>';
result.successful.forEach(item => {
html += `
<div class="result-item">
<div class="file-name">${item.filename}</div>
<div class="file-meta">
Frequency: ${(item.frequency / 1e6).toFixed(2)} MHz |
Protocol: ${item.protocol || 'RAW'}
</div>
</div>
`;
});
}
if (result.failed && result.failed.length > 0) {
html += '<h4>Failed Uploads:</h4>';
result.failed.forEach(item => {
html += `
<div class="result-item error">
<div class="file-name">${item.filename}</div>
<div class="file-meta">Error: ${item.error}</div>
</div>
`;
});
}
container.innerHTML = html;
}
function resetUpload() {
// Hide results
document.getElementById('upload-results').style.display = 'none';
// Clear files
clearFiles();
// Reset form
document.getElementById('default-lat').value = '';
document.getElementById('default-lon').value = '';
document.getElementById('session-uuid').value = '';
}
function useCurrentLocation() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
document.getElementById('default-lat').value = position.coords.latitude.toFixed(6);
document.getElementById('default-lon').value = position.coords.longitude.toFixed(6);
document.getElementById('default-accuracy').value = position.coords.accuracy.toFixed(1);
if (position.coords.altitude) {
document.getElementById('default-altitude').value = position.coords.altitude.toFixed(1);
}
},
(error) => {
alert(`Geolocation error: ${error.message}`);
}
);
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}