From b97372f74df570973ad3290db55c9901901d4bfa Mon Sep 17 00:00:00 2001 From: ske087 Date: Sun, 7 Jun 2026 21:28:09 +0300 Subject: [PATCH] added gunicorn and updated to the last version of server monitorizare --- IT_asset_management/Dockerfile | 2 +- IT_asset_management/app/extensions.py | 18 + IT_asset_management/requirements.txt | 1 + IT_asset_management/run.py | 2 +- NetworkView/backend/.dockerignore | 8 + Server_Monitorizare_v2/.dockerignore | 12 + Server_Monitorizare_v2/.gitignore | 1 + Server_Monitorizare_v2/Dockerfile | 30 + .../ansible/playbooks/migrate_to_wmt.yml | 40 +- .../ansible/playbooks/update_wmt_code.yml | 135 +++ Server_Monitorizare_v2/app/api/wmt.py | 176 ++- Server_Monitorizare_v2/app/models/__init__.py | 4 +- .../app/services/ansible_service.py | 1 + Server_Monitorizare_v2/app/web/ansible.py | 13 +- Server_Monitorizare_v2/app/web/main.py | 63 +- Server_Monitorizare_v2/app/web/wmt.py | 203 +++- .../config/database_config.py | 17 +- Server_Monitorizare_v2/main.py | 2 +- Server_Monitorizare_v2/requirements.txt | 3 +- .../scripts/update_database_schema.py | 17 + Server_Monitorizare_v2/templates/admin.html | 117 +- .../templates/ansible/execute.html | 19 +- .../templates/ansible/playbooks.html | 26 +- Server_Monitorizare_v2/templates/base.html | 998 +++++++++++++++--- .../templates/device_management.html | 103 +- Server_Monitorizare_v2/templates/logs.html | 14 + .../templates/wmt/device_form.html | 28 + .../templates/wmt/index.html | 2 + .../templates/wmt/releases.html | 196 ++++ Server_Monitorizare_v2/wsgi.py | 17 + digiserver-v2/app/extensions.py | 18 + docker-compose.yml | 25 +- nginx/nginx.conf | 22 + portal/app/extensions.py | 18 + portal/app/routes/api.py | 2 + 35 files changed, 2098 insertions(+), 255 deletions(-) create mode 100644 NetworkView/backend/.dockerignore create mode 100644 Server_Monitorizare_v2/.dockerignore create mode 100644 Server_Monitorizare_v2/Dockerfile create mode 100644 Server_Monitorizare_v2/ansible/playbooks/update_wmt_code.yml create mode 100644 Server_Monitorizare_v2/templates/wmt/releases.html create mode 100644 Server_Monitorizare_v2/wsgi.py diff --git a/IT_asset_management/Dockerfile b/IT_asset_management/Dockerfile index 4a607e7..afb36f8 100644 --- a/IT_asset_management/Dockerfile +++ b/IT_asset_management/Dockerfile @@ -22,4 +22,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 5000 -CMD ["python", "run.py"] +CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "4", "--timeout", "120", "run:app"] diff --git a/IT_asset_management/app/extensions.py b/IT_asset_management/app/extensions.py index 2f08516..36de005 100644 --- a/IT_asset_management/app/extensions.py +++ b/IT_asset_management/app/extensions.py @@ -1,6 +1,9 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager +from sqlalchemy import event +from sqlalchemy.engine import Engine +import sqlite3 db = SQLAlchemy() migrate = Migrate() @@ -8,3 +11,18 @@ login_manager = LoginManager() login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' login_manager.login_message_category = 'warning' + + +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record): + """Enable WAL mode and sane concurrency settings for SQLite. + + WAL lets readers and writers operate concurrently, which prevents + 'database is locked' errors when running under multi-worker Gunicorn. + """ + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.close() diff --git a/IT_asset_management/requirements.txt b/IT_asset_management/requirements.txt index e61da4e..943c614 100644 --- a/IT_asset_management/requirements.txt +++ b/IT_asset_management/requirements.txt @@ -14,3 +14,4 @@ cryptography==42.0.8 requests==2.32.3 httpx[http2] docxtpl +gunicorn==23.0.0 diff --git a/IT_asset_management/run.py b/IT_asset_management/run.py index db8226a..3b30eb5 100644 --- a/IT_asset_management/run.py +++ b/IT_asset_management/run.py @@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'development')) if __name__ == '__main__': port = int(os.environ.get('PORT', 5003)) - app.run(host='127.0.0.1', port=port) + app.run(host='0.0.0.0', port=port) diff --git a/NetworkView/backend/.dockerignore b/NetworkView/backend/.dockerignore new file mode 100644 index 0000000..c325177 --- /dev/null +++ b/NetworkView/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.env +data +*.sqlite +*.db +.git +.gitignore diff --git a/Server_Monitorizare_v2/.dockerignore b/Server_Monitorizare_v2/.dockerignore new file mode 100644 index 0000000..b8b8d8b --- /dev/null +++ b/Server_Monitorizare_v2/.dockerignore @@ -0,0 +1,12 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +.git/ +.gitignore +data/ +logs/ +docs/ +*.db +.env diff --git a/Server_Monitorizare_v2/.gitignore b/Server_Monitorizare_v2/.gitignore index 4d54272..b28d1fe 100644 --- a/Server_Monitorizare_v2/.gitignore +++ b/Server_Monitorizare_v2/.gitignore @@ -37,6 +37,7 @@ ansible/playbooks/*.yml !ansible/playbooks/restart_service.yml !ansible/playbooks/migrate_to_wmt.yml !ansible/playbooks/Update_Rest_WMT_client.yml +!ansible/playbooks/update_wmt_code.yml # VS Code .vscode/ diff --git a/Server_Monitorizare_v2/Dockerfile b/Server_Monitorizare_v2/Dockerfile new file mode 100644 index 0000000..41ab802 --- /dev/null +++ b/Server_Monitorizare_v2/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +WORKDIR /app + +# System dependencies: +# openssh-client + sshpass — required by Ansible/paramiko for remote device ops +# gcc — builds paramiko's C extensions +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-client \ + sshpass \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir ansible-core + +COPY . . + +# Runtime data directories (also mounted as volumes in compose) +RUN mkdir -p data logs + +ENV FLASK_ENV=production +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=5000 + +EXPOSE 5000 + +CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "4", "--timeout", "300", "wsgi:app"] diff --git a/Server_Monitorizare_v2/ansible/playbooks/migrate_to_wmt.yml b/Server_Monitorizare_v2/ansible/playbooks/migrate_to_wmt.yml index c76336a..f3091f5 100644 --- a/Server_Monitorizare_v2/ansible/playbooks/migrate_to_wmt.yml +++ b/Server_Monitorizare_v2/ansible/playbooks/migrate_to_wmt.yml @@ -97,13 +97,39 @@ debug: msg: "work_place will be set to: '{{ work_place_value }}'" - # ── 5. Replace work_place value in WMT/data/config.txt ─────────────── - - name: Replace work_place in WMT config.txt - lineinfile: - path: /home/pi/Desktop/WMT/data/config.txt - regexp: '^work_place\s*=.*' - line: "work_place={{ work_place_value }}" - backup: true + # ── 5. Write work_place into WMT/data/config.txt ────────────────────── + # Uses Python (always available on Raspberry Pi) to correctly + # read/write the INI file and set work_place inside [device]. + # Also resets last_synced to epoch so first startup does NOT + # overwrite work_place with a potentially empty server value. + - name: Set work_place in WMT config.txt via Python + ansible.builtin.shell: + cmd: | + python3 - <<'PYEOF' + import configparser, os + path = '/home/pi/Desktop/WMT/data/config.txt' + p = configparser.ConfigParser() + p.read(path) + if not p.has_section('chrome'): + p.add_section('chrome') + if not p.has_section('card_api'): + p.add_section('card_api') + if not p.has_section('server'): + p.add_section('server') + if not p.has_section('device'): + p.add_section('device') + if not p.has_section('meta'): + p.add_section('meta') + p.set('device', 'work_place', '{{ work_place_value }}') + # Reset last_synced so first startup pull from server does not + # overwrite the work_place we just set (server will return the + # correct device_name once this device checks in). + p.set('meta', 'last_synced', '1970-01-01T00:00:00') + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + p.write(f) + print('work_place set to: {{ work_place_value }}') + PYEOF - name: Confirm work_place change command: grep 'work_place' /home/pi/Desktop/WMT/data/config.txt diff --git a/Server_Monitorizare_v2/ansible/playbooks/update_wmt_code.yml b/Server_Monitorizare_v2/ansible/playbooks/update_wmt_code.yml new file mode 100644 index 0000000..70af2c4 --- /dev/null +++ b/Server_Monitorizare_v2/ansible/playbooks/update_wmt_code.yml @@ -0,0 +1,135 @@ +--- +# Update WMT client code from the controller's WMT_project folder +# ────────────────────────────────────────────────────────────────────────── +# Use this for devices that have not yet received the HTTP auto-update, +# or whenever you need to force a code push from the server. +# +# What this playbook does: +# 1. Ensure WMT directory exists on the target +# 2. Back up the current app.py as app.py.bak. +# 3. Copy everything from /home/pi/Desktop/WMT_project/ on the CONTROLLER +# → /home/pi/Desktop/WMT/ on the TARGET +# The data/ directory on the target is fully preserved (never touched) +# 4. Fix file ownership +# 5. Restart the wmt systemd service +# +# The data/ directory (config.txt, idmasa.txt, tag.txt, log.txt, device_info.txt) +# is intentionally excluded — device-specific settings stay intact. +# +# Run via: Ansible > Playbooks > "Update WMT Code" +# ────────────────────────────────────────────────────────────────────────── + +- name: Update WMT client code from WMT_project folder + hosts: all + gather_facts: false + become: false + + vars: + controller_src: /home/pi/Desktop/WMT_project/ + wmt_dir: /home/pi/Desktop/WMT + + tasks: + + # ── 1. Ensure WMT directory exists ──────────────────────────────────── + - name: Ensure WMT directory exists on target + file: + path: "{{ wmt_dir }}" + state: directory + owner: pi + group: pi + mode: '0755' + + # ── 2. Back up current app.py ───────────────────────────────────────── + - name: Read first line of current app.py (for backup filename) + shell: head -1 {{ wmt_dir }}/app.py 2>/dev/null || echo "unknown" + register: local_first_line + changed_when: false + ignore_errors: true + + - name: Extract local version number + set_fact: + local_version: >- + {{ local_first_line.stdout + | regex_search('version\s+([\d.]+)', '\1') + | first | default('old') }} + + - name: Back up current app.py + copy: + src: "{{ wmt_dir }}/app.py" + dest: "{{ wmt_dir }}/app.py.bak.{{ local_version }}" + remote_src: true + owner: pi + group: pi + mode: preserve + ignore_errors: true + + - name: Show backup info + debug: + msg: "Backed up app.py v{{ local_version }} → app.py.bak.{{ local_version }}" + + # ── 3. Snapshot data/ before copy (audit) ──────────────────────────── + - name: List current data/ files (audit) + shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty or missing)" + register: data_files_before + changed_when: false + + - name: Show data/ files that will be preserved + debug: + msg: "data/ contents (will NOT be changed): {{ data_files_before.stdout_lines }}" + + # ── 4. Sync WMT_project → WMT on target, excluding data/ ───────────── + # synchronize uses rsync under the hood; delegate_to pushes + # from the controller to the target. + - name: Sync WMT_project to target (exclude data/ and junk files) + synchronize: + src: "{{ controller_src }}" + dest: "{{ wmt_dir }}/" + recursive: true + delete: false + checksum: true + rsync_opts: + - "--exclude=data/" + - "--exclude=.git/" + - "--exclude=.gitignore" + - "--exclude=__pycache__/" + - "--exclude=*.pyc" + - "--exclude=*.pyo" + - "--exclude=*.log" + - "--exclude=*.bak" + - "--exclude=venv/" + - "--exclude=.venv/" + - "--exclude=node_modules/" + - "--exclude=.*" + register: sync_result + + - name: Show sync summary + debug: + msg: "Sync completed. Changed: {{ sync_result.changed }}" + + # ── 5. Fix ownership ────────────────────────────────────────────────── + - name: Set correct ownership on WMT directory + become: true + file: + path: "{{ wmt_dir }}" + owner: pi + group: pi + recurse: true + + # ── 6. Verify data/ is still intact ────────────────────────────────── + - name: List data/ files after update (verification) + shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty)" + register: data_files_after + changed_when: false + + - name: Show data/ contents after update (should match before) + debug: + msg: "{{ data_files_after.stdout_lines }}" + + # ── 9. Reboot ───────────────────────────────────────────────────────── + - name: Reboot host to apply all changes + become: true + reboot: + msg: "Rebooting after WMT code update " + reboot_timeout: 180 + pre_reboot_delay: 3 + post_reboot_delay: 15 \ No newline at end of file diff --git a/Server_Monitorizare_v2/app/api/wmt.py b/Server_Monitorizare_v2/app/api/wmt.py index d61039d..ee60fdb 100644 --- a/Server_Monitorizare_v2/app/api/wmt.py +++ b/Server_Monitorizare_v2/app/api/wmt.py @@ -2,8 +2,10 @@ WMT (Workstation Management Terminal) configuration API Handles config distribution and device update requests from WMT clients. """ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, send_file from datetime import datetime +from pathlib import Path +import json from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from config.database_config import get_db import logging @@ -96,8 +98,8 @@ def get_device_config(mac_address): _, device_ts, latest_ts = _latest_config_ts(session, mac) payload = { - # Global settings - 'chrome_url': global_cfg.chrome_url, + # Global settings (device custom_chrome_url overrides global chrome_url if set) + 'chrome_url': (device.custom_chrome_url if device and device.custom_chrome_url else global_cfg.chrome_url), 'chrome_local_url': global_cfg.chrome_local_url or '', 'chrome_insecure_origin': global_cfg.chrome_insecure_origin, 'card_api_base_url': global_cfg.card_api_base_url, @@ -125,7 +127,25 @@ def get_device_config(mac_address): @wmt_api_bp.route('/config/update_request', methods=['POST']) def submit_update_request(): """ - WMT client sends current device info as an update request for admin approval. + WMT client sends its current running config so the server can validate it. + Three outcomes: + + 1. Device known AND config matches server record + → record config_synced_at = now, update last_seen + → respond {"status": "ok", "in_sync": true} + Client does nothing. + + 2. Device known BUT config differs from server record + → the server is authoritative; client must pull fresh config + → respond {"status": "sync_required", "in_sync": false} + Client should call GET /api/wmt/config/ to obtain correct values. + + 3. Device unknown (new client – not registered by MAC or hostname) + → create WMTUpdateRequest so admin can approve and assign settings + → respond {"status": "pending_approval"} + Client waits; it will get real config once admin approves. + + card_presence is always applied directly – no approval required. Expected JSON: { @@ -133,7 +153,8 @@ def submit_update_request(): "device_name": "Masa-01", "hostname": "rpi-masa01", "device_ip": "192.168.1.100", - "client_config_mtime": "2026-04-22T09:30:00" // optional + "card_presence": "enable", + "client_config_mtime": "2026-04-22T09:30:00" } """ if not request.is_json: @@ -144,31 +165,138 @@ def submit_update_request(): if not mac: return jsonify({'error': 'mac_address is required'}), 400 + proposed_name = (data.get('device_name') or '').strip() + proposed_hostname = (data.get('hostname') or '').strip() + proposed_ip = (data.get('device_ip') or '').strip() + card_presence = data.get('card_presence') + + def _eq(a, b): + return (a or '') == (b or '') + try: with get_db().get_session() as session: device = session.query(Device).filter_by(mac_address=mac).first() - req = WMTUpdateRequest( - mac_address=mac, - device_id=device.id if device else None, - proposed_device_name=data.get('device_name'), - proposed_hostname=data.get('hostname'), - proposed_device_ip=data.get('device_ip'), - client_config_mtime=data.get('client_config_mtime'), - submitted_at=datetime.utcnow(), - status='pending', + # ── Outcome 3: unknown device ───────────────────────────── + if not device: + # Check if a pending request with the same data already exists + existing = ( + session.query(WMTUpdateRequest) + .filter_by(mac_address=mac, status='pending') + .order_by(WMTUpdateRequest.submitted_at.desc()) + .first() + ) + if existing and ( + _eq(existing.proposed_device_name, proposed_name) and + _eq(existing.proposed_hostname, proposed_hostname) and + _eq(existing.proposed_device_ip, proposed_ip) + ): + existing.submitted_at = datetime.utcnow() + logger.debug(f'WMT unknown device {mac} – refreshed existing pending request') + else: + req = WMTUpdateRequest( + mac_address=mac, + device_id=None, + proposed_device_name=proposed_name or None, + proposed_hostname=proposed_hostname or None, + proposed_device_ip=proposed_ip or None, + client_config_mtime=data.get('client_config_mtime'), + submitted_at=datetime.utcnow(), + status='pending', + ) + session.add(req) + logger.info(f'WMT pending approval request created for unknown device {mac}') + + return jsonify({ + 'status': 'pending_approval', + 'message': 'Device not registered. Awaiting admin approval.', + }), 202 + + # Device is known from here on ───────────────────────────── + device.last_seen = datetime.utcnow() + if card_presence in ('enable', 'disable'): + device.card_presence = card_presence + + # ── Outcome 1: config matches server record ──────────────── + config_in_sync = ( + _eq(proposed_name, device.nume_masa) and + _eq(proposed_hostname, device.hostname) and + _eq(proposed_ip, device.device_ip) ) - session.add(req) - # Update device last_seen - if device: - device.last_seen = datetime.utcnow() - # card_presence is a device capability flag – update directly (no approval needed) - if data.get('card_presence') in ('enable', 'disable'): - device.card_presence = data['card_presence'] + if config_in_sync: + device.config_synced_at = datetime.utcnow() + logger.debug(f'WMT config OK for {mac} – synced_at updated') + return jsonify({ + 'status': 'ok', + 'in_sync': True, + 'message': 'Config matches server record.', + }), 200 + + # ── Outcome 2: config differs – client must pull from server ─ + logger.info( + f'WMT config mismatch for {mac}: ' + f'client has name={proposed_name!r} host={proposed_hostname!r} ip={proposed_ip!r} ' + f'but server has name={device.nume_masa!r} host={device.hostname!r} ip={device.device_ip!r}' + ) + return jsonify({ + 'status': 'sync_required', + 'in_sync': False, + 'message': 'Config differs from server record. Pull updated config.', + }), 200 - logger.info(f'WMT update request received from {mac}') - return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201 except Exception as e: - logger.error(f'Error saving WMT update request from {mac}: {e}') + logger.error(f'Error processing WMT config check from {mac}: {e}') + return jsonify({'error': str(e)}), 500 + + +# --------------------------------------------------------------------------- +# WMT client auto-update endpoints +# --------------------------------------------------------------------------- + +WMT_RELEASES_DIR = Path('data/wmt_releases') + + +@wmt_api_bp.route('/client/version', methods=['GET']) +def get_client_version(): + """ + Returns the latest available WMT client version metadata. + Client calls this to decide if it needs to update. + + Response: { "version": "3.0", "notes": "...", "uploaded_at": "...", "filename": "wmt_v3.0.zip" } + """ + meta_path = WMT_RELEASES_DIR / 'latest.json' + if not meta_path.exists(): + return jsonify({'error': 'No release available'}), 404 + try: + meta = json.loads(meta_path.read_text()) + return jsonify(meta), 200 + except Exception as e: + logger.error(f'Error reading WMT release metadata: {e}') + return jsonify({'error': str(e)}), 500 + + +@wmt_api_bp.route('/client/download', methods=['GET']) +def download_client_release(): + """ + Streams the latest WMT client release zip to the requesting device. + Client downloads this when its version is older than the server version. + """ + meta_path = WMT_RELEASES_DIR / 'latest.json' + if not meta_path.exists(): + return jsonify({'error': 'No release available'}), 404 + try: + meta = json.loads(meta_path.read_text()) + zip_path = WMT_RELEASES_DIR / meta['filename'] + if not zip_path.exists(): + return jsonify({'error': f'Release file not found: {meta["filename"]}'}), 404 + logger.info(f'WMT client downloading release {meta["version"]} from {request.remote_addr}') + return send_file( + str(zip_path.resolve()), + mimetype='application/zip', + as_attachment=True, + download_name=meta['filename'], + ) + except Exception as e: + logger.error(f'Error serving WMT release: {e}') return jsonify({'error': str(e)}), 500 diff --git a/Server_Monitorizare_v2/app/models/__init__.py b/Server_Monitorizare_v2/app/models/__init__.py index d354fcb..f46fcd1 100644 --- a/Server_Monitorizare_v2/app/models/__init__.py +++ b/Server_Monitorizare_v2/app/models/__init__.py @@ -48,9 +48,11 @@ class Device(Base): # WMT (Workstation Management Terminal) integration fields mac_address = Column(String(17), unique=True, nullable=True, index=True) - config_updated_at = Column(DateTime) + config_updated_at = Column(DateTime) # set by admin when pushing new config + config_synced_at = Column(DateTime) # set by server when client confirms in-sync info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1)) card_presence = Column(String(10), default='enable') + custom_chrome_url = Column(String(500), nullable=True) # per-device production URL override (overrides WMTGlobalConfig.chrome_url) # Relationships logs = relationship("LogEntry", back_populates="device") diff --git a/Server_Monitorizare_v2/app/services/ansible_service.py b/Server_Monitorizare_v2/app/services/ansible_service.py index 7576094..703e678 100644 --- a/Server_Monitorizare_v2/app/services/ansible_service.py +++ b/Server_Monitorizare_v2/app/services/ansible_service.py @@ -737,6 +737,7 @@ class AnsibleService: 'system_health': 'Check system health and monitoring status', 'maintenance_mode': 'Put devices in maintenance mode', 'distribute_ssh_keys': 'Push server public key to all devices using password auth', + 'update_wmt_code': 'Push WMT_project code to clients from controller (preserves data/)', } return descriptions.get(playbook_name, f'Execute {playbook_name} playbook') diff --git a/Server_Monitorizare_v2/app/web/ansible.py b/Server_Monitorizare_v2/app/web/ansible.py index 68c2b60..fb03e46 100644 --- a/Server_Monitorizare_v2/app/web/ansible.py +++ b/Server_Monitorizare_v2/app/web/ansible.py @@ -129,10 +129,20 @@ def execute(): seen.add(h['hostname']) settings = ansible_service.load_settings() + + # Discover custom playbooks (exclude built-ins that have dedicated buttons) + _builtin_names = {'update_devices', 'restart_service', 'distribute_ssh_keys', 'system_health'} + custom_playbooks = [] + if ansible_service.playbook_dir.exists(): + for _f in sorted(ansible_service.playbook_dir.glob('*.yml')): + if _f.stem.lower() not in _builtin_names: + custom_playbooks.append({'name': _f.stem, 'filename': _f.name}) + return render_template('ansible/execute.html', inventory=inventory_data, all_inv_hosts=all_inv_hosts, preselect_playbook=preselect, + custom_playbooks=custom_playbooks, use_password_auth=settings.get('use_password_auth', False)) except Exception as e: logging.error(f"Error loading execute form: {e}") @@ -141,6 +151,7 @@ def execute(): inventory={'groups': {}}, all_inv_hosts=[], preselect_playbook='', + custom_playbooks=[], use_password_auth=False) elif request.method == 'POST': @@ -462,7 +473,7 @@ def playbook_content(): from pathlib import Path requested_path = Path(playbook_path) if not requested_path.is_absolute(): - requested_path = ansible_service.playbook_dir / requested_path + requested_path = Path.cwd() / requested_path # Ensure path is within playbook directory try: diff --git a/Server_Monitorizare_v2/app/web/main.py b/Server_Monitorizare_v2/app/web/main.py index 2da02d4..cabaaf6 100644 --- a/Server_Monitorizare_v2/app/web/main.py +++ b/Server_Monitorizare_v2/app/web/main.py @@ -34,7 +34,7 @@ def devices(): with get_db().get_session() as session: devices = session.query(Device).order_by(Device.last_seen.desc()).all() - # Get log count per device + # Log count per device device_log_counts = {} for device in devices: log_count = session.query(LogEntry).filter_by(device_id=device.id).count() @@ -42,14 +42,22 @@ def devices(): pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count() + # Pending approval requests mapped by mac_address for per-device badge + pending_requests = session.query(WMTUpdateRequest).filter_by(status='pending').all() + pending_by_mac = {} + for req in pending_requests: + pending_by_mac[req.mac_address] = pending_by_mac.get(req.mac_address, 0) + 1 + return render_template('device_management.html', devices=devices, device_log_counts=device_log_counts, - pending_count=pending_count) + pending_count=pending_count, + pending_by_mac=pending_by_mac) except Exception as e: logging.error(f"Error loading devices: {e}") flash(f'Error loading devices: {e}', 'error') - return render_template('device_management.html', devices=[], device_log_counts={}, pending_count=0) + return render_template('device_management.html', devices=[], device_log_counts={}, + pending_count=0, pending_by_mac={}) @main_bp.route('/device/') def device_detail(device_id): @@ -516,7 +524,18 @@ def admin(): logging.error(f'Admin inventory stats error: {e}') stats['inventory_hosts'] = '?' stats['inventory_groups_yaml'] = '?' - return render_template('admin.html', stats=stats) + # Pass registered devices for per-device delete UI + devices = [] + try: + with get_db().get_session() as session: + devices = [ + {'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa, + 'mac_address': d.mac_address, 'device_ip': d.device_ip} + for d in session.query(Device).order_by(Device.nume_masa).all() + ] + except Exception as e: + logging.error(f'Admin device list error: {e}') + return render_template('admin.html', stats=stats, devices=devices) @main_bp.route('/admin/clear/logs', methods=['POST']) @@ -532,9 +551,43 @@ def admin_clear_logs(): return jsonify({'success': False, 'error': str(e)}), 500 +@main_bp.route('/admin/clear/device-logs', methods=['POST']) +def admin_clear_device_logs(): + """Delete all log entries from the database (devices stay intact).""" + try: + with get_db().get_session() as session: + count = session.query(LogEntry).delete() + session.commit() + return jsonify({'success': True, 'deleted': count}) + except Exception as e: + logging.error(f'Admin clear device logs error: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@main_bp.route('/admin/delete/device/', methods=['POST']) +def admin_delete_device(device_id): + """Delete a single registered device and its log entries.""" + try: + with get_db().get_session() as session: + device = session.query(Device).filter_by(id=device_id).first() + if not device: + return jsonify({'success': False, 'error': 'Device not found'}), 404 + name = device.nume_masa or device.hostname + session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id}) + session.query(LogEntry).filter_by(device_id=device_id).delete() + session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete() + session.delete(device) + session.commit() + logging.info(f'Admin deleted device {device_id} ({name})') + return jsonify({'success': True, 'name': name}) + except Exception as e: + logging.error(f'Admin delete device {device_id} error: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + @main_bp.route('/admin/clear/devices', methods=['POST']) def admin_clear_devices(): - """Delete all devices (and their log entries) from the database.""" + """Delete ALL devices (and their log entries) from the database.""" try: with get_db().get_session() as session: session.execute(text('DELETE FROM device_inventory_groups')) diff --git a/Server_Monitorizare_v2/app/web/wmt.py b/Server_Monitorizare_v2/app/web/wmt.py index aef6757..08414c8 100644 --- a/Server_Monitorizare_v2/app/web/wmt.py +++ b/Server_Monitorizare_v2/app/web/wmt.py @@ -3,8 +3,12 @@ WMT management web routes – global settings, device registry, update requests. """ import csv import io -from flask import Blueprint, render_template, request, redirect, url_for, flash, Response -from datetime import datetime +import json +import re +import zipfile +from pathlib import Path +from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, jsonify +from datetime import datetime, timezone from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from config.database_config import get_db import logging @@ -295,6 +299,8 @@ def device_edit(device_id): device.location = request.form.get('location', '').strip() or None device.card_presence = request.form.get('card_presence', 'enable') device.description = request.form.get('notes', '').strip() or None + custom_url = request.form.get('custom_chrome_url', '').strip() + device.custom_chrome_url = custom_url if custom_url else None device.config_updated_at = datetime.utcnow() device.info_reviewed_at = datetime.utcnow() flash('Device updated.', 'success') @@ -363,3 +369,196 @@ def device_delete(device_id): logger.error(f'WMT device_delete error: {e}') flash(f'Error: {e}', 'error') return redirect(url_for('wmt_web.devices')) + + +# --------------------------------------------------------------------------- +# WMT Client Release Management +# --------------------------------------------------------------------------- + +WMT_RELEASES_DIR = Path('data/wmt_releases') + + +def _read_release_meta(): + meta_path = WMT_RELEASES_DIR / 'latest.json' + if meta_path.exists(): + try: + return json.loads(meta_path.read_text()) + except Exception: + pass + return None + + +@wmt_web_bp.route('/releases') +def releases(): + """WMT client release management page.""" + meta = _read_release_meta() + zip_size = None + if meta: + zip_path = WMT_RELEASES_DIR / meta.get('filename', '') + if zip_path.exists(): + zip_size = zip_path.stat().st_size + return render_template('wmt/releases.html', meta=meta, zip_size=zip_size) + + +@wmt_web_bp.route('/releases/upload', methods=['POST']) +def releases_upload(): + """Upload a new WMT client release zip and set it as latest.""" + f = request.files.get('release_zip') + version_str = request.form.get('version', '').strip() + notes = request.form.get('notes', '').strip() + + if not f or not f.filename.endswith('.zip'): + flash('Please upload a .zip file.', 'error') + return redirect(url_for('wmt_web.releases')) + + if not version_str: + flash('Version is required.', 'error') + return redirect(url_for('wmt_web.releases')) + + # Validate version format (digits and dots) + if not re.match(r'^\d+(\.\d+)*$', version_str): + flash('Version must be in numeric format (e.g. 3.0 or 3.1.2).', 'error') + return redirect(url_for('wmt_web.releases')) + + try: + WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True) + filename = f'wmt_v{version_str}.zip' + zip_path = WMT_RELEASES_DIR / filename + + # Validate the zip contains app.py + data = f.read() + try: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + names = zf.namelist() + if 'app.py' not in names: + flash('Invalid release: zip must contain app.py at the root.', 'error') + return redirect(url_for('wmt_web.releases')) + except zipfile.BadZipFile: + flash('Uploaded file is not a valid zip archive.', 'error') + return redirect(url_for('wmt_web.releases')) + + zip_path.write_bytes(data) + + meta = { + 'version': version_str, + 'notes': notes, + 'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'), + 'filename': filename, + } + (WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2)) + + logger.info(f'New WMT release uploaded: v{version_str} ({filename})') + flash(f'Release v{version_str} uploaded and set as latest.', 'success') + except Exception as e: + logger.error(f'WMT release upload error: {e}') + flash(f'Upload error: {e}', 'error') + + return redirect(url_for('wmt_web.releases')) + + +@wmt_web_bp.route('/releases/delete', methods=['POST']) +def releases_delete(): + """Remove the current release zip and metadata.""" + try: + meta = _read_release_meta() + if meta: + zip_path = WMT_RELEASES_DIR / meta.get('filename', '') + if zip_path.exists(): + zip_path.unlink() + meta_path = WMT_RELEASES_DIR / 'latest.json' + if meta_path.exists(): + meta_path.unlink() + flash('Release deleted.', 'success') + except Exception as e: + logger.error(f'WMT release delete error: {e}') + flash(f'Error: {e}', 'error') + return redirect(url_for('wmt_web.releases')) + + +# Patterns excluded when building a release from a local folder +_BUILD_EXCLUDE_DIRS = {'.git', '__pycache__', 'data', 'venv', '.venv', 'node_modules'} +_BUILD_EXCLUDE_EXTS = {'.pyc', '.pyo', '.bak', '.log', '.swp'} + + +def _should_exclude(rel_parts): + """Return True if this path should be left out of the release zip.""" + # Any hidden segment (starts with '.') + for part in rel_parts: + if part.startswith('.'): + return True + # Top-level or nested dir exclusion + if rel_parts[0] in _BUILD_EXCLUDE_DIRS: + return True + # Extension exclusion + if Path(rel_parts[-1]).suffix.lower() in _BUILD_EXCLUDE_EXTS: + return True + return False + + +@wmt_web_bp.route('/releases/build', methods=['POST']) +def releases_build(): + """ + Build a release zip directly from a folder on this server, + excluding hidden files, __pycache__, data/, venv/, etc. + """ + folder_path = request.form.get('folder_path', '/home/pi/Desktop/WMT').strip() + version_str = request.form.get('version', '').strip() + notes = request.form.get('notes', '').strip() + + if not version_str: + flash('Version is required.', 'error') + return redirect(url_for('wmt_web.releases')) + if not re.match(r'^\d+(\.\d+)*$', version_str): + flash('Version must be in numeric format (e.g. 3.0).', 'error') + return redirect(url_for('wmt_web.releases')) + + src = Path(folder_path) + if not src.is_dir(): + flash(f'Folder not found: {folder_path}', 'error') + return redirect(url_for('wmt_web.releases')) + + try: + buf = io.BytesIO() + file_count = 0 + has_app_py = False + + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for abs_path in sorted(src.rglob('*')): + if not abs_path.is_file(): + continue + rel = abs_path.relative_to(src) + parts = rel.parts + if _should_exclude(parts): + continue + arcname = str(rel) + zf.write(abs_path, arcname) + file_count += 1 + if arcname == 'app.py': + has_app_py = True + + if not has_app_py: + flash(f'No app.py found in {folder_path} — cannot create release.', 'error') + return redirect(url_for('wmt_web.releases')) + + WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True) + filename = f'wmt_v{version_str}.zip' + zip_path = WMT_RELEASES_DIR / filename + zip_path.write_bytes(buf.getvalue()) + + meta = { + 'version': version_str, + 'notes': notes, + 'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'), + 'filename': filename, + } + (WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2)) + + size_kb = len(buf.getvalue()) // 1024 + logger.info(f'WMT release built from {folder_path}: v{version_str}, {file_count} files, {size_kb} KB') + flash(f'Release v{version_str} built from {folder_path} ({file_count} files, {size_kb} KB).', 'success') + + except Exception as e: + logger.error(f'WMT release build error: {e}') + flash(f'Build error: {e}', 'error') + + return redirect(url_for('wmt_web.releases')) diff --git a/Server_Monitorizare_v2/config/database_config.py b/Server_Monitorizare_v2/config/database_config.py index 988b419..de5213c 100644 --- a/Server_Monitorizare_v2/config/database_config.py +++ b/Server_Monitorizare_v2/config/database_config.py @@ -2,7 +2,8 @@ Database configuration and connection management """ import os -from sqlalchemy import create_engine, MetaData +import sqlite3 +from sqlalchemy import create_engine, MetaData, event from sqlalchemy.orm import sessionmaker from contextlib import contextmanager from app.models import Base @@ -38,7 +39,19 @@ class DatabaseConfig: pool_pre_ping=True, connect_args={"check_same_thread": False} # For SQLite ) - + + # Enable WAL mode + sane concurrency settings on each SQLite connection. + # WAL lets readers and writers operate concurrently, preventing + # 'database is locked' errors under multi-worker Gunicorn. + @event.listens_for(self.engine, "connect") + def _set_sqlite_pragma(dbapi_connection, connection_record): + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.close() + # Create session factory self.Session = sessionmaker(bind=self.engine) diff --git a/Server_Monitorizare_v2/main.py b/Server_Monitorizare_v2/main.py index 95aff63..15d2fd6 100644 --- a/Server_Monitorizare_v2/main.py +++ b/Server_Monitorizare_v2/main.py @@ -46,7 +46,7 @@ def main(): # Run application try: app.run( - host='127.0.0.1', + host='0.0.0.0', port=int(os.environ.get('PORT', 80)), debug=app.config.get('DEBUG', False) ) diff --git a/Server_Monitorizare_v2/requirements.txt b/Server_Monitorizare_v2/requirements.txt index fdf141e..49c3242 100644 --- a/Server_Monitorizare_v2/requirements.txt +++ b/Server_Monitorizare_v2/requirements.txt @@ -3,4 +3,5 @@ SQLAlchemy>=2.0.36 paramiko==3.3.1 PyYAML==6.0.1 requests==2.31.0 -Werkzeug==2.3.7 \ No newline at end of file +Werkzeug==2.3.7 +gunicorn==23.0.0 \ No newline at end of file diff --git a/Server_Monitorizare_v2/scripts/update_database_schema.py b/Server_Monitorizare_v2/scripts/update_database_schema.py index fa1700a..986ef1a 100755 --- a/Server_Monitorizare_v2/scripts/update_database_schema.py +++ b/Server_Monitorizare_v2/scripts/update_database_schema.py @@ -57,6 +57,22 @@ class DatabaseSchemaUpdater: except Exception as e: print(f" ❌ Error adding {column_name}: {e}") + def add_config_synced_at_column(self): + """Add config_synced_at column to devices table (WMT config sync tracking)""" + print("📊 Updating devices table schema (config_synced_at)...") + with self.engine.connect() as conn: + try: + result = conn.execute(text("PRAGMA table_info(devices)")) + existing = [row[1] for row in result.fetchall()] + if 'config_synced_at' not in existing: + conn.execute(text("ALTER TABLE devices ADD COLUMN config_synced_at DATETIME")) + conn.commit() + print(" ✅ Added column: config_synced_at") + else: + print(" ⏭️ Column config_synced_at already exists") + except Exception as e: + print(f" ❌ Error adding config_synced_at: {e}") + def create_tables(self): """Create all tables using SQLAlchemy metadata""" print("🏗️ Creating all database tables...") @@ -109,6 +125,7 @@ if __name__ == "__main__": # Update schema updater.update_playbook_executions_schema() + updater.add_config_synced_at_column() # Verify if updater.verify_schema(): diff --git a/Server_Monitorizare_v2/templates/admin.html b/Server_Monitorizare_v2/templates/admin.html index 6ee79c5..db91478 100644 --- a/Server_Monitorizare_v2/templates/admin.html +++ b/Server_Monitorizare_v2/templates/admin.html @@ -93,30 +93,77 @@ - +
-
+
-
Clear Device Database
+
Clear Device Logs

