Skip to content

Commit 2622da3

Browse files
committed
initial web prototype
1 parent d773846 commit 2622da3

File tree

14 files changed

+2807
-0
lines changed

14 files changed

+2807
-0
lines changed

requirements-web.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Web interface dependencies for mergin-db-sync
2+
flask>=2.0
3+
pyyaml>=6.0
4+
requests
5+
psycopg2
6+

web/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Web interface for mergin-db-sync configuration and management.
3+
"""

web/app.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""
2+
Flask application entry point for mergin-db-sync web interface.
3+
"""
4+
5+
import json
6+
import time
7+
from flask import Flask, render_template, request, jsonify, Response, redirect, url_for
8+
9+
from . import mergin_api
10+
from . import db_api
11+
from . import config_manager
12+
from .sync_manager import get_sync_manager
13+
14+
15+
app = Flask(__name__)
16+
17+
18+
# ============================================================================
19+
# Page Routes
20+
# ============================================================================
21+
22+
@app.route("/")
23+
def index():
24+
"""Landing page - redirect to wizard or dashboard based on config state."""
25+
if config_manager.config_exists():
26+
return redirect(url_for("dashboard"))
27+
return redirect(url_for("wizard"))
28+
29+
30+
@app.route("/wizard")
31+
def wizard():
32+
"""Configuration wizard page."""
33+
return render_template("wizard.html")
34+
35+
36+
@app.route("/dashboard")
37+
def dashboard():
38+
"""Status dashboard page."""
39+
config = config_manager.get_config_for_display()
40+
return render_template("dashboard.html", config=config)
41+
42+
43+
# ============================================================================
44+
# Wizard API Endpoints
45+
# ============================================================================
46+
47+
@app.route("/api/wizard/validate-mergin", methods=["POST"])
48+
def api_validate_mergin():
49+
"""Validate Mergin credentials."""
50+
data = request.get_json()
51+
if not data:
52+
return jsonify({"success": False, "error": "No data provided"}), 400
53+
54+
url = data.get("url", "https://app.merginmaps.com")
55+
username = data.get("username", "")
56+
password = data.get("password", "")
57+
58+
if not username or not password:
59+
return jsonify({"success": False, "error": "Username and password are required"}), 400
60+
61+
result = mergin_api.validate_credentials(url, username, password)
62+
return jsonify(result)
63+
64+
65+
@app.route("/api/wizard/list-projects", methods=["POST"])
66+
def api_list_projects():
67+
"""List user's Mergin projects."""
68+
data = request.get_json()
69+
if not data:
70+
return jsonify({"success": False, "error": "No data provided"}), 400
71+
72+
url = data.get("url", "https://app.merginmaps.com")
73+
username = data.get("username", "")
74+
password = data.get("password", "")
75+
76+
if not username or not password:
77+
return jsonify({"success": False, "error": "Username and password are required"}), 400
78+
79+
result = mergin_api.list_projects(url, username, password)
80+
return jsonify(result)
81+
82+
83+
@app.route("/api/wizard/project-files", methods=["POST"])
84+
def api_project_files():
85+
"""List GeoPackage files in a project."""
86+
data = request.get_json()
87+
if not data:
88+
return jsonify({"success": False, "error": "No data provided"}), 400
89+
90+
url = data.get("url", "https://app.merginmaps.com")
91+
username = data.get("username", "")
92+
password = data.get("password", "")
93+
project = data.get("project", "")
94+
95+
if not username or not password:
96+
return jsonify({"success": False, "error": "Username and password are required"}), 400
97+
if not project:
98+
return jsonify({"success": False, "error": "Project name is required"}), 400
99+
100+
result = mergin_api.list_gpkg_files(url, username, password, project)
101+
return jsonify(result)
102+
103+
104+
@app.route("/api/wizard/test-postgres", methods=["POST"])
105+
def api_test_postgres():
106+
"""Test PostgreSQL connection."""
107+
data = request.get_json()
108+
if not data:
109+
return jsonify({"success": False, "error": "No data provided"}), 400
110+
111+
conn_info = data.get("conn_info", "")
112+
if not conn_info:
113+
return jsonify({"success": False, "error": "Connection string is required"}), 400
114+
115+
result = db_api.test_connection(conn_info)
116+
return jsonify(result)
117+
118+
119+
@app.route("/api/wizard/save-config", methods=["POST"])
120+
def api_save_config():
121+
"""Save complete configuration to YAML file."""
122+
data = request.get_json()
123+
if not data:
124+
return jsonify({"success": False, "error": "No data provided"}), 400
125+
126+
result = config_manager.save_config(data)
127+
return jsonify(result)
128+
129+
130+
@app.route("/api/wizard/load-config", methods=["GET"])
131+
def api_load_config():
132+
"""Load existing configuration."""
133+
try:
134+
config = config_manager.load_config()
135+
return jsonify({"success": True, "config": config})
136+
except Exception as e:
137+
return jsonify({"success": False, "error": str(e)})
138+
139+
140+
# ============================================================================
141+
# Sync Control API Endpoints
142+
# ============================================================================
143+
144+
@app.route("/api/sync/start", methods=["POST"])
145+
def api_sync_start():
146+
"""Start sync daemon subprocess."""
147+
data = request.get_json() or {}
148+
force_init = data.get("force_init", False)
149+
150+
manager = get_sync_manager()
151+
result = manager.start(force_init=force_init)
152+
return jsonify(result)
153+
154+
155+
@app.route("/api/sync/stop", methods=["POST"])
156+
def api_sync_stop():
157+
"""Stop sync daemon subprocess."""
158+
manager = get_sync_manager()
159+
result = manager.stop()
160+
return jsonify(result)
161+
162+
163+
@app.route("/api/sync/status", methods=["GET"])
164+
def api_sync_status():
165+
"""Get current sync status."""
166+
manager = get_sync_manager()
167+
result = manager.get_status()
168+
return jsonify(result)
169+
170+
171+
# ============================================================================
172+
# Log Streaming API Endpoints
173+
# ============================================================================
174+
175+
@app.route("/api/logs/stream")
176+
def api_logs_stream():
177+
"""Server-Sent Events endpoint for real-time logs."""
178+
def generate():
179+
manager = get_sync_manager()
180+
last_index = 0
181+
182+
# Send initial logs
183+
result = manager.get_new_logs_since(0)
184+
if result["logs"]:
185+
data = json.dumps({"logs": result["logs"], "index": result["next_index"]})
186+
yield f"data: {data}\n\n"
187+
last_index = result["next_index"]
188+
189+
# Stream new logs
190+
while True:
191+
result = manager.get_new_logs_since(last_index)
192+
if result["logs"]:
193+
data = json.dumps({"logs": result["logs"], "index": result["next_index"]})
194+
yield f"data: {data}\n\n"
195+
last_index = result["next_index"]
196+
197+
# Also send status updates
198+
status = manager.get_status()
199+
yield f"event: status\ndata: {json.dumps(status)}\n\n"
200+
201+
time.sleep(1)
202+
203+
return Response(generate(), mimetype="text/event-stream")
204+
205+
206+
@app.route("/api/logs/recent", methods=["GET"])
207+
def api_logs_recent():
208+
"""Get recent logs (non-streaming)."""
209+
manager = get_sync_manager()
210+
last_n = request.args.get("n", 100, type=int)
211+
logs = manager.get_logs(last_n)
212+
return jsonify({"success": True, "logs": logs})
213+
214+
215+
# ============================================================================
216+
# Main Entry Point
217+
# ============================================================================
218+
219+
def main():
220+
"""Run the Flask development server."""
221+
print("Starting Mergin DB Sync Web Interface...")
222+
print("Open http://localhost:5000 in your browser")
223+
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
224+
225+
226+
if __name__ == "__main__":
227+
main()

