added gunicorn and updated to the last version of server monitorizare
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
docs/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
<code>.gitignore</code>
|
||||||
|
<code>.lgd-*</code>
|
||||||
|
any hidden file/folder ·
|
||||||
|
<code>__pycache__/</code>
|
||||||
|
<code>*.pyc</code>
|
||||||
|
<code>*.log</code>
|
||||||
|
<code>*.bak</code> ·
|
||||||
|
<code>data/</code>
|
||||||
|
<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 & 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 > 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 & 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.<version></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 %}
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/ {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user