- Deletes all devices and their associated log entries from the database. - Devices will re-register automatically when they next check in. + Deletes all log entries from the database. + Registered devices are not affected and will continue logging automatically.

-
+
- Currently {{ stats.get('devices', '?') }} devices - and {{ stats.get('logs', '?') }} log entries. + Currently {{ stats.get('logs', '?') }} log entries.
-
+ +
+
+
+
Registered Device Registry
+ {{ devices|length }} device(s) +
+
+ {% if devices %} +
+ + + + + + + + + + + + {% for d in devices %} + + + + + + + + {% endfor %} + +
Work PlaceHostnameMACIPDelete
{{ d.nume_masa or '—' }}{{ d.hostname or '—' }}{{ d.mac_address or '—' }}{{ d.device_ip or '—' }} + +
+
+ {% else %} +
+
+ No registered devices. +
+ {% endif %} +
+
+
+
@@ -180,16 +227,17 @@ {% block extra_css %}{% endblock %} @@ -235,119 +866,130 @@
+ @@ -395,55 +1037,77 @@ {% block extra_js %}{% endblock %} diff --git a/Server_Monitorizare_v2/templates/device_management.html b/Server_Monitorizare_v2/templates/device_management.html index f166724..2c5f58b 100644 --- a/Server_Monitorizare_v2/templates/device_management.html +++ b/Server_Monitorizare_v2/templates/device_management.html @@ -1,14 +1,33 @@ {% extends "base.html" %} {% block title %}Devices – {{ app_name }}{% endblock %} -{% block page_title %}Devices{% endblock %} +{% block page_title %}Device Health{% endblock %} {% block extra_css %} {% endblock %} @@ -60,7 +79,7 @@

