@mytec: iter1.5 start, 2.1 planned (ins)
This commit is contained in:
846
RFCP-Iteration-1.5-Frontend-Backend-Integration.md
Normal file
846
RFCP-Iteration-1.5-Frontend-Backend-Integration.md
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# RFCP Iteration 1.5: Frontend ↔ Backend Integration
|
||||||
|
|
||||||
|
**Date:** January 31, 2025
|
||||||
|
**Type:** Full-Stack Integration
|
||||||
|
**Estimated:** 8-12 hours
|
||||||
|
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Goal
|
||||||
|
|
||||||
|
Replace browser-based coverage calculation with backend API. Add propagation model selection UI and real-time progress indication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Current State
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Coverage calculated in Web Workers (browser)
|
||||||
|
- Settings stored in localStorage/IndexedDB
|
||||||
|
- No connection to backend API
|
||||||
|
- ~0.15s calculation time (simple model)
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Full propagation engine ready (1.4)
|
||||||
|
- 4 presets (fast/standard/detailed/full)
|
||||||
|
- 6 propagation models available
|
||||||
|
- API at `https://api.rfcp.eliah.one`
|
||||||
|
|
||||||
|
**Gap:**
|
||||||
|
- Frontend doesn't call backend
|
||||||
|
- No model selection UI
|
||||||
|
- No progress indication for slow calculations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||||
|
│ │ Site Panel │───▶│ Coverage Settings Panel │ │
|
||||||
|
│ └─────────────┘ │ ├── Radius │ │
|
||||||
|
│ │ ├── Resolution │ │
|
||||||
|
│ │ ├── Preset [dropdown] │ NEW │
|
||||||
|
│ │ └── Model toggles │ NEW │
|
||||||
|
│ └───────────┬─────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────────▼────────────────┐ │
|
||||||
|
│ │ Coverage Service (API client) │ │
|
||||||
|
│ │ ├── POST /api/coverage/calculate │ │
|
||||||
|
│ │ ├── Progress polling / SSE │ │
|
||||||
|
│ │ └── Result caching │ │
|
||||||
|
│ └──────────────────────────────┬────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────────▼────────────────┐ │
|
||||||
|
│ │ Map Visualization │ │
|
||||||
|
│ │ ├── Heatmap layer (existing) │ │
|
||||||
|
│ │ └── Model info overlay NEW │ │
|
||||||
|
│ └───────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API │
|
||||||
|
│ POST /api/coverage/calculate │
|
||||||
|
│ GET /api/coverage/presets │
|
||||||
|
│ GET /api/terrain/elevation │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Tasks
|
||||||
|
|
||||||
|
### Task 1.5.1: API Client Service (2-3 hours)
|
||||||
|
|
||||||
|
**frontend/src/services/api.ts:**
|
||||||
|
```typescript
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one';
|
||||||
|
|
||||||
|
export interface CoverageRequest {
|
||||||
|
sites: SiteParams[];
|
||||||
|
settings: CoverageSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteParams {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
height: number;
|
||||||
|
power: number;
|
||||||
|
gain: number;
|
||||||
|
frequency: number;
|
||||||
|
azimuth?: number;
|
||||||
|
beamwidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageSettings {
|
||||||
|
radius: number;
|
||||||
|
resolution: number;
|
||||||
|
min_signal: number;
|
||||||
|
preset?: 'fast' | 'standard' | 'detailed' | 'full';
|
||||||
|
use_terrain?: boolean;
|
||||||
|
use_buildings?: boolean;
|
||||||
|
use_materials?: boolean;
|
||||||
|
use_dominant_path?: boolean;
|
||||||
|
use_street_canyon?: boolean;
|
||||||
|
use_reflections?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoveragePoint {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
rsrp: number;
|
||||||
|
distance: number;
|
||||||
|
has_los: boolean;
|
||||||
|
terrain_loss: number;
|
||||||
|
building_loss: number;
|
||||||
|
reflection_gain: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageResponse {
|
||||||
|
points: CoveragePoint[];
|
||||||
|
count: number;
|
||||||
|
settings: CoverageSettings;
|
||||||
|
stats: CoverageStats;
|
||||||
|
computation_time: number;
|
||||||
|
models_used: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageStats {
|
||||||
|
min_rsrp: number;
|
||||||
|
max_rsrp: number;
|
||||||
|
avg_rsrp: number;
|
||||||
|
los_percentage: number;
|
||||||
|
points_with_buildings: number;
|
||||||
|
points_with_terrain_loss: number;
|
||||||
|
points_with_reflection_gain: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Preset {
|
||||||
|
description: string;
|
||||||
|
use_terrain: boolean;
|
||||||
|
use_buildings: boolean;
|
||||||
|
use_materials: boolean;
|
||||||
|
use_dominant_path: boolean;
|
||||||
|
use_street_canyon: boolean;
|
||||||
|
use_reflections: boolean;
|
||||||
|
estimated_speed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
async getPresets(): Promise<Record<string, Preset>> {
|
||||||
|
const response = await fetch(`${API_BASE}/api/coverage/presets`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch presets');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateCoverage(
|
||||||
|
request: CoverageRequest,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<CoverageResponse> {
|
||||||
|
// Cancel previous request if running
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/api/coverage/calculate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal: this.abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Coverage calculation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCalculation() {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getElevation(lat: number, lon: number): Promise<number> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/terrain/elevation?lat=${lat}&lon=${lon}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return 0;
|
||||||
|
const data = await response.json();
|
||||||
|
return data.elevation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiService();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.2: Coverage Settings Panel Update (3-4 hours)
|
||||||
|
|
||||||
|
**frontend/src/components/panels/CoverageSettingsPanel.tsx:**
|
||||||
|
```typescript
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api, Preset } from '../../services/api';
|
||||||
|
import { useCoverageStore } from '../../store/coverage';
|
||||||
|
import { NumberInput } from '../ui/NumberInput';
|
||||||
|
import { Toggle } from '../ui/Toggle';
|
||||||
|
|
||||||
|
export function CoverageSettingsPanel() {
|
||||||
|
const { settings, updateSettings, isCalculating } = useCoverageStore();
|
||||||
|
const [presets, setPresets] = useState<Record<string, Preset>>({});
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
// Load presets on mount
|
||||||
|
useEffect(() => {
|
||||||
|
api.getPresets().then(setPresets).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePresetChange = (preset: string) => {
|
||||||
|
if (presets[preset]) {
|
||||||
|
updateSettings({
|
||||||
|
preset,
|
||||||
|
...presets[preset]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="coverage-settings-panel">
|
||||||
|
<h3>Coverage Settings</h3>
|
||||||
|
|
||||||
|
{/* Basic Settings */}
|
||||||
|
<NumberInput
|
||||||
|
label="Radius"
|
||||||
|
value={settings.radius / 1000}
|
||||||
|
onChange={(v) => updateSettings({ radius: v * 1000 })}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
unit="km"
|
||||||
|
tooltip="Coverage calculation radius around each site"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Resolution"
|
||||||
|
value={settings.resolution}
|
||||||
|
onChange={(v) => updateSettings({ resolution: v })}
|
||||||
|
min={50}
|
||||||
|
max={500}
|
||||||
|
step={50}
|
||||||
|
unit="m"
|
||||||
|
tooltip="Grid spacing — lower = more accurate but slower"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Min Signal"
|
||||||
|
value={settings.min_signal}
|
||||||
|
onChange={(v) => updateSettings({ min_signal: v })}
|
||||||
|
min={-140}
|
||||||
|
max={-50}
|
||||||
|
step={5}
|
||||||
|
unit="dBm"
|
||||||
|
tooltip="RSRP threshold — points below this are hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Propagation Model Preset */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<label>Propagation Model</label>
|
||||||
|
<select
|
||||||
|
value={settings.preset || 'standard'}
|
||||||
|
onChange={(e) => handlePresetChange(e.target.value)}
|
||||||
|
disabled={isCalculating}
|
||||||
|
>
|
||||||
|
{Object.entries(presets).map(([key, preset]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{key.charAt(0).toUpperCase() + key.slice(1)} — {preset.estimated_speed}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{presets[settings.preset || 'standard'] && (
|
||||||
|
<p className="hint">
|
||||||
|
{presets[settings.preset || 'standard'].description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Toggles */}
|
||||||
|
<div className="advanced-section">
|
||||||
|
<button
|
||||||
|
className="advanced-toggle"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
>
|
||||||
|
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="advanced-options">
|
||||||
|
<Toggle
|
||||||
|
label="Terrain (SRTM)"
|
||||||
|
checked={settings.use_terrain}
|
||||||
|
onChange={(v) => updateSettings({ use_terrain: v })}
|
||||||
|
disabled={isCalculating}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Buildings (OSM)"
|
||||||
|
checked={settings.use_buildings}
|
||||||
|
onChange={(v) => updateSettings({ use_buildings: v })}
|
||||||
|
disabled={isCalculating}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Building Materials"
|
||||||
|
checked={settings.use_materials}
|
||||||
|
onChange={(v) => updateSettings({ use_materials: v })}
|
||||||
|
disabled={isCalculating || !settings.use_buildings}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Dominant Path Analysis"
|
||||||
|
checked={settings.use_dominant_path}
|
||||||
|
onChange={(v) => updateSettings({ use_dominant_path: v })}
|
||||||
|
disabled={isCalculating}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Street Canyon"
|
||||||
|
checked={settings.use_street_canyon}
|
||||||
|
onChange={(v) => updateSettings({ use_street_canyon: v })}
|
||||||
|
disabled={isCalculating}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Reflections"
|
||||||
|
checked={settings.use_reflections}
|
||||||
|
onChange={(v) => updateSettings({ use_reflections: v })}
|
||||||
|
disabled={isCalculating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.3: Coverage Store Update (2-3 hours)
|
||||||
|
|
||||||
|
**frontend/src/store/coverage.ts:**
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { api, CoverageResponse, CoverageSettings, CoveragePoint } from '../services/api';
|
||||||
|
import { useSitesStore } from './sites';
|
||||||
|
|
||||||
|
interface CoverageState {
|
||||||
|
// Data
|
||||||
|
points: CoveragePoint[];
|
||||||
|
stats: CoverageResponse['stats'] | null;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: CoverageSettings;
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
isCalculating: boolean;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
lastCalculation: {
|
||||||
|
time: number;
|
||||||
|
models: string[];
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
updateSettings: (settings: Partial<CoverageSettings>) => void;
|
||||||
|
calculateCoverage: () => Promise<void>;
|
||||||
|
cancelCalculation: () => void;
|
||||||
|
clearCoverage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: CoverageSettings = {
|
||||||
|
radius: 10000,
|
||||||
|
resolution: 200,
|
||||||
|
min_signal: -100,
|
||||||
|
preset: 'standard',
|
||||||
|
use_terrain: true,
|
||||||
|
use_buildings: true,
|
||||||
|
use_materials: true,
|
||||||
|
use_dominant_path: false,
|
||||||
|
use_street_canyon: false,
|
||||||
|
use_reflections: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||||
|
points: [],
|
||||||
|
stats: null,
|
||||||
|
settings: DEFAULT_SETTINGS,
|
||||||
|
isCalculating: false,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
lastCalculation: null,
|
||||||
|
|
||||||
|
updateSettings: (newSettings) => {
|
||||||
|
set((state) => ({
|
||||||
|
settings: { ...state.settings, ...newSettings }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
calculateCoverage: async () => {
|
||||||
|
const { settings } = get();
|
||||||
|
const sites = useSitesStore.getState().sites;
|
||||||
|
|
||||||
|
if (sites.length === 0) {
|
||||||
|
set({ error: 'No sites to calculate coverage for' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isCalculating: true, progress: 0, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert sites to API format
|
||||||
|
const apiSites = sites.flatMap(site =>
|
||||||
|
site.sectors.map(sector => ({
|
||||||
|
lat: site.lat,
|
||||||
|
lon: site.lon,
|
||||||
|
height: sector.height,
|
||||||
|
power: 10 * Math.log10(sector.power * 1000), // W to dBm
|
||||||
|
gain: sector.gain,
|
||||||
|
frequency: sector.frequency,
|
||||||
|
azimuth: sector.antennaType === 'directional' ? sector.azimuth : undefined,
|
||||||
|
beamwidth: sector.antennaType === 'directional' ? sector.beamwidth : undefined,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await api.calculateCoverage({
|
||||||
|
sites: apiSites,
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
points: response.points,
|
||||||
|
stats: response.stats,
|
||||||
|
isCalculating: false,
|
||||||
|
progress: 100,
|
||||||
|
lastCalculation: {
|
||||||
|
time: response.computation_time,
|
||||||
|
models: response.models_used
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
set({ isCalculating: false, progress: 0 });
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
isCalculating: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Calculation failed',
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelCalculation: () => {
|
||||||
|
api.cancelCalculation();
|
||||||
|
set({ isCalculating: false, progress: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCoverage: () => {
|
||||||
|
set({ points: [], stats: null, lastCalculation: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.4: Calculate Button & Progress (2-3 hours)
|
||||||
|
|
||||||
|
**frontend/src/components/CoverageButton.tsx:**
|
||||||
|
```typescript
|
||||||
|
import { useCoverageStore } from '../store/coverage';
|
||||||
|
import { useSitesStore } from '../store/sites';
|
||||||
|
|
||||||
|
export function CoverageButton() {
|
||||||
|
const {
|
||||||
|
isCalculating,
|
||||||
|
progress,
|
||||||
|
calculateCoverage,
|
||||||
|
cancelCalculation,
|
||||||
|
lastCalculation
|
||||||
|
} = useCoverageStore();
|
||||||
|
const sitesCount = useSitesStore((s) => s.sites.length);
|
||||||
|
|
||||||
|
if (isCalculating) {
|
||||||
|
return (
|
||||||
|
<div className="coverage-button calculating">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={cancelCalculation} className="cancel-btn">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="coverage-button">
|
||||||
|
<button
|
||||||
|
onClick={calculateCoverage}
|
||||||
|
disabled={sitesCount === 0}
|
||||||
|
className="calculate-btn"
|
||||||
|
>
|
||||||
|
Calculate Coverage
|
||||||
|
</button>
|
||||||
|
{lastCalculation && (
|
||||||
|
<span className="last-calc-info">
|
||||||
|
{lastCalculation.time.toFixed(1)}s • {lastCalculation.models.length} models
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS (coverage-button.css):**
|
||||||
|
```css
|
||||||
|
.coverage-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculate-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculate-btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculate-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-button.calculating {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 200px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-calc-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.5: Update Heatmap to Use API Points (2-3 hours)
|
||||||
|
|
||||||
|
**frontend/src/components/map/CoverageLayer.tsx:**
|
||||||
|
```typescript
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { useCoverageStore } from '../../store/coverage';
|
||||||
|
import { createHeatmapTiles } from '../../lib/heatmap';
|
||||||
|
|
||||||
|
export function CoverageLayer() {
|
||||||
|
const map = useMap();
|
||||||
|
const { points, settings } = useCoverageStore();
|
||||||
|
|
||||||
|
// Convert API points to heatmap format
|
||||||
|
const heatmapData = useMemo(() => {
|
||||||
|
if (!points.length) return [];
|
||||||
|
|
||||||
|
return points.map(p => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
value: p.rsrp,
|
||||||
|
// Additional data for tooltips
|
||||||
|
hasLos: p.has_los,
|
||||||
|
terrainLoss: p.terrain_loss,
|
||||||
|
buildingLoss: p.building_loss,
|
||||||
|
reflectionGain: p.reflection_gain,
|
||||||
|
}));
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!heatmapData.length) return;
|
||||||
|
|
||||||
|
const tileLayer = createHeatmapTiles(heatmapData, {
|
||||||
|
minSignal: settings.min_signal,
|
||||||
|
maxSignal: -50,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
tileLayer.addTo(map);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(tileLayer);
|
||||||
|
};
|
||||||
|
}, [map, heatmapData, settings.min_signal]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.6: Stats Panel Update (1-2 hours)
|
||||||
|
|
||||||
|
**frontend/src/components/panels/StatsPanel.tsx:**
|
||||||
|
```typescript
|
||||||
|
import { useCoverageStore } from '../../store/coverage';
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { stats, lastCalculation, points } = useCoverageStore();
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="stats-panel empty">
|
||||||
|
<p>Calculate coverage to see statistics</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-panel">
|
||||||
|
<h3>Coverage Statistics</h3>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Points</span>
|
||||||
|
<span className="value">{points.length.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Min RSRP</span>
|
||||||
|
<span className="value">{stats.min_rsrp.toFixed(1)} dBm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Max RSRP</span>
|
||||||
|
<span className="value">{stats.max_rsrp.toFixed(1)} dBm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Avg RSRP</span>
|
||||||
|
<span className="value">{stats.avg_rsrp.toFixed(1)} dBm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Line of Sight</span>
|
||||||
|
<span className="value">{stats.los_percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Terrain Affected</span>
|
||||||
|
<span className="value">{stats.points_with_terrain_loss}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">Building Affected</span>
|
||||||
|
<span className="value">{stats.points_with_buildings}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat">
|
||||||
|
<span className="label">With Reflections</span>
|
||||||
|
<span className="value">{stats.points_with_reflection_gain}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastCalculation && (
|
||||||
|
<div className="calc-info">
|
||||||
|
<p>
|
||||||
|
Calculated in <strong>{lastCalculation.time.toFixed(1)}s</strong>
|
||||||
|
</p>
|
||||||
|
<p className="models">
|
||||||
|
Models: {lastCalculation.models.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.5.7: Environment Configuration (1 hour)
|
||||||
|
|
||||||
|
**frontend/.env.development:**
|
||||||
|
```env
|
||||||
|
VITE_API_URL=https://api.rfcp.eliah.one
|
||||||
|
```
|
||||||
|
|
||||||
|
**frontend/.env.production:**
|
||||||
|
```env
|
||||||
|
VITE_API_URL=https://api.rfcp.eliah.one
|
||||||
|
```
|
||||||
|
|
||||||
|
**frontend/.env.local (for local backend):**
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:8888
|
||||||
|
```
|
||||||
|
|
||||||
|
**frontend/vite.config.ts update:**
|
||||||
|
```typescript
|
||||||
|
export default defineConfig({
|
||||||
|
// ... existing config
|
||||||
|
define: {
|
||||||
|
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start backend (if not running)
|
||||||
|
sudo systemctl start rfcp-backend
|
||||||
|
|
||||||
|
# 2. Start frontend dev
|
||||||
|
cd /opt/rfcp/frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 3. Test in browser
|
||||||
|
# - Create a site
|
||||||
|
# - Select "Fast" preset
|
||||||
|
# - Click Calculate Coverage
|
||||||
|
# - Verify heatmap appears
|
||||||
|
# - Check stats panel shows API data
|
||||||
|
|
||||||
|
# 4. Test presets
|
||||||
|
# - Switch to "Standard" - should take ~30s
|
||||||
|
# - Switch to "Full" (small radius!) - should take ~2min
|
||||||
|
# - Verify models_used changes
|
||||||
|
|
||||||
|
# 5. Test cancel
|
||||||
|
# - Start "Full" calculation
|
||||||
|
# - Click Cancel
|
||||||
|
# - Verify it stops
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Success Criteria
|
||||||
|
|
||||||
|
- [ ] Coverage calculated via backend API (not browser)
|
||||||
|
- [ ] Preset dropdown shows 4 options with descriptions
|
||||||
|
- [ ] Advanced toggles work independently
|
||||||
|
- [ ] Progress indication during calculation
|
||||||
|
- [ ] Cancel button stops calculation
|
||||||
|
- [ ] Stats panel shows API response data
|
||||||
|
- [ ] `computation_time` and `models_used` displayed
|
||||||
|
- [ ] Heatmap renders API points correctly
|
||||||
|
- [ ] Error handling for API failures
|
||||||
|
- [ ] Works with multiple sites/sectors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Files Changed
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── services/
|
||||||
|
│ └── api.ts # NEW - API client
|
||||||
|
├── store/
|
||||||
|
│ └── coverage.ts # MODIFIED - API integration
|
||||||
|
├── components/
|
||||||
|
│ ├── panels/
|
||||||
|
│ │ ├── CoverageSettingsPanel.tsx # MODIFIED - preset UI
|
||||||
|
│ │ └── StatsPanel.tsx # MODIFIED - API stats
|
||||||
|
│ ├── map/
|
||||||
|
│ │ └── CoverageLayer.tsx # MODIFIED - use API points
|
||||||
|
│ └── CoverageButton.tsx # NEW - calculate + progress
|
||||||
|
├── .env.development # NEW
|
||||||
|
├── .env.production # NEW
|
||||||
|
└── vite.config.ts # MODIFIED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- Remove or disable old Web Worker calculation after integration confirmed
|
||||||
|
- Keep localStorage for sites/settings backup (offline fallback)
|
||||||
|
- Consider WebSocket for real progress updates (future enhancement)
|
||||||
|
- API timeout should be generous (5+ minutes for "full" preset)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔜 Next
|
||||||
|
|
||||||
|
After 1.5 complete:
|
||||||
|
- **1.4.1** — Enhanced Environment (R-tree, water, vegetation)
|
||||||
|
- **1.4.2** — Extra Factors (weather, indoor)
|
||||||
|
- **2.1** — Desktop Installer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready for Claude Code** 🚀
|
||||||
730
RFCP-Phase-2.1-Desktop-Installer.md
Normal file
730
RFCP-Phase-2.1-Desktop-Installer.md
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
# RFCP Phase 2.1: Desktop Application & Installer
|
||||||
|
|
||||||
|
**Date:** January 31, 2025
|
||||||
|
**Type:** Packaging & Distribution
|
||||||
|
**Estimated:** 20-30 hours
|
||||||
|
**Priority:** After frontend-backend integration (1.5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Goal
|
||||||
|
|
||||||
|
Package RFCP as standalone desktop application with installer for Windows and Linux. Fully offline capable after initial setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Target Specs
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| Installer size | 200-300 MB |
|
||||||
|
| Installed size | 500MB - 1GB |
|
||||||
|
| Platforms | Windows 10/11, Ubuntu 22.04+ |
|
||||||
|
| Offline | Full offline after region download |
|
||||||
|
| GPU | Optional, configurable in settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ RFCP Desktop App │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────┐ │
|
||||||
|
│ │ Electron Shell │ │
|
||||||
|
│ │ (Chromium + Node.js runtime) │ │
|
||||||
|
│ └──────────────┬──────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────▼──────────────────────┐ │
|
||||||
|
│ │ React Frontend (UI) │ │
|
||||||
|
│ │ localhost:5173 (dev) / bundled │ │
|
||||||
|
│ └──────────────┬──────────────────────┘ │
|
||||||
|
│ │ HTTP API │
|
||||||
|
│ ┌──────────────▼──────────────────────┐ │
|
||||||
|
│ │ FastAPI Backend (Python) │ │
|
||||||
|
│ │ localhost:8888 │ │
|
||||||
|
│ │ ┌────────────────────────────┐ │ │
|
||||||
|
│ │ │ Propagation Engine │ │ │
|
||||||
|
│ │ │ ├── CPU (default) │ │ │
|
||||||
|
│ │ │ └── GPU (optional/CUDA) │ │ │
|
||||||
|
│ │ └────────────────────────────┘ │ │
|
||||||
|
│ └──────────────┬──────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────▼──────────────────────┐ │
|
||||||
|
│ │ Local Data Store │ │
|
||||||
|
│ │ ├── SQLite (projects, settings) │ │
|
||||||
|
│ │ ├── SRTM tiles (elevation) │ │
|
||||||
|
│ │ └── OSM cache (buildings, roads) │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Directory Structure
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```
|
||||||
|
rfcp/
|
||||||
|
├── electron/
|
||||||
|
│ ├── main.js # Electron main process
|
||||||
|
│ ├── preload.js # Context bridge
|
||||||
|
│ ├── package.json # Electron deps
|
||||||
|
│ └── build/ # electron-builder config
|
||||||
|
│ ├── icon.ico
|
||||||
|
│ ├── icon.png
|
||||||
|
│ └── installer.nsh # NSIS customization
|
||||||
|
├── frontend/ # React (existing)
|
||||||
|
├── backend/ # FastAPI (existing)
|
||||||
|
└── scripts/
|
||||||
|
├── build-windows.sh
|
||||||
|
├── build-linux.sh
|
||||||
|
└── package-python.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installed (Windows)
|
||||||
|
```
|
||||||
|
C:\Program Files\RFCP\
|
||||||
|
├── RFCP.exe # Electron app
|
||||||
|
├── resources/
|
||||||
|
│ ├── app.asar # Frontend bundle
|
||||||
|
│ └── backend/
|
||||||
|
│ ├── rfcp-server.exe # PyInstaller bundle
|
||||||
|
│ └── app/ # Python code
|
||||||
|
├── data/
|
||||||
|
│ ├── rfcp.db # SQLite database
|
||||||
|
│ ├── srtm/ # Elevation tiles
|
||||||
|
│ ├── osm/ # OSM cache
|
||||||
|
│ └── projects/ # User projects
|
||||||
|
├── python/ # Embedded Python (if not PyInstaller)
|
||||||
|
└── Uninstall RFCP.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installed (Linux)
|
||||||
|
```
|
||||||
|
/opt/rfcp/
|
||||||
|
├── rfcp # AppImage or binary
|
||||||
|
├── resources/
|
||||||
|
│ └── ...
|
||||||
|
└── data/
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
~/.local/share/rfcp/ # User data
|
||||||
|
├── rfcp.db
|
||||||
|
├── srtm/
|
||||||
|
├── osm/
|
||||||
|
└── projects/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Tasks
|
||||||
|
|
||||||
|
### Task 2.1.1: Electron Shell (4-6 hours)
|
||||||
|
|
||||||
|
**electron/main.js:**
|
||||||
|
```javascript
|
||||||
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let mainWindow;
|
||||||
|
let backendProcess;
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const backendPath = isDev
|
||||||
|
? path.join(__dirname, '../backend')
|
||||||
|
: path.join(process.resourcesPath, 'backend');
|
||||||
|
|
||||||
|
const dataPath = isDev
|
||||||
|
? path.join(__dirname, '../data')
|
||||||
|
: path.join(app.getPath('userData'), 'data');
|
||||||
|
|
||||||
|
// Ensure data directories exist
|
||||||
|
function ensureDataDirs() {
|
||||||
|
const dirs = ['srtm', 'osm', 'projects'];
|
||||||
|
dirs.forEach(dir => {
|
||||||
|
const fullPath = path.join(dataPath, dir);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.mkdirSync(fullPath, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Python backend
|
||||||
|
function startBackend() {
|
||||||
|
const pythonExe = isDev
|
||||||
|
? 'python'
|
||||||
|
: path.join(process.resourcesPath, 'backend', 'rfcp-server.exe');
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
RFCP_DATA_PATH: dataPath,
|
||||||
|
RFCP_PORT: '8888'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
backendProcess = spawn(pythonExe, ['-m', 'uvicorn', 'app.main:app', '--port', '8888'], {
|
||||||
|
cwd: backendPath,
|
||||||
|
env
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
backendProcess = spawn(pythonExe, [], { env });
|
||||||
|
}
|
||||||
|
|
||||||
|
backendProcess.stdout.on('data', (data) => {
|
||||||
|
console.log(`Backend: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
backendProcess.stderr.on('data', (data) => {
|
||||||
|
console.error(`Backend Error: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Wait for backend to be ready
|
||||||
|
const checkBackend = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8888/api/health/');
|
||||||
|
if (response.ok) {
|
||||||
|
clearInterval(checkBackend);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not ready yet
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Timeout after 30s
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkBackend);
|
||||||
|
resolve(); // Continue anyway
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create window
|
||||||
|
async function createWindow() {
|
||||||
|
ensureDataDirs();
|
||||||
|
|
||||||
|
// Show splash while loading
|
||||||
|
const splash = new BrowserWindow({
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
splash.loadFile('splash.html');
|
||||||
|
|
||||||
|
// Start backend
|
||||||
|
await startBackend();
|
||||||
|
|
||||||
|
// Create main window
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1024,
|
||||||
|
minHeight: 768,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js')
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, 'build/icon.png'),
|
||||||
|
title: 'RFCP - RF Coverage Planner'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load frontend
|
||||||
|
if (isDev) {
|
||||||
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(path.join(process.resourcesPath, 'frontend', 'index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close splash
|
||||||
|
splash.close();
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// App lifecycle
|
||||||
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (backendProcess) {
|
||||||
|
backendProcess.kill();
|
||||||
|
}
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (mainWindow === null) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// IPC handlers
|
||||||
|
ipcMain.handle('get-data-path', () => dataPath);
|
||||||
|
ipcMain.handle('get-gpu-info', () => {
|
||||||
|
// Detect GPU availability
|
||||||
|
// Could use node-gpu or check CUDA
|
||||||
|
return {
|
||||||
|
available: false, // TODO: implement detection
|
||||||
|
name: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**electron/preload.js:**
|
||||||
|
```javascript
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('rfcp', {
|
||||||
|
getDataPath: () => ipcRenderer.invoke('get-data-path'),
|
||||||
|
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
||||||
|
platform: process.platform,
|
||||||
|
version: require('./package.json').version
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.1.2: Python Packaging (4-6 hours)
|
||||||
|
|
||||||
|
**PyInstaller spec file (rfcp-server.spec):**
|
||||||
|
```python
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['app/main.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[
|
||||||
|
('app', 'app'),
|
||||||
|
],
|
||||||
|
hiddenimports=[
|
||||||
|
'uvicorn.logging',
|
||||||
|
'uvicorn.protocols.http',
|
||||||
|
'uvicorn.protocols.http.auto',
|
||||||
|
'uvicorn.protocols.websockets',
|
||||||
|
'uvicorn.protocols.websockets.auto',
|
||||||
|
'uvicorn.lifespan.on',
|
||||||
|
'uvicorn.lifespan.off',
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='rfcp-server',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True, # False for production
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon='../electron/build/icon.ico'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build script (scripts/package-python.sh):**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Create venv for packaging
|
||||||
|
python -m venv build_env
|
||||||
|
source build_env/bin/activate # or build_env\Scripts\activate on Windows
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# Build
|
||||||
|
pyinstaller rfcp-server.spec --clean
|
||||||
|
|
||||||
|
# Output in dist/rfcp-server.exe
|
||||||
|
echo "Built: dist/rfcp-server.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.1.3: Electron Builder Config (3-4 hours)
|
||||||
|
|
||||||
|
**electron/package.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "rfcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "RF Coverage Planner for Tactical Communications",
|
||||||
|
"main": "main.js",
|
||||||
|
"author": "UMTC Project",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"build:win": "electron-builder --win",
|
||||||
|
"build:linux": "electron-builder --linux",
|
||||||
|
"build:all": "electron-builder --win --linux"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.0.0",
|
||||||
|
"electron-builder": "^24.9.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "one.eliah.rfcp",
|
||||||
|
"productName": "RFCP",
|
||||||
|
"copyright": "Copyright © 2025 UMTC Project",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist",
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"splash.html"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "../frontend/dist",
|
||||||
|
"to": "frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../backend/dist/rfcp-server",
|
||||||
|
"to": "backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"installerIcon": "build/icon.ico",
|
||||||
|
"uninstallerIcon": "build/icon.ico",
|
||||||
|
"installerHeaderIcon": "build/icon.ico",
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "RFCP"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"category": "Science"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.1.4: First Run & Region Selection (4-6 hours)
|
||||||
|
|
||||||
|
**First run wizard:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Welcome to RFCP │
|
||||||
|
│ RF Coverage Planner │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Select your region for offline maps: │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ [x] Ukraine (~2.5 GB) │ │
|
||||||
|
│ │ [ ] Poland (~1.8 GB) │ │
|
||||||
|
│ │ [ ] Germany (~2.1 GB) │ │
|
||||||
|
│ │ [ ] Custom bounding box... │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Data includes: │
|
||||||
|
│ • Terrain elevation (SRTM 30m) │
|
||||||
|
│ • Building footprints (OSM) │
|
||||||
|
│ • Road network (OSM) │
|
||||||
|
│ • Base map tiles │
|
||||||
|
│ │
|
||||||
|
│ [ ] Download now │
|
||||||
|
│ [ ] Download later (online mode) │
|
||||||
|
│ │
|
||||||
|
│ [Continue →] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Region download API endpoint:**
|
||||||
|
```python
|
||||||
|
@router.post("/regions/download")
|
||||||
|
async def download_region(region: str, background_tasks: BackgroundTasks):
|
||||||
|
"""Start region download in background"""
|
||||||
|
|
||||||
|
REGIONS = {
|
||||||
|
"ukraine": {
|
||||||
|
"bbox": [44.0, 22.0, 52.5, 40.5], # S, W, N, E
|
||||||
|
"srtm_tiles": ["N44E022", "N44E023", ...], # ~120 tiles
|
||||||
|
"estimated_size": 2.5 * 1024 * 1024 * 1024 # 2.5 GB
|
||||||
|
},
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
|
||||||
|
if region not in REGIONS:
|
||||||
|
raise HTTPException(400, "Unknown region")
|
||||||
|
|
||||||
|
task_id = str(uuid4())
|
||||||
|
background_tasks.add_task(download_region_data, task_id, REGIONS[region])
|
||||||
|
|
||||||
|
return {"task_id": task_id, "status": "started"}
|
||||||
|
|
||||||
|
@router.get("/regions/progress/{task_id}")
|
||||||
|
async def get_download_progress(task_id: str):
|
||||||
|
"""Get download progress"""
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "downloading", # started, downloading, extracting, done, error
|
||||||
|
"progress": 45.5, # percentage
|
||||||
|
"current_file": "N48E035.hgt",
|
||||||
|
"downloaded_mb": 1250,
|
||||||
|
"total_mb": 2500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.1.5: Settings - GPU Configuration (2-3 hours)
|
||||||
|
|
||||||
|
**Settings panel addition:**
|
||||||
|
```typescript
|
||||||
|
// frontend/src/components/settings/PerformanceSettings.tsx
|
||||||
|
|
||||||
|
interface PerformanceSettingsProps {
|
||||||
|
gpuInfo: {
|
||||||
|
available: boolean;
|
||||||
|
name: string | null;
|
||||||
|
memory: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PerformanceSettings({ gpuInfo }: PerformanceSettingsProps) {
|
||||||
|
const [useGpu, setUseGpu] = useState(false);
|
||||||
|
const [maxWorkers, setMaxWorkers] = useState(4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Performance</h3>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useGpu}
|
||||||
|
onChange={(e) => setUseGpu(e.target.checked)}
|
||||||
|
disabled={!gpuInfo.available}
|
||||||
|
/>
|
||||||
|
Use GPU acceleration (CUDA)
|
||||||
|
</label>
|
||||||
|
{gpuInfo.available ? (
|
||||||
|
<span className="hint">Detected: {gpuInfo.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="hint warning">No compatible GPU detected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<label>CPU Workers</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="16"
|
||||||
|
value={maxWorkers}
|
||||||
|
onChange={(e) => setMaxWorkers(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span>{maxWorkers} threads</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<label>Default Propagation Model</label>
|
||||||
|
<select>
|
||||||
|
<option value="fast">Fast (terrain only)</option>
|
||||||
|
<option value="standard">Standard (+ buildings)</option>
|
||||||
|
<option value="detailed">Detailed (+ dominant path)</option>
|
||||||
|
<option value="full">Full (all models)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend GPU detection:**
|
||||||
|
```python
|
||||||
|
# app/services/gpu_service.py
|
||||||
|
|
||||||
|
def detect_gpu() -> dict:
|
||||||
|
"""Detect available GPU for CUDA"""
|
||||||
|
result = {
|
||||||
|
"available": False,
|
||||||
|
"name": None,
|
||||||
|
"memory": None,
|
||||||
|
"cuda_version": None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cupy as cp
|
||||||
|
device = cp.cuda.Device(0)
|
||||||
|
props = cp.cuda.runtime.getDeviceProperties(0)
|
||||||
|
|
||||||
|
result["available"] = True
|
||||||
|
result["name"] = props["name"].decode()
|
||||||
|
result["memory"] = props["totalGlobalMem"] // (1024**3) # GB
|
||||||
|
result["cuda_version"] = cp.cuda.runtime.runtimeGetVersion()
|
||||||
|
except ImportError:
|
||||||
|
pass # CuPy not installed
|
||||||
|
except Exception as e:
|
||||||
|
print(f"GPU detection error: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.1.6: Build & Test (3-4 hours)
|
||||||
|
|
||||||
|
**Build script (scripts/build-windows.sh):**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== RFCP Windows Build ==="
|
||||||
|
|
||||||
|
# 1. Build frontend
|
||||||
|
echo "Building frontend..."
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. Build backend
|
||||||
|
echo "Building backend..."
|
||||||
|
cd backend
|
||||||
|
python -m venv build_env
|
||||||
|
source build_env/Scripts/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
pyinstaller rfcp-server.spec --clean
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 3. Build Electron
|
||||||
|
echo "Building Electron app..."
|
||||||
|
cd electron
|
||||||
|
npm install
|
||||||
|
npm run build:win
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "=== Build complete ==="
|
||||||
|
echo "Installer: electron/dist/RFCP Setup*.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test checklist:**
|
||||||
|
```markdown
|
||||||
|
## Install Test
|
||||||
|
- [ ] Installer runs without admin (user install)
|
||||||
|
- [ ] Installer runs with admin (program files)
|
||||||
|
- [ ] Desktop shortcut created
|
||||||
|
- [ ] Start menu entry created
|
||||||
|
- [ ] Uninstaller works
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
- [ ] Splash screen appears
|
||||||
|
- [ ] Backend starts successfully
|
||||||
|
- [ ] Main window loads
|
||||||
|
- [ ] Region selection dialog shows
|
||||||
|
- [ ] Can skip region download
|
||||||
|
|
||||||
|
## Functionality
|
||||||
|
- [ ] Map loads (online tiles)
|
||||||
|
- [ ] Can create sites
|
||||||
|
- [ ] Coverage calculation works
|
||||||
|
- [ ] All presets work
|
||||||
|
- [ ] Settings persist
|
||||||
|
|
||||||
|
## Offline
|
||||||
|
- [ ] Works without internet (after region download)
|
||||||
|
- [ ] Offline tiles load
|
||||||
|
- [ ] Terrain data works
|
||||||
|
- [ ] Buildings/roads cached
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- [ ] GPU toggle appears (if GPU present)
|
||||||
|
- [ ] GPU acceleration works
|
||||||
|
- [ ] Memory usage reasonable (<1GB idle)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Deliverables
|
||||||
|
|
||||||
|
1. **RFCP-Setup-1.0.0.exe** — Windows installer (~200MB)
|
||||||
|
2. **RFCP-1.0.0.AppImage** — Linux portable (~180MB)
|
||||||
|
3. **RFCP-1.0.0.deb** — Debian package (~180MB)
|
||||||
|
4. **SHA256SUMS** — Checksums file
|
||||||
|
5. **README.md** — Installation instructions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔜 Future Enhancements (2.2+)
|
||||||
|
|
||||||
|
- [ ] Auto-updater (electron-updater)
|
||||||
|
- [ ] macOS support (.dmg)
|
||||||
|
- [ ] Portable mode (no install, run from USB)
|
||||||
|
- [ ] Silent install for deployment
|
||||||
|
- [ ] MSI installer for enterprise
|
||||||
|
- [ ] Code signing (Windows/macOS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- SQLite замість MongoDB для локального зберігання (простіше, не потребує сервера)
|
||||||
|
- SRTM tiles ~25MB кожен, Україна потребує ~120 tiles = ~3GB
|
||||||
|
- OSM дані можна завантажити з Geofabrik (ukraine-latest.osm.pbf ~1.5GB)
|
||||||
|
- Map tiles можна кешувати з OpenStreetMap або використати MBTiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready for implementation after 1.5** 🚀
|
||||||
Reference in New Issue
Block a user