diff --git a/ORDER-PERSISTENCE.md b/ORDER-PERSISTENCE.md new file mode 100644 index 0000000..c0e61b7 --- /dev/null +++ b/ORDER-PERSISTENCE.md @@ -0,0 +1,130 @@ +# Order Persistence - Server-Side Storage + +## Overview + +Service order (drag-and-drop positioning) is now persisted server-side using SQLite database, with localStorage as a fallback/cache. + +## Architecture + +``` +Browser (drag-drop) → localStorage (immediate) + → /api/order (async save to server) + ↓ + order-service (Flask) + ↓ + SQLite Database + (/data/services-order.db) +``` + +## Components + +### Backend Service: `order-service.py` +- **Port:** 8082 (internal) +- **Database:** SQLite at `/data/services-order.db` +- **Volume:** `order-data` (persistent across container restarts) + +### API Endpoints + +#### GET `/api/order?user_id=default` +- Retrieves saved service order for a user +- Returns: JSON object mapping group names to ordered service IDs +- Example response: + ```json + { + "Management": ["portainer", "uptime-kuma", "scrutiny"], + "Media": ["jellyfin", "jellyseer", "transmission"] + } + ``` + +#### POST `/api/order` +- Saves service order for a user +- Request body: + ```json + { + "user_id": "default", + "order": { + "Management": ["portainer", "uptime-kuma"], + ... + } + } + ``` +- Response: `{"success": true, "message": "Order saved"}` + +#### DELETE `/api/order?user_id=default` +- Resets order to default (deletes saved order) +- Response: `{"success": true, "message": "Order deleted"}` + +## Database Schema + +```sql +CREATE TABLE service_order ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + order_data TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## Behavior + +### On Page Load +1. JavaScript calls `/api/order?user_id=default` +2. If server has data, use it and cache in localStorage +3. If server returns empty, fall back to localStorage +4. If both empty, use default XML order + +### On Drag-Drop +1. Save to localStorage immediately (instant response) +2. Async POST to `/api/order` in background +3. If server save fails, localStorage still works as fallback + +### Benefits +- **Cross-device sync:** Order syncs across browsers/devices +- **Persistent:** Survives container restarts, browser clearing +- **Fallback:** localStorage works if server is down +- **Fast:** Instant UI update, async server save + +## Data Location + +- **Server:** Docker volume `services-homepage_order-data` + - Inspect: `docker volume inspect services-homepage_order-data` + - Location: `/var/lib/docker/volumes/services-homepage_order-data/_data/` +- **Client:** Browser localStorage key `services-order` + +## Backup/Restore + +### Backup Server Data +```bash +# Copy database from volume +docker run --rm -v services-homepage_order-data:/data -v $(pwd):/backup \ + alpine cp /data/services-order.db /backup/order-backup.db +``` + +### Restore Server Data +```bash +# Copy database to volume +docker run --rm -v services-homepage_order-data:/data -v $(pwd):/backup \ + alpine cp /backup/order-backup.db /data/services-order.db + +# Restart service to load new data +docker restart services-homepage-order-service +``` + +### Reset All Orders +```bash +# Delete all saved orders +curl -X DELETE "http://192.168.2.180:8088/api/order?user_id=default" + +# Or restart with fresh database +docker compose down +docker volume rm services-homepage_order-data +docker compose up -d +``` + +## Future Enhancements + +- Multi-user support (authentication) +- Order history/versioning +- Export/import order via UI +- Sync status indicator in UI +- Conflict resolution for concurrent edits diff --git a/backend/order-service.py b/backend/order-service.py new file mode 100644 index 0000000..4cb4f7e --- /dev/null +++ b/backend/order-service.py @@ -0,0 +1,112 @@ +from flask import Flask, request, jsonify +import sqlite3 +import os +import json +from datetime import datetime + +app = Flask(__name__) + +# Database setup +DB_PATH = '/data/services-order.db' +os.makedirs('/data', exist_ok=True) + +def init_db(): + """Initialize the database with orders table""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS service_order ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + order_data TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() + conn.close() + +# Initialize database on startup +init_db() + +@app.route('/api/order', methods=['GET']) +def get_order(): + """Get the saved service order""" + user_id = request.args.get('user_id', 'default') + + try: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + 'SELECT order_data FROM service_order WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1', + (user_id,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return jsonify(json.loads(row[0])), 200 + else: + return jsonify({}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/order', methods=['POST']) +def save_order(): + """Save the service order""" + user_id = request.json.get('user_id', 'default') + order_data = request.json.get('order', {}) + + if not order_data: + return jsonify({'error': 'No order data provided'}), 400 + + try: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if user already has an order + cursor.execute('SELECT id FROM service_order WHERE user_id = ?', (user_id,)) + existing = cursor.fetchone() + + if existing: + # Update existing order + cursor.execute( + 'UPDATE service_order SET order_data = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?', + (json.dumps(order_data), user_id) + ) + else: + # Insert new order + cursor.execute( + 'INSERT INTO service_order (user_id, order_data) VALUES (?, ?)', + (user_id, json.dumps(order_data)) + ) + + conn.commit() + conn.close() + + return jsonify({'success': True, 'message': 'Order saved'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/order', methods=['DELETE']) +def delete_order(): + """Delete saved order (reset to default)""" + user_id = request.args.get('user_id', 'default') + + try: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute('DELETE FROM service_order WHERE user_id = ?', (user_id,)) + conn.commit() + conn.close() + + return jsonify({'success': True, 'message': 'Order deleted'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint""" + return jsonify({'status': 'ok'}), 200 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8082) diff --git a/docker-compose.yml b/docker-compose.yml index 95db493..61effc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,3 +37,21 @@ services: options: max-size: "5m" max-file: "2" + + order-service: + image: python:3.10-slim + container_name: services-homepage-order-service + working_dir: /app + volumes: + - ./backend/order-service.py:/app/order-service.py:ro + - order-data:/data + command: ["sh", "-c", "pip install flask && python order-service.py"] + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + +volumes: + order-data: diff --git a/js/drag-drop.js b/js/drag-drop.js index 13dd572..94c1a04 100644 --- a/js/drag-drop.js +++ b/js/drag-drop.js @@ -3,30 +3,67 @@ */ const STORAGE_KEY_ORDER = 'services-order'; +const USER_ID = 'default'; // Could be customized per user in the future -// Load saved order from localStorage -function loadSavedOrder() { +// Load saved order from server (with localStorage fallback) +async function loadSavedOrder() { try { + // Try to load from server first + const response = await fetch(`/api/order?user_id=${USER_ID}`); + if (response.ok) { + const serverOrder = await response.json(); + if (Object.keys(serverOrder).length > 0) { + // Sync server order to localStorage + localStorage.setItem(STORAGE_KEY_ORDER, JSON.stringify(serverOrder)); + return serverOrder; + } + } + + // Fallback to localStorage const saved = localStorage.getItem(STORAGE_KEY_ORDER); return saved ? JSON.parse(saved) : {}; } catch (e) { console.error('Error loading saved order:', e); - return {}; + // Fallback to localStorage + try { + const saved = localStorage.getItem(STORAGE_KEY_ORDER); + return saved ? JSON.parse(saved) : {}; + } catch (e2) { + return {}; + } } } -// Save order to localStorage -function saveSavedOrder(orderMap) { +// Save order to server and localStorage +async function saveSavedOrder(orderMap) { try { + // Save to localStorage immediately localStorage.setItem(STORAGE_KEY_ORDER, JSON.stringify(orderMap)); + + // Also save to server + const response = await fetch('/api/order', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + user_id: USER_ID, + order: orderMap + }) + }); + + if (!response.ok) { + console.warn('Failed to save order to server, using localStorage only'); + } } catch (e) { - console.error('Error saving order:', e); + console.error('Error saving order to server:', e); + // localStorage save already happened above, so at least we have local persistence } } // Apply saved order to service groups -function applySavedOrder() { - const orderMap = loadSavedOrder(); +async function applySavedOrder() { + const orderMap = await loadSavedOrder(); document.querySelectorAll('.service-group').forEach(group => { const groupName = group.querySelector('h2')?.textContent; diff --git a/nginx.conf b/nginx.conf index 41a9116..6cc9afc 100644 --- a/nginx.conf +++ b/nginx.conf @@ -32,6 +32,18 @@ http { proxy_read_timeout 5s; } + # Order service API - forwards to order-service + location /api/order { + proxy_pass http://order-service:8082$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Content-Type application/json; + proxy_connect_timeout 3s; + proxy_read_timeout 5s; + } + # Serve static files location / { root /usr/share/nginx/html;