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
|
||||
|
||||
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_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
import sqlite3
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
@@ -8,3 +11,18 @@ login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
"""Enable WAL mode and sane concurrency settings for SQLite.
|
||||
|
||||
WAL lets readers and writers operate concurrently, which prevents
|
||||
'database is locked' errors when running under multi-worker Gunicorn.
|
||||
"""
|
||||
if isinstance(dbapi_connection, sqlite3.Connection):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
@@ -14,3 +14,4 @@ cryptography==42.0.8
|
||||
requests==2.32.3
|
||||
httpx[http2]
|
||||
docxtpl
|
||||
gunicorn==23.0.0
|
||||
|
||||
@@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'development'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5003))
|
||||
app.run(host='127.0.0.1', port=port)
|
||||
app.run(host='0.0.0.0', port=port)
|
||||
|
||||
@@ -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/migrate_to_wmt.yml
|
||||
!ansible/playbooks/Update_Rest_WMT_client.yml
|
||||
!ansible/playbooks/update_wmt_code.yml
|
||||
|
||||
# VS Code
|
||||
.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:
|
||||
msg: "work_place will be set to: '{{ work_place_value }}'"
|
||||
|
||||
# ── 5. Replace work_place value in WMT/data/config.txt ───────────────
|
||||
- name: Replace work_place in WMT config.txt
|
||||
lineinfile:
|
||||
path: /home/pi/Desktop/WMT/data/config.txt
|
||||
regexp: '^work_place\s*=.*'
|
||||
line: "work_place={{ work_place_value }}"
|
||||
backup: true
|
||||
# ── 5. Write work_place into WMT/data/config.txt ──────────────────────
|
||||
# Uses Python (always available on Raspberry Pi) to correctly
|
||||
# read/write the INI file and set work_place inside [device].
|
||||
# Also resets last_synced to epoch so first startup does NOT
|
||||
# overwrite work_place with a potentially empty server value.
|
||||
- name: Set work_place in WMT config.txt via Python
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
python3 - <<'PYEOF'
|
||||
import configparser, os
|
||||
path = '/home/pi/Desktop/WMT/data/config.txt'
|
||||
p = configparser.ConfigParser()
|
||||
p.read(path)
|
||||
if not p.has_section('chrome'):
|
||||
p.add_section('chrome')
|
||||
if not p.has_section('card_api'):
|
||||
p.add_section('card_api')
|
||||
if not p.has_section('server'):
|
||||
p.add_section('server')
|
||||
if not p.has_section('device'):
|
||||
p.add_section('device')
|
||||
if not p.has_section('meta'):
|
||||
p.add_section('meta')
|
||||
p.set('device', 'work_place', '{{ work_place_value }}')
|
||||
# Reset last_synced so first startup pull from server does not
|
||||
# overwrite the work_place we just set (server will return the
|
||||
# correct device_name once this device checks in).
|
||||
p.set('meta', 'last_synced', '1970-01-01T00:00:00')
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
p.write(f)
|
||||
print('work_place set to: {{ work_place_value }}')
|
||||
PYEOF
|
||||
|
||||
- name: Confirm work_place change
|
||||
command: grep 'work_place' /home/pi/Desktop/WMT/data/config.txt
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
# Update WMT client code from the controller's WMT_project folder
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Use this for devices that have not yet received the HTTP auto-update,
|
||||
# or whenever you need to force a code push from the server.
|
||||
#
|
||||
# What this playbook does:
|
||||
# 1. Ensure WMT directory exists on the target
|
||||
# 2. Back up the current app.py as app.py.bak.<version>
|
||||
# 3. Copy everything from /home/pi/Desktop/WMT_project/ on the CONTROLLER
|
||||
# → /home/pi/Desktop/WMT/ on the TARGET
|
||||
# The data/ directory on the target is fully preserved (never touched)
|
||||
# 4. Fix file ownership
|
||||
# 5. Restart the wmt systemd service
|
||||
#
|
||||
# The data/ directory (config.txt, idmasa.txt, tag.txt, log.txt, device_info.txt)
|
||||
# is intentionally excluded — device-specific settings stay intact.
|
||||
#
|
||||
# Run via: Ansible > Playbooks > "Update WMT Code"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Update WMT client code from WMT_project folder
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
become: false
|
||||
|
||||
vars:
|
||||
controller_src: /home/pi/Desktop/WMT_project/
|
||||
wmt_dir: /home/pi/Desktop/WMT
|
||||
|
||||
tasks:
|
||||
|
||||
# ── 1. Ensure WMT directory exists ────────────────────────────────────
|
||||
- name: Ensure WMT directory exists on target
|
||||
file:
|
||||
path: "{{ wmt_dir }}"
|
||||
state: directory
|
||||
owner: pi
|
||||
group: pi
|
||||
mode: '0755'
|
||||
|
||||
# ── 2. Back up current app.py ─────────────────────────────────────────
|
||||
- name: Read first line of current app.py (for backup filename)
|
||||
shell: head -1 {{ wmt_dir }}/app.py 2>/dev/null || echo "unknown"
|
||||
register: local_first_line
|
||||
changed_when: false
|
||||
ignore_errors: true
|
||||
|
||||
- name: Extract local version number
|
||||
set_fact:
|
||||
local_version: >-
|
||||
{{ local_first_line.stdout
|
||||
| regex_search('version\s+([\d.]+)', '\1')
|
||||
| first | default('old') }}
|
||||
|
||||
- name: Back up current app.py
|
||||
copy:
|
||||
src: "{{ wmt_dir }}/app.py"
|
||||
dest: "{{ wmt_dir }}/app.py.bak.{{ local_version }}"
|
||||
remote_src: true
|
||||
owner: pi
|
||||
group: pi
|
||||
mode: preserve
|
||||
ignore_errors: true
|
||||
|
||||
- name: Show backup info
|
||||
debug:
|
||||
msg: "Backed up app.py v{{ local_version }} → app.py.bak.{{ local_version }}"
|
||||
|
||||
# ── 3. Snapshot data/ before copy (audit) ────────────────────────────
|
||||
- name: List current data/ files (audit)
|
||||
shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty or missing)"
|
||||
register: data_files_before
|
||||
changed_when: false
|
||||
|
||||
- name: Show data/ files that will be preserved
|
||||
debug:
|
||||
msg: "data/ contents (will NOT be changed): {{ data_files_before.stdout_lines }}"
|
||||
|
||||
# ── 4. Sync WMT_project → WMT on target, excluding data/ ─────────────
|
||||
# synchronize uses rsync under the hood; delegate_to pushes
|
||||
# from the controller to the target.
|
||||
- name: Sync WMT_project to target (exclude data/ and junk files)
|
||||
synchronize:
|
||||
src: "{{ controller_src }}"
|
||||
dest: "{{ wmt_dir }}/"
|
||||
recursive: true
|
||||
delete: false
|
||||
checksum: true
|
||||
rsync_opts:
|
||||
- "--exclude=data/"
|
||||
- "--exclude=.git/"
|
||||
- "--exclude=.gitignore"
|
||||
- "--exclude=__pycache__/"
|
||||
- "--exclude=*.pyc"
|
||||
- "--exclude=*.pyo"
|
||||
- "--exclude=*.log"
|
||||
- "--exclude=*.bak"
|
||||
- "--exclude=venv/"
|
||||
- "--exclude=.venv/"
|
||||
- "--exclude=node_modules/"
|
||||
- "--exclude=.*"
|
||||
register: sync_result
|
||||
|
||||
- name: Show sync summary
|
||||
debug:
|
||||
msg: "Sync completed. Changed: {{ sync_result.changed }}"
|
||||
|
||||
# ── 5. Fix ownership ──────────────────────────────────────────────────
|
||||
- name: Set correct ownership on WMT directory
|
||||
become: true
|
||||
file:
|
||||
path: "{{ wmt_dir }}"
|
||||
owner: pi
|
||||
group: pi
|
||||
recurse: true
|
||||
|
||||
# ── 6. Verify data/ is still intact ──────────────────────────────────
|
||||
- name: List data/ files after update (verification)
|
||||
shell: ls -1 {{ wmt_dir }}/data/ 2>/dev/null || echo "(empty)"
|
||||
register: data_files_after
|
||||
changed_when: false
|
||||
|
||||
- name: Show data/ contents after update (should match before)
|
||||
debug:
|
||||
msg: "{{ data_files_after.stdout_lines }}"
|
||||
|
||||
# ── 9. Reboot ─────────────────────────────────────────────────────────
|
||||
- name: Reboot host to apply all changes
|
||||
become: true
|
||||
reboot:
|
||||
msg: "Rebooting after WMT code update "
|
||||
reboot_timeout: 180
|
||||
pre_reboot_delay: 3
|
||||
post_reboot_delay: 15
|
||||
@@ -2,8 +2,10 @@
|
||||
WMT (Workstation Management Terminal) configuration API
|
||||
Handles config distribution and device update requests from WMT clients.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
@@ -96,8 +98,8 @@ def get_device_config(mac_address):
|
||||
_, device_ts, latest_ts = _latest_config_ts(session, mac)
|
||||
|
||||
payload = {
|
||||
# Global settings
|
||||
'chrome_url': global_cfg.chrome_url,
|
||||
# Global settings (device custom_chrome_url overrides global chrome_url if set)
|
||||
'chrome_url': (device.custom_chrome_url if device and device.custom_chrome_url else global_cfg.chrome_url),
|
||||
'chrome_local_url': global_cfg.chrome_local_url or '',
|
||||
'chrome_insecure_origin': global_cfg.chrome_insecure_origin,
|
||||
'card_api_base_url': global_cfg.card_api_base_url,
|
||||
@@ -125,7 +127,25 @@ def get_device_config(mac_address):
|
||||
@wmt_api_bp.route('/config/update_request', methods=['POST'])
|
||||
def submit_update_request():
|
||||
"""
|
||||
WMT client sends current device info as an update request for admin approval.
|
||||
WMT client sends its current running config so the server can validate it.
|
||||
Three outcomes:
|
||||
|
||||
1. Device known AND config matches server record
|
||||
→ record config_synced_at = now, update last_seen
|
||||
→ respond {"status": "ok", "in_sync": true}
|
||||
Client does nothing.
|
||||
|
||||
2. Device known BUT config differs from server record
|
||||
→ the server is authoritative; client must pull fresh config
|
||||
→ respond {"status": "sync_required", "in_sync": false}
|
||||
Client should call GET /api/wmt/config/<mac> to obtain correct values.
|
||||
|
||||
3. Device unknown (new client – not registered by MAC or hostname)
|
||||
→ create WMTUpdateRequest so admin can approve and assign settings
|
||||
→ respond {"status": "pending_approval"}
|
||||
Client waits; it will get real config once admin approves.
|
||||
|
||||
card_presence is always applied directly – no approval required.
|
||||
|
||||
Expected JSON:
|
||||
{
|
||||
@@ -133,7 +153,8 @@ def submit_update_request():
|
||||
"device_name": "Masa-01",
|
||||
"hostname": "rpi-masa01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"client_config_mtime": "2026-04-22T09:30:00" // optional
|
||||
"card_presence": "enable",
|
||||
"client_config_mtime": "2026-04-22T09:30:00"
|
||||
}
|
||||
"""
|
||||
if not request.is_json:
|
||||
@@ -144,31 +165,138 @@ def submit_update_request():
|
||||
if not mac:
|
||||
return jsonify({'error': 'mac_address is required'}), 400
|
||||
|
||||
proposed_name = (data.get('device_name') or '').strip()
|
||||
proposed_hostname = (data.get('hostname') or '').strip()
|
||||
proposed_ip = (data.get('device_ip') or '').strip()
|
||||
card_presence = data.get('card_presence')
|
||||
|
||||
def _eq(a, b):
|
||||
return (a or '') == (b or '')
|
||||
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||
|
||||
req = WMTUpdateRequest(
|
||||
mac_address=mac,
|
||||
device_id=device.id if device else None,
|
||||
proposed_device_name=data.get('device_name'),
|
||||
proposed_hostname=data.get('hostname'),
|
||||
proposed_device_ip=data.get('device_ip'),
|
||||
client_config_mtime=data.get('client_config_mtime'),
|
||||
submitted_at=datetime.utcnow(),
|
||||
status='pending',
|
||||
# ── Outcome 3: unknown device ─────────────────────────────
|
||||
if not device:
|
||||
# Check if a pending request with the same data already exists
|
||||
existing = (
|
||||
session.query(WMTUpdateRequest)
|
||||
.filter_by(mac_address=mac, status='pending')
|
||||
.order_by(WMTUpdateRequest.submitted_at.desc())
|
||||
.first()
|
||||
)
|
||||
if existing and (
|
||||
_eq(existing.proposed_device_name, proposed_name) and
|
||||
_eq(existing.proposed_hostname, proposed_hostname) and
|
||||
_eq(existing.proposed_device_ip, proposed_ip)
|
||||
):
|
||||
existing.submitted_at = datetime.utcnow()
|
||||
logger.debug(f'WMT unknown device {mac} – refreshed existing pending request')
|
||||
else:
|
||||
req = WMTUpdateRequest(
|
||||
mac_address=mac,
|
||||
device_id=None,
|
||||
proposed_device_name=proposed_name or None,
|
||||
proposed_hostname=proposed_hostname or None,
|
||||
proposed_device_ip=proposed_ip or None,
|
||||
client_config_mtime=data.get('client_config_mtime'),
|
||||
submitted_at=datetime.utcnow(),
|
||||
status='pending',
|
||||
)
|
||||
session.add(req)
|
||||
logger.info(f'WMT pending approval request created for unknown device {mac}')
|
||||
|
||||
return jsonify({
|
||||
'status': 'pending_approval',
|
||||
'message': 'Device not registered. Awaiting admin approval.',
|
||||
}), 202
|
||||
|
||||
# Device is known from here on ─────────────────────────────
|
||||
device.last_seen = datetime.utcnow()
|
||||
if card_presence in ('enable', 'disable'):
|
||||
device.card_presence = card_presence
|
||||
|
||||
# ── Outcome 1: config matches server record ────────────────
|
||||
config_in_sync = (
|
||||
_eq(proposed_name, device.nume_masa) and
|
||||
_eq(proposed_hostname, device.hostname) and
|
||||
_eq(proposed_ip, device.device_ip)
|
||||
)
|
||||
session.add(req)
|
||||
|
||||
# Update device last_seen
|
||||
if device:
|
||||
device.last_seen = datetime.utcnow()
|
||||
# card_presence is a device capability flag – update directly (no approval needed)
|
||||
if data.get('card_presence') in ('enable', 'disable'):
|
||||
device.card_presence = data['card_presence']
|
||||
if config_in_sync:
|
||||
device.config_synced_at = datetime.utcnow()
|
||||
logger.debug(f'WMT config OK for {mac} – synced_at updated')
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'in_sync': True,
|
||||
'message': 'Config matches server record.',
|
||||
}), 200
|
||||
|
||||
# ── Outcome 2: config differs – client must pull from server ─
|
||||
logger.info(
|
||||
f'WMT config mismatch for {mac}: '
|
||||
f'client has name={proposed_name!r} host={proposed_hostname!r} ip={proposed_ip!r} '
|
||||
f'but server has name={device.nume_masa!r} host={device.hostname!r} ip={device.device_ip!r}'
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'sync_required',
|
||||
'in_sync': False,
|
||||
'message': 'Config differs from server record. Pull updated config.',
|
||||
}), 200
|
||||
|
||||
logger.info(f'WMT update request received from {mac}')
|
||||
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving WMT update request from {mac}: {e}')
|
||||
logger.error(f'Error processing WMT config check from {mac}: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMT client auto-update endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WMT_RELEASES_DIR = Path('data/wmt_releases')
|
||||
|
||||
|
||||
@wmt_api_bp.route('/client/version', methods=['GET'])
|
||||
def get_client_version():
|
||||
"""
|
||||
Returns the latest available WMT client version metadata.
|
||||
Client calls this to decide if it needs to update.
|
||||
|
||||
Response: { "version": "3.0", "notes": "...", "uploaded_at": "...", "filename": "wmt_v3.0.zip" }
|
||||
"""
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if not meta_path.exists():
|
||||
return jsonify({'error': 'No release available'}), 404
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
return jsonify(meta), 200
|
||||
except Exception as e:
|
||||
logger.error(f'Error reading WMT release metadata: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wmt_api_bp.route('/client/download', methods=['GET'])
|
||||
def download_client_release():
|
||||
"""
|
||||
Streams the latest WMT client release zip to the requesting device.
|
||||
Client downloads this when its version is older than the server version.
|
||||
"""
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if not meta_path.exists():
|
||||
return jsonify({'error': 'No release available'}), 404
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
zip_path = WMT_RELEASES_DIR / meta['filename']
|
||||
if not zip_path.exists():
|
||||
return jsonify({'error': f'Release file not found: {meta["filename"]}'}), 404
|
||||
logger.info(f'WMT client downloading release {meta["version"]} from {request.remote_addr}')
|
||||
return send_file(
|
||||
str(zip_path.resolve()),
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=meta['filename'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error serving WMT release: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -48,9 +48,11 @@ class Device(Base):
|
||||
|
||||
# WMT (Workstation Management Terminal) integration fields
|
||||
mac_address = Column(String(17), unique=True, nullable=True, index=True)
|
||||
config_updated_at = Column(DateTime)
|
||||
config_updated_at = Column(DateTime) # set by admin when pushing new config
|
||||
config_synced_at = Column(DateTime) # set by server when client confirms in-sync
|
||||
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
|
||||
card_presence = Column(String(10), default='enable')
|
||||
custom_chrome_url = Column(String(500), nullable=True) # per-device production URL override (overrides WMTGlobalConfig.chrome_url)
|
||||
|
||||
# Relationships
|
||||
logs = relationship("LogEntry", back_populates="device")
|
||||
|
||||
@@ -737,6 +737,7 @@ class AnsibleService:
|
||||
'system_health': 'Check system health and monitoring status',
|
||||
'maintenance_mode': 'Put devices in maintenance mode',
|
||||
'distribute_ssh_keys': 'Push server public key to all devices using password auth',
|
||||
'update_wmt_code': 'Push WMT_project code to clients from controller (preserves data/)',
|
||||
}
|
||||
return descriptions.get(playbook_name, f'Execute {playbook_name} playbook')
|
||||
|
||||
|
||||
@@ -129,10 +129,20 @@ def execute():
|
||||
seen.add(h['hostname'])
|
||||
|
||||
settings = ansible_service.load_settings()
|
||||
|
||||
# Discover custom playbooks (exclude built-ins that have dedicated buttons)
|
||||
_builtin_names = {'update_devices', 'restart_service', 'distribute_ssh_keys', 'system_health'}
|
||||
custom_playbooks = []
|
||||
if ansible_service.playbook_dir.exists():
|
||||
for _f in sorted(ansible_service.playbook_dir.glob('*.yml')):
|
||||
if _f.stem.lower() not in _builtin_names:
|
||||
custom_playbooks.append({'name': _f.stem, 'filename': _f.name})
|
||||
|
||||
return render_template('ansible/execute.html',
|
||||
inventory=inventory_data,
|
||||
all_inv_hosts=all_inv_hosts,
|
||||
preselect_playbook=preselect,
|
||||
custom_playbooks=custom_playbooks,
|
||||
use_password_auth=settings.get('use_password_auth', False))
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading execute form: {e}")
|
||||
@@ -141,6 +151,7 @@ def execute():
|
||||
inventory={'groups': {}},
|
||||
all_inv_hosts=[],
|
||||
preselect_playbook='',
|
||||
custom_playbooks=[],
|
||||
use_password_auth=False)
|
||||
|
||||
elif request.method == 'POST':
|
||||
@@ -462,7 +473,7 @@ def playbook_content():
|
||||
from pathlib import Path
|
||||
requested_path = Path(playbook_path)
|
||||
if not requested_path.is_absolute():
|
||||
requested_path = ansible_service.playbook_dir / requested_path
|
||||
requested_path = Path.cwd() / requested_path
|
||||
|
||||
# Ensure path is within playbook directory
|
||||
try:
|
||||
|
||||
@@ -34,7 +34,7 @@ def devices():
|
||||
with get_db().get_session() as session:
|
||||
devices = session.query(Device).order_by(Device.last_seen.desc()).all()
|
||||
|
||||
# Get log count per device
|
||||
# Log count per device
|
||||
device_log_counts = {}
|
||||
for device in devices:
|
||||
log_count = session.query(LogEntry).filter_by(device_id=device.id).count()
|
||||
@@ -42,14 +42,22 @@ def devices():
|
||||
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
# Pending approval requests mapped by mac_address for per-device badge
|
||||
pending_requests = session.query(WMTUpdateRequest).filter_by(status='pending').all()
|
||||
pending_by_mac = {}
|
||||
for req in pending_requests:
|
||||
pending_by_mac[req.mac_address] = pending_by_mac.get(req.mac_address, 0) + 1
|
||||
|
||||
return render_template('device_management.html',
|
||||
devices=devices,
|
||||
device_log_counts=device_log_counts,
|
||||
pending_count=pending_count)
|
||||
pending_count=pending_count,
|
||||
pending_by_mac=pending_by_mac)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading devices: {e}")
|
||||
flash(f'Error loading devices: {e}', 'error')
|
||||
return render_template('device_management.html', devices=[], device_log_counts={}, pending_count=0)
|
||||
return render_template('device_management.html', devices=[], device_log_counts={},
|
||||
pending_count=0, pending_by_mac={})
|
||||
|
||||
@main_bp.route('/device/<int:device_id>')
|
||||
def device_detail(device_id):
|
||||
@@ -516,7 +524,18 @@ def admin():
|
||||
logging.error(f'Admin inventory stats error: {e}')
|
||||
stats['inventory_hosts'] = '?'
|
||||
stats['inventory_groups_yaml'] = '?'
|
||||
return render_template('admin.html', stats=stats)
|
||||
# Pass registered devices for per-device delete UI
|
||||
devices = []
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
devices = [
|
||||
{'id': d.id, 'hostname': d.hostname, 'nume_masa': d.nume_masa,
|
||||
'mac_address': d.mac_address, 'device_ip': d.device_ip}
|
||||
for d in session.query(Device).order_by(Device.nume_masa).all()
|
||||
]
|
||||
except Exception as e:
|
||||
logging.error(f'Admin device list error: {e}')
|
||||
return render_template('admin.html', stats=stats, devices=devices)
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/logs', methods=['POST'])
|
||||
@@ -532,9 +551,43 @@ def admin_clear_logs():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/device-logs', methods=['POST'])
|
||||
def admin_clear_device_logs():
|
||||
"""Delete all log entries from the database (devices stay intact)."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
count = session.query(LogEntry).delete()
|
||||
session.commit()
|
||||
return jsonify({'success': True, 'deleted': count})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin clear device logs error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/delete/device/<int:device_id>', methods=['POST'])
|
||||
def admin_delete_device(device_id):
|
||||
"""Delete a single registered device and its log entries."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(id=device_id).first()
|
||||
if not device:
|
||||
return jsonify({'success': False, 'error': 'Device not found'}), 404
|
||||
name = device.nume_masa or device.hostname
|
||||
session.execute(text('DELETE FROM device_inventory_groups WHERE device_id = :id'), {'id': device_id})
|
||||
session.query(LogEntry).filter_by(device_id=device_id).delete()
|
||||
session.query(WMTUpdateRequest).filter_by(device_id=device_id).delete()
|
||||
session.delete(device)
|
||||
session.commit()
|
||||
logging.info(f'Admin deleted device {device_id} ({name})')
|
||||
return jsonify({'success': True, 'name': name})
|
||||
except Exception as e:
|
||||
logging.error(f'Admin delete device {device_id} error: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route('/admin/clear/devices', methods=['POST'])
|
||||
def admin_clear_devices():
|
||||
"""Delete all devices (and their log entries) from the database."""
|
||||
"""Delete ALL devices (and their log entries) from the database."""
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
session.execute(text('DELETE FROM device_inventory_groups'))
|
||||
|
||||
@@ -3,8 +3,12 @@ WMT management web routes – global settings, device registry, update requests.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response, jsonify
|
||||
from datetime import datetime, timezone
|
||||
from app.models import WMTGlobalConfig, Device, WMTUpdateRequest
|
||||
from config.database_config import get_db
|
||||
import logging
|
||||
@@ -295,6 +299,8 @@ def device_edit(device_id):
|
||||
device.location = request.form.get('location', '').strip() or None
|
||||
device.card_presence = request.form.get('card_presence', 'enable')
|
||||
device.description = request.form.get('notes', '').strip() or None
|
||||
custom_url = request.form.get('custom_chrome_url', '').strip()
|
||||
device.custom_chrome_url = custom_url if custom_url else None
|
||||
device.config_updated_at = datetime.utcnow()
|
||||
device.info_reviewed_at = datetime.utcnow()
|
||||
flash('Device updated.', 'success')
|
||||
@@ -363,3 +369,196 @@ def device_delete(device_id):
|
||||
logger.error(f'WMT device_delete error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.devices'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMT Client Release Management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WMT_RELEASES_DIR = Path('data/wmt_releases')
|
||||
|
||||
|
||||
def _read_release_meta():
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if meta_path.exists():
|
||||
try:
|
||||
return json.loads(meta_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases')
|
||||
def releases():
|
||||
"""WMT client release management page."""
|
||||
meta = _read_release_meta()
|
||||
zip_size = None
|
||||
if meta:
|
||||
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
|
||||
if zip_path.exists():
|
||||
zip_size = zip_path.stat().st_size
|
||||
return render_template('wmt/releases.html', meta=meta, zip_size=zip_size)
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/upload', methods=['POST'])
|
||||
def releases_upload():
|
||||
"""Upload a new WMT client release zip and set it as latest."""
|
||||
f = request.files.get('release_zip')
|
||||
version_str = request.form.get('version', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not f or not f.filename.endswith('.zip'):
|
||||
flash('Please upload a .zip file.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
if not version_str:
|
||||
flash('Version is required.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
# Validate version format (digits and dots)
|
||||
if not re.match(r'^\d+(\.\d+)*$', version_str):
|
||||
flash('Version must be in numeric format (e.g. 3.0 or 3.1.2).', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
try:
|
||||
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f'wmt_v{version_str}.zip'
|
||||
zip_path = WMT_RELEASES_DIR / filename
|
||||
|
||||
# Validate the zip contains app.py
|
||||
data = f.read()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
names = zf.namelist()
|
||||
if 'app.py' not in names:
|
||||
flash('Invalid release: zip must contain app.py at the root.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
except zipfile.BadZipFile:
|
||||
flash('Uploaded file is not a valid zip archive.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
zip_path.write_bytes(data)
|
||||
|
||||
meta = {
|
||||
'version': version_str,
|
||||
'notes': notes,
|
||||
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'filename': filename,
|
||||
}
|
||||
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
|
||||
|
||||
logger.info(f'New WMT release uploaded: v{version_str} ({filename})')
|
||||
flash(f'Release v{version_str} uploaded and set as latest.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release upload error: {e}')
|
||||
flash(f'Upload error: {e}', 'error')
|
||||
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/delete', methods=['POST'])
|
||||
def releases_delete():
|
||||
"""Remove the current release zip and metadata."""
|
||||
try:
|
||||
meta = _read_release_meta()
|
||||
if meta:
|
||||
zip_path = WMT_RELEASES_DIR / meta.get('filename', '')
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
meta_path = WMT_RELEASES_DIR / 'latest.json'
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
flash('Release deleted.', 'success')
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release delete error: {e}')
|
||||
flash(f'Error: {e}', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
|
||||
# Patterns excluded when building a release from a local folder
|
||||
_BUILD_EXCLUDE_DIRS = {'.git', '__pycache__', 'data', 'venv', '.venv', 'node_modules'}
|
||||
_BUILD_EXCLUDE_EXTS = {'.pyc', '.pyo', '.bak', '.log', '.swp'}
|
||||
|
||||
|
||||
def _should_exclude(rel_parts):
|
||||
"""Return True if this path should be left out of the release zip."""
|
||||
# Any hidden segment (starts with '.')
|
||||
for part in rel_parts:
|
||||
if part.startswith('.'):
|
||||
return True
|
||||
# Top-level or nested dir exclusion
|
||||
if rel_parts[0] in _BUILD_EXCLUDE_DIRS:
|
||||
return True
|
||||
# Extension exclusion
|
||||
if Path(rel_parts[-1]).suffix.lower() in _BUILD_EXCLUDE_EXTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@wmt_web_bp.route('/releases/build', methods=['POST'])
|
||||
def releases_build():
|
||||
"""
|
||||
Build a release zip directly from a folder on this server,
|
||||
excluding hidden files, __pycache__, data/, venv/, etc.
|
||||
"""
|
||||
folder_path = request.form.get('folder_path', '/home/pi/Desktop/WMT').strip()
|
||||
version_str = request.form.get('version', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not version_str:
|
||||
flash('Version is required.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
if not re.match(r'^\d+(\.\d+)*$', version_str):
|
||||
flash('Version must be in numeric format (e.g. 3.0).', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
src = Path(folder_path)
|
||||
if not src.is_dir():
|
||||
flash(f'Folder not found: {folder_path}', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
file_count = 0
|
||||
has_app_py = False
|
||||
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for abs_path in sorted(src.rglob('*')):
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
rel = abs_path.relative_to(src)
|
||||
parts = rel.parts
|
||||
if _should_exclude(parts):
|
||||
continue
|
||||
arcname = str(rel)
|
||||
zf.write(abs_path, arcname)
|
||||
file_count += 1
|
||||
if arcname == 'app.py':
|
||||
has_app_py = True
|
||||
|
||||
if not has_app_py:
|
||||
flash(f'No app.py found in {folder_path} — cannot create release.', 'error')
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
WMT_RELEASES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f'wmt_v{version_str}.zip'
|
||||
zip_path = WMT_RELEASES_DIR / filename
|
||||
zip_path.write_bytes(buf.getvalue())
|
||||
|
||||
meta = {
|
||||
'version': version_str,
|
||||
'notes': notes,
|
||||
'uploaded_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'filename': filename,
|
||||
}
|
||||
(WMT_RELEASES_DIR / 'latest.json').write_text(json.dumps(meta, indent=2))
|
||||
|
||||
size_kb = len(buf.getvalue()) // 1024
|
||||
logger.info(f'WMT release built from {folder_path}: v{version_str}, {file_count} files, {size_kb} KB')
|
||||
flash(f'Release v{version_str} built from {folder_path} ({file_count} files, {size_kb} KB).', 'success')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'WMT release build error: {e}')
|
||||
flash(f'Build error: {e}', 'error')
|
||||
|
||||
return redirect(url_for('wmt_web.releases'))
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Database configuration and connection management
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
import sqlite3
|
||||
from sqlalchemy import create_engine, MetaData, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from contextlib import contextmanager
|
||||
from app.models import Base
|
||||
@@ -38,7 +39,19 @@ class DatabaseConfig:
|
||||
pool_pre_ping=True,
|
||||
connect_args={"check_same_thread": False} # For SQLite
|
||||
)
|
||||
|
||||
|
||||
# Enable WAL mode + sane concurrency settings on each SQLite connection.
|
||||
# WAL lets readers and writers operate concurrently, preventing
|
||||
# 'database is locked' errors under multi-worker Gunicorn.
|
||||
@event.listens_for(self.engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
if isinstance(dbapi_connection, sqlite3.Connection):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
# Create session factory
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def main():
|
||||
# Run application
|
||||
try:
|
||||
app.run(
|
||||
host='127.0.0.1',
|
||||
host='0.0.0.0',
|
||||
port=int(os.environ.get('PORT', 80)),
|
||||
debug=app.config.get('DEBUG', False)
|
||||
)
|
||||
|
||||
@@ -3,4 +3,5 @@ SQLAlchemy>=2.0.36
|
||||
paramiko==3.3.1
|
||||
PyYAML==6.0.1
|
||||
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:
|
||||
print(f" ❌ Error adding {column_name}: {e}")
|
||||
|
||||
def add_config_synced_at_column(self):
|
||||
"""Add config_synced_at column to devices table (WMT config sync tracking)"""
|
||||
print("📊 Updating devices table schema (config_synced_at)...")
|
||||
with self.engine.connect() as conn:
|
||||
try:
|
||||
result = conn.execute(text("PRAGMA table_info(devices)"))
|
||||
existing = [row[1] for row in result.fetchall()]
|
||||
if 'config_synced_at' not in existing:
|
||||
conn.execute(text("ALTER TABLE devices ADD COLUMN config_synced_at DATETIME"))
|
||||
conn.commit()
|
||||
print(" ✅ Added column: config_synced_at")
|
||||
else:
|
||||
print(" ⏭️ Column config_synced_at already exists")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error adding config_synced_at: {e}")
|
||||
|
||||
def create_tables(self):
|
||||
"""Create all tables using SQLAlchemy metadata"""
|
||||
print("🏗️ Creating all database tables...")
|
||||
@@ -109,6 +125,7 @@ if __name__ == "__main__":
|
||||
|
||||
# Update schema
|
||||
updater.update_playbook_executions_schema()
|
||||
updater.add_config_synced_at_column()
|
||||
|
||||
# Verify
|
||||
if updater.verify_schema():
|
||||
|
||||
@@ -93,30 +93,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Devices -->
|
||||
<!-- Clear Device Logs -->
|
||||
<div class="col-md-3">
|
||||
<div class="card danger-card h-100">
|
||||
<div class="card warning-card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Clear Device Database</h5>
|
||||
<h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Clear Device Logs</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<p class="text-muted flex-grow-1">
|
||||
Deletes <strong>all devices</strong> and their associated log entries from the database.
|
||||
Devices will re-register automatically when they next check in.
|
||||
Deletes <strong>all log entries</strong> from the database.
|
||||
Registered devices are <strong>not affected</strong> and will continue logging automatically.
|
||||
</p>
|
||||
<div class="alert alert-danger py-2 mb-3">
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
Currently <strong id="badge-devices">{{ stats.get('devices', '?') }}</strong> devices
|
||||
and <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
|
||||
Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
|
||||
</div>
|
||||
<button class="btn btn-danger w-100"
|
||||
onclick="runAction('clear-devices', 'Delete ALL devices and their logs? This cannot be undone.')">
|
||||
<i class="fas fa-trash me-2"></i>Clear All Devices
|
||||
<button class="btn btn-warning w-100"
|
||||
onclick="runAction('clear-device-logs', 'Delete ALL device log entries? Devices stay registered. This cannot be undone.')">
|
||||
<i class="fas fa-trash me-2"></i>Clear Device Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Device Registry -->
|
||||
<div class="col-md-6">
|
||||
<div class="card danger-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5>
|
||||
<span class="badge bg-light text-danger">{{ devices|length }} device(s)</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if devices %}
|
||||
<div class="table-responsive" style="max-height:320px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Work Place</th>
|
||||
<th>Hostname</th>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th class="text-end">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="device-registry-body">
|
||||
{% for d in devices %}
|
||||
<tr id="device-row-{{ d.id }}">
|
||||
<td>{{ d.nume_masa or '—' }}</td>
|
||||
<td>{{ d.hostname or '—' }}</td>
|
||||
<td><code>{{ d.mac_address or '—' }}</code></td>
|
||||
<td>{{ d.device_ip or '—' }}</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteDevice({{ d.id }}, '{{ (d.nume_masa or d.hostname)|e }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="fas fa-check-circle fa-2x mb-2 text-success"></i><br>
|
||||
No registered devices.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Ansible Inventory -->
|
||||
<div class="col-md-3">
|
||||
<div class="card danger-card h-100">
|
||||
@@ -180,16 +227,17 @@
|
||||
|
||||
<script>
|
||||
const ENDPOINTS = {
|
||||
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
|
||||
'clear-devices': '{{ url_for("main.admin_clear_devices") }}',
|
||||
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
|
||||
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
|
||||
'clear-logs': '{{ url_for("main.admin_clear_logs") }}',
|
||||
'clear-device-logs': '{{ url_for("main.admin_clear_device_logs") }}',
|
||||
'clear-inventory': '{{ url_for("main.admin_clear_inventory") }}',
|
||||
'clear-wmt': '{{ url_for("main.admin_clear_wmt") }}'
|
||||
};
|
||||
|
||||
function runAction(action, confirmMsg) {
|
||||
if (!confirm(confirmMsg)) return;
|
||||
const btn = event.currentTarget;
|
||||
btn.disabled = true;
|
||||
const origLabel = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Working…';
|
||||
|
||||
fetch(ENDPOINTS[action], {
|
||||
@@ -206,19 +254,44 @@ function runAction(action, confirmMsg) {
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('danger', 'Network error: ' + err))
|
||||
.finally(() => {
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = origLabel; });
|
||||
}
|
||||
|
||||
function deleteDevice(deviceId, deviceName) {
|
||||
if (!confirm(`Delete device "${deviceName}" and all its logs and WMT requests?\n\nThis cannot be undone.`)) return;
|
||||
const btn = event.currentTarget;
|
||||
btn.disabled = true;
|
||||
const origLabel = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
fetch(`/admin/delete/device/${deviceId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Remove row from table without page reload
|
||||
const row = document.getElementById(`device-row-${deviceId}`);
|
||||
if (row) row.remove();
|
||||
showToast('success', `Device "${data.name}" deleted.`);
|
||||
refreshStats();
|
||||
} else {
|
||||
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origLabel;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('danger', 'Network error: ' + err);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.innerHTML.replace(/<span.*?><\/span>/, '<i class="fas fa-trash me-2"></i>');
|
||||
// Re-render button label properly
|
||||
btn.innerHTML = '<i class="fas fa-trash me-2"></i>' + btn.textContent.trim();
|
||||
btn.innerHTML = origLabel;
|
||||
});
|
||||
}
|
||||
|
||||
function buildMessage(action, data) {
|
||||
if (action === 'clear-logs')
|
||||
if (action === 'clear-logs' || action === 'clear-device-logs')
|
||||
return `Deleted ${data.deleted} log entries.`;
|
||||
if (action === 'clear-devices')
|
||||
return `Deleted ${data.deleted_devices} devices and ${data.deleted_logs} log entries.`;
|
||||
if (action === 'clear-inventory')
|
||||
return `Inventory reset. ${data.groups_deleted} group(s) removed.`;
|
||||
if (action === 'clear-wmt')
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card playbook-card mb-3" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
|
||||
<div class="card playbook-card mb-2" data-name="distribute_ssh_keys" onclick="selectPlaybook('distribute_ssh_keys')">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -87,10 +87,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card playbook-card mb-3" data-name="update_wmt_code" onclick="selectPlaybook('update_wmt_code')">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1 text-danger"><i class="fas fa-code-branch me-1"></i>Update WMT Code</h6>
|
||||
<p class="small text-muted mb-0">Force-push latest WMT release to clients that missed auto-update (preserves data/)</p>
|
||||
</div>
|
||||
<span class="badge bg-danger ms-2 flex-shrink-0">Built-in</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted fw-bold mb-2 small text-uppercase">Custom Playbooks</h6>
|
||||
<select class="form-select" id="customPlaybook">
|
||||
<option value="">— select custom playbook —</option>
|
||||
{% for pb in custom_playbooks %}
|
||||
<option value="{{ pb.name }}"
|
||||
{% if preselect_playbook == pb.filename or preselect_playbook == pb.name %}selected{% endif %}>
|
||||
{{ pb.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" name="playbook" id="selectedPlaybook">
|
||||
</div>
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
.code-editor-area {
|
||||
min-height: 400px;
|
||||
min-height: 600px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.CodeMirror {
|
||||
height: 400px;
|
||||
height: 600px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.playbook-actions {
|
||||
@@ -38,6 +38,26 @@
|
||||
padding: 1rem;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
body.dark-mode .playbook-actions {
|
||||
background: #2a2a2a;
|
||||
border-bottom-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .playbook-item.selected {
|
||||
border-color: #64b5f6;
|
||||
background-color: #1a2a3a;
|
||||
}
|
||||
|
||||
body.dark-mode #welcomeMessage h4,
|
||||
body.dark-mode #welcomeMessage p {
|
||||
color: #888888 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .code-editor-area {
|
||||
border-color: #444444;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -244,7 +264,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="playbookContent" style="max-height: 400px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
|
||||
<pre id="playbookContent" style="max-height: 600px; overflow-y: auto; background-color: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Devices – {{ app_name }}{% endblock %}
|
||||
{% block page_title %}Devices{% endblock %}
|
||||
{% block page_title %}Device Health{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; }
|
||||
.sync-ok { color: #2ecc71; }
|
||||
.sync-old { color: #e74c3c; }
|
||||
.tbl-sm td, .tbl-sm th { padding: 0.45rem 0.6rem; font-size: 0.88rem; vertical-align: middle; }
|
||||
|
||||
/* Sync status pills */
|
||||
.sync-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sync-ok { background: #d4edda; color: #155724; }
|
||||
.sync-required { background: #fff3cd; color: #856404; }
|
||||
.sync-pending { background: #cce5ff; color: #004085; }
|
||||
.sync-never { background: #e2e3e5; color: #495057; }
|
||||
|
||||
body.dark-mode .sync-ok { background: #1a3a25; color: #6fcf97; }
|
||||
body.dark-mode .sync-required { background: #3a2e00; color: #f0c040; }
|
||||
body.dark-mode .sync-pending { background: #003060; color: #7ec8e3; }
|
||||
body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,7 +79,7 @@
|
||||
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none">
|
||||
<div class="card-body py-3">
|
||||
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4>
|
||||
<small class="text-muted">Pending Requests</small>
|
||||
<small class="text-muted">Pending Approval</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -88,7 +107,7 @@
|
||||
<th>Work Place</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP</th>
|
||||
<th>MAC Address</th>
|
||||
<th>MAC / Type</th>
|
||||
<th>Status</th>
|
||||
<th>Logs</th>
|
||||
<th>Last Seen</th>
|
||||
@@ -98,20 +117,35 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
{% set is_wmt = device.mac_address is not none and device.mac_address != '' %}
|
||||
{% set has_pending = is_wmt and (pending_by_mac.get(device.mac_address, 0) > 0) %}
|
||||
<tr class="device-row"
|
||||
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
|
||||
|
||||
<!-- Work Place -->
|
||||
<td>
|
||||
<strong>{{ device.nume_masa or '—' }}</strong>
|
||||
{% if is_wmt %}
|
||||
<br><small class="text-muted"><i class="fas fa-tablet-alt me-1"></i>WMT</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Hostname -->
|
||||
<td>{{ device.hostname }}</td>
|
||||
|
||||
<!-- IP -->
|
||||
<td><code>{{ device.device_ip }}</code></td>
|
||||
|
||||
<!-- MAC / Type -->
|
||||
<td>
|
||||
{% if device.mac_address %}
|
||||
{% if is_wmt %}
|
||||
<code class="mac-badge">{{ device.mac_address }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td>
|
||||
{% if device.status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
@@ -121,24 +155,55 @@
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Logs -->
|
||||
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
||||
|
||||
<!-- Last Seen -->
|
||||
<td class="text-muted">
|
||||
{% if device.last_seen %}
|
||||
{{ device.last_seen | local_dt }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Config Sync status -->
|
||||
<td>
|
||||
{% if device.mac_address and device.config_updated_at %}
|
||||
<span class="sync-ok" title="{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
{{ device.config_updated_at | local_dt('%m-%d %H:%M') }}
|
||||
</span>
|
||||
{% elif device.mac_address %}
|
||||
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
|
||||
{% else %}
|
||||
{% if not is_wmt %}
|
||||
<span class="text-muted">—</span>
|
||||
|
||||
{% elif has_pending %}
|
||||
{# Unknown device waiting for admin approval #}
|
||||
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}"
|
||||
class="sync-pill sync-pending text-decoration-none"
|
||||
title="Admin approval required for this device">
|
||||
<i class="fas fa-user-clock"></i> Pending Approval
|
||||
</a>
|
||||
|
||||
{% elif device.config_synced_at %}
|
||||
{# Device checked in and server confirmed configs match #}
|
||||
<span class="sync-pill sync-ok"
|
||||
title="Last confirmed in-sync: {{ device.config_synced_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
OK · {{ device.config_synced_at | local_dt('%m-%d %H:%M') }}
|
||||
</span>
|
||||
|
||||
{% elif device.config_updated_at %}
|
||||
{# Admin pushed config but client hasn't confirmed sync yet #}
|
||||
<span class="sync-pill sync-required"
|
||||
title="Config updated {{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }} – awaiting client check-in">
|
||||
<i class="fas fa-arrow-circle-down"></i> Awaiting client
|
||||
</span>
|
||||
|
||||
{% else %}
|
||||
{# WMT device registered but never checked in #}
|
||||
<span class="sync-pill sync-never"
|
||||
title="Client has not checked in yet">
|
||||
<i class="fas fa-question-circle"></i> Never synced
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('main.device_detail', device_id=device.id) }}"
|
||||
class="btn btn-sm btn-outline-primary py-0" title="View Details">
|
||||
@@ -175,6 +240,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync legend -->
|
||||
<div class="d-flex flex-wrap gap-3 mt-3 ms-1">
|
||||
<small class="text-muted"><strong>Config Sync legend:</strong></small>
|
||||
<small><span class="sync-pill sync-ok"><i class="fas fa-check-circle"></i> OK</span> — client confirmed configs match</small>
|
||||
<small><span class="sync-pill sync-required"><i class="fas fa-arrow-circle-down"></i> Awaiting client</span> — server pushed new config, waiting for client check-in</small>
|
||||
<small><span class="sync-pill sync-pending"><i class="fas fa-user-clock"></i> Pending Approval</span> — new/unknown device waiting for admin</small>
|
||||
<small><span class="sync-pill sync-never"><i class="fas fa-question-circle"></i> Never synced</span> — registered but hasn't checked in</small>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<div class="modal fade" id="addDeviceModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -276,3 +350,4 @@ async function submitAddDevice(event) {
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -61,6 +61,20 @@
|
||||
margin: 10px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Dark mode overrides for this page */
|
||||
body.dark-mode .filter-container {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .log-entry:hover {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
body.dark-mode .log-meta {
|
||||
color: #888888;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -72,6 +72,34 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6 class="text-muted mb-1">Chrome Launch – Production URL Override</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
Leave blank to use the global default URL configured in
|
||||
<a href="{{ url_for('wmt_web.settings') }}" target="_blank">WMT Settings</a>.
|
||||
Fill in only if this device needs a different production page.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Custom Production URL
|
||||
<small class="text-muted fw-normal">(device-specific override)</small>
|
||||
</label>
|
||||
<input type="url" name="custom_chrome_url" class="form-control"
|
||||
value="{{ device.custom_chrome_url or '' if device else '' }}"
|
||||
placeholder="Leave blank to use global default">
|
||||
{% if device and device.custom_chrome_url %}
|
||||
<div class="form-text text-warning">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
This device uses a custom URL instead of the global default.
|
||||
Clear the field to revert to the global setting.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text text-success">
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
Using global default production URL.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if device %}
|
||||
<div class="alert alert-light border small mb-4">
|
||||
<strong>Last seen:</strong>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Work Place</th>
|
||||
<th>Client Name</th>
|
||||
<th>MAC</th>
|
||||
<th>IP</th>
|
||||
<th>Last Seen</th>
|
||||
@@ -126,6 +127,7 @@
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td><strong>{{ d.device_name or '—' }}</strong></td>
|
||||
<td>{{ d.hostname or '—' }}</td>
|
||||
<td><code>{{ d.mac_address }}</code></td>
|
||||
<td>{{ d.device_ip or '—' }}</td>
|
||||
<td class="text-muted small">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}WMT Client Releases – {{ app_name }}{% endblock %}
|
||||
{% block page_title %}WMT Client Release Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Current release info -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-box-open me-2"></i>Current Release
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if meta %}
|
||||
<table class="table table-sm mb-3">
|
||||
<tr><th style="width:130px">Version</th>
|
||||
<td><span class="badge bg-primary fs-6">v{{ meta.version }}</span></td></tr>
|
||||
<tr><th>File</th>
|
||||
<td><code>{{ meta.filename }}</code></td></tr>
|
||||
<tr><th>Size</th>
|
||||
<td>{% if zip_size %}{{ (zip_size / 1024 / 1024) | round(2) }} MB{% else %}<span class="text-danger">file missing!</span>{% endif %}</td></tr>
|
||||
<tr><th>Uploaded</th>
|
||||
<td>{{ meta.uploaded_at }}</td></tr>
|
||||
<tr><th>Notes</th>
|
||||
<td>{{ meta.notes or '—' }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('wmt_api.download_client_release') }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-download me-1"></i>Download zip
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('wmt_web.releases_delete') }}"
|
||||
onsubmit="return confirm('Delete release v{{ meta.version }}? Clients will not find an update until you upload a new one.')">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="fas fa-trash me-1"></i>Delete release
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-inbox fa-3x mb-3 d-block"></i>
|
||||
No release uploaded yet.<br>
|
||||
<small>Upload a zip below to enable client auto-update.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload new release -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-upload me-2"></i>Upload New Release
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('wmt_web.releases_upload') }}"
|
||||
enctype="multipart/form-data">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Version <span class="text-danger">*</span></label>
|
||||
<input type="text" name="version" class="form-control" placeholder="e.g. 3.0"
|
||||
pattern="\d+(\.\d+)*" required>
|
||||
<div class="form-text">Numeric only, e.g. <code>3.0</code> or <code>3.1.2</code>.
|
||||
Must be higher than the current version to trigger client updates.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Release Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
placeholder="What changed in this version?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Release Zip <span class="text-danger">*</span></label>
|
||||
<input type="file" name="release_zip" class="form-control" accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Must contain <code>app.py</code> at the zip root.<br>
|
||||
Include <code>dependency_utils.py</code>, <code>config.py</code>,
|
||||
<code>Files/reposytory/</code>, and <code>Files/Screen.html</code> for a full release.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info py-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Uploading will <strong>replace</strong> the current release immediately.
|
||||
All WMT clients will download the new version on their next 5-minute sync cycle.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-upload me-2"></i>Upload & 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_caching import Cache
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
import sqlite3
|
||||
|
||||
# Initialize extensions (will be bound to app in create_app)
|
||||
db = SQLAlchemy()
|
||||
@@ -21,3 +24,18 @@ cors = CORS()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
"""Enable WAL mode and sane concurrency settings for SQLite.
|
||||
|
||||
WAL lets readers and writers operate concurrently, which prevents
|
||||
'database is locked' errors when running under multi-worker Gunicorn.
|
||||
"""
|
||||
if isinstance(dbapi_connection, sqlite3.Connection):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
+23
-2
@@ -66,12 +66,32 @@ services:
|
||||
- itassets-docx:/app/docx_output
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PORT=5000
|
||||
- SECRET_KEY=${ITASSETS_SECRET_KEY:-change-itassets-secret}
|
||||
- SQLALCHEMY_DATABASE_URI=sqlite:////app/data/itassets.db
|
||||
- PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production}
|
||||
- FLASK_APP=run.py
|
||||
command: >
|
||||
sh -c "flask db upgrade && python run.py"
|
||||
sh -c "flask db upgrade && gunicorn -b 0.0.0.0:5000 -w 4 --timeout 120 run:app"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- edp-network
|
||||
|
||||
# ── Server Monitor (Raspberry Pi monitoring + Ansible) ────────────────────────
|
||||
srvmonitor-app:
|
||||
build: ./Server_Monitorizare_v2
|
||||
container_name: edp-srvmonitor
|
||||
expose:
|
||||
- "5000"
|
||||
volumes:
|
||||
- ./Server_Monitorizare_v2/data:/app/data
|
||||
- ./Server_Monitorizare_v2/logs:/app/logs
|
||||
- ./Server_Monitorizare_v2/ansible/inventory:/app/ansible/inventory
|
||||
- ./Server_Monitorizare_v2/ansible/ssh_keys:/app/ansible/ssh_keys
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PORT=5000
|
||||
- PORTAL_JWT_SECRET=${PORTAL_JWT_SECRET:-change-this-jwt-secret-in-production}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- edp-network
|
||||
@@ -123,9 +143,10 @@ services:
|
||||
- itassets-app
|
||||
- networkview-backend
|
||||
- networkview-frontend
|
||||
- srvmonitor-app
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:80/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -12,6 +12,7 @@ http {
|
||||
upstream portal_upstream { server portal:5001; }
|
||||
upstream digiserver_upstream { server digiserver-app:5000; }
|
||||
upstream itassets_upstream { server itassets-app:5000; }
|
||||
upstream srvmonitor_upstream { server srvmonitor-app:5000; }
|
||||
upstream nv_backend_upstream { server networkview-backend:3001; }
|
||||
upstream nv_frontend_upstream { server networkview-frontend:80; }
|
||||
|
||||
@@ -138,6 +139,27 @@ http {
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# ── Server Monitor — /srvmonitor/ ─────────────────────────────────────
|
||||
location /srvmonitor/ {
|
||||
auth_request /portal-verify;
|
||||
auth_request_set $auth_user_id $upstream_http_x_auth_user_id;
|
||||
auth_request_set $auth_username $upstream_http_x_auth_username;
|
||||
auth_request_set $auth_role $upstream_http_x_auth_role;
|
||||
|
||||
proxy_pass http://srvmonitor_upstream/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Script-Name /srvmonitor;
|
||||
proxy_set_header X-Auth-User-Id $auth_user_id;
|
||||
proxy_set_header X-Auth-Username $auth_username;
|
||||
proxy_set_header X-Auth-Role $auth_role;
|
||||
# Ansible playbook runs can take a while to stream output
|
||||
proxy_read_timeout 300s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# ── NetworkView API — /networkview/api/ ────────────────────────────────
|
||||
# Must be declared BEFORE the broader /networkview/ location
|
||||
location /networkview/api/ {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
import sqlite3
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
@@ -8,3 +11,18 @@ login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please sign in to access the platform.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
"""Enable WAL mode and sane concurrency settings for SQLite.
|
||||
|
||||
WAL lets readers and writers operate concurrently, which prevents
|
||||
'database is locked' errors when running under multi-worker Gunicorn.
|
||||
"""
|
||||
if isinstance(dbapi_connection, sqlite3.Connection):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
@@ -67,4 +67,6 @@ def _app_from_uri(uri):
|
||||
return 'itassets'
|
||||
if uri.startswith('/networkview/'):
|
||||
return 'networkview'
|
||||
if uri.startswith('/srvmonitor/'):
|
||||
return 'srvmonitor'
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user