feat(monitoring): add priority screenshot pipeline with screenshot_type + docs cleanup
Implement end-to-end support for typed screenshots and priority rendering in monitoring. Added - Accept and forward screenshot_type from MQTT screenshot/dashboard payloads (periodic, event_start, event_stop) - Extend screenshot upload handling to persist typed screenshots and metadata - Add dedicated priority screenshot serving endpoint with fallback behavior - Extend monitoring overview with priority screenshot fields and summary count - Add configurable PRIORITY_SCREENSHOT_TTL_SECONDS window for active priority state Fixed - Ensure screenshot cache-busting updates reliably via screenshot hash updates - Preserve normal periodic screenshot flow while introducing event_start/event_stop priority path Improved - Monitoring dashboard now displays screenshot type badges - Adaptive polling: faster refresh while priority screenshots are active - Priority screenshot presentation is surfaced immediately to operators Docs - Update README and copilot-instructions to match new screenshot_type behavior, priority endpoint, TTL config, monitoring fields, and retention model - Remove redundant/duplicate documentation blocks and improve troubleshooting section clarity
This commit is contained in:
@@ -4,11 +4,58 @@ from flask import Blueprint, request, jsonify
|
||||
from server.permissions import admin_or_higher
|
||||
from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups
|
||||
import sys
|
||||
import os
|
||||
import glob
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
sys.path.append('/workspace')
|
||||
|
||||
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
|
||||
|
||||
VALID_SCREENSHOT_TYPES = {"periodic", "event_start", "event_stop"}
|
||||
|
||||
|
||||
def _normalize_screenshot_type(raw_type):
|
||||
if raw_type is None:
|
||||
return "periodic"
|
||||
normalized = str(raw_type).strip().lower()
|
||||
if normalized in VALID_SCREENSHOT_TYPES:
|
||||
return normalized
|
||||
return "periodic"
|
||||
|
||||
|
||||
def _parse_screenshot_timestamp(raw_timestamp):
|
||||
if raw_timestamp is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw_timestamp, (int, float)):
|
||||
ts_value = float(raw_timestamp)
|
||||
if ts_value > 1e12:
|
||||
ts_value = ts_value / 1000.0
|
||||
return datetime.fromtimestamp(ts_value, timezone.utc)
|
||||
|
||||
if isinstance(raw_timestamp, str):
|
||||
ts = raw_timestamp.strip()
|
||||
if not ts:
|
||||
return None
|
||||
if ts.isdigit():
|
||||
ts_value = float(ts)
|
||||
if ts_value > 1e12:
|
||||
ts_value = ts_value / 1000.0
|
||||
return datetime.fromtimestamp(ts_value, timezone.utc)
|
||||
|
||||
ts_normalized = ts.replace("Z", "+00:00") if ts.endswith("Z") else ts
|
||||
parsed = datetime.fromisoformat(ts_normalized)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@clients_bp.route("/sync-all-groups", methods=["POST"])
|
||||
@admin_or_higher
|
||||
@@ -282,9 +329,6 @@ def upload_screenshot(uuid):
|
||||
Screenshots are stored as {uuid}.jpg in the screenshots folder.
|
||||
Keeps last 20 screenshots per client (auto-cleanup).
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import glob
|
||||
session = Session()
|
||||
client = session.query(Client).filter_by(uuid=uuid).first()
|
||||
if not client:
|
||||
@@ -293,6 +337,7 @@ def upload_screenshot(uuid):
|
||||
|
||||
try:
|
||||
screenshot_timestamp = None
|
||||
screenshot_type = "periodic"
|
||||
|
||||
# Handle JSON payload with base64-encoded image
|
||||
if request.is_json:
|
||||
@@ -300,31 +345,8 @@ def upload_screenshot(uuid):
|
||||
if "image" not in data:
|
||||
return jsonify({"error": "Missing 'image' field in JSON payload"}), 400
|
||||
|
||||
raw_timestamp = data.get("timestamp")
|
||||
if raw_timestamp is not None:
|
||||
try:
|
||||
if isinstance(raw_timestamp, (int, float)):
|
||||
ts_value = float(raw_timestamp)
|
||||
if ts_value > 1e12:
|
||||
ts_value = ts_value / 1000.0
|
||||
screenshot_timestamp = datetime.fromtimestamp(ts_value, timezone.utc)
|
||||
elif isinstance(raw_timestamp, str):
|
||||
ts = raw_timestamp.strip()
|
||||
if ts:
|
||||
if ts.isdigit():
|
||||
ts_value = float(ts)
|
||||
if ts_value > 1e12:
|
||||
ts_value = ts_value / 1000.0
|
||||
screenshot_timestamp = datetime.fromtimestamp(ts_value, timezone.utc)
|
||||
else:
|
||||
ts_normalized = ts.replace("Z", "+00:00") if ts.endswith("Z") else ts
|
||||
screenshot_timestamp = datetime.fromisoformat(ts_normalized)
|
||||
if screenshot_timestamp.tzinfo is None:
|
||||
screenshot_timestamp = screenshot_timestamp.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
screenshot_timestamp = screenshot_timestamp.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
screenshot_timestamp = None
|
||||
screenshot_timestamp = _parse_screenshot_timestamp(data.get("timestamp"))
|
||||
screenshot_type = _normalize_screenshot_type(data.get("screenshot_type") or data.get("screenshotType"))
|
||||
|
||||
# Decode base64 image
|
||||
image_data = base64.b64decode(data["image"])
|
||||
@@ -341,8 +363,8 @@ def upload_screenshot(uuid):
|
||||
|
||||
# Store screenshot with timestamp to track latest
|
||||
now_utc = screenshot_timestamp or datetime.now(timezone.utc)
|
||||
timestamp = now_utc.strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{uuid}_{timestamp}.jpg"
|
||||
timestamp = now_utc.strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"{uuid}_{timestamp}_{screenshot_type}.jpg"
|
||||
filepath = os.path.join(screenshots_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
@@ -353,13 +375,42 @@ def upload_screenshot(uuid):
|
||||
with open(latest_filepath, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Keep a dedicated copy for high-priority event screenshots.
|
||||
if screenshot_type in ("event_start", "event_stop"):
|
||||
priority_filepath = os.path.join(screenshots_dir, f"{uuid}_priority.jpg")
|
||||
with open(priority_filepath, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
metadata_path = os.path.join(screenshots_dir, f"{uuid}_meta.json")
|
||||
metadata = {}
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
|
||||
metadata.update({
|
||||
"latest_screenshot_type": screenshot_type,
|
||||
"latest_received_at": now_utc.isoformat(),
|
||||
})
|
||||
if screenshot_type in ("event_start", "event_stop"):
|
||||
metadata["last_priority_screenshot_type"] = screenshot_type
|
||||
metadata["last_priority_received_at"] = now_utc.isoformat()
|
||||
|
||||
with open(metadata_path, "w", encoding="utf-8") as meta_file:
|
||||
json.dump(metadata, meta_file)
|
||||
|
||||
# Update screenshot receive timestamp for monitoring dashboard
|
||||
client.last_screenshot_analyzed = now_utc
|
||||
client.last_screenshot_hash = hashlib.md5(image_data).hexdigest()
|
||||
session.commit()
|
||||
|
||||
# Cleanup: keep only last 20 timestamped screenshots per client
|
||||
pattern = os.path.join(screenshots_dir, f"{uuid}_*.jpg")
|
||||
existing_screenshots = sorted(glob.glob(pattern))
|
||||
existing_screenshots = sorted(
|
||||
[path for path in glob.glob(pattern) if not path.endswith("_priority.jpg")]
|
||||
)
|
||||
|
||||
# Keep last 20, delete older ones
|
||||
max_screenshots = 20
|
||||
@@ -376,7 +427,8 @@ def upload_screenshot(uuid):
|
||||
"success": True,
|
||||
"message": f"Screenshot received for client {uuid}",
|
||||
"filename": filename,
|
||||
"size": len(image_data)
|
||||
"size": len(image_data),
|
||||
"screenshot_type": screenshot_type,
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user