168 lines
7.7 KiB
Markdown
168 lines
7.7 KiB
Markdown
# Local Testing — Serving SPQ Builds
|
|
|
|
Two approaches depending on which version you're testing.
|
|
|
|
## v1: Vanilla JS (standalone HTML files)
|
|
|
|
Served from `/mnt/seagate8tb/Websites/ShopProQuote/` (no build step, just HTML+JS+CSS).
|
|
|
|
```bash
|
|
cd /mnt/seagate8tb/Websites/ShopProQuote
|
|
python3 -m http.server 4173 --bind 0.0.0.0
|
|
```
|
|
|
|
## v2: React SPA (built with Vite)
|
|
|
|
Served from `/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist/`. **Plain http.server breaks login** because the built frontend connects to PocketBase at `/pb` (relative path). The simple server returns 404 for `/pb/*` requests. You need a Python reverse proxy that:
|
|
|
|
1. Serves static files from `dist/`
|
|
2. Has SPA fallback (all non-file routes → `index.html`)
|
|
3. Proxies `/pb/*` → PocketBase at `127.0.0.1:8091/api/*`
|
|
4. Sends CORS headers on every response (including OPTIONS preflight)
|
|
|
|
### Template: `/tmp/spq-backup-server.py`
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Serve SPQ dist + proxy /pb to PocketBase with CORS."""
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
import urllib.request
|
|
import os
|
|
|
|
DIST = '/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist'
|
|
PB = 'http://127.0.0.1:8091'
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=DIST, **kwargs)
|
|
|
|
def end_headers(self):
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
|
|
super().end_headers()
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'):
|
|
self.proxy()
|
|
else:
|
|
full = os.path.join(DIST, self.path.lstrip('/'))
|
|
if not os.path.exists(full) or (os.path.isdir(full) and self.path != '/'):
|
|
self.path = '/index.html'
|
|
super().do_GET()
|
|
|
|
def do_POST(self):
|
|
if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'):
|
|
self.proxy()
|
|
else:
|
|
self.send_error(405)
|
|
|
|
def do_PUT(self): self.proxy()
|
|
def do_PATCH(self): self.proxy()
|
|
def do_DELETE(self): self.proxy()
|
|
|
|
def proxy(self):
|
|
# PB SDK calls /pb/api/collections/... — path already includes /api/
|
|
# DeepSeek calls go to api.deepseek.com
|
|
if self.path.startswith('/deepseek'):
|
|
url = 'https://api.deepseek.com' + self.path[len('/deepseek'):]
|
|
else:
|
|
url = PB + self.path
|
|
if self.path.startswith('/pb'):
|
|
url = PB + self.path[3:] # /pb/api/... → /api/... on PB
|
|
if not url.startswith(PB + '/api'):
|
|
url = PB + '/api' + self.path[3:]
|
|
body = None
|
|
if self.headers.get('Content-Length'):
|
|
body = self.rfile.read(int(self.headers['Content-Length']))
|
|
req = urllib.request.Request(url, data=body, method=self.command)
|
|
for k, v in self.headers.items():
|
|
if k.lower() not in ('host', 'connection', 'origin', 'referer'):
|
|
req.add_header(k, v)
|
|
# Add DeepSeek API key
|
|
if self.path.startswith('/deepseek'):
|
|
with open('/tmp/deepseek_key.txt') as f:
|
|
req.add_header('Authorization', f'Bearer {f.read().strip()}')
|
|
try:
|
|
resp = urllib.request.urlopen(req)
|
|
self.send_response(resp.status)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ('transfer-encoding', 'connection'):
|
|
self.send_header(k, v)
|
|
self.end_headers()
|
|
self.wfile.write(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
self.send_response(e.code)
|
|
self.end_headers()
|
|
self.wfile.write(e.read())
|
|
|
|
if __name__ == '__main__':
|
|
server = HTTPServer(('0.0.0.0', 4173), Handler)
|
|
print('Serving SPQ on http://0.0.0.0:4173 (PB proxy → 8091)')
|
|
server.serve_forever()
|
|
```
|
|
|
|
### Usage
|
|
|
|
**IMPORTANT**: The proxy also routes `/deepseek/*` → `https://api.deepseek.com/` using the API key from `/tmp/deepseek_key.txt`. The key is extracted from `/etc/nginx/sites-enabled/shopproquote` on startup. Without this, AI Write, Generate Priorities, and AI Suggest all fail silently.
|
|
|
|
```bash
|
|
# Extract API key and start proxy
|
|
python3 -c "
|
|
import re
|
|
with open('/etc/nginx/sites-enabled/shopproquote') as f:
|
|
m = re.search(r'Bearer\s+(sk-\S+)', f.read())
|
|
if m:
|
|
with open('/tmp/deepseek_key.txt','w') as out:
|
|
out.write(m.group(1).rstrip('\"'))
|
|
print('Key saved')
|
|
"
|
|
|
|
python3 /tmp/spq-backup-server.py &
|
|
# Verify
|
|
curl -s -o /dev/null -w '%{http_code}' http://localhost:4173/
|
|
# → 200
|
|
curl -s -X POST http://localhost:4173/pb/collections/users/auth-with-password \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"identity":"demo@shop.com","password":"test1234"}'
|
|
# → {"token":"..."} (login works through proxy)
|
|
|
|
# Verify DeepSeek
|
|
curl -s -X POST http://localhost:4173/deepseek/v1/chat/completions \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Say hello"}],"temperature":0,"thinking":{"type":"disabled"},"max_tokens":50}'
|
|
# → {"choices":[{"message":{"content":"Hello!"}}]} (AI works)
|
|
|
|
### Pitfalls
|
|
|
|
- **No `--bind 0.0.0.0` on plain http.server** → LAN devices get connection refused. The proxy template above binds `0.0.0.0` by default.
|
|
- **Missing CORS headers** → browser blocks all PB requests. The proxy adds CORS on every response + handles OPTIONS preflight.
|
|
- **Missing SPA fallback** → `/dashboard`, `/quote` etc. return 404 on page refresh. The proxy falls back to `index.html` for non-file paths.
|
|
- **PB URL mismatch** — v2's `pocketbase.ts` uses `VITE_PB_URL` env var (defaults to `/pb`). If the build was made with a different URL, adjust the proxy or rebuild.
|
|
- **Backup paths vary** — the timestamped backup folder (`ShopProQuote.backup-YYYYMMDD-HHMM`) changes per backup. Update DIST path in the script.
|
|
- **Dashboard 400 / Something went wrong** — if the PocketBase quotes collection is missing the created system field, the sort=-created query returns HTTP 400. Fix: use sort=-id instead. Same issue applies to repairOrders collection.
|
|
|
|
- **AI features return no results** — the /deepseek/ path must be proxied to https://api.deepseek.com/ with the API key from /tmp/deepseek_key.txt. The AI module at src/lib/ai.ts calls /deepseek/v1/chat/completions with model: deepseek-v4-flash and thinking: disabled. Without proxy setup, all AI calls return null.
|
|
|
|
- **React input focus loss** — if a ServiceRow-like component is defined as a nested function inside its parent, every zustand state update causes a re-render that creates a new function identity. React unmounts/remounts the DOM, killing input focus. Fix: extract to a file-level component wrapped in memo, pass all state via props.
|
|
|
|
## V2 Feature Coverage
|
|
|
|
As of 2026-06-27, spq-v2 covers ~90% of v1's feature set. See `references/v1-v2-feature-gap.md` for a detailed comparison table of what's built vs what's still only in v1. Missing PocketBase collections (`repair_orders`, `invoices`, `service_advisors`, `vehicles`) cause error states on some pages — create them via admin UI to unlock full functionality.
|
|
|
|
For mass porting patterns (parallel delegation), see `references/mass-port-delegation.md`.
|
|
|
|
## Common Port
|
|
|
|
Use **4173** for local SPQ testing. Check availability:
|
|
|
|
```bash
|
|
ss -tlnp | grep 4173 || echo "port free"
|
|
```
|
|
|
|
Kill any existing server: `pkill -f "spq-backup-server.py"` or `pkill -f "python3 -m http.server 4173"`
|