184 lines
12 KiB
Python
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)
|