Add prebuilt frontend
@@ -25,10 +25,11 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js LTS or current (20, 22, 24, 25)
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
Node.js is not required to run the app when using the bundled prebuilt frontend. You only need Node>=20 + recent NPM if you want to develop the frontend or rebuild the web UI from source.
|
||||
|
||||
<details>
|
||||
<summary>Finding your serial port</summary>
|
||||
|
||||
@@ -76,9 +77,6 @@ cd Remote-Terminal-for-MeshCore
|
||||
# Install backend dependencies
|
||||
uv sync
|
||||
|
||||
# Build frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
@@ -101,16 +99,16 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at http://localhost:8000
|
||||
Access at http://localhost:8000.
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
>
|
||||
> If you're running from a source checkout and want to rebuild the UI yourself, install Node.js and run `cd frontend && npm install && npm run build`. The backend prefers `frontend/dist` when present and otherwise falls back to the bundled prebuilt frontend.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
|
||||
```bash
|
||||
@@ -177,7 +175,7 @@ uv run uvicorn app.main:app --reload
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
|
||||
npm run build # Production build to dist/
|
||||
npm run build # Production build to dist/ (used preferentially) and prebuilt/ (bundled to save low-memory friends from needing to build)
|
||||
```
|
||||
|
||||
Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
@@ -207,7 +205,7 @@ cd frontend
|
||||
npm run lint:fix # esLint + auto-fix
|
||||
npm run test:run # run tests
|
||||
npm run format # prettier (always writes)
|
||||
npm run build # build the frontend
|
||||
npm run build # build frontend/dist and frontend/prebuilt
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -298,11 +296,6 @@ cd /opt/remoteterm
|
||||
sudo -u remoteterm uv venv
|
||||
sudo -u remoteterm uv sync
|
||||
|
||||
# Build frontend (required for the backend to serve the web UI)
|
||||
cd /opt/remoteterm/frontend
|
||||
sudo -u remoteterm npm install
|
||||
sudo -u remoteterm npm run build
|
||||
|
||||
# Install and start service
|
||||
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
@@ -314,6 +307,8 @@ sudo journalctl -u remoteterm -f
|
||||
```
|
||||
|
||||
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
|
||||
|
||||
If you are deploying from a source checkout that does not include the bundled prebuilt frontend, install Node.js and run `cd /opt/remoteterm/frontend && sudo -u remoteterm npm install && sudo -u remoteterm npm run build` before starting the service.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
FRONTEND_BUILD_INSTRUCTIONS = "Run 'cd frontend && npm install && npm run build'."
|
||||
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
@@ -48,40 +49,38 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available.
|
||||
|
||||
Returns True when routes are registered, False when frontend files are
|
||||
missing/incomplete. Missing frontend files are logged but are not fatal.
|
||||
"""
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
"""Resolve and validate a built frontend directory."""
|
||||
frontend_dir = frontend_dir.resolve()
|
||||
index_file = frontend_dir / "index.html"
|
||||
assets_dir = frontend_dir / "assets"
|
||||
|
||||
if not frontend_dir.exists():
|
||||
logger.error(
|
||||
"Frontend build directory not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
if log_failures:
|
||||
logger.error("Frontend build directory not found at %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
if not frontend_dir.is_dir():
|
||||
logger.error(
|
||||
"Frontend build path is not a directory: %s. "
|
||||
"API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
if log_failures:
|
||||
logger.error("Frontend build path is not a directory: %s.", frontend_dir)
|
||||
return False, frontend_dir
|
||||
|
||||
if not index_file.exists():
|
||||
logger.error(
|
||||
"Frontend index file not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
index_file,
|
||||
)
|
||||
if log_failures:
|
||||
logger.error("Frontend index file not found at %s.", index_file)
|
||||
return False, frontend_dir
|
||||
|
||||
return True, frontend_dir
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available."""
|
||||
valid, frontend_dir = _validate_frontend_dir(frontend_dir)
|
||||
if not valid:
|
||||
return False
|
||||
|
||||
index_file = frontend_dir / "index.html"
|
||||
assets_dir = frontend_dir / "assets"
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount(
|
||||
"/assets",
|
||||
@@ -157,6 +156,30 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def register_first_available_frontend_static_routes(
|
||||
app: FastAPI, frontend_dirs: list[Path]
|
||||
) -> Path | None:
|
||||
"""Register frontend routes from the first valid build directory."""
|
||||
for i, candidate in enumerate(frontend_dirs):
|
||||
valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
|
||||
if not valid:
|
||||
continue
|
||||
|
||||
if register_frontend_static_routes(app, resolved_candidate):
|
||||
logger.info("Selected frontend build directory %s", resolved_candidate)
|
||||
return resolved_candidate
|
||||
|
||||
if i < len(frontend_dirs) - 1:
|
||||
logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
|
||||
|
||||
logger.error(
|
||||
"No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
|
||||
", ".join(str(path.resolve()) for path in frontend_dirs),
|
||||
FRONTEND_BUILD_INSTRUCTIONS,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
"""Register a fallback route that tells the user to build the frontend."""
|
||||
|
||||
@@ -164,7 +187,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,10 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import settings as server_settings
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.frontend_static import (
|
||||
register_first_available_frontend_static_routes,
|
||||
register_frontend_missing_fallback,
|
||||
)
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
@@ -151,6 +154,9 @@ app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if not register_frontend_static_routes(app, FRONTEND_DIR):
|
||||
FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
|
||||
if not register_first_available_frontend_static_routes(
|
||||
app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
|
||||
):
|
||||
register_frontend_missing_fallback(app)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc && vite build && vite build --outDir prebuilt",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,2 @@
|
||||
import{r as d,D as j,j as e,U as y,m as N}from"./index-BJ3L5G2s.js";import{M as w,T as _}from"./leaflet-TNk2YzRY.js";import{C as k,P as C}from"./Popup-JtfOLvZE.js";import{u as M}from"./hooks-NBTgSuxX.js";const i={recent:"#06b6d4",today:"#2563eb",stale:"#f59e0b",old:"#64748b"},R="#0f172a",p="#f8fafc";function E(a){if(a==null)return i.old;const r=Date.now()/1e3-a,n=3600,l=86400;return r<n?i.recent:r<l?i.today:r<3*l?i.stale:i.old}function v({contacts:a,focusedContact:t}){const r=M(),[n,l]=d.useState(!1);return d.useEffect(()=>{if(t&&t.lat!=null&&t.lon!=null){r.setView([t.lat,t.lon],12),l(!0);return}if(n)return;const c=()=>{if(a.length===0){r.setView([20,0],2),l(!0);return}if(a.length===1){r.setView([a[0].lat,a[0].lon],10),l(!0);return}const u=a.map(m=>[m.lat,m.lon]);r.fitBounds(u,{padding:[50,50],maxZoom:12}),l(!0)};"geolocation"in navigator?navigator.geolocation.getCurrentPosition(u=>{r.setView([u.coords.latitude,u.coords.longitude],8),l(!0)},()=>{c()},{timeout:5e3,maximumAge:3e5}):c()},[r,a,n,t]),null}function V({contacts:a,focusedKey:t}){const r=Date.now()/1e3-604800,n=d.useMemo(()=>a.filter(s=>j(s.lat,s.lon)&&(s.public_key===t||s.last_seen!=null&&s.last_seen>r)),[a,t,r]),l=d.useMemo(()=>t&&n.find(s=>s.public_key===t)||null,[t,n]),c=l!=null&&(l.last_seen==null||l.last_seen<=r),u=d.useRef({}),m=d.useCallback((s,o)=>{u.current[s]=o},[]);return d.useEffect(()=>{if(l&&u.current[l.public_key]){const s=setTimeout(()=>{var o;(o=u.current[l.public_key])==null||o.openPopup()},100);return()=>clearTimeout(s)}},[l]),e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsxs("div",{className:"px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between",children:[e.jsxs("span",{children:["Showing ",n.length," contact",n.length!==1?"s":""," heard in the last 7 days",c?" plus the focused contact":""]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.recent},"aria-hidden":"true"})," ","<1h"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.today},"aria-hidden":"true"})," ","<1d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.stale},"aria-hidden":"true"})," ","<3d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.old},"aria-hidden":"true"})," ","older"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full border-2",style:{borderColor:p,backgroundColor:i.today},"aria-hidden":"true"})," ","repeater"]})]})]}),e.jsx("div",{className:"flex-1 relative",style:{zIndex:0},role:"img","aria-label":"Map showing mesh node locations",children:e.jsxs(w,{center:[20,0],zoom:2,className:"h-full w-full",style:{background:"#1a1a2e"},children:[e.jsx(_,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),e.jsx(v,{contacts:n,focusedContact:l}),n.map(s=>{const o=s.type===y,x=E(s.last_seen),f=s.name||s.public_key.slice(0,12),h=s.last_seen!=null?N(s.last_seen):"Never heard by this server",g=o?10:7;return e.jsx(d.Fragment,{children:e.jsx(k,{ref:b=>m(s.public_key,b),center:[s.lat,s.lon],radius:g,pathOptions:{color:o?p:R,fillColor:x,fillOpacity:.9,weight:o?3:2},children:e.jsx(C,{children:e.jsxs("div",{className:"text-sm",children:[e.jsxs("div",{className:"font-medium flex items-center gap-1",children:[o&&e.jsx("span",{title:"Repeater","aria-hidden":"true",children:"🛜"}),f]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-1",children:["Last heard: ",h]}),e.jsxs("div",{className:"text-xs text-gray-400 mt-1 font-mono",children:[s.lat.toFixed(5),", ",s.lon.toFixed(5)]})]})})},s.public_key)},s.public_key)})]})})]})}export{V as MapView};
|
||||
//# sourceMappingURL=MapView-DG71Do9A.js.map
|
||||
@@ -0,0 +1,2 @@
|
||||
import{j as l}from"./index-BJ3L5G2s.js";import{d as m,l as u,a as f,e as d,M as x,T as y}from"./leaflet-TNk2YzRY.js";import{C as p,P as c}from"./Popup-JtfOLvZE.js";const g=m(function({positions:n,...t},o){const s=new u.Polyline(n,t);return f(s,d(o,{overlayContainer:s}))},function(n,t,o){t.positions!==o.positions&&n.setLatLngs(t.positions)});function C({neighbors:i,radioLat:n,radioLon:t,radioName:o}){const s=i.filter(e=>e.lat!=null&&e.lon!=null),r=n!=null&&t!=null&&!(n===0&&t===0);if(s.length===0&&!r)return null;const h=r?[n,t]:[s[0].lat,s[0].lon];return l.jsx("div",{className:"min-h-48 flex-1 rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing repeater neighbor locations",children:l.jsxs(x,{center:h,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[l.jsx(y,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),r&&s.map((e,a)=>l.jsx(g,{positions:[[n,t],[e.lat,e.lon]],pathOptions:{color:"#3b82f6",weight:1.5,opacity:.5,dashArray:"6 4"}},`line-${a}`)),r&&l.jsx(p,{center:[n,t],radius:8,pathOptions:{color:"#1d4ed8",fillColor:"#3b82f6",fillOpacity:1,weight:2},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm font-medium",children:o||"Our Radio"})})}),s.map((e,a)=>l.jsx(p,{center:[e.lat,e.lon],radius:6,pathOptions:{color:"#000",fillColor:e.snr>=6?"#22c55e":e.snr>=0?"#eab308":"#ef4444",fillOpacity:.8,weight:1},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm",children:e.name||e.pubkey_prefix})})},a))]})})}export{C as NeighborsMiniMap};
|
||||
//# sourceMappingURL=NeighborsMiniMap-D6bvxuvI.js.map
|
||||
@@ -0,0 +1,9 @@
|
||||
import{r as x,D as l,j as i}from"./index-BJ3L5G2s.js";import{c as O,l as b,a as y,e as w,b as C,M as L,T as M,L as R}from"./leaflet-TNk2YzRY.js";import{u as T}from"./hooks-NBTgSuxX.js";const h=O(function({position:n,...t},o){const r=new b.Marker(n,t);return y(r,w(o,{overlayContainer:r}))},function(n,t,o){t.position!==o.position&&n.setLatLng(t.position),t.icon!=null&&t.icon!==o.icon&&n.setIcon(t.icon),t.zIndexOffset!=null&&t.zIndexOffset!==o.zIndexOffset&&n.setZIndexOffset(t.zIndexOffset),t.opacity!=null&&t.opacity!==o.opacity&&n.setOpacity(t.opacity),n.dragging!=null&&t.draggable!==o.draggable&&(t.draggable===!0?n.dragging.enable():n.dragging.disable())}),p=C(function(n,t){const o=new b.Tooltip(n,t.overlayContainer);return y(o,t)},function(n,t,{position:o},r){x.useEffect(function(){const s=t.overlayContainer;if(s==null)return;const{instance:u}=n,f=a=>{a.tooltip===u&&(o!=null&&u.setLatLng(o),u.update(),r(!0))},c=a=>{a.tooltip===u&&r(!1)};return s.on({tooltipopen:f,tooltipclose:c}),s.bindTooltip(u),function(){s.off({tooltipopen:f,tooltipclose:c}),s._map!=null&&s.unbindTooltip()}},[n,t,r,o])}),m=["#f97316","#eab308","#22c55e","#06b6d4","#ec4899","#f43f5e","#a855f7","#64748b"],E="#3b82f6",S="#8b5cf6";function g(e,n){return R.divIcon({className:"",iconSize:[24,24],iconAnchor:[12,12],html:`<div style="
|
||||
width:24px;height:24px;border-radius:50%;
|
||||
background:${n};color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:11px;font-weight:700;
|
||||
border:2px solid rgba(255,255,255,0.8);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.4);
|
||||
">${e}</div>`})}function z(e){return m[e%m.length]}function N(e){const n=[];l(e.sender.lat,e.sender.lon)&&n.push([e.sender.lat,e.sender.lon]);for(const t of e.hops)for(const o of t.matches)l(o.lat,o.lon)&&n.push([o.lat,o.lon]);return l(e.receiver.lat,e.receiver.lon)&&n.push([e.receiver.lat,e.receiver.lon]),n}function I({points:e}){const n=T(),t=x.useRef(!1);return x.useEffect(()=>{t.current||e.length===0||(t.current=!0,e.length===1?n.setView(e[0],12):n.fitBounds(e,{padding:[30,30],maxZoom:14}))},[n,e]),null}function k({resolved:e,senderInfo:n}){const t=N(e),o=t.length>0;let r=2,d=0;l(e.sender.lat,e.sender.lon)&&d++,l(e.receiver.lat,e.receiver.lon)&&d++;for(const f of e.hops)f.matches.length===0?r++:(r+=f.matches.length,d+=f.matches.filter(c=>l(c.lat,c.lon)).length);const s=o&&d<r;if(!o)return i.jsx("div",{className:"h-14 rounded border border-border bg-muted/30 flex items-center justify-center text-sm text-muted-foreground",children:"No nodes in this route have GPS coordinates"});const u=t[0];return i.jsxs("div",{children:[i.jsx("div",{className:"rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing message route between nodes",style:{height:220},children:i.jsxs(L,{center:u,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[i.jsx(M,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),i.jsx(I,{points:t}),l(e.sender.lat,e.sender.lon)&&i.jsx(h,{position:[e.sender.lat,e.sender.lon],icon:g("S",E),children:i.jsx(p,{direction:"top",offset:[0,-14],children:n.name||"Sender"})}),e.hops.map((f,c)=>f.matches.filter(a=>l(a.lat,a.lon)).map((a,j)=>i.jsx(h,{position:[a.lat,a.lon],icon:g(String(c+1),z(c)),children:i.jsx(p,{direction:"top",offset:[0,-14],children:a.name||a.public_key.slice(0,12)})},`hop-${c}-${j}`))),l(e.receiver.lat,e.receiver.lon)&&i.jsx(h,{position:[e.receiver.lat,e.receiver.lon],icon:g("R",S),children:i.jsx(p,{direction:"top",offset:[0,-14],children:e.receiver.name||"Receiver"})})]})}),s&&i.jsx("p",{className:"text-xs text-muted-foreground mt-1",children:"Some nodes in this route have no GPS and are not shown"})]})}export{k as PathRouteMap};
|
||||
//# sourceMappingURL=PathRouteMap-DiGw6Qka.js.map
|
||||
@@ -0,0 +1,2 @@
|
||||
import{d,l as f,a as s,e as C,b as m}from"./leaflet-TNk2YzRY.js";import{r as P}from"./index-BJ3L5G2s.js";function v(o,n,e){n.center!==e.center&&o.setLatLng(n.center),n.radius!=null&&n.radius!==e.radius&&o.setRadius(n.radius)}const b=d(function({center:n,children:e,...r},p){const t=new f.CircleMarker(n,r);return s(t,C(p,{overlayContainer:t}))},v),k=m(function(n,e){const r=new f.Popup(n,e.overlayContainer);return s(r,e)},function(n,e,{position:r},p){P.useEffect(function(){const{instance:a}=n;function i(u){u.popup===a&&(a.update(),p(!0))}function c(u){u.popup===a&&p(!1)}return e.map.on({popupopen:i,popupclose:c}),e.overlayContainer==null?(r!=null&&a.setLatLng(r),a.openOn(e.map)):e.overlayContainer.bindPopup(a),function(){var l;e.map.off({popupopen:i,popupclose:c}),(l=e.overlayContainer)==null||l.unbindPopup(),e.map.removeLayer(a)}},[n,e,p,r])});export{b as C,k as P};
|
||||
//# sourceMappingURL=Popup-JtfOLvZE.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"Popup-JtfOLvZE.js","sources":["../../node_modules/@react-leaflet/core/lib/circle.js","../../node_modules/react-leaflet/lib/CircleMarker.js","../../node_modules/react-leaflet/lib/Popup.js"],"sourcesContent":["export function updateCircle(layer, props, prevProps) {\n if (props.center !== prevProps.center) {\n layer.setLatLng(props.center);\n }\n if (props.radius != null && props.radius !== prevProps.radius) {\n layer.setRadius(props.radius);\n }\n}\n","import { createElementObject, createPathComponent, extendContext, updateCircle } from '@react-leaflet/core';\nimport { CircleMarker as LeafletCircleMarker } from 'leaflet';\nexport const CircleMarker = createPathComponent(function createCircleMarker({ center , children: _c , ...options }, ctx) {\n const marker = new LeafletCircleMarker(center, options);\n return createElementObject(marker, extendContext(ctx, {\n overlayContainer: marker\n }));\n}, updateCircle);\n","import { createElementObject, createOverlayComponent } from '@react-leaflet/core';\nimport { Popup as LeafletPopup } from 'leaflet';\nimport { useEffect } from 'react';\nexport const Popup = createOverlayComponent(function createPopup(props, context) {\n const popup = new LeafletPopup(props, context.overlayContainer);\n return createElementObject(popup, context);\n}, function usePopupLifecycle(element, context, { position }, setOpen) {\n useEffect(function addPopup() {\n const { instance } = element;\n function onPopupOpen(event) {\n if (event.popup === instance) {\n instance.update();\n setOpen(true);\n }\n }\n function onPopupClose(event) {\n if (event.popup === instance) {\n setOpen(false);\n }\n }\n context.map.on({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n if (context.overlayContainer == null) {\n // Attach to a Map\n if (position != null) {\n instance.setLatLng(position);\n }\n instance.openOn(context.map);\n } else {\n // Attach to container component\n context.overlayContainer.bindPopup(instance);\n }\n return function removePopup() {\n context.map.off({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n context.overlayContainer?.unbindPopup();\n context.map.removeLayer(instance);\n };\n }, [\n element,\n context,\n setOpen,\n position\n ]);\n});\n"],"names":["updateCircle","layer","props","prevProps","CircleMarker","createPathComponent","center","_c","options","ctx","marker","LeafletCircleMarker","createElementObject","extendContext","Popup","createOverlayComponent","context","popup","LeafletPopup","element","position","setOpen","useEffect","instance","onPopupOpen","event","onPopupClose","_a"],"mappings":"yGAAO,SAASA,EAAaC,EAAOC,EAAOC,EAAW,CAC9CD,EAAM,SAAWC,EAAU,QAC3BF,EAAM,UAAUC,EAAM,MAAM,EAE5BA,EAAM,QAAU,MAAQA,EAAM,SAAWC,EAAU,QACnDF,EAAM,UAAUC,EAAM,MAAM,CAEpC,CCLY,MAACE,EAAeC,EAAoB,SAA4B,CAAE,OAAAC,EAAS,SAAUC,EAAK,GAAGC,CAAO,EAAIC,EAAK,CACrH,MAAMC,EAAS,IAAIC,eAAoBL,EAAQE,CAAO,EACtD,OAAOI,EAAoBF,EAAQG,EAAcJ,EAAK,CAClD,iBAAkBC,CAC1B,CAAK,CAAC,CACN,EAAGV,CAAY,ECJFc,EAAQC,EAAuB,SAAqBb,EAAOc,EAAS,CAC7E,MAAMC,EAAQ,IAAIC,EAAAA,MAAahB,EAAOc,EAAQ,gBAAgB,EAC9D,OAAOJ,EAAoBK,EAAOD,CAAO,CAC7C,EAAG,SAA2BG,EAASH,EAAS,CAAE,SAAAI,CAAQ,EAAKC,EAAS,CACpEC,EAAAA,UAAU,UAAoB,CAC1B,KAAM,CAAE,SAAAC,CAAQ,EAAMJ,EACtB,SAASK,EAAYC,EAAO,CACpBA,EAAM,QAAUF,IAChBA,EAAS,OAAM,EACfF,EAAQ,EAAI,EAEpB,CACA,SAASK,EAAaD,EAAO,CACrBA,EAAM,QAAUF,GAChBF,EAAQ,EAAK,CAErB,CACA,OAAAL,EAAQ,IAAI,GAAG,CACX,UAAWQ,EACX,WAAYE,CACxB,CAAS,EACGV,EAAQ,kBAAoB,MAExBI,GAAY,MACZG,EAAS,UAAUH,CAAQ,EAE/BG,EAAS,OAAOP,EAAQ,GAAG,GAG3BA,EAAQ,iBAAiB,UAAUO,CAAQ,EAExC,UAAuB,OAC1BP,EAAQ,IAAI,IAAI,CACZ,UAAWQ,EACX,WAAYE,CAC5B,CAAa,GACDC,EAAAX,EAAQ,mBAAR,MAAAW,EAA0B,cAC1BX,EAAQ,IAAI,YAAYO,CAAQ,CACpC,CACJ,EAAG,CACCJ,EACAH,EACAK,EACAD,CACR,CAAK,CACL,CAAC","x_google_ignoreList":[0,1,2]}
|
||||
@@ -0,0 +1,2 @@
|
||||
import"./index-BJ3L5G2s.js";import{u as t}from"./leaflet-TNk2YzRY.js";function r(){return t().map}export{r as u};
|
||||
//# sourceMappingURL=hooks-NBTgSuxX.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"hooks-NBTgSuxX.js","sources":["../../node_modules/react-leaflet/lib/hooks.js"],"sourcesContent":["import { useLeafletContext } from '@react-leaflet/core';\nimport { useEffect } from 'react';\nexport function useMap() {\n return useLeafletContext().map;\n}\nexport function useMapEvent(type, handler) {\n const map = useMap();\n useEffect(function addMapEventHandler() {\n // @ts-ignore event type\n map.on(type, handler);\n return function removeMapEventHandler() {\n // @ts-ignore event type\n map.off(type, handler);\n };\n }, [\n map,\n type,\n handler\n ]);\n return map;\n}\nexport function useMapEvents(handlers) {\n const map = useMap();\n useEffect(function addMapEventHandlers() {\n map.on(handlers);\n return function removeMapEventHandlers() {\n map.off(handlers);\n };\n }, [\n map,\n handlers\n ]);\n return map;\n}\n"],"names":["useMap","useLeafletContext"],"mappings":"sEAEO,SAASA,GAAS,CACrB,OAAOC,EAAiB,EAAG,GAC/B","x_google_ignoreList":[0]}
|
||||
@@ -0,0 +1,2 @@
|
||||
import{r as s,j as l,Q as f,l as u}from"./index-BJ3L5G2s.js";var N=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],h=N.reduce((a,r)=>{const t=f(`Primitive.${r}`),o=s.forwardRef((i,e)=>{const{asChild:p,...n}=i,m=p?t:r;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),l.jsx(m,{...n,ref:e})});return o.displayName=`Primitive.${r}`,{...a,[r]:o}},{}),x="Separator",c="horizontal",w=["horizontal","vertical"],d=s.forwardRef((a,r)=>{const{decorative:t,orientation:o=c,...i}=a,e=S(o)?o:c,n=t?{role:"none"}:{"aria-orientation":e==="vertical"?e:void 0,role:"separator"};return l.jsx(h.div,{"data-orientation":e,...n,...i,ref:r})});d.displayName=x;function S(a){return w.includes(a)}var v=d;const O=s.forwardRef(({className:a,orientation:r="horizontal",decorative:t=!0,...o},i)=>l.jsx(v,{ref:i,decorative:t,orientation:r,className:u("shrink-0 bg-border",r==="horizontal"?"h-[1px] w-full":"h-full w-[1px]",a),...o}));O.displayName=v.displayName;export{O as S};
|
||||
//# sourceMappingURL=separator-ChUIbJIJ.js.map
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512pt" height="512pt" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m455.68 85.902c-31.289 0-56.32 25.031-56.32 56.32 0 11.379 3.4141 21.617 8.5352 30.152l-106.38 135.39c12.516 6.2578 23.895 15.359 32.996 25.602l107.52-136.54c4.5508 1.1367 9.1016 1.707 13.652 1.707 31.289 0 56.32-25.031 56.32-56.32 0-30.719-25.031-56.32-56.32-56.32z"/>
|
||||
<path d="m256 343.04c-5.6875 0-10.809 0.57031-15.93 2.2773l-106.38-135.96c-9.1016 10.809-20.48 19.344-32.996 25.602l106.38 135.96c-5.1211 8.5352-7.3945 18.203-7.3945 28.445 0 31.289 25.031 56.32 56.32 56.32s56.32-25.031 56.32-56.32c0-31.293-25.031-56.324-56.32-56.324z"/>
|
||||
<path d="m356.69 114.91c3.9805-13.652 10.238-26.738 19.344-37.547-38.113-13.652-78.508-21.047-120.04-21.047-59.164 0-115.48 14.789-166.12 42.668-9.1016-6.8281-21.051-10.809-33.562-10.809-31.289-0.57031-56.32 25.027-56.32 55.75 0 31.289 25.031 56.32 56.32 56.32 31.289 0 56.32-25.031 56.32-56.32 0-3.4141-0.57031-6.8281-1.1367-9.6719 44.371-23.895 93.297-36.41 144.5-36.41 34.703 0 68.836 5.6914 100.69 17.066z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MCTerm" />
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
// fetches before it discovers and starts downloading the JS bundle.
|
||||
// React hooks consume the promises via window.__prefetch.
|
||||
var fetchJsonOrThrow = function(url) {
|
||||
return fetch(url).then(function(response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text().then(function(rawError) {
|
||||
var message = rawError || response.statusText || ('HTTP ' + response.status);
|
||||
try {
|
||||
var parsed = JSON.parse(rawError);
|
||||
if (parsed && typeof parsed.detail === 'string' && parsed.detail.length > 0) {
|
||||
message = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// Keep raw text fallback when body is not JSON.
|
||||
}
|
||||
throw new Error('Prefetch failed for ' + url + ': ' + message);
|
||||
});
|
||||
}).catch(function(err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BJ3L5G2s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Kkb-Bio9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -5,8 +5,10 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend_static import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
FRONTEND_BUILD_INSTRUCTIONS,
|
||||
INDEX_CACHE_CONTROL,
|
||||
STATIC_FILE_CACHE_CONTROL,
|
||||
register_first_available_frontend_static_routes,
|
||||
register_frontend_missing_fallback,
|
||||
register_frontend_static_routes,
|
||||
)
|
||||
@@ -28,8 +30,7 @@ def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 404
|
||||
assert "npm install" in resp.json()["detail"]
|
||||
assert "npm run build" in resp.json()["detail"]
|
||||
assert FRONTEND_BUILD_INSTRUCTIONS in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_missing_index_logs_error_and_skips_frontend_routes(tmp_path, caplog):
|
||||
@@ -120,3 +121,41 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
assert data["start_url"] == "https://mesh.example.com:8443/"
|
||||
assert data["scope"] == "https://mesh.example.com:8443/"
|
||||
assert data["id"] == "https://mesh.example.com:8443/"
|
||||
|
||||
|
||||
def test_first_available_prefers_dist_over_prebuilt(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
dist_dir = frontend_dir / "dist"
|
||||
prebuilt_dir = frontend_dir / "prebuilt"
|
||||
dist_dir.mkdir(parents=True)
|
||||
prebuilt_dir.mkdir(parents=True)
|
||||
(dist_dir / "index.html").write_text("<html><body>dist</body></html>")
|
||||
(prebuilt_dir / "index.html").write_text("<html><body>prebuilt</body></html>")
|
||||
|
||||
selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
|
||||
|
||||
assert selected == dist_dir.resolve()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "dist" in response.text
|
||||
|
||||
|
||||
def test_first_available_uses_prebuilt_when_dist_missing(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
dist_dir = frontend_dir / "dist"
|
||||
prebuilt_dir = frontend_dir / "prebuilt"
|
||||
prebuilt_dir.mkdir(parents=True)
|
||||
(prebuilt_dir / "index.html").write_text("<html><body>prebuilt</body></html>")
|
||||
|
||||
selected = register_first_available_frontend_static_routes(app, [dist_dir, prebuilt_dir])
|
||||
|
||||
assert selected == prebuilt_dir.resolve()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "prebuilt" in response.text
|
||||
|
||||