added gunicorn and updated to the last version of server monitorizare

This commit is contained in:
ske087
2026-06-07 21:28:09 +03:00
parent 0aefadbfd8
commit b97372f74d
35 changed files with 2098 additions and 255 deletions
+1 -1
View File
@@ -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"]
+18
View File
@@ -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()
+1
View File
@@ -14,3 +14,4 @@ cryptography==42.0.8
requests==2.32.3
httpx[http2]
docxtpl
gunicorn==23.0.0
+1 -1
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.env
data
*.sqlite
*.db
.git
.gitignore
+12
View File
@@ -0,0 +1,12 @@
.venv/
venv/
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
data/
logs/
docs/
*.db
.env
+1
View File
@@ -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/
+30
View File
@@ -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"]
@@ -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
@@ -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.<version>
# 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
+152 -24
View File
@@ -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/<mac> 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
@@ -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")
@@ -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')
+12 -1
View File
@@ -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:
+58 -5
View File
@@ -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/<int:device_id>')
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/<int:device_id>', 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'))
+201 -2
View File
@@ -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'))
@@ -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
@@ -39,6 +40,18 @@ class DatabaseConfig:
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)
+1 -1
View File
@@ -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)
)
+1
View File
@@ -4,3 +4,4 @@ paramiko==3.3.1
PyYAML==6.0.1
requests==2.31.0
Werkzeug==2.3.7
gunicorn==23.0.0
@@ -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():
+95 -22
View File
@@ -93,30 +93,77 @@
</div>
</div>
<!-- Clear Devices -->
<!-- Clear Device Logs -->
<div class="col-md-3">
<div class="card danger-card h-100">
<div class="card warning-card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Clear Device Database</h5>
<h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Clear Device Logs</h5>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1">
Deletes <strong>all devices</strong> and their associated log entries from the database.
Devices will re-register automatically when they next check in.
Deletes <strong>all log entries</strong> from the database.
Registered devices are <strong>not affected</strong> and will continue logging automatically.
</p>
<div class="alert alert-danger py-2 mb-3">
<div class="alert alert-warning py-2 mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div>
<button class="btn btn-danger w-100"
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear All Devices
<button class="btn btn-warning w-100"
onclick="runAction('clear-device-logs', 'Delete ALL device log entries? Devices stay registered. This cannot be undone.')">
<i class="fas fa-trash me-2"></i>Clear Device Logs
</button>
</div>
</div>
</div>
<!-- Registered Device Registry -->
<div class="col-md-6">
<div class="card danger-card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5>
<span class="badge bg-light text-danger">{{ devices|length }} device(s)</span>
</div>
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive" style="max-height:320px;overflow-y:auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Work Place</th>
<th>Hostname</th>
<th>MAC</th>
<th>IP</th>
<th class="text-end">Delete</th>
</tr>
</thead>
<tbody id="device-registry-body">
{% for d in devices %}
<tr id="device-row-{{ d.id }}">
<td>{{ d.nume_masa or '—' }}</td>
<td>{{ d.hostname or '—' }}</td>
<td><code>{{ d.mac_address or '—' }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-end">
<button class="btn btn-danger btn-sm"
onclick="deleteDevice({{ d.id }}, '{{ (d.nume_masa or d.hostname)|e }}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i><br>
No registered devices.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Clear Ansible Inventory -->
<div class="col-md-3">
<div class="card danger-card h-100">
@@ -180,16 +227,17 @@
<script>
const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-device-logs': '{{ url_for("main.admin_clear_device_logs") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
};
function runAction(action, confirmMsg) {
if (!confirm(confirmMsg)) return;
const btn = event.currentTarget;
btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
fetch(ENDPOINTS[action], {
@@ -206,19 +254,44 @@ function runAction(action, confirmMsg) {
}
})
.catch(err => showToast('danger', 'Network error: ' + err))
.finally(() => {
.finally(() => { btn.disabled = false; btn.innerHTML = origLabel; });
}
function deleteDevice(deviceId, deviceName) {
if (!confirm(`Delete device "${deviceName}" and all its logs and WMT requests?\n\nThis cannot be undone.`)) return;
const btn = event.currentTarget;
btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch(`/admin/delete/device/${deviceId}`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
// Remove row from table without page reload
const row = document.getElementById(`device-row-${deviceId}`);
if (row) row.remove();
showToast('success', `Device "${data.name}" deleted.`);
refreshStats();
} else {
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
btn.disabled = false;
btn.innerHTML = origLabel;
}
})
.catch(err => {
showToast('danger', 'Network error: ' + err);
btn.disabled = false;
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>');
// Re-render button label properly
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
btn.innerHTML = origLabel;
});
}
function buildMessage(action, data) {
if (action === 'clear-logs')
if (action === 'clear-logs' || action === 'clear-device-logs')
return `Deleted ${data.deleted} log entries.`;
if (action === 'clear-devices')
return `Deleted ${data.deleted_devices} devices and ${data.deleted_logs} log entries.`;
if (action === 'clear-inventory')
return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
if (action === 'clear-wmt')
@@ -76,7 +76,7 @@
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
<div class="card playbook-card mb-2" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -87,10 +87,27 @@
</div>
</div>
</div>
<div class="card playbook-card mb-3" data-name="update_wmt_code" onclick="selectPlaybook('update_wmt_code')">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1 text-danger"><i class="fas fa-code-branch me-1"></i>Update WMT Code</h6>
<p class="small text-muted mb-0">Force-push latest WMT release to clients that missed auto-update (preserves data/)</p>
</div>
<span class="badge bg-danger ms-2 flex-shrink-0">Built-in</span>
</div>
</div>
</div>
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook">
<option value="">— select custom playbook —</option>
{% for pb in custom_playbooks %}
<option value="{{ pb.name }}"
{% if preselect_playbook == pb.filename or preselect_playbook == pb.name %}selected{% endif %}>
{{ pb.name }}
</option>
{% endfor %}
</select>
<input type="hidden" name="playbook" id="selectedPlaybook">
</div>
@@ -21,12 +21,12 @@
background-color: #f8f9ff;
}
.code-editor-area {
min-height: 400px;
min-height: 600px;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
.CodeMirror {
height: 400px;
height: 600px;
border-radius: 0.375rem;
}
.playbook-actions {
@@ -38,6 +38,26 @@
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
}
/* Dark mode overrides */
body.dark-mode .playbook-actions {
background: #2a2a2a;
border-bottom-color: #444444;
}
body.dark-mode .playbook-item.selected {
border-color: #64b5f6;
background-color: #1a2a3a;
}
body.dark-mode #welcomeMessage h4,
body.dark-mode #welcomeMessage p {
color: #888888 !important;
}
body.dark-mode .code-editor-area {
border-color: #444444;
}
</style>
{% endblock %}
@@ -244,7 +264,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre id="playbookContent" style="max-height: 400px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
<pre id="playbookContent" style="max-height: 600px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
File diff suppressed because it is too large Load Diff
@@ -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 %}
<style>
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; }
.sync-ok { color: #2ecc71; }
.sync-old { color: #e74c3c; }
.tbl-sm td, .tbl-sm th { padding: 0.45rem 0.6rem; font-size: 0.88rem; vertical-align: middle; }
/* Sync status pills */
.sync-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.78rem;
font-weight: 600;
padding: 3px 9px;
border-radius: 20px;
white-space: nowrap;
}
.sync-ok { background: #d4edda; color: #155724; }
.sync-required { background: #fff3cd; color: #856404; }
.sync-pending { background: #cce5ff; color: #004085; }
.sync-never { background: #e2e3e5; color: #495057; }
body.dark-mode .sync-ok { background: #1a3a25; color: #6fcf97; }
body.dark-mode .sync-required { background: #3a2e00; color: #f0c040; }
body.dark-mode .sync-pending { background: #003060; color: #7ec8e3; }
body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
</style>
{% endblock %}
@@ -60,7 +79,7 @@
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none">
<div class="card-body py-3">
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4>
<small class="text-muted">Pending Requests</small>
<small class="text-muted">Pending Approval</small>
</div>
</a>
</div>
@@ -88,7 +107,7 @@
<th>Work Place</th>
<th>Hostname</th>
<th>IP</th>
<th>MAC Address</th>
<th>MAC / Type</th>
<th>Status</th>
<th>Logs</th>
<th>Last Seen</th>
@@ -98,20 +117,35 @@
</thead>
<tbody>
{% 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) %}
<tr class="device-row"
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
<!-- Work Place -->
<td>
<strong>{{ device.nume_masa or '—' }}</strong>
{% if is_wmt %}
<br><small class="text-muted"><i class="fas fa-tablet-alt me-1"></i>WMT</small>
{% endif %}
</td>
<!-- Hostname -->
<td>{{ device.hostname }}</td>
<!-- IP -->
<td><code>{{ device.device_ip }}</code></td>
<!-- MAC / Type -->
<td>
{% if device.mac_address %}
{% if is_wmt %}
<code class="mac-badge">{{ device.mac_address }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Status -->
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">Active</span>
@@ -121,24 +155,55 @@
<span class="badge bg-danger">Offline</span>
{% endif %}
</td>
<!-- Logs -->
<td>{{ device_log_counts.get(device.id, 0) }}</td>
<!-- Last Seen -->
<td class="text-muted">
{% if device.last_seen %}
{{ device.last_seen | local_dt }}
{% else %}—{% endif %}
</td>
<!-- Config Sync status -->
<td>
{% if device.mac_address and device.config_updated_at %}
<span class="sync-ok" title="{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
<i class="fas fa-check-circle"></i>
{{ device.config_updated_at | local_dt('%m-%d %H:%M') }}
</span>
{% elif device.mac_address %}
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
{% else %}
{% if not is_wmt %}
<span class="text-muted"></span>
{% elif has_pending %}
{# Unknown device waiting for admin approval #}
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}"
class="sync-pill sync-pending text-decoration-none"
title="Admin approval required for this device">
<i class="fas fa-user-clock"></i> Pending Approval
</a>
{% elif device.config_synced_at %}
{# Device checked in and server confirmed configs match #}
<span class="sync-pill sync-ok"
title="Last confirmed in-sync: {{ device.config_synced_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
<i class="fas fa-check-circle"></i>
OK · {{ device.config_synced_at | local_dt('%m-%d %H:%M') }}
</span>
{% elif device.config_updated_at %}
{# Admin pushed config but client hasn't confirmed sync yet #}
<span class="sync-pill sync-required"
title="Config updated {{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }} awaiting client check-in">
<i class="fas fa-arrow-circle-down"></i> Awaiting client
</span>
{% else %}
{# WMT device registered but never checked in #}
<span class="sync-pill sync-never"
title="Client has not checked in yet">
<i class="fas fa-question-circle"></i> Never synced
</span>
{% endif %}
</td>
<!-- Actions -->
<td class="text-end text-nowrap">
<a href="{{ url_for('main.device_detail', device_id=device.id) }}"
class="btn btn-sm btn-outline-primary py-0" title="View Details">
@@ -175,6 +240,15 @@
</div>
</div>
<!-- Sync legend -->
<div class="d-flex flex-wrap gap-3 mt-3 ms-1">
<small class="text-muted"><strong>Config Sync legend:</strong></small>
<small><span class="sync-pill sync-ok"><i class="fas fa-check-circle"></i> OK</span> — client confirmed configs match</small>
<small><span class="sync-pill sync-required"><i class="fas fa-arrow-circle-down"></i> Awaiting client</span> — server pushed new config, waiting for client check-in</small>
<small><span class="sync-pill sync-pending"><i class="fas fa-user-clock"></i> Pending Approval</span> — new/unknown device waiting for admin</small>
<small><span class="sync-pill sync-never"><i class="fas fa-question-circle"></i> Never synced</span> — registered but hasn't checked in</small>
</div>
<!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@@ -276,3 +350,4 @@ async function submitAddDevice(event) {
}
</script>
{% endblock %}
@@ -61,6 +61,20 @@
margin: 10px 0;
word-break: break-word;
}
/* Dark mode overrides for this page */
body.dark-mode .filter-container {
background-color: #2a2a2a;
border: 1px solid #444444;
}
body.dark-mode .log-entry:hover {
background-color: #2e2e2e;
}
body.dark-mode .log-meta {
color: #888888;
}
</style>
{% endblock %}
@@ -72,6 +72,34 @@
</select>
</div>
<hr>
<h6 class="text-muted mb-1">Chrome Launch Production URL Override</h6>
<p class="text-muted small mb-3">
Leave blank to use the global default URL configured in
<a href="{{ url_for('wmt_web.settings') }}" target="_blank">WMT Settings</a>.
Fill in only if this device needs a different production page.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Custom Production URL
<small class="text-muted fw-normal">(device-specific override)</small>
</label>
<input type="url" name="custom_chrome_url" class="form-control"
value="{{ device.custom_chrome_url or '' if device else '' }}"
placeholder="Leave blank to use global default">
{% if device and device.custom_chrome_url %}
<div class="form-text text-warning">
<i class="fas fa-exclamation-triangle me-1"></i>
This device uses a custom URL instead of the global default.
Clear the field to revert to the global setting.
</div>
{% else %}
<div class="form-text text-success">
<i class="fas fa-check-circle me-1"></i>
Using global default production URL.
</div>
{% endif %}
</div>
{% if device %}
<div class="alert alert-light border small mb-4">
<strong>Last seen:</strong>
@@ -116,6 +116,7 @@
<thead class="table-light">
<tr>
<th>Work Place</th>
<th>Client Name</th>
<th>MAC</th>
<th>IP</th>
<th>Last Seen</th>
@@ -126,6 +127,7 @@
{% for d in devices %}
<tr>
<td><strong>{{ d.device_name or '—' }}</strong></td>
<td>{{ d.hostname or '—' }}</td>
<td><code>{{ d.mac_address }}</code></td>
<td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small">
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}WMT Client Releases {{ app_name }}{% endblock %}
{% block page_title %}WMT Client Release Management{% endblock %}
{% block content %}
<div class="row g-4">
<!-- Current release info -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-box-open me-2"></i>Current Release
</div>
<div class="card-body">
{% if meta %}
<table class="table table-sm mb-3">
<tr><th style="width:130px">Version</th>
<td><span class="badge bg-primary fs-6">v{{ meta.version }}</span></td></tr>
<tr><th>File</th>
<td><code>{{ meta.filename }}</code></td></tr>
<tr><th>Size</th>
<td>{% if zip_size %}{{ (zip_size / 1024 / 1024) | round(2) }} MB{% else %}<span class="text-danger">file missing!</span>{% endif %}</td></tr>
<tr><th>Uploaded</th>
<td>{{ meta.uploaded_at }}</td></tr>
<tr><th>Notes</th>
<td>{{ meta.notes or '—' }}</td></tr>
</table>
<div class="d-flex gap-2">
<a href="{{ url_for('wmt_api.download_client_release') }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-download me-1"></i>Download zip
</a>
<form method="post" action="{{ url_for('wmt_web.releases_delete') }}"
onsubmit="return confirm('Delete release v{{ meta.version }}? Clients will not find an update until you upload a new one.')">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash me-1"></i>Delete release
</button>
</form>
</div>
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-inbox fa-3x mb-3 d-block"></i>
No release uploaded yet.<br>
<small>Upload a zip below to enable client auto-update.</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Upload new release -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-upload me-2"></i>Upload New Release
</div>
<div class="card-body">
<form method="post" action="{{ url_for('wmt_web.releases_upload') }}"
enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-semibold">Version <span class="text-danger">*</span></label>
<input type="text" name="version" class="form-control" placeholder="e.g. 3.0"
pattern="\d+(\.\d+)*" required>
<div class="form-text">Numeric only, e.g. <code>3.0</code> or <code>3.1.2</code>.
Must be higher than the current version to trigger client updates.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Release Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="What changed in this version?"></textarea>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Release Zip <span class="text-danger">*</span></label>
<input type="file" name="release_zip" class="form-control" accept=".zip" required>
<div class="form-text">
Must contain <code>app.py</code> at the zip root.<br>
Include <code>dependency_utils.py</code>, <code>config.py</code>,
<code>Files/reposytory/</code>, and <code>Files/Screen.html</code> for a full release.
</div>
</div>
<div class="alert alert-info py-2">
<i class="fas fa-info-circle me-1"></i>
Uploading will <strong>replace</strong> the current release immediately.
All WMT clients will download the new version on their next 5-minute sync cycle.
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Upload &amp; Set as Latest
</button>
</form>
</div>
</div>
</div>
<!-- Build from local folder -->
<div class="col-12">
<div class="card border-success">
<div class="card-header bg-success bg-opacity-10 text-success">
<i class="fas fa-folder-open me-2"></i>Build Release from Server Folder
</div>
<div class="card-body">
<p class="text-muted mb-3">
Package the WMT source folder that lives on <em>this</em> server into a clean release zip.
Hidden files (<code>.git</code>, <code>.gitignore</code>, <code>.lgd-nfy0</code>, …),
<code>__pycache__</code>, <code>data/</code>, <code>venv/</code> and compiled
<code>.pyc</code> / <code>.log</code> files are automatically excluded.
</p>
<form method="post" action="{{ url_for('wmt_web.releases_build') }}">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label fw-semibold">Source Folder <span class="text-danger">*</span></label>
<input type="text" name="folder_path" class="form-control font-monospace"
value="/home/pi/Desktop/WMT" required>
<div class="form-text">Absolute path to the WMT folder on this server.</div>
</div>
<div class="col-md-2">
<label class="form-label fw-semibold">Version <span class="text-danger">*</span></label>
<input type="text" name="version" class="form-control" placeholder="e.g. 3.1"
pattern="\d+(\.\d+)*" required>
</div>
<div class="col-md-5">
<label class="form-label fw-semibold">Release Notes</label>
<input type="text" name="notes" class="form-control"
placeholder="Short description of changes">
</div>
</div>
<div class="mt-3 p-2 bg-light rounded border small text-muted">
<strong>Excluded automatically:</strong>
<code>.git/</code> &nbsp;
<code>.gitignore</code> &nbsp;
<code>.lgd-*</code> &nbsp;
any hidden file/folder &nbsp;·&nbsp;
<code>__pycache__/</code> &nbsp;
<code>*.pyc</code> &nbsp;
<code>*.log</code> &nbsp;
<code>*.bak</code> &nbsp;·&nbsp;
<code>data/</code> &nbsp;
<code>venv/</code>
</div>
<button type="submit" class="btn btn-success mt-3"
onclick="return confirm('Build a new release from the specified folder?')">
<i class="fas fa-hammer me-2"></i>Build &amp; Set as Latest
</button>
</form>
</div>
</div>
</div>
<!-- How it works -->
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<i class="fas fa-info-circle me-2"></i>How Auto-Update Works
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">1</div>
<strong>Client checks version</strong><br>
<small class="text-muted">Every 5 min, client calls
<code>GET /api/wmt/client/version</code>
and compares server version with its own (from line 1 of app.py)</small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">2</div>
<strong>Download if outdated</strong><br>
<small class="text-muted">If server version &gt; local version,
client downloads <code>GET /api/wmt/client/download</code></small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">3</div>
<strong>Apply &amp; back up</strong><br>
<small class="text-muted">Zip is extracted over the WMT folder.
Old <code>app.py</code> is backed up as <code>app.py.bak.&lt;version&gt;</code></small>
</div>
<div class="col-md-3 text-center">
<div class="fs-1 text-primary mb-1">4</div>
<strong>Service restart</strong><br>
<small class="text-muted">Client calls <code>sudo systemctl restart wmt</code>
(or reboots) to start the new code</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
"""
WSGI entry point for running Server Monitor via Gunicorn.
Gunicorn imports `app` from this module. Because Gunicorn bypasses the
startup logic in main.py, this module also ensures the database tables
exist (idempotent) so a fresh deployment works on first boot.
"""
import os
from app import create_app
from config.database_config import get_db
app = create_app(os.environ.get('FLASK_ENV', 'production'))
# Ensure database tables exist (idempotent: only creates missing tables).
with app.app_context():
get_db().create_tables()
+18
View File
@@ -8,6 +8,9 @@ from flask_login import LoginManager
from flask_migrate import Migrate
from flask_caching import Cache
from flask_cors import CORS
from sqlalchemy import event
from sqlalchemy.engine import Engine
import sqlite3
# Initialize extensions (will be bound to app in create_app)
db = SQLAlchemy()
@@ -21,3 +24,18 @@ cors = CORS()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
@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()
+23 -2
View File
@@ -66,12 +66,32 @@ services:
- itassets-docx:/app/docx_output
environment:
- FLASK_ENV=production
- PORT=5000
- SECRET_KEY=${ITASSETS_SECRET_KEY:-change-itassets-secret}
- SQLALCHEMY_DATABASE_URI=sqlite:////app/data/itassets.db
- PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production}
- FLASK_APP=run.py
command: >
sh -c "flask db upgrade && python run.py"
sh -c "flask db upgrade && gunicorn -b 0.0.0.0:5000 -w 4 --timeout 120 run:app"
restart: unless-stopped
networks:
- edp-network
# ── Server Monitor (Raspberry Pi monitoring + Ansible) ────────────────────────
srvmonitor-app:
build: ./Server_Monitorizare_v2
container_name: edp-srvmonitor
expose:
- "5000"
volumes:
- ./Server_Monitorizare_v2/data:/app/data
- ./Server_Monitorizare_v2/logs:/app/logs
- ./Server_Monitorizare_v2/ansible/inventory:/app/ansible/inventory
- ./Server_Monitorizare_v2/ansible/ssh_keys:/app/ansible/ssh_keys
environment:
- FLASK_ENV=production
- PORT=5000
- PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production}
restart: unless-stopped
networks:
- edp-network
@@ -123,9 +143,10 @@ services:
- itassets-app
- networkview-backend
- networkview-frontend
- srvmonitor-app
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:80/health"]
interval: 30s
timeout: 10s
retries: 3
+22
View File
@@ -12,6 +12,7 @@ http {
upstream portal_upstream { server portal:5001; }
upstream digiserver_upstream { server digiserver-app:5000; }
upstream itassets_upstream { server itassets-app:5000; }
upstream srvmonitor_upstream { server srvmonitor-app:5000; }
upstream nv_backend_upstream { server networkview-backend:3001; }
upstream nv_frontend_upstream { server networkview-frontend:80; }
@@ -138,6 +139,27 @@ http {
client_max_body_size 50M;
}
# ── Server Monitor /srvmonitor/ ─────────────────────────────────────
location /srvmonitor/ {
auth_request /portal-verify;
auth_request_set $auth_user_id $upstream_http_x_auth_user_id;
auth_request_set $auth_username $upstream_http_x_auth_username;
auth_request_set $auth_role $upstream_http_x_auth_role;
proxy_pass http://srvmonitor_upstream/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /srvmonitor;
proxy_set_header X-Auth-User-Id $auth_user_id;
proxy_set_header X-Auth-Username $auth_username;
proxy_set_header X-Auth-Role $auth_role;
# Ansible playbook runs can take a while to stream output
proxy_read_timeout 300s;
client_max_body_size 50M;
}
# ── NetworkView API /networkview/api/ ────────────────────────────────
# Must be declared BEFORE the broader /networkview/ location
location /networkview/api/ {
+18
View File
@@ -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 sign in to access the platform.'
login_manager.login_message_category = 'info'
@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()
+2
View File
@@ -67,4 +67,6 @@ def _app_from_uri(uri):
return 'itassets'
if uri.startswith('/networkview/'):
return 'networkview'
if uri.startswith('/srvmonitor/'):
return 'srvmonitor'
return None