# RECON Dashboard — API Keys Tab Deployment ## Context SSH into the RECON LXC as zvx: `ssh zvx@100.64.0.24` (or 192.168.1.130) Working directory: `/opt/recon/` The dashboard is a Flask app in `lib/api.py` running on port 8420 as a systemd service (`recon.service`). We're adding: 1. A new `lib/key_manager.py` module (thread-safe, hot-reloadable API key store) 2. A new "API Keys" tab on the dashboard 3. API endpoints for key management 4. Hot-reload integration — enricher and extractor pull keys from KeyManager instead of .env directly ## Step 1: Deploy key_manager.py Create `/opt/recon/lib/key_manager.py` with the contents of the attached `key_manager.py` file. Copy it exactly — it's a complete, tested module. Verify it loads: ```bash cd /opt/recon && source venv/bin/activate python3 -c " from lib.key_manager import get_key_manager km = get_key_manager() print(f'Keys loaded: {km.get_gemini_key_count()}') print(f'Masked: {km.get_masked_keys()}') " ``` This should show the 4 Gemini keys currently in `.env`. ## Step 2: Add API routes to lib/api.py Add these routes to `lib/api.py`. Find where the other `/api/` routes are defined and add these in the same pattern: ```python from lib.key_manager import get_key_manager # ── API Keys Management ── @app.route('/keys') def keys_page(): """API Keys management page.""" return render_template_string(KEYS_TEMPLATE) @app.route('/api/keys', methods=['GET']) def api_get_keys(): """Get all API keys (masked) with stats.""" km = get_key_manager() return jsonify({ 'gemini': { 'keys': km.get_masked_keys(), 'count': km.get_gemini_key_count(), }, # Placeholder sections for future services 'services': { 'tei': { 'host': config.get('embedding', {}).get('tei_host', 'unknown'), 'port': config.get('embedding', {}).get('tei_port', 'unknown'), 'status': 'managed in config.yaml' }, 'qdrant': { 'host': config.get('vector_db', {}).get('host', 'unknown'), 'port': config.get('vector_db', {}).get('port', 'unknown'), 'status': 'managed in config.yaml' }, 'ollama': { 'host': config.get('embedding', {}).get('ollama_host', 'unknown'), 'port': config.get('embedding', {}).get('ollama_port', 'unknown'), 'status': 'managed in config.yaml' } } }) @app.route('/api/keys/gemini', methods=['POST']) def api_add_gemini_key(): """Add a new Gemini API key.""" data = request.get_json() if not data or 'key' not in data: return jsonify({'error': 'Missing "key" field'}), 400 km = get_key_manager() try: # Optionally validate before adding if data.get('validate', True): valid, msg = km.validate_key(data['key']) if not valid: return jsonify({'error': f'Key validation failed: {msg}'}), 400 idx = km.add_gemini_key(data['key']) return jsonify({'success': True, 'index': idx, 'count': km.get_gemini_key_count()}) except ValueError as e: return jsonify({'error': str(e)}), 400 @app.route('/api/keys/gemini/', methods=['PUT']) def api_replace_gemini_key(index): """Replace a Gemini API key at a specific index.""" data = request.get_json() if not data or 'key' not in data: return jsonify({'error': 'Missing "key" field'}), 400 km = get_key_manager() try: if data.get('validate', True): valid, msg = km.validate_key(data['key']) if not valid: return jsonify({'error': f'Key validation failed: {msg}'}), 400 km.replace_gemini_key(index, data['key']) return jsonify({'success': True, 'count': km.get_gemini_key_count()}) except (IndexError, ValueError) as e: return jsonify({'error': str(e)}), 400 @app.route('/api/keys/gemini/', methods=['DELETE']) def api_delete_gemini_key(index): """Remove a Gemini API key by index.""" km = get_key_manager() try: masked = km.remove_gemini_key(index) return jsonify({'success': True, 'removed': masked, 'count': km.get_gemini_key_count()}) except (IndexError, ValueError) as e: return jsonify({'error': str(e)}), 400 @app.route('/api/keys/gemini/validate', methods=['POST']) def api_validate_gemini_keys(): """Validate all loaded Gemini keys.""" km = get_key_manager() results = km.validate_all() return jsonify({'results': results}) @app.route('/api/keys/gemini//validate', methods=['POST']) def api_validate_single_gemini_key(index): """Validate a single Gemini key by index.""" km = get_key_manager() key = km.get_gemini_key(index) if key is None: return jsonify({'error': f'No key at index {index}'}), 404 valid, msg = km.validate_key(key) return jsonify({'index': index, 'valid': valid, 'message': msg}) @app.route('/api/keys/gemini/reveal/', methods=['POST']) def api_reveal_gemini_key(index): """Reveal full key (for copy). Requires confirmation in request body.""" data = request.get_json() or {} if not data.get('confirm'): return jsonify({'error': 'Send {"confirm": true} to reveal key'}), 400 km = get_key_manager() key = km.get_gemini_key(index) if key is None: return jsonify({'error': f'No key at index {index}'}), 404 return jsonify({'index': index, 'key': key}) @app.route('/api/keys/reload', methods=['POST']) def api_reload_keys(): """Force reload keys from .env file.""" km = get_key_manager() count = km.reload_from_env() return jsonify({'success': True, 'count': count}) ``` **Important:** Make sure `config` refers to whatever variable holds the parsed `config.yaml` in the existing code. Look at how other routes reference config and use the same pattern (likely `get_config()` from `lib/utils.py`). ## Step 3: Add the KEYS_TEMPLATE Add this HTML template string to `lib/api.py`, alongside the other template strings (DASHBOARD_TEMPLATE, SEARCH_TEMPLATE, etc.): ```python KEYS_TEMPLATE = """ RECON — API Keys

API Key Management

Manage API keys for pipeline workers. Changes take effect immediately — no restart required.

🔑 Gemini API Keys 0 keys
Loading keys...
Used by: Enrichment (text → concepts, 16 workers) · Vision OCR (scanned PDF fallback) · Title extraction
🔌 Service Endpoints config.yaml
Loading...
Service endpoints are currently managed in /opt/recon/config.yaml. Dashboard editing coming in a future update.

Confirm

Are you sure?

""" ``` ## Step 4: Add "API Keys" to the nav on ALL existing pages Find the nav HTML in every existing template (DASHBOARD_TEMPLATE, SEARCH_TEMPLATE, CATALOGUE_TEMPLATE, UPLOAD_TEMPLATE, WEB_INGEST_TEMPLATE, FAILURES_TEMPLATE). Each has a `