Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Welcome. This repo powers RETINA, a passive-radar system: a network of receiver
nodes detect aircraft by listening to reflections of broadcast transmitters
(bistatic radar), and the backend turns those detections into tracks and live
positions shown on a web map. It started life as "Tower Finder" (a tool to find
suitable broadcast illuminators near a receiver), which is still one of the
surfaces.
positions shown on a web map. It started life as "Tower Finder", a tool to find
suitable broadcast illuminators near a receiver; that feature now lives entirely
in tower-finder-service, both its API and its UI.

Read this top-to-bottom once; it should get you from a fresh clone to running
the whole thing locally and understanding how the pieces fit. For deeper dives,
Expand All @@ -21,7 +21,11 @@ One FastAPI backend serves several React front-ends, distinguished by subdomain:
| **testmap** | Live aircraft map fed by the simulation fleet (synthetic nodes) — the main dev/demo surface. `testmap.retina.fm` is served by the **staging** droplet, the only environment still running a fleet. |
| **map** | Production live map showing only real radar nodes. |
| **dashboard** | Admin app (auth required): node ownership, claim codes, MLAT verification, metrics. |
| **Tower Finder** | The original illuminator search. The SPA is here; the `/api/towers`, `/api/elevation` and `/api/config` API is **tower-finder-service** (separate repo and container), which nginx proxies to on every vhost. This backend no longer implements it. |

Illuminator search is deliberately absent from that table: **tower-finder-service**
(separate repo and container) owns the API and the UI both, and serves
`towers.retina.fm` from its own edge. Our vhosts only proxy `/api/towers`,
`/api/elevation` and `/api/config` to it.

Receiver nodes connect over TCP and stream detection frames. The pipeline
(tracker → geolocator) turns frames into aircraft positions, broadcast to the
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@

This repo powers RETINA, a passive-radar system: receiver nodes detect aircraft
from reflections of broadcast transmitters, and the backend turns those
detections into live tracks shown on a web map. "Tower Finder" — the original
illuminator-search feature — is one of several surfaces (along with the live map
and the admin dashboard), and is the one whose API now lives in its own service.
detections into live tracks shown on a web map. Its surfaces are the live map
and the admin dashboard.

> **New here?** Start with [`ONBOARDING.md`](ONBOARDING.md) for the full picture
> and local setup, and [`docs/architecture.md`](docs/architecture.md) for how the
> pieces fit together.

## Tower Finder feature
## Illuminator search

Web application that helps passive radar operators find suitable broadcast tower
illuminators near their location: given coordinates, it returns nearby FM/VHF/UHF
transmitters ranked by suitability for passive radar use.
Finding a suitable broadcast illuminator near a receiver is **tower-finder-service**,
a separate repo and container. It owns both halves: the API (`/api/towers`,
`/api/elevation`, `/api/config`) and the UI, which it serves itself on
`towers.retina.fm` through its own edge.

This repo owns the SPA (`frontend/`) and the nginx routing. The search API itself
(`/api/towers`, plus `/api/elevation` and `/api/config`) is served by
**tower-finder-service**, a separate repo and container that every vhost is
proxied to; the monolith's own copy of that stack was deleted once the proxy went
live, so there is one implementation and one ranking answer.
What remains here is the proxy seam. `api.retina.fm/towers` forwards to the
service for callers that want a clean public API name, and the other vhosts
still forward `/api/towers`, `/api/elevation` and `/api/config` so that a
request arriving at one of them reaches the single implementation rather than a
404 from this backend.

## Project Structure

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ only by subdomain, resolved client-side in `frontend/src/utils/domains.ts`:
staging and local stacks run a fleet, so `testmap.retina.fm` is served by the
staging droplet rather than production.
- **map** (`map.retina.fm`) — production live map, real radar nodes only.
- **Tower Finder** — `/api/towers` illuminator search (the original feature).
The SPA is this repo's; the API is proxied to tower-finder-service, which owns
the only implementation since the monolith's copy was deleted.
- **Illuminator search** — not a surface of this repo. tower-finder-service owns
both the API and the UI, and serves `towers.retina.fm` from its own edge. The
vhosts here proxy `/api/towers`, `/api/elevation` and `/api/config` to it.
- **dashboard** (`dashboard/`, separate SPA) — admin: node ownership, claim
codes, MLAT verification, metrics. Auth required.

Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/live-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ async function rowsOrSkip(page: Page) {
test.describe("Live Map — page identity", () => {
test("page title contains RETINA", async ({ page }) => {
await page.goto(BASE);
// The HTML <title> is static "Tower Finder" for all domains;
// the domain identity is exposed in the h1 element instead.
// Asserted on the h1 rather than the HTML <title>, which is static across
// every domain this bundle serves.
await expect(page.locator("h1")).toContainText(/RETINA/i);
});

Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tower Finder</title>
<title>RETINA</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Expand Down
147 changes: 2 additions & 145 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,13 @@ body {
overflow-y: auto;
}

/* The Physics tab is a light surface, like the rest of the map chrome. */
.app-body.physics-active {
background: #060d18;
background: var(--bg-primary);
max-width: none;
padding: 0;
}

/* The Physics tab only renders on map hostnames, and there it is a light
surface like the rest of the map chrome. The dark rule above stays for the
Tower Finder build, which never reaches this tab. */
.app.map-surface .app-body.physics-active {
background: var(--bg-primary);
}

.app-body.live-active {
padding: 0;
max-width: none;
Expand All @@ -115,148 +109,11 @@ body {
min-height: 0;
}

.top-section {
display: grid;
grid-template-columns: 360px 1fr;
gap: 20px;
margin-bottom: 24px;
}

@media (max-width: 900px) {
.top-section {
grid-template-columns: 1fr;
}
.app-body {
padding: 16px;
}
.app-header {
padding: 14px 16px;
}
}

/* ---- loading ---- */
.loading-section {
margin-bottom: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}

.spinner {
width: 36px;
height: 36px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

.loading-bar {
width: 100%;
max-width: 360px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}

.loading-bar-inner {
height: 100%;
width: 35%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 2px;
animation: slide 1.4s ease-in-out infinite;
}

@keyframes slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(380%); }
}

.loading-text {
color: #6b7280;
font-size: 0.85rem;
text-align: center;
}

/* ---- summary stats ---- */
.summary-strip {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}

.stat-card {
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
min-width: 120px;
}

.stat-card .stat-value {
font-size: 1.35rem;
font-weight: 700;
color: #1a1a2e;
line-height: 1.2;
}

.stat-card .stat-label {
font-size: 0.72rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
margin-top: 2px;
}

/* ---- error ---- */
.error-banner {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 20px;
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 10px;
}

.error-banner::before {
content: "!";
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #dc2626;
color: #fff;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}

.no-results {
text-align: center;
color: #9ca3af;
padding: 48px 20px;
font-size: 0.95rem;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
Loading
Loading