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 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_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from sqlalchemy import event
from sqlalchemy.engine import Engine
import sqlite3
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
@@ -8,3 +11,18 @@ login_manager = LoginManager()
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.' login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'warning' 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 requests==2.32.3
httpx[http2] httpx[http2]
docxtpl 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__': if __name__ == '__main__':
port = int(os.environ.get('PORT', 5003)) 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/restart_service.yml
!ansible/playbooks/migrate_to_wmt.yml !ansible/playbooks/migrate_to_wmt.yml
!ansible/playbooks/Update_Rest_WMT_client.yml !ansible/playbooks/Update_Rest_WMT_client.yml
!ansible/playbooks/update_wmt_code.yml
# VS Code # VS Code
.vscode/ .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: debug:
msg: "work_place will be set to: '{{ work_place_value }}'" msg: "work_place will be set to: '{{ work_place_value }}'"
# ── 5. Replace work_place value in WMT/data/config.txt ─────────────── # ── 5. Write work_place into WMT/data/config.txt ──────────────────────
- name: Replace work_place in WMT config.txt # Uses Python (always available on Raspberry Pi) to correctly
lineinfile: # read/write the INI file and set work_place inside [device].
path: /home/pi/Desktop/WMT/data/config.txt # Also resets last_synced to epoch so first startup does NOT
regexp: '^work_place\s*=.*' # overwrite work_place with a potentially empty server value.
line: "work_place={{ work_place_value }}" - name: Set work_place in WMT config.txt via Python
backup: true 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 - name: Confirm work_place change
command: grep 'work_place' /home/pi/Desktop/WMT/data/config.txt 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 WMT (Workstation Management Terminal) configuration API
Handles config distribution and device update requests from WMT clients. 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 datetime import datetime
from pathlib import Path
import json
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db from config.database_config import get_db
import logging import logging
@@ -96,8 +98,8 @@ def get_device_config(mac_address):
_, device_ts, latest_ts = _latest_config_ts(session, mac) _, device_ts, latest_ts = _latest_config_ts(session, mac)
payload = { payload = {
# Global settings # Global settings (device custom_chrome_url overrides global chrome_url if set)
'chrome_url': global_cfg.chrome_url, '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_local_url': global_cfg.chrome_local_url or '',
'chrome_insecure_origin': global_cfg.chrome_insecure_origin, 'chrome_insecure_origin': global_cfg.chrome_insecure_origin,
'card_api_base_url': global_cfg.card_api_base_url, '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']) @wmt_api_bp.route('/config/update_request', methods=['POST'])
def submit_update_request(): 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: Expected JSON:
{ {
@@ -133,7 +153,8 @@ def submit_update_request():
"device_name": "Masa-01", "device_name": "Masa-01",
"hostname": "rpi-masa01", "hostname": "rpi-masa01",
"device_ip": "192.168.1.100", "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: if not request.is_json:
@@ -144,31 +165,138 @@ def submit_update_request():
if not mac: if not mac:
return jsonify({'error': 'mac_address is required'}), 400 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: try:
with get_db().get_session() as session: with get_db().get_session() as session:
device = session.query(Device).filter_by(mac_address=mac).first() device = session.query(Device).filter_by(mac_address=mac).first()
req = WMTUpdateRequest( # ── Outcome 3: unknown device ─────────────────────────────
mac_address=mac, if not device:
device_id=device.id if device else None, # Check if a pending request with the same data already exists
proposed_device_name=data.get('device_name'), existing = (
proposed_hostname=data.get('hostname'), session.query(WMTUpdateRequest)
proposed_device_ip=data.get('device_ip'), .filter_by(mac_address=mac, status='pending')
client_config_mtime=data.get('client_config_mtime'), .order_by(WMTUpdateRequest.submitted_at.desc())
submitted_at=datetime.utcnow(), .first()
status='pending', )
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 config_in_sync:
if device: device.config_synced_at = datetime.utcnow()
device.last_seen = datetime.utcnow() logger.debug(f'WMT config OK for {mac} synced_at updated')
# card_presence is a device capability flag update directly (no approval needed) return jsonify({
if data.get('card_presence') in ('enable', 'disable'): 'status': 'ok',
device.card_presence = data['card_presence'] '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: 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 return jsonify({'error': str(e)}), 500
@@ -48,9 +48,11 @@ class Device(Base):
# WMT (Workstation Management Terminal) integration fields # WMT (Workstation Management Terminal) integration fields
mac_address = Column(String(17), unique=True, nullable=True, index=True) 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)) info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
card_presence = Column(String(10), default='enable') 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 # Relationships
logs = relationship("LogEntry", back_populates="device") logs = relationship("LogEntry", back_populates="device")
@@ -737,6 +737,7 @@ class AnsibleService:
'system_health': 'Check system health and monitoring status', 'system_health': 'Check system health and monitoring status',
'maintenance_mode': 'Put devices in maintenance mode', 'maintenance_mode': 'Put devices in maintenance mode',
'distribute_ssh_keys': 'Push server public key to all devices using password auth', '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') return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
+12 -1
View File
@@ -129,10 +129,20 @@ def execute():
seen.add(h['hostname']) seen.add(h['hostname'])
settings = ansible_service.load_settings() 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', return render_template('ansible/execute.html',
inventory=inventory_data, inventory=inventory_data,
all_inv_hosts=all_inv_hosts, all_inv_hosts=all_inv_hosts,
preselect_playbook=preselect, preselect_playbook=preselect,
custom_playbooks=custom_playbooks,
use_password_auth=settings.get('use_password_auth', False)) use_password_auth=settings.get('use_password_auth', False))
except Exception as e: except Exception as e:
logging.error(f"Error loading execute form: {e}") logging.error(f"Error loading execute form: {e}")
@@ -141,6 +151,7 @@ def execute():
inventory={'groups': {}}, inventory={'groups': {}},
all_inv_hosts=[], all_inv_hosts=[],
preselect_playbook='', preselect_playbook='',
custom_playbooks=[],
use_password_auth=False) use_password_auth=False)
elif request.method == 'POST': elif request.method == 'POST':
@@ -462,7 +473,7 @@ def playbook_content():
from pathlib import Path from pathlib import Path
requested_path = Path(playbook_path) requested_path = Path(playbook_path)
if not requested_path.is_absolute(): 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 # Ensure path is within playbook directory
try: try:
+58 -5
View File
@@ -34,7 +34,7 @@ def devices():
with get_db().get_session() as session: with get_db().get_session() as session:
devices = session.query(Device).order_by(Device.last_seen.desc()).all() devices = session.query(Device).order_by(Device.last_seen.desc()).all()
# Get log count per device # Log count per device
device_log_counts = {} device_log_counts = {}
for device in devices: for device in devices:
log_count = session.query(LogEntry).filter_by(device_id=device.id).count() 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_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', return render_template('device_management.html',
devices=devices, devices=devices,
device_log_counts=device_log_counts, device_log_counts=device_log_counts,
pending_count=pending_count) pending_count=pending_count,
pending_by_mac=pending_by_mac)
except Exception as e: except Exception as e:
logging.error(f"Error loading devices: {e}") logging.error(f"Error loading devices: {e}")
flash(f'Error loading devices: {e}', 'error') 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>') @main_bp.route('/device/<int:device_id>')
def device_detail(device_id): def device_detail(device_id):
@@ -516,7 +524,18 @@ def admin():
logging.error(f'Admin inventory stats error: {e}') logging.error(f'Admin inventory stats error: {e}')
stats['inventory_hosts'] = '?' stats['inventory_hosts'] = '?'
stats['inventory_groups_yaml'] = '?' 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']) @main_bp.route('/admin/clear/logs', methods=['POST'])
@@ -532,9 +551,43 @@ def admin_clear_logs():
return jsonify({'success': False, 'error': str(e)}), 500 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']) @main_bp.route('/admin/clear/devices', methods=['POST'])
def admin_clear_devices(): 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: try:
with get_db().get_session() as session: with get_db().get_session() as session:
session.execute(text('DELETE FROM device_inventory_groups')) 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 csv
import io import io
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response import json
from datetime import datetime 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 app.models import WMTGlobalConfig, Device, WMTUpdateRequest
from config.database_config import get_db from config.database_config import get_db
import logging import logging
@@ -295,6 +299,8 @@ def device_edit(device_id):
device.location = request.form.get('location', '').strip() or None device.location = request.form.get('location', '').strip() or None
device.card_presence = request.form.get('card_presence', 'enable') device.card_presence = request.form.get('card_presence', 'enable')
device.description = request.form.get('notes', '').strip() or None 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.config_updated_at = datetime.utcnow()
device.info_reviewed_at = datetime.utcnow() device.info_reviewed_at = datetime.utcnow()
flash('Device updated.', 'success') flash('Device updated.', 'success')
@@ -363,3 +369,196 @@ def device_delete(device_id):
logger.error(f'WMT device_delete error: {e}') logger.error(f'WMT device_delete error: {e}')
flash(f'Error: {e}', 'error') flash(f'Error: {e}', 'error')
return redirect(url_for('wmt_web.devices')) 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 Database configuration and connection management
""" """
import os import os
from sqlalchemy import create_engine, MetaData import sqlite3
from sqlalchemy import create_engine, MetaData, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from contextlib import contextmanager from contextlib import contextmanager
from app.models import Base from app.models import Base
@@ -38,7 +39,19 @@ class DatabaseConfig:
pool_pre_ping=True, pool_pre_ping=True,
connect_args={"check_same_thread": False} # For SQLite 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 # Create session factory
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
+1 -1
View File
@@ -46,7 +46,7 @@ def main():
# Run application # Run application
try: try:
app.run( app.run(
host='127.0.0.1', host='0.0.0.0',
port=int(os.environ.get('PORT', 80)), port=int(os.environ.get('PORT', 80)),
debug=app.config.get('DEBUG', False) debug=app.config.get('DEBUG', False)
) )
+2 -1
View File
@@ -3,4 +3,5 @@ SQLAlchemy>=2.0.36
paramiko==3.3.1 paramiko==3.3.1
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
Werkzeug==2.3.7 Werkzeug==2.3.7
gunicorn==23.0.0
@@ -57,6 +57,22 @@ class DatabaseSchemaUpdater:
except Exception as e: except Exception as e:
print(f" ❌ Error adding {column_name}: {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): def create_tables(self):
"""Create all tables using SQLAlchemy metadata""" """Create all tables using SQLAlchemy metadata"""
print("🏗️ Creating all database tables...") print("🏗️ Creating all database tables...")
@@ -109,6 +125,7 @@ if __name__ == "__main__":
# Update schema # Update schema
updater.update_playbook_executions_schema() updater.update_playbook_executions_schema()
updater.add_config_synced_at_column()
# Verify # Verify
if updater.verify_schema(): if updater.verify_schema():
+95 -22
View File
@@ -93,30 +93,77 @@
</div> </div>
</div> </div>
<!-- Clear Devices --> <!-- Clear Device Logs -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card danger-card h-100"> <div class="card warning-card h-100">
<div class="card-header"> <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>
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<p class="text-muted flex-grow-1"> <p class="text-muted flex-grow-1">
Deletes <strong>all devices</strong> and their associated log entries from the database. Deletes <strong>all log entries</strong> from the database.
Devices will re-register automatically when they next check in. Registered devices are <strong>not affected</strong> and will continue logging automatically.
</p> </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> <i class="fas fa-exclamation-triangle me-1"></i>
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
</div> </div>
<button class="btn btn-danger w-100" <button class="btn btn-warning w-100"
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')"> 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 All Devices <i class="fas fa-trash me-2"></i>Clear Device Logs
</button> </button>
</div> </div>
</div> </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 --> <!-- Clear Ansible Inventory -->
<div class="col-md-3"> <div class="col-md-3">
<div class="card danger-card h-100"> <div class="card danger-card h-100">
@@ -180,16 +227,17 @@
<script> <script>
const ENDPOINTS = { const ENDPOINTS = {
'clear-logs': '{{ url_for("main.admin_clear_logs") }}', 'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
'clear-devices': '{{ url_for("main.admin_clear_devices") }}', 'clear-device-logs': '{{ url_for("main.admin_clear_device_logs") }}',
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}', 'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}' 'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
}; };
function runAction(action, confirmMsg) { function runAction(action, confirmMsg) {
if (!confirm(confirmMsg)) return; if (!confirm(confirmMsg)) return;
const btn = event.currentTarget; const btn = event.currentTarget;
btn.disabled = true; btn.disabled = true;
const origLabel = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…'; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
fetch(ENDPOINTS[action], { fetch(ENDPOINTS[action], {
@@ -206,19 +254,44 @@ function runAction(action, confirmMsg) {
} }
}) })
.catch(err => showToast('danger', 'Network error: ' + err)) .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.disabled = false;
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>'); btn.innerHTML = origLabel;
// Re-render button label properly
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
}); });
} }
function buildMessage(action, data) { function buildMessage(action, data) {
if (action === 'clear-logs') if (action === 'clear-logs' || action === 'clear-device-logs')
return `Deleted ${data.deleted} log entries.`; 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') if (action === 'clear-inventory')
return `Inventory reset. ${data.groups_deleted} group(s) removed.`; return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
if (action === 'clear-wmt') if (action === 'clear-wmt')
@@ -76,7 +76,7 @@
</div> </div>
</div> </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="card-body py-2">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -87,10 +87,27 @@
</div> </div>
</div> </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> <h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
<select class="form-select" id="customPlaybook"> <select class="form-select" id="customPlaybook">
<option value="">— select custom playbook —</option> <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> </select>
<input type="hidden" name="playbook" id="selectedPlaybook"> <input type="hidden" name="playbook" id="selectedPlaybook">
</div> </div>
@@ -21,12 +21,12 @@
background-color: #f8f9ff; background-color: #f8f9ff;
} }
.code-editor-area { .code-editor-area {
min-height: 400px; min-height: 600px;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.CodeMirror { .CodeMirror {
height: 400px; height: 600px;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.playbook-actions { .playbook-actions {
@@ -38,6 +38,26 @@
padding: 1rem; padding: 1rem;
margin: -1rem -1rem 1rem -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> </style>
{% endblock %} {% endblock %}
@@ -244,7 +264,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <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" %} {% extends "base.html" %}
{% block title %}Devices {{ app_name }}{% endblock %} {% block title %}Devices {{ app_name }}{% endblock %}
{% block page_title %}Devices{% endblock %} {% block page_title %}Device Health{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; } .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; } .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> </style>
{% endblock %} {% endblock %}
@@ -60,7 +79,7 @@
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none"> <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"> <div class="card-body py-3">
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4> <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> </div>
</a> </a>
</div> </div>
@@ -88,7 +107,7 @@
<th>Work Place</th> <th>Work Place</th>
<th>Hostname</th> <th>Hostname</th>
<th>IP</th> <th>IP</th>
<th>MAC Address</th> <th>MAC / Type</th>
<th>Status</th> <th>Status</th>
<th>Logs</th> <th>Logs</th>
<th>Last Seen</th> <th>Last Seen</th>
@@ -98,20 +117,35 @@
</thead> </thead>
<tbody> <tbody>
{% for device in devices %} {% 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" <tr class="device-row"
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}"> data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
<!-- Work Place -->
<td> <td>
<strong>{{ device.nume_masa or '—' }}</strong> <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> </td>
<!-- Hostname -->
<td>{{ device.hostname }}</td> <td>{{ device.hostname }}</td>
<!-- IP -->
<td><code>{{ device.device_ip }}</code></td> <td><code>{{ device.device_ip }}</code></td>
<!-- MAC / Type -->
<td> <td>
{% if device.mac_address %} {% if is_wmt %}
<code class="mac-badge">{{ device.mac_address }}</code> <code class="mac-badge">{{ device.mac_address }}</code>
{% else %} {% else %}
<span class="text-muted"></span> <span class="text-muted"></span>
{% endif %} {% endif %}
</td> </td>
<!-- Status -->
<td> <td>
{% if device.status == 'active' %} {% if device.status == 'active' %}
<span class="badge bg-success">Active</span> <span class="badge bg-success">Active</span>
@@ -121,24 +155,55 @@
<span class="badge bg-danger">Offline</span> <span class="badge bg-danger">Offline</span>
{% endif %} {% endif %}
</td> </td>
<!-- Logs -->
<td>{{ device_log_counts.get(device.id, 0) }}</td> <td>{{ device_log_counts.get(device.id, 0) }}</td>
<!-- Last Seen -->
<td class="text-muted"> <td class="text-muted">
{% if device.last_seen %} {% if device.last_seen %}
{{ device.last_seen | local_dt }} {{ device.last_seen | local_dt }}
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<!-- Config Sync status -->
<td> <td>
{% if device.mac_address and device.config_updated_at %} {% if not is_wmt %}
<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 %}
<span class="text-muted"></span> <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 %} {% endif %}
</td> </td>
<!-- Actions -->
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
<a href="{{ url_for('main.device_detail', device_id=device.id) }}" <a href="{{ url_for('main.device_detail', device_id=device.id) }}"
class="btn btn-sm btn-outline-primary py-0" title="View Details"> class="btn btn-sm btn-outline-primary py-0" title="View Details">
@@ -175,6 +240,15 @@
</div> </div>
</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 --> <!-- Add Device Modal -->
<div class="modal fade" id="addDeviceModal" tabindex="-1"> <div class="modal fade" id="addDeviceModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
@@ -276,3 +350,4 @@ async function submitAddDevice(event) {
} }
</script> </script>
{% endblock %} {% endblock %}
@@ -61,6 +61,20 @@
margin: 10px 0; margin: 10px 0;
word-break: break-word; 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> </style>
{% endblock %} {% endblock %}
@@ -72,6 +72,34 @@
</select> </select>
</div> </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 %} {% if device %}
<div class="alert alert-light border small mb-4"> <div class="alert alert-light border small mb-4">
<strong>Last seen:</strong> <strong>Last seen:</strong>
@@ -116,6 +116,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Work Place</th> <th>Work Place</th>
<th>Client Name</th>
<th>MAC</th> <th>MAC</th>
<th>IP</th> <th>IP</th>
<th>Last Seen</th> <th>Last Seen</th>
@@ -126,6 +127,7 @@
{% for d in devices %} {% for d in devices %}
<tr> <tr>
<td><strong>{{ d.device_name or '—' }}</strong></td> <td><strong>{{ d.device_name or '—' }}</strong></td>
<td>{{ d.hostname or '—' }}</td>
<td><code>{{ d.mac_address }}</code></td> <td><code>{{ d.mac_address }}</code></td>
<td>{{ d.device_ip or '—' }}</td> <td>{{ d.device_ip or '—' }}</td>
<td class="text-muted small"> <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_migrate import Migrate
from flask_caching import Cache from flask_caching import Cache
from flask_cors import CORS 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) # Initialize extensions (will be bound to app in create_app)
db = SQLAlchemy() db = SQLAlchemy()
@@ -21,3 +24,18 @@ cors = CORS()
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.' login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info' 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 - itassets-docx:/app/docx_output
environment: environment:
- FLASK_ENV=production - FLASK_ENV=production
- PORT=5000
- SECRET_KEY=${ITASSETS_SECRET_KEY:-change-itassets-secret} - SECRET_KEY=${ITASSETS_SECRET_KEY:-change-itassets-secret}
- SQLALCHEMY_DATABASE_URI=sqlite:////app/data/itassets.db - SQLALCHEMY_DATABASE_URI=sqlite:////app/data/itassets.db
- PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production} - PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production}
- FLASK_APP=run.py - FLASK_APP=run.py
command: > 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 restart: unless-stopped
networks: networks:
- edp-network - edp-network
@@ -123,9 +143,10 @@ services:
- itassets-app - itassets-app
- networkview-backend - networkview-backend
- networkview-frontend - networkview-frontend
- srvmonitor-app
restart: unless-stopped restart: unless-stopped
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+22
View File
@@ -12,6 +12,7 @@ http {
upstream portal_upstream { server portal:5001; } upstream portal_upstream { server portal:5001; }
upstream digiserver_upstream { server digiserver-app:5000; } upstream digiserver_upstream { server digiserver-app:5000; }
upstream itassets_upstream { server itassets-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_backend_upstream { server networkview-backend:3001; }
upstream nv_frontend_upstream { server networkview-frontend:80; } upstream nv_frontend_upstream { server networkview-frontend:80; }
@@ -138,6 +139,27 @@ http {
client_max_body_size 50M; 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/ ──────────────────────────────── # ── NetworkView API /networkview/api/ ────────────────────────────────
# Must be declared BEFORE the broader /networkview/ location # Must be declared BEFORE the broader /networkview/ location
location /networkview/api/ { location /networkview/api/ {
+18
View File
@@ -1,6 +1,9 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from sqlalchemy import event
from sqlalchemy.engine import Engine
import sqlite3
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
@@ -8,3 +11,18 @@ login_manager = LoginManager()
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please sign in to access the platform.' login_manager.login_message = 'Please sign in to access the platform.'
login_manager.login_message_category = 'info' 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' return 'itassets'
if uri.startswith('/networkview/'): if uri.startswith('/networkview/'):
return 'networkview' return 'networkview'
if uri.startswith('/srvmonitor/'):
return 'srvmonitor'
return None return None