Files
mek-tech-consulting/python-editor/app.py
T

184 lines
12 KiB
Python

import os
import sqlite3
import json
from flask import Flask, render_template_string, request, jsonify, send_from_directory
DB_PATH = os.environ.get('DB_PATH', '/app/data/consulting.db')
app = Flask(__name__, static_folder='static', static_url_path='/python-editor/static')
HTML = '''<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Architectuur Designer — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#0d1117;--bg-card:#161b22;--bg-nav:#161b22;--border:#30363d;--text:#e6edf3;--text-muted:#8b949e;--accent:#58a6ff;--accent2:#3fb950;--danger:#f85149;--warning:#d29922;--purple:#bc8cff}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;overflow:hidden;height:100vh}
.nav{display:flex;align-items:center;gap:.75rem;padding:.5rem 1rem;background:var(--bg-nav);border-bottom:1px solid var(--border);height:48px}
.nav-brand{font-weight:700;font-size:1rem;color:var(--text);display:flex;align-items:center;gap:.4rem}
.nav-brand svg{width:20px;height:20px}
.nav-sep{width:1px;height:20px;background:var(--border)}
.nav input,.nav select{padding:.3rem .5rem;font-size:.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:inherit}
.nav input{width:180px}
.nav .btn{padding:.3rem .7rem;font-size:.7rem;border-radius:4px;border:none;cursor:pointer;font-weight:500;color:#fff}
.btn-primary{background:#238636}.btn-primary:hover{background:#2ea043}
.btn-accent{background:var(--accent)}.btn-accent:hover{background:#79c0ff}
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)!important}
.btn-ghost:hover{color:var(--text);background:var(--bg-card)}
.nav-status{font-size:.7rem;color:var(--text-muted);margin-left:auto}
.layout{display:flex;height:calc(100vh - 48px)}
.sidebar{width:200px;background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar input{margin:.5rem;padding:.35rem .5rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
.palette{flex:1;overflow-y:auto;padding:0 .5rem .5rem}
.cat{font-size:.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin:.6rem .25rem .25rem;padding:0 .25rem}
.p-item{display:flex;align-items:center;gap:.35rem;padding:.3rem .45rem;margin-bottom:1px;border-radius:4px;cursor:grab;font-size:.7rem;color:var(--text-muted);transition:all .1s;user-select:none}
.p-item:hover{background:var(--bg-card);color:var(--text)}
.p-icon{width:18px;height:18px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:.5rem;font-weight:700;flex-shrink:0}
.canvas-wrap{flex:1;position:relative;overflow:hidden;background:var(--bg);background-image:linear-gradient(rgba(88,166,255,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(88,166,255,.04) 1px,transparent 1px);background-size:20px 20px;cursor:grab}
.canvas-wrap:active{cursor:grabbing}
.viewport{position:absolute;top:0;left:0;transform-origin:0 0;width:6000px;height:4000px}
.node{position:absolute;min-width:120px;min-height:40px;background:var(--bg-card);border:2px solid var(--border);border-radius:6px;padding:.35rem .5rem;cursor:move;user-select:none;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:.7rem;color:var(--text-muted);z-index:10;transition:box-shadow .15s,border-color .15s}
.node:hover{box-shadow:0 0 20px rgba(88,166,255,.15);z-index:11}
.node.selected{border-color:var(--accent)!important;box-shadow:0 0 25px rgba(88,166,255,.3);z-index:12}
.node .nl{font-weight:600;font-size:.7rem;color:var(--text);text-align:center;line-height:1.2}
.node .ns{font-size:.6rem;color:var(--text-muted);line-height:1.2}
.node .rm{position:absolute;top:-6px;right:-6px;width:14px;height:14px;border-radius:50%;background:#3d1214;border:1px solid var(--danger);color:var(--danger);display:none;align-items:center;justify-content:center;font-size:.45rem;cursor:pointer}
.node:hover .rm{display:flex}
.node .port{width:9px;height:9px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);position:absolute;cursor:crosshair;z-index:5;transition:all .15s}
.node .port:hover{background:var(--accent);transform:scale(1.4)}
.port-in{left:-5px;top:50%;transform:translateY(-50%)}
.port-out{right:-5px;top:50%;transform:translateY(-50%)}
.props{width:240px;background:var(--bg-nav);border-left:1px solid var(--border);display:none;flex-direction:column;padding:.75rem;flex-shrink:0}
.props.open{display:flex}
.props h3{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.75rem}
.pg{margin-bottom:.5rem}
.pg label{font-size:.65rem;color:var(--text-muted);display:block;margin-bottom:.15rem}
.pg input,.pg select{width:100%;padding:.3rem .45rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
svg.conn{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:4}
.zoom{position:absolute;bottom:1rem;right:1rem;background:var(--bg-nav);border:1px solid var(--border);border-radius:6px;padding:.25rem .5rem;font-size:.65rem;color:var(--text-muted);z-index:30}
.stats{font-size:.6rem;color:var(--text-muted);padding:.4rem .5rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="nav">
<span class="nav-brand"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> Mek-Tech Designer</span>
<div class="nav-sep"></div>
<input id="diagramName" placeholder="Diagram naam...">
<select id="clientSelect"><option value="">Cliënt...</option></select>
<button class="btn btn-primary" onclick="save()">Opslaan</button>
<button class="btn btn-ghost" onclick="undo()">↩</button>
<button class="btn btn-ghost" onclick="redo()">↪</button>
<button class="btn btn-ghost" onclick="fit()">⊞</button>
<span class="nav-status" id="status"></span>
</div>
<div class="layout">
<div class="sidebar">
<input id="paletteSearch" placeholder="Zoek component..." oninput="filterPalette(this.value)">
<div class="palette" id="palette"></div>
<div class="stats" id="stats">0 componenten · 0 connecties</div>
</div>
<div class="canvas-wrap" id="wrap">
<div class="viewport" id="viewport">
<svg class="conn" id="svg"></svg>
<div id="canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></div>
</div>
<div class="zoom" id="zoom">100%</div>
</div>
<div class="props" id="props">
<h3>Eigenschappen</h3>
<div class="pg"><label>Label</label><input id="pLabel" onchange="up('label',this.value)"></div>
<div class="pg"><label>Subtitel</label><input id="pSub" onchange="up('sub',this.value)"></div>
<div class="pg"><label>Type</label><select id="pType" onchange="up('type',this.value)"></select></div>
<div class="pg"><label>Breedte</label><input id="pWidth" type="number" min="100" max="400" onchange="up('w',+this.value)"></div>
<button style="margin-top:auto;padding:.4rem;background:#3d1214;color:var(--danger);border:1px solid var(--danger);border-radius:4px;cursor:pointer;font-size:.7rem" onclick="delNode()">Verwijder</button>
</div>
</div>
<script>
const DIAGRAM_ID = '{{ diagram_id }}';
const NODES_JSON = {{ nodes_json | safe }};
const EDGES_JSON = {{ edges_json | safe }};
const NODE_TYPES = {
'source-db':{label:'Database',color:'#d29922',css:'bd-src'},'source-api':{label:'API Service',color:'#d29922',css:'bd-src'},
'source-iot':{label:'IoT Sensor',color:'#d29922',css:'bd-src'},'source-stream':{label:'Streaming',color:'#d29922',css:'bd-src'},
'ingest-kafka':{label:'Kafka',color:'#58a6ff',css:'bd-ing'},'ingest-airbyte':{label:'Airbyte',color:'#58a6ff',css:'bd-ing'},
'ingest-flink':{label:'Spark/Flink',color:'#58a6ff',css:'bd-ing'},'storage-minio':{label:'MinIO/S3',color:'#3fb950',css:'bd-sto'},
'storage-iceberg':{label:'Iceberg',color:'#3fb950',css:'bd-sto'},'storage-warehouse':{label:'Data Warehouse',color:'#3fb950',css:'bd-sto'},
'storage-lake':{label:'Lakehouse',color:'#3fb950',css:'bd-sto'},'compute-spark':{label:'Apache Spark',color:'#bc8cff',css:'bd-comp'},
'compute-trino':{label:'Trino/Presto',color:'#bc8cff',css:'bd-comp'},'compute-k8s':{label:'Kubernetes',color:'#bc8cff',css:'bd-comp'},
'compute-dbt':{label:'dbt',color:'#bc8cff',css:'bd-comp'},'ai-ollama':{label:'Ollama/vLLM',color:'#f85149',css:'bd-ai'},
'ai-mlflow':{label:'MLflow',color:'#f85149',css:'bd-ai'},'ai-vector':{label:'Vector Store',color:'#f85149',css:'bd-ai'},
'ai-model':{label:'Model Serving',color:'#f85149',css:'bd-ai'},'queue-kafka':{label:'Kafka Queue',color:'#79c0ff',css:'bd-q'},
'queue-rabbit':{label:'RabbitMQ',color:'#79c0ff',css:'bd-q'},'network-switch':{label:'Switch',color:'#7ee787',css:'bd-net'},
'network-router':{label:'Router',color:'#7ee787',css:'bd-net'},'network-fw':{label:'Firewall',color:'#7ee787',css:'bd-net'},
'network-lb':{label:'Load Balancer',color:'#7ee787',css:'bd-net'},'security-iam':{label:'IAM/Auth',color:'#f778ba',css:'bd-sec'},
'security-vault':{label:'Vault',color:'#f778ba',css:'bd-sec'},'security-grafana':{label:'Grafana',color:'#f778ba',css:'bd-sec'}
};
const CATS = [['Data Bronnen',['source-db','source-api','source-iot','source-stream']],['Ingest',['ingest-kafka','ingest-airbyte','ingest-flink']],['Opslag',['storage-minio','storage-iceberg','storage-warehouse']],['Compute',['compute-spark','compute-trino','compute-k8s','compute-dbt']],['AI/ML',['ai-ollama','ai-mlflow','ai-vector','ai-model']],['Messaging',['queue-kafka','queue-rabbit']],['Netwerk',['network-switch','network-router','network-fw','network-lb']],['Security',['security-iam','security-vault','security-grafana']]];
</script>
<script src="/python-editor/static/editor.js"></script>
</body>
</html>'''
@app.route('/python-editor/')
def index():
diagram_id = request.args.get('id')
nodes_json = '[]'
edges_json = '[]'
if diagram_id:
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT nodes FROM data_diagrams WHERE id=?', (diagram_id,))
row = c.fetchone()
conn.close()
if row:
data = json.loads(row[0])
nodes_json = json.dumps(data.get('nodes', []))
edges_json = json.dumps(data.get('edges', []))
except: pass
return render_template_string(HTML, diagram_id=diagram_id or '', nodes_json=nodes_json, edges_json=edges_json)
@app.route('/python-editor/api/clients')
def api_clients():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, name FROM clients ORDER BY name')
clients = [{'id': r[0], 'name': r[1]} for r in c.fetchall()]
conn.close()
return jsonify(clients)
@app.route('/python-editor/api/save', methods=['POST'])
def api_save():
data = request.get_json()
name = data.get('name')
client_id = data.get('client_id')
nodes = json.dumps(data.get('nodes', []))
edges = json.dumps(data.get('edges', []))
diagram_id = data.get('id')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if diagram_id:
c.execute('UPDATE data_diagrams SET name=?, client_id=?, nodes=?, edges=?, updated_at=datetime(\'now\') WHERE id=?',
(name, client_id, nodes, edges, diagram_id))
else:
c.execute('INSERT INTO data_diagrams (client_id, name, nodes, edges, diagram_type) VALUES (?,?,?,?,?)',
(client_id, name, nodes, edges, 'data_architecture'))
diagram_id = c.lastrowid
conn.commit()
conn.close()
return jsonify({'id': diagram_id})
if __name__ == '__main__':
port = int(os.environ.get('PY_PORT', 3001))
app.run(host='0.0.0.0', port=port, debug=False)