{{ pending_count }}

- Pending Requests + Pending Approval
@@ -88,7 +107,7 @@ Work Place Hostname IP - MAC Address + MAC / Type Status Logs Last Seen @@ -98,20 +117,35 @@ {% for device in devices %} + {% set is_wmt = device.mac_address is not none and device.mac_address != '' %} + {% set has_pending = is_wmt and (pending_by_mac.get(device.mac_address, 0) > 0) %} + + {{ device.nume_masa or '—' }} + {% if is_wmt %} +
WMT + {% endif %} + + {{ device.hostname }} + + {{ device.device_ip }} + + - {% if device.mac_address %} + {% if is_wmt %} {{ device.mac_address }} {% else %} {% endif %} + + {% if device.status == 'active' %} Active @@ -121,24 +155,55 @@ Offline {% endif %} + + {{ device_log_counts.get(device.id, 0) }} + + {% if device.last_seen %} {{ device.last_seen | local_dt }} {% else %}—{% endif %} + + - {% if device.mac_address and device.config_updated_at %} - - - {{ device.config_updated_at | local_dt('%m-%d %H:%M') }} - - {% elif device.mac_address %} - Never - {% else %} + {% if not is_wmt %} + + {% elif has_pending %} + {# Unknown device waiting for admin approval #} + + Pending Approval + + + {% elif device.config_synced_at %} + {# Device checked in and server confirmed configs match #} + + + OK · {{ device.config_synced_at | local_dt('%m-%d %H:%M') }} + + + {% elif device.config_updated_at %} + {# Admin pushed config but client hasn't confirmed sync yet #} + + Awaiting client + + + {% else %} + {# WMT device registered but never checked in #} + + Never synced + {% endif %} + + @@ -175,6 +240,15 @@
+ +
+ Config Sync legend: + OK — client confirmed configs match + Awaiting client — server pushed new config, waiting for client check-in + Pending Approval — new/unknown device waiting for admin + Never synced — registered but hasn't checked in +
+