web/config_manager.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
YAML configuration file read/write utilities for the web interface.
3+
"""
4+
5+
import os
6+
import pathlib
7+
import yaml
8+
9+
10+
# Default config path - in project root
11+
DEFAULT_CONFIG_PATH = pathlib.Path(__file__).parent.parent / "web_config.yaml"
12+
13+
14+
def get_config_path() -> pathlib.Path:
15+
"""Get the configuration file path."""
16+
return DEFAULT_CONFIG_PATH
17+
18+
19+
def load_config() -> dict:
20+
"""
21+
Load configuration from YAML file.
22+
23+
Returns:
24+
dict with configuration or empty dict if file doesn't exist
25+
"""
26+
config_path = get_config_path()
27+
if not config_path.exists():
28+
return {}
29+
30+
try:
31+
with open(config_path, "r") as f:
32+
config = yaml.safe_load(f)
33+
return config if config else {}
34+
except yaml.YAMLError as e:
35+
raise ValueError(f"Invalid YAML in config file: {e}")
36+
except IOError as e:
37+
raise IOError(f"Could not read config file: {e}")
38+
39+
40+
def save_config(config_data: dict) -> dict:
41+
"""
42+
Save configuration to YAML file.
43+
44+
Args:
45+
config_data: dict containing:
46+
- mergin: {url, username, password}
47+
- init_from: 'gpkg' or 'db'
48+
- connection: {conn_info, modified, base, mergin_project, sync_file}
49+
- daemon: {sleep_time}
50+
51+
Returns:
52+
dict with 'success' boolean and 'path' or 'error' string
53+
"""
54+
config_path = get_config_path()
55+
56+
# Build the config structure matching config.yaml.default format
57+
config = {
58+
"mergin": {
59+
"url": config_data.get("mergin", {}).get("url", "https://app.merginmaps.com"),
60+
"username": config_data.get("mergin", {}).get("username", ""),
61+
"password": config_data.get("mergin", {}).get("password", ""),
62+
},
63+
"init_from": config_data.get("init_from", "gpkg"),
64+
"connections": [
65+
{
66+
"driver": "postgres",
67+
"conn_info": config_data.get("connection", {}).get("conn_info", ""),
68+
"modified": config_data.get("connection", {}).get("modified", ""),
69+
"base": config_data.get("connection", {}).get("base", ""),
70+
"mergin_project": config_data.get("connection", {}).get("mergin_project", ""),
71+
"sync_file": config_data.get("connection", {}).get("sync_file", ""),
72+
}
73+
],
74+
"daemon": {
75+
"sleep_time": config_data.get("daemon", {}).get("sleep_time", 10),
76+
},
77+
}
78+
79+
try:
80+
with open(config_path, "w") as f:
81+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
82+
83+
return {
84+
"success": True,
85+
"path": str(config_path)
86+
}
87+
except IOError as e:
88+
return {
89+
"success": False,
90+
"error": f"Could not write config file: {e}"
91+
}
92+
except yaml.YAMLError as e:
93+
return {
94+
"success": False,
95+
"error": f"Error generating YAML: {e}"
96+
}
97+
98+
99+
def config_exists() -> bool:
100+
"""Check if configuration file exists."""
101+
return get_config_path().exists()
102+
103+
104+
def get_config_for_display() -> dict:
105+
"""
106+
Load config and prepare it for display (mask password).
107+
108+
Returns:
109+
dict with configuration, password masked
110+
"""
111+
config = load_config()
112+
if not config:
113+
return {}
114+
115+
# Mask password for display
116+
display_config = config.copy()
117+
if "mergin" in display_config and "password" in display_config["mergin"]:
118+
display_config["mergin"]["password"] = "********"
119+
120+
return display_config

0 commit comments

Comments
 (0)