From 1c2a0647cbde86a5f4cf88d3671545409f71b01f Mon Sep 17 00:00:00 2001 From: PotatoMaxwell Date: Wed, 27 May 2026 14:27:31 +0800 Subject: [PATCH] Multiuser system initiated with log recording --- INTRANET_DEPLOYMENT.md | 63 +++++ backend/database.py | 129 +++++++++- backend/server.py | 135 +++++++++- .../layout/mxpic_project_1/.project.json | 4 + .../engineer/layout/test_proj/.project.json | 4 + database/mxpic_data.db | Bin 16384 -> 20480 bytes frontend/canvas.html | 45 +++- frontend/dashboard.html | 239 ++++++++++++++++++ run_intranet_server.ps1 | 11 + 9 files changed, 619 insertions(+), 11 deletions(-) create mode 100644 INTRANET_DEPLOYMENT.md create mode 100644 database/engineer/layout/mxpic_project_1/.project.json create mode 100644 database/engineer/layout/test_proj/.project.json create mode 100644 run_intranet_server.ps1 diff --git a/INTRANET_DEPLOYMENT.md b/INTRANET_DEPLOYMENT.md new file mode 100644 index 0000000..dd95c2f --- /dev/null +++ b/INTRANET_DEPLOYMENT.md @@ -0,0 +1,63 @@ +# mxPIC EDA Intranet Deployment + +## Start on the office LAN + +1. On the host computer, open PowerShell in this repository. +2. Set a persistent secret key: + +```powershell +$env:MXPIC_SECRET_KEY = "replace-with-a-long-random-secret" +``` + +3. Start the server: + +```powershell +.\run_intranet_server.ps1 +``` + +The app listens on `0.0.0.0:3000`, so other users can open: + +```text +http://:3000 +``` + +Find the host IP with: + +```powershell +ipconfig +``` + +Use the IPv4 address on the company LAN adapter. + +## Windows firewall + +If coworkers cannot connect, allow inbound TCP port `3000` on the host computer. + +## Accounts + +Default local accounts: + +```text +admin / 123456 +engineer / 123456 +``` + +Change these passwords from the dashboard profile panel before regular use. + +Each user stores projects under: + +```text +database//layout +``` + +## Useful environment variables + +```text +MXPIC_HOST=0.0.0.0 +MXPIC_PORT=3000 +MXPIC_DEBUG=0 +MXPIC_SECRET_KEY= +MXPIC_COOKIE_SECURE=0 +``` + +Set `MXPIC_COOKIE_SECURE=1` only when serving through HTTPS. diff --git a/backend/database.py b/backend/database.py index 42941aa..df3d7fd 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2,12 +2,20 @@ import sqlite3 import os from werkzeug.security import generate_password_hash +from datetime import datetime # Save the database in the backend folder DB_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "database", "mxpic_data.db")) +def connect_db(): + conn = sqlite3.connect(DB_FILE, timeout=20) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=20000") + return conn + def init_db(): - conn = sqlite3.connect(DB_FILE) + os.makedirs(os.path.dirname(DB_FILE), exist_ok=True) + conn = connect_db() cursor = conn.cursor() # Create Users Table @@ -18,25 +26,138 @@ def init_db(): password_hash TEXT NOT NULL ) ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + project TEXT, + cell TEXT, + detail TEXT, + ip_address TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + ''') + + cursor.execute("PRAGMA table_info(users)") + existing_columns = {row[1] for row in cursor.fetchall()} + migrations = { + "created_at": "ALTER TABLE users ADD COLUMN created_at TEXT", + "credits": "ALTER TABLE users ADD COLUMN credits INTEGER NOT NULL DEFAULT 0", + "occupation": "ALTER TABLE users ADD COLUMN occupation TEXT NOT NULL DEFAULT 'intern'", + } + for column, statement in migrations.items(): + if column not in existing_columns: + cursor.execute(statement) + + now = datetime.utcnow().strftime("%Y-%m-%d") + cursor.execute("UPDATE users SET created_at = ? WHERE created_at IS NULL OR created_at = ''", (now,)) - # Insert a test user if the table is empty + # Insert default users for local multi-account development. cursor.execute("SELECT * FROM users WHERE username = 'admin'") if not cursor.fetchone(): test_hash = generate_password_hash("123456") - cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ("admin", test_hash)) + cursor.execute( + "INSERT INTO users (username, password_hash, created_at, credits, occupation) VALUES (?, ?, ?, ?, ?)", + ("admin", test_hash, now, 0, "principle engineer") + ) print("Test user created. Username: admin | Password: 123456") + cursor.execute("SELECT * FROM users WHERE username = 'engineer'") + if not cursor.fetchone(): + test_hash = generate_password_hash("123456") + cursor.execute( + "INSERT INTO users (username, password_hash, created_at, credits, occupation) VALUES (?, ?, ?, ?, ?)", + ("engineer", test_hash, now, 0, "junior engineer") + ) + print("Second test user created. Username: engineer | Password: 123456") + conn.commit() conn.close() def get_user(username): - conn = sqlite3.connect(DB_FILE) + conn = connect_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash FROM users WHERE username = ?", (username,)) user = cursor.fetchone() conn.close() return user +def get_user_profile(user_id): + conn = connect_db() + cursor = conn.cursor() + cursor.execute( + "SELECT id, username, created_at, credits, occupation FROM users WHERE id = ?", + (user_id,) + ) + user = cursor.fetchone() + conn.close() + return user + +def get_user_auth_by_id(user_id): + conn = connect_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, password_hash FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + return user + +def update_user_occupation(user_id, occupation): + conn = connect_db() + cursor = conn.cursor() + cursor.execute("UPDATE users SET occupation = ? WHERE id = ?", (occupation, user_id)) + conn.commit() + conn.close() + +def update_user_password(user_id, password): + conn = connect_db() + cursor = conn.cursor() + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), user_id)) + conn.commit() + conn.close() + +def add_user_log(user_id, username, action, project=None, cell=None, detail=None, ip_address=None): + conn = connect_db() + cursor = conn.cursor() + cursor.execute( + ''' + INSERT INTO user_logs (user_id, username, action, project, cell, detail, ip_address, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', + ( + user_id, + username, + action, + project, + cell, + detail, + ip_address, + datetime.utcnow().isoformat(timespec="seconds") + "Z" + ) + ) + conn.commit() + conn.close() + +def list_user_logs(user_id, limit=200): + conn = connect_db() + cursor = conn.cursor() + cursor.execute( + ''' + SELECT id, action, project, cell, detail, ip_address, created_at + FROM user_logs + WHERE user_id = ? + ORDER BY id DESC + LIMIT ? + ''', + (user_id, limit) + ) + rows = cursor.fetchall() + conn.close() + return rows + if __name__ == "__main__": init_db() print("Database initialized successfully.") diff --git a/backend/server.py b/backend/server.py index 66f2d53..17e39c0 100644 --- a/backend/server.py +++ b/backend/server.py @@ -25,7 +25,12 @@ DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database')) app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) -app.secret_key = 'super_secret_mxpic_key' +app.secret_key = os.environ.get('MXPIC_SECRET_KEY', 'change_me_for_intranet_deployment') +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE='Lax', + SESSION_COOKIE_SECURE=os.environ.get('MXPIC_COOKIE_SECURE', '0').lower() in {'1', 'true', 'yes'}, +) app.json.sort_keys = False database.init_db() @@ -40,6 +45,31 @@ def login_required_json(view_func): return wrapper +def request_ip(): + forwarded_for = request.headers.get('X-Forwarded-For', '') + if forwarded_for: + return forwarded_for.split(',')[0].strip() + return request.remote_addr + + +def record_action(action, project=None, cell=None, detail=None): + if 'user_id' not in session: + return + if isinstance(detail, (dict, list)): + detail = json.dumps(detail, ensure_ascii=False) + elif detail is not None: + detail = str(detail) + database.add_user_log( + session.get('user_id'), + session.get('username', 'unknown'), + action, + project=safe_name(project, '') if project else None, + cell=safe_name(cell, '') if cell else None, + detail=detail, + ip_address=request_ip() + ) + + def safe_name(value, fallback): """Keep user/project/cell names filesystem-friendly without changing display names.""" value = (value or '').strip() @@ -187,6 +217,7 @@ def login(): if user and check_password_hash(user[2], password): session['user_id'] = user[0] session['username'] = user[1] + record_action('login') return redirect(url_for('dashboard')) else: return render_template('login.html', error="Invalid username or password") @@ -211,10 +242,16 @@ def canvas(): @app.route('/logout') def logout(): """Clear session and return to login.""" + record_action('logout') session.clear() return redirect(url_for('home')) +@app.route('/api/health') +def health_check(): + return jsonify({"status": "ok", "service": "mxpic_eda"}) + + @app.route('/api/technologies', methods=['GET']) @login_required_json def list_technologies(): @@ -239,6 +276,89 @@ def list_technologies(): return jsonify({"technologies": technologies}) +@app.route('/api/profile', methods=['GET', 'PATCH']) +@login_required_json +def account_profile(): + occupations = {'intern', 'senior engineer', 'junior engineer', 'principle engineer'} + user_id = session.get('user_id') + + if request.method == 'PATCH': + data = request.get_json(silent=True) or {} + occupation = (data.get('occupation') or '').strip().lower() + if occupation not in occupations: + return jsonify({"error": "Invalid occupation"}), 400 + database.update_user_occupation(user_id, occupation) + record_action('profile.update_occupation', detail={"occupation": occupation}) + + profile = database.get_user_profile(user_id) + if not profile: + return jsonify({"error": "Profile not found"}), 404 + + return jsonify({ + "id": f"mx-{int(profile[0]):06d}", + "username": profile[1], + "created_at": profile[2], + "credits": profile[3] or 0, + "occupation": profile[4] or "intern", + "occupations": sorted(occupations) + }) + + +@app.route('/api/profile/password', methods=['POST']) +@login_required_json +def change_password(): + data = request.get_json(silent=True) or {} + current_password = data.get('current_password') or '' + new_password = data.get('new_password') or '' + if len(new_password) < 6: + return jsonify({"error": "New password must be at least 6 characters"}), 400 + + user = database.get_user_auth_by_id(session.get('user_id')) + if not user or not check_password_hash(user[2], current_password): + return jsonify({"error": "Current password is incorrect"}), 400 + + database.update_user_password(user[0], new_password) + record_action('profile.change_password') + return jsonify({"message": "Password updated"}) + + +@app.route('/api/logs', methods=['GET', 'POST']) +@login_required_json +def user_logs(): + if request.method == 'POST': + data = request.get_json(silent=True) or {} + action = safe_name(data.get('action'), '') + if not action: + return jsonify({"error": "Action is required"}), 400 + record_action( + action, + project=data.get('project'), + cell=data.get('cell'), + detail=data.get('detail') + ) + return jsonify({"message": "logged"}) + + try: + limit = min(500, max(1, int(request.args.get('limit', 200)))) + except ValueError: + limit = 200 + rows = database.list_user_logs(session.get('user_id'), limit=limit) + return jsonify({ + "logs": [ + { + "id": row[0], + "action": row[1], + "project": row[2], + "cell": row[3], + "detail": row[4], + "ip_address": row[5], + "created_at": row[6] + } + for row in rows + ] + }) + + @app.route('/api/projects', methods=['GET']) @login_required_json def list_projects(): @@ -291,6 +411,7 @@ def create_project(): "name": project_name, "technology": technology }) + record_action('project.create', project=project_name, detail={"technology": technology}) return jsonify({"name": project_name, "technology": technology}), 201 @@ -337,6 +458,7 @@ def delete_project(project_name): return jsonify({"error": "Project not found"}), 404 shutil.rmtree(target) + record_action('project.delete', project=project_name) return jsonify({"message": "deleted", "project": safe_name(project_name, 'project_1')}) @@ -350,8 +472,10 @@ def rename_cell(project_name, cell_name): if not target.startswith(project_dir + os.sep): return jsonify({"error": "Invalid cell path"}), 400 if not os.path.exists(target): + record_action('canvas.delete_missing', project=project_name, cell=cell) return jsonify({"message": "already deleted", "cell": cell}) os.remove(target) + record_action('canvas.delete', project=project_name, cell=cell) return jsonify({"message": "deleted", "cell": cell}) data = request.get_json(silent=True) or {} @@ -367,6 +491,7 @@ def rename_cell(project_name, cell_name): if os.path.exists(old_path): os.rename(old_path, new_path) + record_action('canvas.rename', project=project_name, cell=new_cell, detail={"old_cell": old_cell}) return jsonify({"message": "renamed", "old_cell": old_cell, "cell": new_cell}) @@ -387,6 +512,7 @@ def save_layout(): with open(save_path, 'w', encoding='utf-8') as f: f.write(content) + record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content)}) return jsonify({ "message": "successfully saved", "project": project, @@ -433,8 +559,11 @@ def getCompImg(component_name): return jsonify({"error": "No image found"}), 404 if __name__ == '__main__': - print("Starting mxpic EDA Server on http://127.0.0.1:3000") - app.run(host='127.0.0.1', port=3000, debug=True) + host = os.environ.get('MXPIC_HOST', '0.0.0.0') + port = int(os.environ.get('MXPIC_PORT', '3000')) + debug = os.environ.get('MXPIC_DEBUG', '0').lower() in {'1', 'true', 'yes'} + print(f"Starting mxpic EDA Server on http://{host}:{port}") + app.run(host=host, port=port, debug=debug, threaded=True) diff --git a/database/engineer/layout/mxpic_project_1/.project.json b/database/engineer/layout/mxpic_project_1/.project.json new file mode 100644 index 0000000..4f868b9 --- /dev/null +++ b/database/engineer/layout/mxpic_project_1/.project.json @@ -0,0 +1,4 @@ +{ + "name": "mxpic_project_1", + "technology": "Silterra/EMO1_2ML_CU_Al_RDL" +} \ No newline at end of file diff --git a/database/engineer/layout/test_proj/.project.json b/database/engineer/layout/test_proj/.project.json new file mode 100644 index 0000000..1a02b5f --- /dev/null +++ b/database/engineer/layout/test_proj/.project.json @@ -0,0 +1,4 @@ +{ + "name": "test_proj", + "technology": "Silterra/EMO1_2ML_CU_Al_RDL" +} \ No newline at end of file diff --git a/database/mxpic_data.db b/database/mxpic_data.db index c2a697cef7d0ffd27a0d714fb6f4b8e7ccf94b8f..e270b38628a37e236184acda0b080b472db89d79 100644 GIT binary patch delta 895 zcmZuv&rcIU6rL?D6xMbipsPgE(U1VKnBCcSe_%8sZi%5OP)Z`FW_EU#EGgS&cZu;p zplFOoaT6~lM!9%K&vNk2zu?gWccKRlZcAE>GRaG3zW2TN=KE%Lce&k+>ZAN znGE-8I3Q+?ZTZZr*6QnicaxYa8?zMyRc0R*4b*|dd$7P)9Sc1vRg8I~9PUFQ+=l7! zExg7P8aQKm8r7g^#jKJ;%r}jp< zK0y$;m&4&G{6ezmp9yWiast0&LU$y3Y!qf!ixnhhQJvDQ2I(5|KWD;m1LnCCQ{hp% z9EZQbw{Qd=CVS((?I#S6VUC9nKrD3tQU~eAVe?&zZ!U02AnYyQN=o5c=JO5~u`(^@ zr?J}b9G5zc8bi!ocU;E2=0Yq9##-Dv9muEN^F}ZuV^z^+v>9o<_AIF9R(&m}%f`}@ zZH?!dMk%%ws*5(p*pgI5rBtS(oR_evD2xzOl0{usm~GoTlBU^2G))Z?YReiE^}Ll= zRZYo>B8ggPOwMbHYGF()MP?FFG)i==GMtY(Gf5_N)*F(Z-hR)V;2p z$dvEr(r*xJ({Wo`LNoK>=0(N1p#u>!22TL|1y8;Xp&t-k=^5iX7Q~W0LVvqKD&leQ e6u{Gn^%L$dC5HOB9)WB95Y?%pkzP$jIQJpuoVuzy!oBKnw$n6LpM*nHco^ zYkB#9FfjA&W8fFyKg+jov!H?x-{gIKL0pYijO^m#;*3qflUMU@V$_^`mfwUEsGX5N zkbyrCsLq|Aqfv*El|j5IaPnH&4~$?pvO-!}_7yyX|>U>4_0Ov%m6;{{86W8nV= Slz79>#lZ-aDJ@PdDh2@TeJJz* diff --git a/frontend/canvas.html b/frontend/canvas.html index 9c81447..96845f3 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -1784,6 +1784,14 @@ setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]); }, []); + const recordUserAction = useCallback((action, payload = {}) => { + fetch('/api/logs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, ...payload }) + }).catch(() => {}); + }, []); + const syncCompositePlacement = useCallback((projectName, compositeName, mode = 'add') => { setStandaloneComposites(prev => { if (mode === 'add') return prev.filter(name => name !== compositeName); @@ -1915,13 +1923,21 @@ const handleDelete = useCallback(() => { if (!activePage) return; - const selectedNodeIds = new Set(activePage.nodes.filter(n => n.selected && n.id !== 'page-port').map(n => n.id)); + const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port'); + const selectedNodeIds = new Set(selectedNodes.map(n => n.id)); if (selectedNodeIds.size > 0) { const newNodes = activePage.nodes.filter(n => !selectedNodeIds.has(n.id)); const newEdges = activePage.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)); setPages(prev => prev.map(p => p.id === activePage.id ? { ...p, nodes: newNodes, edges: newEdges } : p)); + recordUserAction('instance.delete', { + project: currentProjectName, + cell: activePage.name, + detail: { + instances: selectedNodes.map(node => node.data?.componentDisplayName || node.id) + } + }); } - }, [activePage]); + }, [activePage, currentProjectName, recordUserAction]); useEffect(() => { const handleKeyDown = (e) => { @@ -2479,7 +2495,8 @@ ...prev, [currentProjectName]: [...(prev[currentProjectName] || []), cellName] })); - }, [pages, currentProjectName]); + recordUserAction('canvas.create', { project: currentProjectName, cell: cellName }); + }, [pages, currentProjectName, recordUserAction]); const closePage = useCallback((pageId) => { setPages(prev => { @@ -2616,6 +2633,11 @@ }; }); } + recordUserAction('instance.create', { + project: currentProjectName, + cell: activePage?.name, + detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'standaloneComposite' } + }); return; } if (parsedData.type === 'composite') { @@ -2653,6 +2675,11 @@ }; }); } + recordUserAction('instance.create', { + project: currentProjectName, + cell: activePage?.name, + detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'composite' } + }); return; } if (!activePageId) { @@ -2690,6 +2717,11 @@ if (p.id !== activePageId) return p; return { ...p, nodes: p.nodes.concat(newNode) }; })); + recordUserAction('instance.create', { + project: currentProjectName, + cell: activePage?.name, + detail: { component: selectedComponent, instance: componentDisplayName, category: parsedData.category } + }); return; } const componentDisplayName = generateComponentDisplayName(); @@ -2709,7 +2741,12 @@ if (p.id !== activePageId) return p; return { ...p, nodes: p.nodes.concat(newNode) }; })); - }, [activePageId, activePage, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement]); + recordUserAction('instance.create', { + project: currentProjectName, + cell: activePage?.name, + detail: { component: parsedData.name, instance: componentDisplayName, category: parsedData.category } + }); + }, [activePageId, activePage, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName]); const onConnect = useCallback((connection) => { if (!activePageId) return; diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 1fad8ca..00e7da4 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -154,6 +154,97 @@ color: var(--accent); } + .profile-panel { + margin-top: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent), + var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + padding: 18px; + box-shadow: 0 16px 34px var(--shadow); + } + + .profile-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; + } + + .profile-title { + font-family: 'IBM Plex Mono', Consolas, monospace; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .profile-grid { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + gap: 12px; + } + + .profile-item { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; + min-width: 0; + } + + .profile-item label { + display: block; + margin-bottom: 6px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + } + + .profile-value { + color: var(--text-main); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .profile-item select { + width: 100%; + background: var(--panel-soft); + border: none; + color: var(--text-main); + font: inherit; + font-weight: 700; + outline: none; + } + + .profile-item select option { + background: var(--bg-card); + color: var(--text-main); + } + + .profile-action { + border: 1px solid var(--border); + background: var(--panel-soft); + color: var(--text-main); + border-radius: 8px; + height: 34px; + padding: 0 13px; + cursor: pointer; + font-family: inherit; + font-weight: 700; + } + + .profile-action:hover { + border-color: var(--accent); + color: var(--accent); + } + .section-title { font-family: 'IBM Plex Mono', Consolas, monospace; font-size: 0.78rem; @@ -456,6 +547,15 @@ grid-template-columns: 1fr; } + .profile-header { + align-items: flex-start; + flex-direction: column; + } + + .profile-grid { + grid-template-columns: 1fr; + } + .project-card { align-items: flex-start; grid-template-columns: 30px minmax(0, 1fr) auto; @@ -486,6 +586,35 @@

Welcome back, {{ username }}!

+
+
+
Account Profile
+ +
+
+
+ +
-
+
+
+ +
{{ username }}
+
+
+ +
-
+
+
+ +
0
+
+
+ + +
+
+
+
Your Projects
  • Loading projects...
  • @@ -524,6 +653,28 @@
+ + diff --git a/run_intranet_server.ps1 b/run_intranet_server.ps1 new file mode 100644 index 0000000..f4b0f32 --- /dev/null +++ b/run_intranet_server.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" + +if (-not $env:MXPIC_SECRET_KEY) { + $env:MXPIC_SECRET_KEY = "replace-this-with-a-long-random-office-secret" +} + +$env:MXPIC_HOST = "0.0.0.0" +$env:MXPIC_PORT = "3000" +$env:MXPIC_DEBUG = "0" + +python backend\server.py