Implement server-side order persistence with SQLite and Flask API
This commit is contained in:
130
ORDER-PERSISTENCE.md
Normal file
130
ORDER-PERSISTENCE.md
Normal file
@@ -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
|
||||||
112
backend/order-service.py
Normal file
112
backend/order-service.py
Normal file
@@ -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)
|
||||||
@@ -37,3 +37,21 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "5m"
|
max-size: "5m"
|
||||||
max-file: "2"
|
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:
|
||||||
|
|||||||
@@ -3,30 +3,67 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY_ORDER = 'services-order';
|
const STORAGE_KEY_ORDER = 'services-order';
|
||||||
|
const USER_ID = 'default'; // Could be customized per user in the future
|
||||||
|
|
||||||
// Load saved order from localStorage
|
// Load saved order from server (with localStorage fallback)
|
||||||
function loadSavedOrder() {
|
async function loadSavedOrder() {
|
||||||
try {
|
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);
|
const saved = localStorage.getItem(STORAGE_KEY_ORDER);
|
||||||
return saved ? JSON.parse(saved) : {};
|
return saved ? JSON.parse(saved) : {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading saved order:', 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
|
// Save order to server and localStorage
|
||||||
function saveSavedOrder(orderMap) {
|
async function saveSavedOrder(orderMap) {
|
||||||
try {
|
try {
|
||||||
|
// Save to localStorage immediately
|
||||||
localStorage.setItem(STORAGE_KEY_ORDER, JSON.stringify(orderMap));
|
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) {
|
} 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
|
// Apply saved order to service groups
|
||||||
function applySavedOrder() {
|
async function applySavedOrder() {
|
||||||
const orderMap = loadSavedOrder();
|
const orderMap = await loadSavedOrder();
|
||||||
|
|
||||||
document.querySelectorAll('.service-group').forEach(group => {
|
document.querySelectorAll('.service-group').forEach(group => {
|
||||||
const groupName = group.querySelector('h2')?.textContent;
|
const groupName = group.querySelector('h2')?.textContent;
|
||||||
|
|||||||
12
nginx.conf
12
nginx.conf
@@ -32,6 +32,18 @@ http {
|
|||||||
proxy_read_timeout 5s;
|
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
|
# Serve static files
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
Reference in New Issue
Block a user