Files
hermes-config/skills/self-hosting/shop-pro-quote/references/local-testing.md
T
2026-07-12 10:17:17 -04:00

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"`