C:\TripServer\backups\admin_server\admin_server_backup_20260702_142642.py
# -*- coding: utf-8 -*-
from flask import Flask, request, redirect, send_from_directory, make_response, Response
from werkzeug.utils import secure_filename
import os
import time
import sys
import re
import shutil
import py_compile
import threading
import html as html_lib
import glob
import zipfile
import subprocess
import ipaddress
app = Flask(__name__)
ADMIN_PANEL_VERSION = "TripServer Admin v20.0 camera one-time auto-install + audio recording"
ADMIN_STARTED_AT = time.strftime("%Y-%m-%d %H:%M:%S")
BASE_DIR = r"C:\TripServer"
ADMIN_HOST = "0.0.0.0"
ADMIN_PORT = 8000
FOLDERS = {
"JAPAN": os.path.join(BASE_DIR, "JAPAN"),
"USA": os.path.join(BASE_DIR, "USA"),
"Moneyapp": os.path.join(BASE_DIR, "Moneyapp"),
"MoneyHome": os.path.join(BASE_DIR, "MoneyHome"),
"TripForm": os.path.join(BASE_DIR, "TripForm"),
}
APP_FOLDERS = {"JAPAN", "USA", "Moneyapp", "MoneyHome", "TripForm"}
JAPAN_PRESENTATION_FILES = {
"flight_intro": "japan-flight-intro.html",
"japan_experience": "japan-experience.html",
}
USA_PRESENTATION_FILES = {
"usa_presentation": "usa2027-presentation.html",
"usa_presentation_tv": "usa2027-presentation-tv.html",
}
USA_EXTRA_HTML_FILES = {
"usa_trip_tv": "usa2027-tv.html",
}
USA_MUSIC_FILES = {
"usa_music": os.path.join("media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"),
"usa_music_part2": os.path.join("media", "usa-presentation", "music", "usa-part2-theme.mp3"),
}
USA_IMAGE_KEYS = ({"intro", "budget", "roadtrip-map", "summary", "extra-big-sur", "extra-diner", "extra-death-valley", "extra-mono-lake"} | {f"day-{i:02d}" for i in range(1, 37)})
USA_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
JAPAN_MUSIC_TARGETS = {
# The Japan experience presentation contains several fallback paths. Keep all aliases synced.
"japan_experience_music": [
os.path.join("media", "japan-experience", "music", "japan-theme.mp3"),
os.path.join("media", "music", "japan-theme.mp3"),
"japan-theme.mp3",
],
"japan_flight_music": [
os.path.join("media", "japan-flight", "music", "flight-intro.mp3"),
],
}
AUDIO_UPLOAD_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".webm", ".mp4"}
JAPAN_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
JAPAN_IMAGES_ZIP_MAX_BYTES = 130 * 1024 * 1024
JAPAN_IMAGES_ZIP_MAX_FILES = 220
JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 220 * 1024 * 1024
JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 18 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_BYTES = 180 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_FILES = 320
TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES = 260 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES = 35 * 1024 * 1024
TRIP_PACKAGE_EXTENSIONS = {".html", ".htm", ".jpg", ".jpeg", ".png", ".webp", ".mp3", ".css", ".js", ".json", ".txt", ".svg"}
ADMIN_SERVER_PATH = os.path.abspath(__file__)
ADMIN_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server")
ADMIN_UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "tmp", "admin_server_uploads")
ADMIN_MAX_UPLOAD_BYTES = 2 * 1024 * 1024
USA_IMAGES_ZIP_MAX_BYTES = 80 * 1024 * 1024
USA_IMAGES_ZIP_MAX_FILES = 160
USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 140 * 1024 * 1024
USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 12 * 1024 * 1024
ADMIN_RESTART_CMD_PATH = os.path.join(BASE_DIR, "restart_admin_server.cmd")
ADMIN_RESTART_LOG_PATH = os.path.join(BASE_DIR, "admin_restart.log")
ADMIN_RESTART_HELPER_PATH = os.path.join(BASE_DIR, "restart_admin_server_helper.py")
UPLOAD_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "uploaded_files")
# External watchdog. This is intentionally separate from the Flask process: if the admin
# server is down, Windows Task Scheduler can still run this script every minute and bring
# the admin back without walking to the physical computer.
WATCHDOG_TASK_NAME = os.environ.get("TRIPSERVER_WATCHDOG_TASK", "TripServerAdminWatchdog")
WATCHDOG_SCRIPT_PATH = os.path.join(BASE_DIR, "tripserver_admin_watchdog.py")
WATCHDOG_LOG_PATH = os.path.join(BASE_DIR, "tripserver_watchdog.log")
WATCHDOG_STATE_PATH = os.path.join(BASE_DIR, "tripserver_watchdog_state.txt")
WATCHDOG_INTERVAL_MINUTES = int(os.environ.get("TRIPSERVER_WATCHDOG_INTERVAL_MINUTES", "1"))
WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = int(os.environ.get("TRIPSERVER_WATCHDOG_RESTART_COOLDOWN", "90"))
# Admin log viewer. Only these fixed paths are exposed in the UI; user-supplied paths are never accepted.
LOG_FILE_REGISTRY = {
"restart": {"label": "לוג אתחול פאנל", "path": ADMIN_RESTART_LOG_PATH, "note": "Restart helper, העלאת admin חדש, שחזור גיבוי ופעולות מחשב."},
"watchdog": {"label": "לוג Watchdog", "path": WATCHDOG_LOG_PATH, "note": "בדיקות Health, הפעלה אוטומטית של הפאנל, cooldown ושגיאות Task Scheduler."},
}
LOG_TAIL_DEFAULT_LINES = 120
LOG_TAIL_MAX_LINES = 500
# Smart media optimizer settings.
# Images uploaded to fixed slide slots are always stored as real .jpg files.
# Audio uploaded to fixed music slots is always stored as .mp3 when FFmpeg is available.
IMAGE_OPTIMIZER_MAX_LONG_EDGE = int(os.environ.get("TRIPSERVER_IMAGE_MAX_LONG_EDGE", "1920"))
IMAGE_OPTIMIZER_TARGET_BYTES = int(os.environ.get("TRIPSERVER_IMAGE_TARGET_BYTES", str(1100 * 1024)))
IMAGE_OPTIMIZER_QUALITIES = tuple(int(q) for q in os.environ.get("TRIPSERVER_IMAGE_QUALITIES", "88,86,84,82,80,78").split(",") if q.strip())
AUDIO_UPLOAD_MAX_BYTES = int(os.environ.get("TRIPSERVER_AUDIO_MAX_BYTES", str(80 * 1024 * 1024)))
AUDIO_MP3_BITRATE = os.environ.get("TRIPSERVER_AUDIO_MP3_BITRATE", "192k")
AUDIO_MP3_SAMPLE_RATE = os.environ.get("TRIPSERVER_AUDIO_MP3_SAMPLE_RATE", "44100")
MEDIA_AUDIO_ACCEPT = ".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"
MEDIA_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*"
# Power actions are intentionally guarded: POST only, LAN/local client only, typed Hebrew confirmation, and cancel support.
POWER_ACTIONS_ENABLED = os.environ.get("TRIPSERVER_ENABLE_POWER_ACTIONS", "1").strip().lower() not in {"0", "false", "no", "off"}
POWER_MIN_DELAY_SECONDS = 30
POWER_MAX_DELAY_SECONDS = 3600
POWER_DEFAULT_DELAY_SECONDS = 60
POWER_CONFIRM_TEXT = {
"shutdown": "כיבוי",
"reboot": "הפעלה מחדש",
}
POWER_LABELS = {
"shutdown": "כיבוי מחשב",
"reboot": "ריסטארט למחשב",
}
# Webcam / room camera control. The camera is never opened automatically on page load.
# It starts only when the user presses the camera button, and it is guarded to LAN/Tailscale/private IPs.
CAMERA_ENABLED = os.environ.get("TRIPSERVER_ENABLE_WEBCAM", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_INDEX = int(os.environ.get("TRIPSERVER_WEBCAM_INDEX", "0"))
CAMERA_WIDTH = int(os.environ.get("TRIPSERVER_WEBCAM_WIDTH", "1280"))
CAMERA_HEIGHT = int(os.environ.get("TRIPSERVER_WEBCAM_HEIGHT", "720"))
CAMERA_FPS = max(1, min(20, int(os.environ.get("TRIPSERVER_WEBCAM_FPS", "8"))))
CAMERA_JPEG_QUALITY = max(45, min(92, int(os.environ.get("TRIPSERVER_WEBCAM_JPEG_QUALITY", "78"))))
CAMERA_MAX_STREAM_SECONDS = int(os.environ.get("TRIPSERVER_WEBCAM_MAX_STREAM_SECONDS", "1800"))
CAMERA_FRAME_INTERVAL_SECONDS = 1.0 / float(CAMERA_FPS)
# Optional audio + recording layer. Audio and recording are manual-only and guarded by the same
# LAN/Tailscale/private-IP checks as the live camera. FFmpeg is required for audio and MP4 recording.
CAMERA_AUDIO_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_AUDIO", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_RECORDING_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_RECORDING", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_VIDEO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_VIDEO_DEVICE", "").strip()
CAMERA_AUDIO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_AUDIO_DEVICE", "").strip()
CAMERA_RECORDINGS_DIR = os.environ.get("TRIPSERVER_CAMERA_RECORDINGS_DIR", os.path.join(BASE_DIR, "camera_recordings"))
CAMERA_RECORD_FPS = max(1, min(30, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_FPS", "15"))))
CAMERA_RECORD_MAX_SECONDS = max(30, min(8 * 3600, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_MAX_SECONDS", "3600"))))
CAMERA_AUDIO_BITRATE = os.environ.get("TRIPSERVER_CAMERA_AUDIO_BITRATE", "128k")
# Camera component installer. First-run auto installer: if OpenCV is missing, the admin
# starts a background pip installation automatically when the Flask process starts.
# It uses the same Python executable that runs this admin server and writes a log.
# The camera itself still never turns on automatically; only the dependency installer runs.
CAMERA_SETUP_LOG_PATH = os.path.join(BASE_DIR, "camera_setup.log")
CAMERA_SETUP_CONFIRM_TEXT = "התקן"
CAMERA_OPENCV_PACKAGE = os.environ.get("TRIPSERVER_OPENCV_PACKAGE", "opencv-python").strip() or "opencv-python"
CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS = int(os.environ.get("TRIPSERVER_OPENCV_INSTALL_TIMEOUT_SECONDS", "900"))
CAMERA_AUTO_INSTALL_OPENCV = os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_AUTO_INSTALL_DELAY_SECONDS = int(os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_DELAY_SECONDS", "4"))
CAMERA_SETUP_MARKER_PATH = os.path.join(BASE_DIR, "camera_opencv_installed.ok")
CAMERA_SETUP_STATE = {"running": False, "started_at": "", "finished_at": "", "ok": False, "last_message": "", "mode": "idle", "auto_requested": False}
CAMERA_SETUP_LOCK = threading.RLock()
LOG_FILE_REGISTRY["camera_setup"] = {"label": "לוג התקנת רכיבי מצלמה", "path": CAMERA_SETUP_LOG_PATH, "note": "התקנת OpenCV דרך pip ובדיקת רכיבי מצלמה."}
def safe_zip_relpath(raw_name):
raw = (raw_name or "").replace("\\", "/").strip("/")
if not raw or raw.startswith("__MACOSX/"):
return None
parts = [p for p in raw.split("/") if p and p not in (".", "..")]
if not parts or len(parts) != len(raw.split("/")):
return None
return "/".join(parts)
def file_ext(filename):
return os.path.splitext(filename or "")[1].lower()
def is_likely_jpeg_file(path):
try:
with open(path, "rb") as fh:
return fh.read(3) == b"\xff\xd8\xff"
except Exception:
return False
def is_likely_mp3_file(path):
"""Fast MP3 sniffing: ID3 tag or MPEG audio frame sync near the beginning."""
try:
with open(path, "rb") as fh:
data = fh.read(4096)
if data.startswith(b"ID3"):
return True
for idx in range(0, max(0, len(data) - 1)):
if data[idx] == 0xFF and (data[idx + 1] & 0xE0) == 0xE0:
return True
return False
except Exception:
return False
def find_ffmpeg_executable():
candidates = [
os.environ.get("FFMPEG_PATH"),
"ffmpeg",
r"C:\\ffmpeg\\bin\\ffmpeg.exe",
r"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
r"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
]
for candidate in candidates:
if not candidate:
continue
found = shutil.which(candidate) if os.path.basename(candidate) == candidate else (candidate if os.path.exists(candidate) else None)
if found:
return found
return None
def media_engine_status():
status = {"pillow": False, "ffmpeg": bool(find_ffmpeg_executable()), "ffmpeg_path": find_ffmpeg_executable() or ""}
try:
import PIL # noqa: F401
status["pillow"] = True
except Exception:
status["pillow"] = False
return status
def format_bytes(num):
try:
num = int(num)
except Exception:
return str(num)
units = ["B", "KB", "MB", "GB"]
value = float(num)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
value /= 1024
def convert_or_copy_image_to_jpg(source_path, target_path, original_name=""):
"""Create a real optimized JPG file; never writes a fake .jpg.
Smart behavior:
- Uses Pillow when installed.
- Applies EXIF orientation.
- Converts alpha/transparency to a white background.
- Downscales only when the long edge is larger than IMAGE_OPTIMIZER_MAX_LONG_EDGE.
- Uses progressive/optimized JPEG and a quality loop to reduce weight without aggressive damage.
- If Pillow is missing, accepts only true JPEG bytes and copies them safely.
"""
ensure_parent_dir(target_path)
original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0
try:
from PIL import Image, ImageOps
with Image.open(source_path) as img:
img.verify()
with Image.open(source_path) as img:
img = ImageOps.exif_transpose(img)
original_dimensions = tuple(img.size)
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
rgba = img.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
background.alpha_composite(rgba)
img = background.convert("RGB")
elif img.mode != "RGB":
img = img.convert("RGB")
resized = False
max_edge = max(img.size) if img.size else 0
if IMAGE_OPTIMIZER_MAX_LONG_EDGE and max_edge > IMAGE_OPTIMIZER_MAX_LONG_EDGE:
scale = IMAGE_OPTIMIZER_MAX_LONG_EDGE / float(max_edge)
new_size = (max(1, int(round(img.size[0] * scale))), max(1, int(round(img.size[1] * scale))))
img = img.resize(new_size, Image.Resampling.LANCZOS)
resized = True
tmp_candidates = []
qualities = IMAGE_OPTIMIZER_QUALITIES or (88, 84, 80)
for quality in qualities:
tmp_candidate = f"{target_path}.q{quality}.tmp"
img.save(tmp_candidate, "JPEG", quality=int(quality), optimize=True, progressive=True)
tmp_candidates.append((quality, tmp_candidate, os.path.getsize(tmp_candidate)))
if os.path.getsize(tmp_candidate) <= IMAGE_OPTIMIZER_TARGET_BYTES:
break
# Keep the highest-quality candidate that reaches target size; otherwise keep the smallest candidate.
under_target = [item for item in tmp_candidates if item[2] <= IMAGE_OPTIMIZER_TARGET_BYTES]
chosen = under_target[0] if under_target else min(tmp_candidates, key=lambda item: item[2])
for quality, path, _size in tmp_candidates:
if path != chosen[1]:
try:
os.remove(path)
except Exception:
pass
os.replace(chosen[1], target_path)
final_size = os.path.getsize(target_path)
return {
"mode": "optimized_jpg",
"quality": chosen[0],
"original_size": original_size,
"final_size": final_size,
"original_dimensions": original_dimensions,
"final_dimensions": tuple(img.size),
"resized": resized,
"saved_bytes": max(0, original_size - final_size),
}
except ImportError:
if is_likely_jpeg_file(source_path):
tmp_target = target_path + ".tmp"
shutil.copy2(source_path, tmp_target)
os.replace(tmp_target, target_path)
final_size = os.path.getsize(target_path)
return {
"mode": "copied_real_jpeg_without_pillow",
"quality": "source",
"original_size": original_size,
"final_size": final_size,
"original_dimensions": "unknown",
"final_dimensions": "unknown",
"resized": False,
"saved_bytes": max(0, original_size - final_size),
}
raise ValueError("Pillow is not installed, so non-JPG images cannot be converted. Install Pillow with: py -m pip install pillow")
except ValueError:
raise
except Exception as exc:
raise ValueError("Image conversion/compression failed: " + repr(exc))
def convert_or_copy_audio_to_mp3(source_path, target_path, original_name=""):
"""Store uploaded audio as a real .mp3 file.
If FFmpeg exists, transcode supported audio/video containers to MP3.
If FFmpeg is missing, accept only files that are already real MP3 bytes, even if their extension is wrong.
"""
ensure_parent_dir(target_path)
original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0
if original_size <= 0:
raise ValueError("קובץ האודיו ריק.")
if original_size > AUDIO_UPLOAD_MAX_BYTES:
raise ValueError(f"קובץ האודיו גדול מדי: {format_bytes(original_size)}. מגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}")
source_is_mp3 = is_likely_mp3_file(source_path)
ffmpeg = find_ffmpeg_executable()
if ffmpeg:
tmp_mp3 = target_path + ".ffmpeg.tmp.mp3"
cmd = [
ffmpeg,
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", source_path,
"-vn",
"-codec:a", "libmp3lame",
"-b:a", AUDIO_MP3_BITRATE,
"-ar", AUDIO_MP3_SAMPLE_RATE,
tmp_mp3,
]
run = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if run.returncode != 0 or not os.path.exists(tmp_mp3) or os.path.getsize(tmp_mp3) <= 0:
try:
if os.path.exists(tmp_mp3):
os.remove(tmp_mp3)
except Exception:
pass
raise ValueError("FFmpeg failed to convert audio to MP3: " + ((run.stderr or run.stdout or "unknown error")[-1200:]))
converted_size = os.path.getsize(tmp_mp3)
if source_is_mp3 and converted_size > int(original_size * 1.05):
# Avoid bloating an already efficient MP3. Still normalize the target filename to .mp3.
try:
os.remove(tmp_mp3)
except Exception:
pass
tmp_target = target_path + ".copy.tmp"
shutil.copy2(source_path, tmp_target)
os.replace(tmp_target, target_path)
final_size = os.path.getsize(target_path)
return {"mode": "copied_existing_mp3_smaller_than_reencode", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg}
os.replace(tmp_mp3, target_path)
final_size = os.path.getsize(target_path)
return {"mode": "converted_to_mp3_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg, "bitrate": AUDIO_MP3_BITRATE}
if source_is_mp3:
tmp_target = target_path + ".copy.tmp"
shutil.copy2(source_path, tmp_target)
os.replace(tmp_target, target_path)
final_size = os.path.getsize(target_path)
return {"mode": "copied_real_mp3_without_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": "not_found"}
raise ValueError("FFmpeg is not installed, so this audio file cannot be converted to MP3. Install FFmpeg or upload a real MP3 file.")
def save_audio_upload_to_target(uploaded_file, target_path, backup_group="audio"):
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload"
tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_{stamp}_{os.getpid()}_{safe_name}")
uploaded_file.save(tmp_path)
try:
backup_path = backup_existing_file(target_path, backup_group)
media = convert_or_copy_audio_to_mp3(tmp_path, target_path, uploaded_file.filename or "")
return {"path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path, "media": media}
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def save_audio_upload_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group):
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload"
source_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_{safe_name}")
converted_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_converted.mp3")
uploaded_file.save(source_tmp)
try:
media = convert_or_copy_audio_to_mp3(source_tmp, converted_tmp, uploaded_file.filename or "")
sha = file_sha256(converted_tmp)
results = []
for rel_path in rel_paths:
target_path = os.path.join(base_folder, rel_path)
ensure_parent_dir(target_path)
backup_path = backup_existing_file(target_path, backup_group)
tmp_target = target_path + ".tmp"
shutil.copy2(converted_tmp, tmp_target)
os.replace(tmp_target, target_path)
results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path})
return {"size": os.path.getsize(converted_tmp), "sha256": sha, "targets": results, "media": media}
finally:
for p in (source_tmp, converted_tmp):
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
def media_details_text(media):
if not media:
return ""
lines = []
if media.get("mode"):
lines.append(f"מצב עיבוד: {media.get('mode')}")
if media.get("original_size") is not None and media.get("final_size") is not None:
lines.append(f"גודל לפני: {format_bytes(media.get('original_size'))}")
lines.append(f"גודל אחרי: {format_bytes(media.get('final_size'))}")
saved = int(media.get("saved_bytes") or 0)
if saved > 0:
lines.append(f"חיסכון: {format_bytes(saved)}")
if media.get("original_dimensions"):
lines.append(f"מידות מקור: {media.get('original_dimensions')}")
if media.get("final_dimensions"):
lines.append(f"מידות סופיות: {media.get('final_dimensions')}")
if media.get("quality"):
lines.append(f"איכות JPG: {media.get('quality')}")
if media.get("bitrate"):
lines.append(f"ביטרייט MP3: {media.get('bitrate')}")
if media.get("ffmpeg"):
lines.append(f"FFmpeg: {media.get('ffmpeg')}")
return "\n".join(lines)
def save_usa_presentation_image(image_key, uploaded_file):
if image_key not in USA_IMAGE_KEYS:
return None
original_name = uploaded_file.filename or ""
if file_ext(original_name) not in USA_IMAGE_EXTENSIONS:
raise ValueError("סיומת תמונה לא נתמכת. העלה JPG/JPEG/PNG/WebP/BMP/TIFF.")
image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images")
os.makedirs(image_dir, exist_ok=True)
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_image_{stamp}_{os.getpid()}_{secure_filename(image_key)}")
uploaded_file.save(tmp_path)
try:
if os.path.getsize(tmp_path) <= 0:
raise ValueError("קובץ התמונה ריק.")
if os.path.getsize(tmp_path) > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
raise ValueError("קובץ התמונה גדול מדי.")
target_path = os.path.join(image_dir, image_key + ".jpg")
backup_path = backup_existing_file(target_path, "USA_images_single")
media = convert_or_copy_image_to_jpg(tmp_path, target_path, original_name)
for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}:
old_path = os.path.join(image_dir, image_key + old_ext)
if os.path.exists(old_path):
try:
backup_existing_file(old_path, "USA_images_single_old_ext")
os.remove(old_path)
except Exception:
pass
return {"rel": os.path.join("media", "usa-presentation", "images", image_key + ".jpg"), "path": target_path, "mode": media.get("mode", "optimized"), "media": media, "backup": backup_path, "sha256": file_sha256(target_path), "size": os.path.getsize(target_path)}
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def save_usa_presentation_images_zip(uploaded_file):
image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images")
os.makedirs(image_dir, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_{stamp}.zip")
tmp_extract = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_extract_{stamp}")
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
os.makedirs(tmp_extract, exist_ok=True)
uploaded_file.save(tmp_zip)
zip_size = os.path.getsize(tmp_zip)
if zip_size > USA_IMAGES_ZIP_MAX_BYTES:
try:
os.remove(tmp_zip)
except Exception:
pass
raise ValueError(f"zip_size_exceeded:{zip_size}")
imported = []
skipped = []
seen = set()
try:
with zipfile.ZipFile(tmp_zip, "r") as zf:
infos = [info for info in zf.infolist() if not info.is_dir()]
if len(infos) > USA_IMAGES_ZIP_MAX_FILES:
raise ValueError(f"too_many_files:{len(infos)}")
total_uncompressed = sum(max(0, info.file_size) for info in infos)
if total_uncompressed > USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES:
raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")
for info in infos:
raw_name = (info.filename or "").replace("\\", "/")
base = os.path.basename(raw_name)
if not base or base.startswith(".") or raw_name.startswith("__MACOSX/"):
continue
if info.file_size > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
skipped.append(base + " — גדול מדי")
continue
stem, ext = os.path.splitext(base)
stem = stem.strip()
ext = ext.lower()
if ext not in USA_IMAGE_EXTENSIONS:
skipped.append(base + " — סיומת לא נתמכת")
continue
if stem not in USA_IMAGE_KEYS:
skipped.append(base + " — שם לא שייך למצגת")
continue
if stem in seen:
skipped.append(base + " — כפילות ב־ZIP")
continue
seen.add(stem)
source_tmp = os.path.join(tmp_extract, stem + ext)
with zf.open(info, "r") as source, open(source_tmp, "wb") as dest:
shutil.copyfileobj(source, dest)
target = os.path.join(image_dir, stem + ".jpg")
try:
backup_existing_file(target, "USA_images_zip")
media = convert_or_copy_image_to_jpg(source_tmp, target, base)
for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}:
old_path = os.path.join(image_dir, stem + old_ext)
if os.path.exists(old_path):
try:
backup_existing_file(old_path, "USA_images_zip_old_ext")
os.remove(old_path)
except Exception:
pass
imported.append(stem + ".jpg" + " — " + format_bytes(media.get("original_size", 0)) + " → " + format_bytes(media.get("final_size", 0)))
except Exception as exc:
skipped.append(base + " — לא הומר/נדחס ל־JPG: " + str(exc))
finally:
try:
os.remove(tmp_zip)
except Exception:
pass
try:
shutil.rmtree(tmp_extract, ignore_errors=True)
except Exception:
pass
imported.sort()
skipped.sort()
# Missing means absent on disk after the import, not merely absent from this specific ZIP.
missing = sorted(key + ".jpg" for key in USA_IMAGE_KEYS if not os.path.exists(os.path.join(image_dir, key + ".jpg")))
return imported, skipped, missing
for folder_path in FOLDERS.values():
os.makedirs(folder_path, exist_ok=True)
os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "images"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "music"), exist_ok=True)
os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
def no_cache_response(response):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0, private"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Prevent old HTML/images from being reused after upload when the browser sends If-None-Match/If-Modified-Since.
response.headers.pop("ETag", None)
response.headers.pop("Last-Modified", None)
return response
@app.after_request
def add_global_no_cache_headers(response):
return no_cache_response(response)
def serve_file_no_cache(directory, file_name):
try:
response = make_response(send_from_directory(directory, file_name, conditional=False, max_age=0))
except TypeError:
# Compatibility fallback for older Flask/Werkzeug versions.
response = make_response(send_from_directory(directory, file_name))
return no_cache_response(response)
def file_sha256(path):
import hashlib
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def ensure_parent_dir(path):
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
def backup_existing_file(target_path, backup_group="uploads"):
if not os.path.exists(target_path) or not os.path.isfile(target_path):
return None
stamp = time.strftime("%Y%m%d_%H%M%S")
safe_group = secure_filename(str(backup_group)) or "uploads"
safe_name = secure_filename(os.path.basename(target_path)) or "file"
backup_dir = os.path.join(UPLOAD_BACKUP_DIR, safe_group)
os.makedirs(backup_dir, exist_ok=True)
backup_path = os.path.join(backup_dir, f"{stamp}_{safe_name}")
shutil.copy2(target_path, backup_path)
return backup_path
def atomic_save_upload(uploaded_file, target_path, backup_group="uploads"):
"""Save uploaded file atomically: tmp -> validate size -> backup old -> os.replace."""
ensure_parent_dir(target_path)
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
safe_target = secure_filename(os.path.basename(target_path)) or "upload.bin"
tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"tmp_{stamp}_{os.getpid()}_{safe_target}")
try:
uploaded_file.save(tmp_path)
size = os.path.getsize(tmp_path)
if size <= 0:
raise ValueError("uploaded file is empty")
backup_path = backup_existing_file(target_path, backup_group)
os.replace(tmp_path, target_path)
final_size = os.path.getsize(target_path)
if final_size != size:
raise IOError(f"size mismatch after save: tmp={size}, final={final_size}")
return {"path": target_path, "size": final_size, "backup": backup_path, "sha256": file_sha256(target_path)}
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def find_html_file(folder_path):
index_path = os.path.join(folder_path, "index.html")
if os.path.exists(index_path):
return "index.html"
html_files = [f for f in os.listdir(folder_path) if f.lower().endswith((".html", ".htm"))]
if html_files:
return html_files[0]
return None
def clean_wrong_main_files(folder_path):
wrong_names = {"html", "htm", "index", "index.htm", "download", "unknown"}
for name in os.listdir(folder_path):
full_path = os.path.join(folder_path, name)
if not os.path.isfile(full_path):
continue
if name.lower().strip() in wrong_names:
try:
os.remove(full_path)
except Exception:
pass
def save_uploaded_file(folder, uploaded_file):
folder_path = FOLDERS[folder]
os.makedirs(folder_path, exist_ok=True)
original_filename = uploaded_file.filename or ""
safe_name = secure_filename(original_filename)
if folder in APP_FOLDERS:
clean_wrong_main_files(folder_path)
filename = "index.html"
else:
filename = safe_name or f"upload_{int(time.time())}"
target_path = os.path.join(folder_path, filename)
result = atomic_save_upload(uploaded_file, target_path, backup_group=f"{folder}_main")
return filename, result
def save_japan_presentation(presentation_key, uploaded_file):
if presentation_key not in JAPAN_PRESENTATION_FILES:
return None
japan_folder = FOLDERS["JAPAN"]
os.makedirs(japan_folder, exist_ok=True)
filename = JAPAN_PRESENTATION_FILES[presentation_key]
target_path = os.path.join(japan_folder, filename)
result = atomic_save_upload(uploaded_file, target_path, backup_group="JAPAN_presentations")
return filename, result
def save_usa_presentation(presentation_key, uploaded_file):
if presentation_key not in USA_PRESENTATION_FILES:
return None
usa_folder = FOLDERS["USA"]
os.makedirs(usa_folder, exist_ok=True)
filename = USA_PRESENTATION_FILES[presentation_key]
target_path = os.path.join(usa_folder, filename)
result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_presentations")
return filename, result
def save_usa_music(music_key, uploaded_file):
if music_key not in USA_MUSIC_FILES:
return None
usa_folder = FOLDERS["USA"]
rel_path = USA_MUSIC_FILES[music_key]
full_path = os.path.join(usa_folder, rel_path)
result = save_audio_upload_to_target(uploaded_file, full_path, backup_group="USA_music")
return rel_path, result
def save_usa_extra_html(extra_key, uploaded_file):
if extra_key not in USA_EXTRA_HTML_FILES:
return None
filename = USA_EXTRA_HTML_FILES[extra_key]
target_path = os.path.join(FOLDERS["USA"], filename)
result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_extra_html")
return filename, result
def save_file_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group):
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"multi_upload_{stamp}_{os.getpid()}")
uploaded_file.save(tmp_path)
try:
size = os.path.getsize(tmp_path)
if size <= 0:
raise ValueError("uploaded file is empty")
sha = file_sha256(tmp_path)
results = []
for rel_path in rel_paths:
target_path = os.path.join(base_folder, rel_path)
ensure_parent_dir(target_path)
backup_path = backup_existing_file(target_path, backup_group)
tmp_target = target_path + ".tmp"
shutil.copy2(tmp_path, tmp_target)
os.replace(tmp_target, target_path)
results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path})
return {"size": size, "sha256": sha, "targets": results}
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def save_japan_music(music_key, uploaded_file):
if music_key not in JAPAN_MUSIC_TARGETS:
return None
return save_audio_upload_to_multiple_targets(uploaded_file, FOLDERS["JAPAN"], JAPAN_MUSIC_TARGETS[music_key], "JAPAN_music")
def normalize_japan_image_rel(raw_name):
safe = safe_zip_relpath(raw_name)
if not safe:
return None
lower = safe.lower()
marker = "/media/images/"
if marker in lower:
idx = lower.index(marker) + len(marker)
return safe[idx:]
if lower.startswith("media/images/"):
return safe[len("media/images/"):]
if lower.startswith("images/"):
return safe[len("images/"):]
marker2 = "/images/"
if marker2 in lower:
idx = lower.index(marker2) + len(marker2)
return safe[idx:]
return None
def save_japan_images_zip(uploaded_file):
image_root = os.path.join(FOLDERS["JAPAN"], "media", "images")
os.makedirs(image_root, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"japan_images_{stamp}.zip")
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
uploaded_file.save(tmp_zip)
imported, skipped, overwritten = [], [], []
try:
if os.path.getsize(tmp_zip) > JAPAN_IMAGES_ZIP_MAX_BYTES:
raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}")
with zipfile.ZipFile(tmp_zip, "r") as zf:
infos = [info for info in zf.infolist() if not info.is_dir()]
if len(infos) > JAPAN_IMAGES_ZIP_MAX_FILES:
raise ValueError(f"too_many_files:{len(infos)}")
total_uncompressed = sum(max(0, info.file_size) for info in infos)
if total_uncompressed > JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES:
raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")
for info in infos:
rel = normalize_japan_image_rel(info.filename)
base = os.path.basename((info.filename or "").replace("\\", "/"))
if not rel:
skipped.append(base + " — לא בתיקיית images/media/images")
continue
if info.file_size > JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
skipped.append(rel + " — גדול מדי")
continue
if file_ext(rel) not in JAPAN_IMAGE_EXTENSIONS:
skipped.append(rel + " — סיומת לא נתמכת")
continue
# Prevent traversal after normalization.
if not safe_zip_relpath(rel):
skipped.append(base + " — נתיב לא בטוח")
continue
target = os.path.join(image_root, *rel.split("/"))
ensure_parent_dir(target)
if os.path.exists(target):
overwritten.append(rel)
backup_existing_file(target, "JAPAN_images_zip")
tmp_target = target + ".tmp"
with zf.open(info, "r") as source, open(tmp_target, "wb") as dest:
shutil.copyfileobj(source, dest)
os.replace(tmp_target, target)
imported.append(rel)
finally:
try:
os.remove(tmp_zip)
except Exception:
pass
return sorted(imported), sorted(skipped), sorted(overwritten)
def normalize_trip_package_rel(trip, raw_name):
safe = safe_zip_relpath(raw_name)
if not safe:
return None
lower = safe.lower()
if trip == "USA":
if lower.startswith("usa/"):
return safe[4:]
# Also allow a package that contains USA files at root.
# Do not accept root index.html from Netlify ZIPs, because it is usually only a root redirect/landing file and would overwrite USA/index.html.
if lower in {"usa2027-presentation.html", "usa2027-presentation-tv.html", "usa2027-tv.html"} or lower.startswith("media/usa-presentation/"):
return safe
return None
if trip == "JAPAN":
if lower.startswith("japan/"):
return safe[6:]
if lower in {"index.html", "japan-experience.html", "japan-flight-intro.html", "japan-theme.mp3"} or lower.startswith("media/"):
return safe
return None
return None
def save_trip_package_zip(trip, uploaded_file):
if trip not in {"USA", "JAPAN"}:
return None
base_folder = FOLDERS[trip]
os.makedirs(base_folder, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"{trip.lower()}_package_{stamp}.zip")
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
uploaded_file.save(tmp_zip)
imported, skipped, overwritten = [], [], []
try:
if os.path.getsize(tmp_zip) > TRIP_PACKAGE_ZIP_MAX_BYTES:
raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}")
with zipfile.ZipFile(tmp_zip, "r") as zf:
infos = [info for info in zf.infolist() if not info.is_dir()]
if len(infos) > TRIP_PACKAGE_ZIP_MAX_FILES:
raise ValueError(f"too_many_files:{len(infos)}")
total_uncompressed = sum(max(0, info.file_size) for info in infos)
if total_uncompressed > TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES:
raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")
for info in infos:
rel = normalize_trip_package_rel(trip, info.filename)
base = os.path.basename((info.filename or "").replace("\\", "/"))
if not rel:
skipped.append(base + " — לא שייך לחבילת " + trip)
continue
if info.file_size > TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES:
skipped.append(rel + " — גדול מדי")
continue
ext = file_ext(rel)
if ext not in TRIP_PACKAGE_EXTENSIONS:
skipped.append(rel + " — סיומת לא נתמכת")
continue
if not safe_zip_relpath(rel):
skipped.append(base + " — נתיב לא בטוח")
continue
target = os.path.join(base_folder, *rel.split("/"))
ensure_parent_dir(target)
if os.path.exists(target):
overwritten.append(rel)
backup_existing_file(target, f"{trip}_package_zip")
tmp_target = target + ".tmp"
with zf.open(info, "r") as source, open(tmp_target, "wb") as dest:
shutil.copyfileobj(source, dest)
os.replace(tmp_target, target)
imported.append(rel)
finally:
try:
os.remove(tmp_zip)
except Exception:
pass
return sorted(imported), sorted(skipped), sorted(overwritten)
def describe_path(path):
exists = os.path.exists(path)
item = {"path": path, "exists": exists}
if exists and os.path.isfile(path):
try:
item["size"] = os.path.getsize(path)
item["mtime"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path)))
item["sha256"] = file_sha256(path)[:16]
except Exception as exc:
item["error"] = repr(exc)
return item
def count_files(root, extensions=None):
total = 0
if not os.path.isdir(root):
return 0
for dirpath, _, filenames in os.walk(root):
for name in filenames:
if extensions is None or file_ext(name) in extensions:
total += 1
return total
def list_relative_files(root, extensions=None):
files = []
if not os.path.isdir(root):
return files
for dirpath, _, filenames in os.walk(root):
for name in filenames:
if extensions is None or file_ext(name) in extensions:
full = os.path.join(dirpath, name)
rel = os.path.relpath(full, root).replace("\\", "/")
files.append(rel)
return sorted(files, key=lambda x: x.lower())
def normalize_web_path(path):
value = str(path or "").strip().replace("\\", "/")
value = value.split("?", 1)[0].split("#", 1)[0]
while value.startswith("./"):
value = value[2:]
return value
def analyze_japan_presentation_images(presentation_path=None):
"""Count images that Japan experience actually uses, not just files in media/images."""
presentation_path = presentation_path or os.path.join(FOLDERS["JAPAN"], "japan-experience.html")
image_root = os.path.join(FOLDERS["JAPAN"], "media", "images")
folder_files = list_relative_files(image_root, JAPAN_IMAGE_EXTENSIONS)
result = {
"presentation_path": presentation_path,
"presentation_exists": os.path.isfile(presentation_path),
"folder_images": len(folder_files),
"image_slides": 0,
"external_image_uses": 0,
"embedded_image_uses": 0,
"remote_image_uses": 0,
"other_src_uses": 0,
"unique_external_images": 0,
"missing_external_images": 0,
"unused_folder_images": 0,
"chapter_slides": 0,
"notes": [],
"missing_external_list": [],
"unused_folder_list": [],
}
if not result["presentation_exists"]:
result["notes"].append("קובץ המצגת japan-experience.html לא נמצא, לכן נספרו רק קבצים בתיקיית התמונות.")
return result
try:
with open(presentation_path, "r", encoding="utf-8", errors="ignore") as fh:
html_text = fh.read()
except Exception as exc:
result["notes"].append("לא ניתן לקרוא את קובץ מצגת יפן: " + repr(exc))
return result
refs = [m.group(2).strip() for m in re.finditer(r'["\']src["\']\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()]
if not refs:
refs = [m.group(2).strip() for m in re.finditer(r'\bsrc\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()]
external = []
embedded = []
remote = []
other = []
for ref in refs:
clean = normalize_web_path(ref)
if clean.startswith("media/images/"):
external.append(clean[len("media/images/"):])
elif clean.startswith("data:image"):
embedded.append(ref)
elif clean.startswith("http://") or clean.startswith("https://"):
remote.append(ref)
else:
other.append(ref)
unique_external = sorted(set(external), key=lambda x: x.lower())
folder_lower = {f.lower(): f for f in folder_files}
used_lower = {f.lower() for f in unique_external}
missing = [rel for rel in unique_external if rel.lower() not in folder_lower]
unused = [rel for rel in folder_files if rel.lower() not in used_lower]
result.update({
"image_slides": len(refs),
"external_image_uses": len(external),
"embedded_image_uses": len(embedded),
"remote_image_uses": len(remote),
"other_src_uses": len(other),
"unique_external_images": len(unique_external),
"missing_external_images": len(missing),
"unused_folder_images": len(unused),
"chapter_slides": len(re.findall(r'["\']chapter["\']\s*:\s*true', html_text)),
"missing_external_list": missing[:40],
"unused_folder_list": unused[:40],
})
if result["image_slides"] == 0:
result["notes"].append("לא נמצאו src בשקפי מצגת יפן. ייתכן שמבנה המצגת השתנה.")
if result["missing_external_images"]:
result["notes"].append("יש תמונות שהמצגת מבקשת אבל אינן קיימות בתיקיית media/images.")
if result["unused_folder_images"]:
result["notes"].append("יש תמונות בתיקייה שאינן בשימוש במצגת הפעילה.")
return result
def compact_japan_usage_for_health():
usage = analyze_japan_presentation_images()
return {
"image_slides_total": usage.get("image_slides", 0),
"external_files_used": usage.get("external_image_uses", 0),
"embedded_images": usage.get("embedded_image_uses", 0),
"folder_images": usage.get("folder_images", 0),
"missing_external_images": usage.get("missing_external_images", 0),
"unused_folder_images": usage.get("unused_folder_images", 0),
"chapter_slides": usage.get("chapter_slides", 0),
"presentation_exists": usage.get("presentation_exists", False),
}
def render_admin_status():
rows = []
def add(label, path):
info = describe_path(path)
status = "✅ קיים" if info.get("exists") else "❌ חסר"
size = f"{info.get('size', 0):,}" if info.get("exists") and "size" in info else "—"
mtime = info.get("mtime", "—")
sha = info.get("sha256", "—")
rows.append(f"<tr><td>{html_lib.escape(label)}</td><td>{status}</td><td>{size}</td><td>{html_lib.escape(mtime)}</td><td>{html_lib.escape(sha)}</td><td><code>{html_lib.escape(path)}</code></td></tr>")
add("פאנל פעיל", ADMIN_SERVER_PATH)
add("טיול יפן ראשי", os.path.join(FOLDERS["JAPAN"], "index.html"))
add("פתיח טיסה יפן", os.path.join(FOLDERS["JAPAN"], "japan-flight-intro.html"))
add("מצגת יפן", os.path.join(FOLDERS["JAPAN"], "japan-experience.html"))
add("מוזיקת מצגת יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music", "japan-theme.mp3"))
add("מוזיקת פתיח יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music", "flight-intro.mp3"))
add("טיול ארה״ב ראשי", os.path.join(FOLDERS["USA"], "index.html"))
add("טיול ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-tv.html"))
add("מצגת ארה״ב", os.path.join(FOLDERS["USA"], "usa2027-presentation.html"))
add("מצגת ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-presentation-tv.html"))
add("שיר ארה״ב חלק 1", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"))
add("שיר ארה״ב חלק 2", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-part2-theme.mp3"))
add("תקציב טיולים", os.path.join(FOLDERS["Moneyapp"], "index.html"))
add("תקציב משפחתי", os.path.join(FOLDERS["MoneyHome"], "index.html"))
add("טופס פתיחת טיול", os.path.join(FOLDERS["TripForm"], "index.html"))
usa_img_count = count_files(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), USA_IMAGE_EXTENSIONS)
japan_usage = analyze_japan_presentation_images()
japan_img_count = japan_usage["folder_images"]
japan_usage_ok = japan_usage["presentation_exists"] and japan_usage["missing_external_images"] == 0
japan_usage_badge = "✅ תקין" if japan_usage_ok else "⚠️ לבדיקה"
japan_usage_details = (
f"{japan_usage['external_image_uses']} קבצים חיצוניים + "
f"{japan_usage['embedded_image_uses']} מוטמעות · "
f"בתיקייה: {japan_img_count} · "
f"לא בשימוש: {japan_usage['unused_folder_images']} · "
f"חסרות: {japan_usage['missing_external_images']}"
)
watchdog_info = watchdog_status(include_log=False)
watchdog_label = "ON" if watchdog_info.get("task_installed") else "OFF"
missing_html = ""
if japan_usage["missing_external_images"]:
missing_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["missing_external_list"][:20])
missing_html = f"<div class='warn'><b>תמונות חסרות למצגת יפן</b><ul>{missing_items}</ul></div>"
unused_html = ""
if japan_usage["unused_folder_images"]:
unused_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["unused_folder_list"][:20])
more = ""
if japan_usage["unused_folder_images"] > 20:
more = f"<p>ועוד {japan_usage['unused_folder_images'] - 20} קבצים שלא מוצגים כאן.</p>"
unused_html = f"<details class='details'><summary>תמונות בתיקיית יפן שלא בשימוש במצגת ({japan_usage['unused_folder_images']})</summary><ul>{unused_items}</ul>{more}</details>"
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>סטטוס TripServer</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:24px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#93c5fd}}table{{width:100%;border-collapse:collapse;background:#1e293b;border-radius:18px;overflow:hidden}}th,td{{border-bottom:1px solid #334155;padding:10px;text-align:right;vertical-align:top}}th{{background:#111827;color:#bfdbfe}}code{{direction:ltr;display:block;white-space:normal;color:#fde68a}}.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:18px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:16px}}.num{{font-size:30px;font-weight:900;color:#fde68a}}
.small{{color:#cbd5e1;font-size:13px;line-height:1.5;margin-top:6px}}.warn{{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:14px;margin:14px 0}}.details{{background:#111827;border:1px solid #334155;border-radius:16px;padding:14px;margin:14px 0;color:#cbd5e1}}summary{{cursor:pointer;font-weight:900;color:#fde68a}}ul{{margin:10px 0 0}}
</style></head><body><div class="wrap"><a href="/admin">⬅ חזרה לפאנל</a><h1>סטטוס קבצים פעילים</h1><p>כאן רואים בפועל מה מותקן כרגע ב־C:\\TripServer, כולל גודל, תאריך שינוי ו־SHA קצר. זה נועד למנוע מצב של “העליתי אבל אני רואה גרסה ישנה”.</p><div class="cards"><div class="card"><div>תמונות מצגת ארה״ב</div><div class="num">{usa_img_count}</div><div class="small">כרטיסיות העלאה מסונכרנות לשקפי ארה״ב.</div></div><div class="card"><div>מצגת יפן — תמונות בשימוש בפועל</div><div class="num">{japan_usage['image_slides']}</div><div class="small">{japan_usage_badge} · {japan_usage_details}</div></div><div class="card"><div>קבצים בתיקיית תמונות יפן</div><div class="num">{japan_img_count}</div><div class="small">זה מספר קבצים בתיקייה, לא מספר התמונות שהמצגת משתמשת בהן.</div></div><div class="card"><div>גרסת פאנל</div><div>{html_lib.escape(ADMIN_PANEL_VERSION)}</div></div><div class="card"><div>Watchdog</div><div class="num">{watchdog_label}</div><div class="small">משימה מתוזמנת חיצונית לשמירת הפאנל באוויר.</div></div></div>{missing_html}{unused_html}<table><thead><tr><th>רכיב</th><th>מצב</th><th>גודל bytes</th><th>עודכן</th><th>SHA16</th><th>נתיב</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div></body></html>
"""
def validate_admin_server_upload(tmp_path):
size = os.path.getsize(tmp_path)
if size <= 0:
return False, "הקובץ ריק."
if size > ADMIN_MAX_UPLOAD_BYTES:
return False, f"הקובץ גדול מדי: {size:,} bytes. המגבלה היא {ADMIN_MAX_UPLOAD_BYTES:,} bytes."
try:
py_compile.compile(tmp_path, doraise=True)
except py_compile.PyCompileError as exc:
return False, "בדיקת קומפילציה נכשלה:\n" + str(exc)
except Exception as exc:
return False, "שגיאה בבדיקת הקובץ:\n" + repr(exc)
try:
with open(tmp_path, "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read(400000)
except Exception as exc:
return False, "לא ניתן לקרוא את הקובץ:\n" + repr(exc)
required_tokens = ["Flask", "@app.route", "app.run", "BASE_DIR", "ADMIN_PORT", "restart_process_later", "upload_admin_server", "run_self_test_suite", "build_watchdog_code", "watchdog_status"]
missing = [token for token in required_tokens if token not in content]
if missing:
return False, "הקובץ עבר קומפילציה, אבל לא נראה כמו admin_server.py של TripServer. חסרים סימנים: " + ", ".join(missing)
return True, "OK"
def admin_status_page(title, message, ok=True, details=""):
color = "#16a34a" if ok else "#dc2626"
bg = "#052e1b" if ok else "#3f1d1d"
safe_title = html_lib.escape(title)
safe_message = html_lib.escape(message).replace("\n", "<br>")
safe_details = html_lib.escape(details).replace("\n", "<br>")
details_html = f"<pre>{safe_details}</pre>" if details else ""
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{safe_title}</title>
<style>
body{{margin:0;font-family:Arial;background:#0f172a;color:white;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}}
.card{{width:min(780px,94vw);background:#1e293b;border-radius:24px;padding:26px;box-shadow:0 18px 70px rgba(0,0,0,.35)}}
.badge{{display:inline-block;background:{bg};border:1px solid {color};color:white;border-radius:999px;padding:8px 14px;font-weight:bold;margin-bottom:12px}}
h1{{margin:0 0 12px;font-size:30px}}
p{{line-height:1.7;color:#e2e8f0}}
pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:14px;padding:14px;overflow:auto;max-height:360px}}
a,button{{display:inline-block;margin:8px 0 0 8px;background:#2563eb;color:white;border:0;border-radius:12px;padding:12px 16px;text-decoration:none;font-weight:bold;cursor:pointer}}
button.restart{{background:#ea580c}}
.advanced-note{{margin-top:10px;color:#cbd5e1;font-size:13px;line-height:1.55}}
form{{display:inline}}
</style>
</head>
<body>
<div class="card">
<div class="badge">TripServer Admin</div>
<h1>{safe_title}</h1>
<p>{safe_message}</p>
{details_html}
<a href="/admin">חזרה לפאנל</a>
<form action="/restart_admin_server" method="post" onsubmit="return confirm('זו בדיקת אתחול מתקדמת. להפעיל מחדש את הפאנל עכשיו?')"><button class="restart">בדיקת אתחול פאנל — מתקדם</button></form>
<div class="advanced-note">לא חובה אחרי התקנת Watchdog. מיועד לבדיקה יזומה או אחרי עדכון/שחזור קובץ אדמין.</div>
</div>
</body>
</html>
"""
def backup_current_admin_server(prefix="admin_server_backup"):
os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
backup_path = os.path.join(ADMIN_BACKUP_DIR, f"{prefix}_{stamp}.py")
shutil.copy2(ADMIN_SERVER_PATH, backup_path)
return backup_path
def latest_admin_backup():
os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
backups = glob.glob(os.path.join(ADMIN_BACKUP_DIR, "admin_server_backup_*.py"))
if not backups:
return None
return max(backups, key=os.path.getmtime)
def log_admin_restart(message):
try:
os.makedirs(BASE_DIR, exist_ok=True)
with open(ADMIN_RESTART_LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(message) + "\n")
except Exception:
pass
def build_restart_helper_code(script_path, python_exe, work_dir, current_pid=None, backup_path=None, expected_sha=None, reason=""):
"""Build a hard-restart helper that can recover from a stuck old process.
v16 change: the helper does not merely start a new python process. It first waits
for port 8000 to be released, then force-kills the old/admin PID or any process
still listening on the admin port. It then starts the new server, checks /health.json,
and can roll back to the previous admin backup if the new server fails to boot.
"""
return "\n".join([
"# -*- coding: utf-8 -*-",
"import os",
"import sys",
"import subprocess",
"import time",
"import socket",
"import json",
"import urllib.request",
"import hashlib",
"import shutil",
"",
f"script_path = {script_path!r}",
f"python_exe = {python_exe!r}",
f"work_dir = {work_dir!r}",
f"log_path = {ADMIN_RESTART_LOG_PATH!r}",
f"admin_port = {ADMIN_PORT!r}",
f"health_url = {'http://127.0.0.1:%s/health.json' % ADMIN_PORT!r}",
f"current_pid = {int(current_pid or 0)!r}",
f"backup_path = {backup_path!r}",
f"expected_sha = {expected_sha!r}",
f"reason = {reason!r}",
"",
"def log(message):",
" try:",
" os.makedirs(os.path.dirname(log_path), exist_ok=True)",
" with open(log_path, 'a', encoding='utf-8') as fh:",
" fh.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | ' + str(message) + '\\n')",
" except Exception:",
" pass",
"",
"def file_sha256(path):",
" h = hashlib.sha256()",
" with open(path, 'rb') as fh:",
" for chunk in iter(lambda: fh.read(1024 * 1024), b''):",
" h.update(chunk)",
" return h.hexdigest()",
"",
"def port_open():",
" try:",
" with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=1.0):",
" return True",
" except Exception:",
" return False",
"",
"def wait_port_closed(timeout=20):",
" deadline = time.time() + timeout",
" while time.time() < deadline:",
" if not port_open():",
" return True",
" time.sleep(0.5)",
" return not port_open()",
"",
"def wait_health(timeout=40):",
" deadline = time.time() + timeout",
" last = 'not checked'",
" while time.time() < deadline:",
" try:",
" req = urllib.request.Request(health_url, headers={'Accept': 'application/json'})",
" with urllib.request.urlopen(req, timeout=3.0) as resp:",
" raw = resp.read(12000).decode('utf-8', errors='replace')",
" data = json.loads(raw)",
" if data.get('ok'):",
" return True, data.get('version', 'OK')",
" last = raw[:500]",
" except Exception as exc:",
" last = repr(exc)",
" time.sleep(1.0)",
" return False, last",
"",
"def pids_on_port():",
" pids = []",
" if os.name != 'nt':",
" return pids",
" try:",
" cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)",
" out = (cp.stdout or '') + '\\n' + (cp.stderr or '')",
" marker1 = ':' + str(admin_port)",
" for line in out.splitlines():",
" lower = line.lower()",
" if marker1 not in line or 'listen' not in lower:",
" continue",
" parts = line.split()",
" if parts:",
" try:",
" pid = int(parts[-1])",
" if pid and pid not in pids:",
" pids.append(pid)",
" except Exception:",
" pass",
" except Exception as exc:",
" log('netstat failed: ' + repr(exc))",
" return pids",
"",
"def kill_pid_tree(pid):",
" try:",
" pid = int(pid)",
" except Exception:",
" return False",
" if pid <= 0 or pid == os.getpid():",
" return False",
" try:",
" if os.name == 'nt':",
" cp = subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], capture_output=True, text=True, shell=False, timeout=12)",
" log('taskkill pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))",
" return cp.returncode == 0",
" else:",
" os.kill(pid, 9)",
" return True",
" except Exception as exc:",
" log('kill pid failed pid=' + str(pid) + ' err=' + repr(exc))",
" return False",
"",
"def hard_release_port():",
" if wait_port_closed(8):",
" log('port already closed')",
" return True",
" log('port still open; current_pid=' + str(current_pid) + ' pids=' + str(pids_on_port()))",
" if current_pid:",
" kill_pid_tree(current_pid)",
" time.sleep(1.2)",
" for pid in pids_on_port():",
" kill_pid_tree(pid)",
" ok = wait_port_closed(18)",
" log('port release result=' + str(ok) + ' remaining_pids=' + str(pids_on_port()))",
" return ok",
"",
"def start_server(label):",
" flags = 0",
" if os.name == 'nt':",
" flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)",
" env = os.environ.copy()",
" env.setdefault('PYTHONIOENCODING', 'utf-8')",
" os.makedirs(os.path.dirname(log_path), exist_ok=True)",
" with open(log_path, 'a', encoding='utf-8') as log_file:",
" log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | starting ' + label + ': ' + script_path + '\\n')",
" log_file.flush()",
" proc = subprocess.Popen(",
" [python_exe, script_path],",
" cwd=work_dir,",
" stdin=subprocess.DEVNULL,",
" stdout=log_file,",
" stderr=log_file,",
" close_fds=(False if os.name == 'nt' else True),",
" creationflags=flags,",
" start_new_session=(os.name != 'nt'),",
" env=env,",
" )",
" log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | ' + label + ' pid=' + str(proc.pid) + '\\n')",
" log_file.flush()",
" return proc.pid",
"",
"def rollback_to_backup():",
" if not backup_path or not os.path.exists(backup_path):",
" log('rollback unavailable: no backup_path or backup missing')",
" return False",
" try:",
" failed_path = script_path + '.failed_' + time.strftime('%Y%m%d_%H%M%S') + '.py'",
" if os.path.exists(script_path):",
" shutil.copy2(script_path, failed_path)",
" log('failed admin copied to ' + failed_path)",
" shutil.copy2(backup_path, script_path)",
" log('backup restored from ' + backup_path)",
" return True",
" except Exception as exc:",
" log('rollback failed: ' + repr(exc))",
" return False",
"",
"def main():",
" log('restart orchestration started reason=' + str(reason) + ' script=' + script_path + ' python=' + python_exe + ' expected_sha=' + str(expected_sha))",
" time.sleep(3.0)",
" if expected_sha:",
" try:",
" actual = file_sha256(script_path)",
" log('active admin sha=' + actual)",
" if actual.lower() != str(expected_sha).lower():",
" log('WARNING: active sha differs from expected uploaded sha')",
" except Exception as exc:",
" log('sha check failed: ' + repr(exc))",
" hard_release_port()",
" start_server('new-admin')",
" ok, info = wait_health(45)",
" log('new-admin health=' + str(ok) + ' info=' + str(info))",
" if ok:",
" return 0",
" # New version did not boot. Kill anything on the port and restore last backup.",
" for pid in pids_on_port():",
" kill_pid_tree(pid)",
" wait_port_closed(10)",
" if rollback_to_backup():",
" start_server('rollback-admin')",
" ok2, info2 = wait_health(45)",
" log('rollback-admin health=' + str(ok2) + ' info=' + str(info2))",
" return 0 if ok2 else 20",
" return 10",
"",
"if __name__ == '__main__':",
" raise SystemExit(main())",
"",
]) + "\n"
def validate_restart_helper_source(helper_code):
try:
compile(helper_code, "<tripserver_restart_helper>", "exec")
return True, "OK"
except Exception as exc:
return False, repr(exc)
def write_restart_helper(script_path, python_exe, work_dir, backup_path=None, expected_sha=None, reason=""):
helper_code = build_restart_helper_code(script_path, python_exe, work_dir, os.getpid(), backup_path, expected_sha, reason)
ok, info = validate_restart_helper_source(helper_code)
if not ok:
raise RuntimeError("Generated restart helper is invalid: " + info)
ensure_parent_dir(ADMIN_RESTART_HELPER_PATH)
with open(ADMIN_RESTART_HELPER_PATH, "w", encoding="utf-8") as fh:
fh.write(helper_code)
py_compile.compile(ADMIN_RESTART_HELPER_PATH, doraise=True)
return ADMIN_RESTART_HELPER_PATH
def restart_process_later(backup_path=None, expected_sha=None, reason="manual_restart"):
"""Hard self-restart for Windows/iPhone admin workflow.
v16: the helper can force-release a stuck port and roll back to the previous admin
backup if the newly uploaded admin does not pass /health.json after boot.
"""
time.sleep(1.2)
script_path = os.path.abspath(ADMIN_SERVER_PATH)
python_exe = sys.executable or "python"
work_dir = os.path.dirname(script_path) or BASE_DIR
try:
log_admin_restart(f"hard restart requested | reason={reason} | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | pid={os.getpid()} | backup={backup_path} | expected_sha={expected_sha} | argv={sys.argv}")
helper_path = write_restart_helper(script_path, python_exe, work_dir, backup_path=backup_path, expected_sha=expected_sha, reason=reason)
flags = 0
if os.name == "nt":
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
subprocess.Popen(
[python_exe, helper_path],
cwd=work_dir,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=(False if os.name == "nt" else True),
creationflags=flags,
start_new_session=(os.name != "nt"),
)
log_admin_restart("hard restart helper launched: " + helper_path)
os._exit(0)
except Exception as exc:
log_admin_restart("hard restart helper failed: " + repr(exc))
try:
subprocess.Popen([python_exe, script_path], cwd=work_dir, close_fds=True, start_new_session=(os.name != "nt"))
os._exit(0)
except Exception as exc2:
log_admin_restart("direct restart fallback failed: " + repr(exc2))
try:
os.execv(python_exe, [python_exe, script_path])
except Exception as exc3:
log_admin_restart("execv fallback failed: " + repr(exc3))
os._exit(1)
def build_watchdog_code(script_path, python_exe, work_dir):
"""Build a one-shot watchdog script for Windows Task Scheduler.
v16 change: if the port is open but /health.json fails repeatedly, the watchdog
treats it as a stuck admin process, kills the process that owns port 8000, and
starts admin_server.py again. This is the case that forced a physical reboot.
"""
return "\n".join([
"# -*- coding: utf-8 -*-",
"import os",
"import sys",
"import time",
"import socket",
"import subprocess",
"import urllib.request",
"import json",
"",
f"script_path = {script_path!r}",
f"python_exe = {python_exe!r}",
f"work_dir = {work_dir!r}",
f"admin_port = {ADMIN_PORT!r}",
f"health_url = {'http://127.0.0.1:%s/health.json' % ADMIN_PORT!r}",
f"log_path = {WATCHDOG_LOG_PATH!r}",
f"state_path = {WATCHDOG_STATE_PATH!r}",
f"cooldown_seconds = {WATCHDOG_MIN_RESTART_INTERVAL_SECONDS!r}",
"",
"def log(message):",
" try:",
" os.makedirs(os.path.dirname(log_path), exist_ok=True)",
" with open(log_path, 'a', encoding='utf-8') as fh:",
" fh.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | ' + str(message) + '\\n')",
" except Exception:",
" pass",
"",
"def load_state():",
" try:",
" raw = open(state_path, 'r', encoding='utf-8').read().strip()",
" if not raw:",
" return {}",
" try:",
" data = json.loads(raw)",
" return data if isinstance(data, dict) else {}",
" except Exception:",
" return {'last_start': float(raw)}",
" except Exception:",
" return {}",
"",
"def save_state(state):",
" try:",
" os.makedirs(os.path.dirname(state_path), exist_ok=True)",
" with open(state_path, 'w', encoding='utf-8') as fh:",
" json.dump(state, fh)",
" except Exception:",
" pass",
"",
"def port_open():",
" try:",
" with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=2.0):",
" return True",
" except Exception:",
" return False",
"",
"def pids_on_port():",
" pids = []",
" if os.name != 'nt':",
" return pids",
" try:",
" cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)",
" out = (cp.stdout or '') + '\\n' + (cp.stderr or '')",
" marker = ':' + str(admin_port)",
" for line in out.splitlines():",
" if marker not in line or 'listen' not in line.lower():",
" continue",
" parts = line.split()",
" if parts:",
" try:",
" pid = int(parts[-1])",
" if pid and pid not in pids:",
" pids.append(pid)",
" except Exception:",
" pass",
" except Exception as exc:",
" log('netstat failed: ' + repr(exc))",
" return pids",
"",
"def kill_pid_tree(pid):",
" try:",
" pid = int(pid)",
" except Exception:",
" return False",
" if pid <= 0 or pid == os.getpid():",
" return False",
" try:",
" if os.name == 'nt':",
" cp = subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], capture_output=True, text=True, shell=False, timeout=12)",
" log('taskkill pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))",
" return cp.returncode == 0",
" os.kill(pid, 9)",
" return True",
" except Exception as exc:",
" log('kill pid failed pid=' + str(pid) + ' err=' + repr(exc))",
" return False",
"",
"def wait_port_closed(timeout=12):",
" deadline = time.time() + timeout",
" while time.time() < deadline:",
" if not port_open():",
" return True",
" time.sleep(0.6)",
" return not port_open()",
"",
"def health_ok():",
" try:",
" req = urllib.request.Request(health_url, headers={'Accept': 'application/json'})",
" with urllib.request.urlopen(req, timeout=4.0) as resp:",
" raw = resp.read(8192).decode('utf-8', errors='replace')",
" data = json.loads(raw)",
" return bool(data.get('ok')), 'OK' if data.get('ok') else raw[:500]",
" except Exception as exc:",
" return False, repr(exc)",
"",
"def recently_started(state):",
" try:",
" last = float(state.get('last_start') or 0)",
" return (time.time() - last) < float(cooldown_seconds)",
" except Exception:",
" return False",
"",
"def mark_started(state):",
" state['last_start'] = time.time()",
" state['failure_count'] = 0",
" save_state(state)",
"",
"def script_compiles():",
" try:",
" import py_compile",
" py_compile.compile(script_path, doraise=True)",
" return True, 'OK'",
" except Exception as exc:",
" return False, repr(exc)",
"",
"def launch_admin(state):",
" flags = 0",
" if os.name == 'nt':",
" flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)",
" env = os.environ.copy()",
" env.setdefault('PYTHONIOENCODING', 'utf-8')",
" os.makedirs(os.path.dirname(log_path), exist_ok=True)",
" with open(log_path, 'a', encoding='utf-8') as log_file:",
" log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | starting admin: ' + script_path + '\\n')",
" log_file.flush()",
" proc = subprocess.Popen(",
" [python_exe, script_path],",
" cwd=work_dir,",
" stdin=subprocess.DEVNULL,",
" stdout=log_file,",
" stderr=log_file,",
" close_fds=(False if os.name == 'nt' else True),",
" creationflags=flags,",
" start_new_session=(os.name != 'nt'),",
" env=env,",
" )",
" log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | started pid=' + str(proc.pid) + '\\n')",
" mark_started(state)",
"",
"def main():",
" state = load_state()",
" ok, info = health_ok()",
" if ok:",
" if state.get('failure_count'):",
" state['failure_count'] = 0",
" save_state(state)",
" return 0",
" fail_count = int(state.get('failure_count') or 0) + 1",
" state['failure_count'] = fail_count",
" save_state(state)",
" is_port_open = port_open()",
" log('health failed count=' + str(fail_count) + ' port_open=' + str(is_port_open) + ' info=' + str(info))",
" if recently_started(state):",
" log('restart cooldown active; skip this minute')",
" return 3",
" if is_port_open:",
" pids = pids_on_port()",
" if fail_count < 2:",
" log('port is open but health failed; waiting for second failure before hard kill. pids=' + str(pids))",
" return 2",
" log('port open + repeated health failure; killing stuck pids=' + str(pids))",
" for pid in pids:",
" kill_pid_tree(pid)",
" wait_port_closed(14)",
" if not os.path.exists(script_path):",
" log('admin script not found: ' + script_path)",
" return 4",
" ok_compile, compile_info = script_compiles()",
" if not ok_compile:",
" log('admin script does not compile; not starting. info=' + str(compile_info))",
" return 5",
" try:",
" launch_admin(state)",
" return 10",
" except Exception as exc:",
" log('failed to launch admin: ' + repr(exc))",
" return 6",
"",
"if __name__ == '__main__':",
" raise SystemExit(main())",
"",
]) + "\n"
def validate_watchdog_source(watchdog_code):
try:
compile(watchdog_code, "<tripserver_watchdog>", "exec")
return True, "OK"
except Exception as exc:
return False, repr(exc)
def write_watchdog_script():
script_path = os.path.abspath(ADMIN_SERVER_PATH)
python_exe = sys.executable or "python"
work_dir = os.path.dirname(script_path) or BASE_DIR
source = build_watchdog_code(script_path, python_exe, work_dir)
ok, info = validate_watchdog_source(source)
if not ok:
raise RuntimeError("Generated watchdog script is invalid: " + info)
ensure_parent_dir(WATCHDOG_SCRIPT_PATH)
with open(WATCHDOG_SCRIPT_PATH, "w", encoding="utf-8") as fh:
fh.write(source)
py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True)
return WATCHDOG_SCRIPT_PATH
def read_log_tail(path, max_lines=18):
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
return "".join(lines[-max_lines:]).strip()
except Exception:
return ""
def safe_int(value, default, min_value=None, max_value=None):
try:
parsed = int(value)
except Exception:
parsed = default
if min_value is not None:
parsed = max(min_value, parsed)
if max_value is not None:
parsed = min(max_value, parsed)
return parsed
def log_file_summary(log_key, max_lines=LOG_TAIL_DEFAULT_LINES):
meta = LOG_FILE_REGISTRY.get(log_key)
if not meta:
return None
path = meta["path"]
exists = os.path.exists(path)
size = os.path.getsize(path) if exists else 0
updated = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))) if exists else "לא קיים עדיין"
tail = read_log_tail(path, max_lines=max_lines) if exists else "אין עדיין לוג. הוא יווצר אחרי פעולה ראשונה."
return {
"key": log_key,
"label": meta["label"],
"note": meta.get("note", ""),
"path": path,
"exists": exists,
"size": size,
"size_label": format_bytes(size),
"updated": updated,
"tail": tail,
}
def build_logs_payload(max_lines=LOG_TAIL_DEFAULT_LINES):
max_lines = safe_int(max_lines, LOG_TAIL_DEFAULT_LINES, 20, LOG_TAIL_MAX_LINES)
items = []
for key in LOG_FILE_REGISTRY:
info = log_file_summary(key, max_lines=max_lines)
if info:
items.append(info)
return {
"ok": True,
"version": ADMIN_PANEL_VERSION,
"checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"lines": max_lines,
"logs": items,
}
def render_admin_logs():
payload = build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES))
cards = []
for info in payload["logs"]:
badge_cls = "ok" if info["exists"] else "warn"
badge_text = "קיים" if info["exists"] else "לא נוצר עדיין"
tail = html_lib.escape(info["tail"] or "אין תוכן להצגה.").replace("\n", "<br>")
cards.append(f'''
<div class="log-card">
<div class="log-head">
<div><h2>{html_lib.escape(info["label"])}</h2><p>{html_lib.escape(info["note"])}</p></div>
<span class="badge {badge_cls}">{badge_text}</span>
</div>
<div class="meta"><b>נתיב</b><code>{html_lib.escape(info["path"])}</code></div>
<div class="stats"><span>גודל: <b>{html_lib.escape(info["size_label"])}</b></span><span>עודכן: <b>{html_lib.escape(info["updated"])}</b></span></div>
<pre>{tail}</pre>
</div>''')
return f'''
<!DOCTYPE html>
<html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>לוגים TripServer</title>
<style>
body{{margin:0;background:#07111f;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#dbeafe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1e3a8a,#0f172a 62%,#111827);border:1px solid #1d4ed8;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.6}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}.actions a{{background:#2563eb;color:white;border-radius:999px;padding:10px 14px;font-weight:900}}.actions a.secondary{{background:#1e293b;border:1px solid #334155}}.log-card{{background:#101c31;border:1px solid #253650;border-radius:24px;padding:18px;margin:14px 0;box-shadow:0 18px 55px rgba(0,0,0,.22)}}.log-head{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}}.log-head h2{{margin:0 0 6px;font-size:24px}}.badge{{white-space:nowrap;border-radius:999px;padding:7px 11px;font-weight:900}}.badge.ok{{background:#14532d;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#451a03;color:#fed7aa;border:1px solid #f97316}}.meta{{margin-top:10px;color:#cbd5e1}}code{{direction:ltr;display:block;background:#0b1220;border:1px solid #334155;border-radius:12px;padding:10px;color:#fde68a;margin-top:6px;white-space:normal}}.stats{{display:flex;gap:12px;flex-wrap:wrap;color:#cbd5e1;margin:12px 0}}.stats span{{background:#0b1220;border:1px solid #334155;border-radius:999px;padding:8px 10px}}pre{{direction:ltr;text-align:left;white-space:pre-wrap;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:16px;padding:14px;overflow:auto;max-height:420px;font-size:13px;line-height:1.55}}@media(max-width:700px){{body{{padding:14px}}.hero{{padding:18px;border-radius:22px}}h1{{font-size:28px}}.log-head{{display:block}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📜 מרכז לוגים</h1><p>צפייה נוחה בלוגי האדמין וה־Watchdog בלי לפתוח קבצים ידנית במחשב. המסך לקריאה בלבד ולא מוחק או משנה שום דבר.</p><div class="actions"><a href="/admin/logs">רענן</a><a class="secondary" href="/admin/logs?lines=250">הצג 250 שורות</a><a class="secondary" href="/admin/logs?lines=500">הצג 500 שורות</a><a class="secondary" href="/admin/logs.json" target="_blank">JSON טכני</a><a class="secondary" href="/admin/self-test">בדיקה עצמית</a></div></div>{''.join(cards)}</div></body></html>
'''
def query_watchdog_task():
if os.name != "nt":
return {"supported": False, "installed": False, "ok": False, "details": "Task Scheduler זמין רק על Windows."}
try:
completed = subprocess.run(["schtasks", "/Query", "/TN", WATCHDOG_TASK_NAME, "/FO", "LIST"], capture_output=True, text=True, shell=False, timeout=12)
output = (completed.stdout or completed.stderr or "").strip()
return {"supported": True, "installed": completed.returncode == 0, "ok": completed.returncode == 0, "details": output}
except Exception as exc:
return {"supported": True, "installed": False, "ok": False, "details": repr(exc)}
def watchdog_status(include_log=True, include_task=True):
generated = build_watchdog_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
source_ok, source_info = validate_watchdog_source(generated)
file_exists = os.path.exists(WATCHDOG_SCRIPT_PATH)
file_compiles = False
file_info = "לא נכתב עדיין"
if file_exists:
try:
py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True)
file_compiles = True
file_info = "OK"
except Exception as exc:
file_info = repr(exc)
task = query_watchdog_task() if include_task else {"supported": None, "installed": None, "ok": None, "details": "not queried"}
return {
"source_ok": source_ok,
"source_status": source_info,
"script_path": WATCHDOG_SCRIPT_PATH,
"script_exists": file_exists,
"script_compiles": file_compiles,
"script_status": file_info,
"task_name": WATCHDOG_TASK_NAME,
"task_supported": task.get("supported"),
"task_installed": task.get("installed"),
"task_status": task.get("details", ""),
"interval_minutes": WATCHDOG_INTERVAL_MINUTES,
"cooldown_seconds": WATCHDOG_MIN_RESTART_INTERVAL_SECONDS,
"log_path": WATCHDOG_LOG_PATH,
"log_tail": read_log_tail(WATCHDOG_LOG_PATH) if include_log else "",
}
def install_watchdog_task():
script_path = write_watchdog_script()
if os.name != "nt":
return False, "קובץ ה־Watchdog נכתב, אבל התקנת Task Scheduler נתמכת רק על Windows.", f"script={script_path}"
task_command = f'"{sys.executable or "python"}" "{script_path}"'
cmd = ["schtasks", "/Create", "/TN", WATCHDOG_TASK_NAME, "/TR", task_command, "/SC", "MINUTE", "/MO", str(max(1, WATCHDOG_INTERVAL_MINUTES)), "/F"]
completed = subprocess.run(cmd, capture_output=True, text=True, shell=False, timeout=20)
output = (completed.stdout or completed.stderr or "").strip()
if completed.returncode != 0:
return False, "קובץ ה־Watchdog נכתב, אבל התקנת המשימה נכשלה.", output or f"return code: {completed.returncode}"
return True, "Watchdog הותקן כמשימה מתוזמנת שרצה כל דקה.", output
def uninstall_watchdog_task():
if os.name != "nt":
return False, "הסרת Task Scheduler נתמכת רק על Windows.", ""
completed = subprocess.run(["schtasks", "/Delete", "/TN", WATCHDOG_TASK_NAME, "/F"], capture_output=True, text=True, shell=False, timeout=20)
output = (completed.stdout or completed.stderr or "").strip()
if completed.returncode != 0:
return False, "לא הצלחתי להסיר את משימת ה־Watchdog, או שהיא לא קיימת.", output or f"return code: {completed.returncode}"
return True, "משימת ה־Watchdog הוסרה.", output
def run_watchdog_once_now():
script_path = write_watchdog_script()
completed = subprocess.run([sys.executable or "python", script_path], capture_output=True, text=True, shell=False, timeout=25)
output = (completed.stdout or completed.stderr or "").strip()
ok = completed.returncode in {0, 2, 3, 10}
return ok, f"Watchdog רץ פעם אחת והחזיר קוד {completed.returncode}.", output or read_log_tail(WATCHDOG_LOG_PATH)
def render_admin_watchdog():
status = watchdog_status(include_log=True)
installed = bool(status.get("task_installed"))
script_ok = bool(status.get("script_exists")) and bool(status.get("script_compiles"))
source_ok = bool(status.get("source_ok"))
state_badge = "מותקן ופעיל" if installed else "לא מותקן עדיין"
state_cls = "ok" if installed and source_ok else "warn"
task_text = html_lib.escape(status.get("task_status") or "")
log_tail = html_lib.escape(status.get("log_tail") or "אין עדיין לוג.").replace("\n", "<br>")
script_label = "✅ קיים ותקין" if script_ok else "⚠️ עדיין לא נכתב/לא תקין"
task_label = "✅ מותקנת" if installed else "⚠️ לא מותקנת"
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>TripServer Watchdog</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#14532d,#0f172a 60%,#111827);border:1px solid #166534;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#713f12;color:#fde68a;border:1px solid #f59e0b}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.card b{{display:block;font-size:18px;margin-bottom:8px}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:14px;padding:13px;max-height:340px;overflow:auto}}button{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer}}button.orange{{background:#ea580c}}button.danger{{background:#dc2626}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}}.actions a{{display:inline-flex;background:#172033;border:1px solid #334155;border-radius:999px;padding:10px 14px;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:18px;padding:14px;margin:14px 0;color:#cbd5e1;line-height:1.7}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🛡️ Watchdog לשמירת פאנל האדמין</h1><p>שכבת ביטחון חיצונית: Windows מריץ סקריפט קטן כל דקה. אם הפאנל לא עונה ב־localhost:{ADMIN_PORT}, הוא מפעיל מחדש את admin_server.py לבד.</p><span class="badge {state_cls}">{state_badge}</span><div class="actions"><a href="/admin/self-test">בדיקה עצמית</a><a href="/admin/health">Health</a><a href="/admin/status">סטטוס קבצים</a></div></div>
<div class="grid"><div class="card"><b>סקריפט Watchdog</b><p>{script_label}</p><code>{html_lib.escape(status.get('script_path',''))}</code></div><div class="card"><b>משימה מתוזמנת</b><p>{task_label}</p><p>שם: <code>{html_lib.escape(status.get('task_name',''))}</code></p><p>תדירות: כל {status.get('interval_minutes')} דקה</p></div><div class="card"><b>Cooldown</b><p>{status.get('cooldown_seconds')} שניות בין ניסיונות הפעלה כדי למנוע לופ.</p></div></div>
<div class="note"><b>מה זה עושה בפועל?</b><br>ה־Watchdog לא מחליף קבצים, לא מכבה מחשב ולא עושה restart למחשב. הוא רק בודק אם פאנל האדמין חי, ואם לא — מפעיל את קובץ האדמין הפעיל מחדש. זה בדיוק הכיסוי למקרה שבו העלאת admin או restart נפלו ואתה לא רוצה ללכת פיזית למחשב.</div>
<div class="card"><b>פעולות</b><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין Watchdog מתוזמן שרץ כל דקה?')"><button>התקן / עדכן Watchdog</button></form><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ בדיקת Watchdog חד־פעמית עכשיו?')"><button class="orange">הרץ פעם אחת עכשיו</button></form><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog?')"><button class="danger">הסר Watchdog</button></form></div>
<div class="card"><b>סטטוס Task Scheduler</b><pre>{task_text or 'אין מידע.'}</pre></div><div class="card"><b>לוג אחרון</b><pre>{log_tail}</pre></div></div></body></html>
"""
def get_client_ip():
# Use the direct remote address rather than trusting arbitrary X-Forwarded-For headers.
return request.remote_addr or ""
def is_private_or_loopback_ip(ip_text):
try:
ip = ipaddress.ip_address((ip_text or "").split("%", 1)[0])
tailscale_ipv4 = ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10")
tailscale_ipv6 = ip.version == 6 and ip in ipaddress.ip_network("fd7a:115c:a1e0::/48")
return ip.is_loopback or ip.is_private or ip.is_link_local or tailscale_ipv4 or tailscale_ipv6
except Exception:
return False
def power_action_allowed():
if not POWER_ACTIONS_ENABLED:
return False, "פעולות כיבוי/ריסטארט מחשב כבויות בהגדרת TRIPSERVER_ENABLE_POWER_ACTIONS."
ip = get_client_ip()
if not is_private_or_loopback_ip(ip):
return False, f"פעולת מחשב חסומה מכתובת שאינה מקומית/רשת ביתית: {ip}"
if os.name != "nt":
return False, "פעולות מחשב נתמכות כאן רק על Windows."
return True, "OK"
def normalize_power_delay(raw_value):
try:
delay = int(raw_value)
except Exception:
delay = POWER_DEFAULT_DELAY_SECONDS
return max(POWER_MIN_DELAY_SECONDS, min(POWER_MAX_DELAY_SECONDS, delay))
def run_power_command(action, delay_seconds):
if action == "shutdown":
mode = "/s"
label = "כיבוי מחשב"
elif action == "reboot":
mode = "/r"
label = "ריסטארט למחשב"
else:
raise ValueError("unknown power action")
delay = normalize_power_delay(delay_seconds)
comment = f"TripServer Admin scheduled {label}"
cmd = ["shutdown", mode, "/t", str(delay), "/c", comment]
completed = subprocess.run(cmd, capture_output=True, text=True, shell=False)
if completed.returncode != 0:
raise RuntimeError((completed.stderr or completed.stdout or "shutdown command failed").strip())
log_admin_restart(f"POWER {action} scheduled in {delay}s by {get_client_ip()}")
return label, delay, completed.stdout.strip() or completed.stderr.strip()
def cancel_power_command():
completed = subprocess.run(["shutdown", "/a"], capture_output=True, text=True, shell=False)
# Windows returns non-zero when there is no pending shutdown. Treat it as useful feedback, not a server crash.
log_admin_restart(f"POWER cancel requested by {get_client_ip()} | rc={completed.returncode} | out={completed.stdout.strip()} | err={completed.stderr.strip()}")
return completed.returncode, (completed.stdout or completed.stderr or "").strip()
def camera_access_allowed():
if not CAMERA_ENABLED:
return False, "מצלמת החדר כבויה בהגדרת TRIPSERVER_ENABLE_WEBCAM."
ip = get_client_ip()
if not is_private_or_loopback_ip(ip):
return False, f"גישה למצלמה חסומה מכתובת שאינה מקומית/רשת ביתית/Tailscale: {ip}"
return True, "OK"
def camera_engine_status():
status = {"enabled": CAMERA_ENABLED, "opencv": False, "opencv_version": "", "camera_index": CAMERA_INDEX}
try:
import cv2 # noqa: F401
status["opencv"] = True
status["opencv_version"] = getattr(cv2, "__version__", "")
except Exception as exc:
status["opencv"] = False
status["error"] = str(exc)
return status
class WebcamManager:
def __init__(self):
self.lock = threading.RLock()
self.cap = None
self.opened_at = 0.0
self.last_frame_at = 0.0
self.frames = 0
self.last_error = ""
self.stop_requested = False
def _import_cv2(self):
import cv2
return cv2
def _open_locked(self):
cv2 = self._import_cv2()
if self.cap is not None and self.cap.isOpened():
return cv2
self.stop_requested = False
backend = getattr(cv2, "CAP_DSHOW", 0) if os.name == "nt" else 0
cap = cv2.VideoCapture(CAMERA_INDEX, backend)
try:
cap.set(getattr(cv2, "CAP_PROP_FRAME_WIDTH", 3), CAMERA_WIDTH)
cap.set(getattr(cv2, "CAP_PROP_FRAME_HEIGHT", 4), CAMERA_HEIGHT)
cap.set(getattr(cv2, "CAP_PROP_FPS", 5), CAMERA_FPS)
except Exception:
pass
if not cap or not cap.isOpened():
try:
cap.release()
except Exception:
pass
self.last_error = f"לא הצלחתי לפתוח מצלמה index={CAMERA_INDEX}. בדוק הרשאות Windows או מצלמה תפוסה באפליקציה אחרת."
raise RuntimeError(self.last_error)
self.cap = cap
self.opened_at = time.time()
self.frames = 0
self.last_error = ""
return cv2
def read_jpeg(self):
with self.lock:
if self.stop_requested:
raise RuntimeError("המצלמה כובתה ידנית")
cv2 = self._open_locked()
ok, frame = self.cap.read()
if not ok or frame is None:
time.sleep(0.15)
ok, frame = self.cap.read()
if not ok or frame is None:
self.last_error = "לא התקבלה תמונה מהמצלמה."
raise RuntimeError(self.last_error)
ok, encoded = cv2.imencode(".jpg", frame, [int(getattr(cv2, "IMWRITE_JPEG_QUALITY", 1)), CAMERA_JPEG_QUALITY])
if not ok:
self.last_error = "קידוד תמונת המצלמה ל־JPG נכשל."
raise RuntimeError(self.last_error)
self.frames += 1
self.last_frame_at = time.time()
return encoded.tobytes()
def stop(self):
with self.lock:
self.stop_requested = True
if self.cap is not None:
try:
self.cap.release()
except Exception:
pass
self.cap = None
self.opened_at = 0.0
def status(self):
with self.lock:
active = bool(self.cap is not None and self.cap.isOpened())
return {
"enabled": CAMERA_ENABLED,
"active": active,
"camera_index": CAMERA_INDEX,
"width": CAMERA_WIDTH,
"height": CAMERA_HEIGHT,
"fps": CAMERA_FPS,
"jpeg_quality": CAMERA_JPEG_QUALITY,
"opened_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.opened_at)) if self.opened_at else "",
"last_frame_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.last_frame_at)) if self.last_frame_at else "",
"frames": self.frames,
"last_error": self.last_error,
}
CAMERA_MANAGER = WebcamManager()
def log_camera_event(message):
try:
os.makedirs(BASE_DIR, exist_ok=True)
with open(os.path.join(BASE_DIR, "camera_security.log"), "a", encoding="utf-8") as fh:
fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(message) + "\n")
except Exception:
pass
def ffmpeg_dshow_devices():
"""Return DirectShow audio/video devices available to FFmpeg on Windows."""
devices = {"video": [], "audio": [], "error": "", "raw": ""}
ffmpeg = find_ffmpeg_executable()
if not ffmpeg:
devices["error"] = "FFmpeg לא נמצא."
return devices
if os.name != "nt":
devices["error"] = "זיהוי התקני מצלמה/מיקרופון דרך DirectShow נתמך כאן בעיקר ב־Windows."
return devices
try:
completed = subprocess.run(
[ffmpeg, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=12,
)
raw = (completed.stdout or "") + "\n" + (completed.stderr or "")
devices["raw"] = raw[-6000:]
for name, kind in re.findall(r'"([^"]+)"\s+\((video|audio)\)', raw, flags=re.I):
bucket = "video" if kind.lower() == "video" else "audio"
if name not in devices[bucket]:
devices[bucket].append(name)
except Exception as exc:
devices["error"] = str(exc)
return devices
def selected_ffmpeg_devices():
devices = ffmpeg_dshow_devices()
video = CAMERA_VIDEO_DEVICE or (devices.get("video") or [""])[0]
audio = CAMERA_AUDIO_DEVICE or (devices.get("audio") or [""])[0]
return video, audio, devices
def dshow_input_name(video_device="", audio_device=""):
parts = []
if video_device:
parts.append("video=" + video_device)
if audio_device:
parts.append("audio=" + audio_device)
return ":".join(parts)
class CameraAudioStreamManager:
def __init__(self):
self.lock = threading.RLock()
self.proc = None
self.started_at = 0.0
self.device = ""
self.last_error = ""
def start_locked(self):
if self.proc is not None and self.proc.poll() is None:
return self.proc
if not CAMERA_AUDIO_ENABLED:
raise RuntimeError("אודיו מצלמה כבוי בהגדרת TRIPSERVER_ENABLE_CAMERA_AUDIO.")
ffmpeg = find_ffmpeg_executable()
if not ffmpeg:
raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו חי.")
_, audio, devices = selected_ffmpeg_devices()
if not audio:
raise RuntimeError("לא נמצא התקן מיקרופון ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_AUDIO_DEVICE.")
cmd = [
ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + audio,
"-vn", "-ac", "1", "-ar", "44100", "-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3", "pipe:1",
]
self.proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
self.started_at = time.time()
self.device = audio
self.last_error = ""
log_camera_event(f"audio stream started by {get_client_ip()} | device={audio}")
return self.proc
def stream_chunks(self):
try:
with self.lock:
proc = self.start_locked()
while proc is not None and proc.poll() is None:
chunk = proc.stdout.read(4096) if proc.stdout else b""
if not chunk:
break
yield chunk
except GeneratorExit:
pass
except Exception as exc:
self.last_error = str(exc)
yield b""
finally:
self.stop()
def stop(self):
with self.lock:
proc = self.proc
self.proc = None
if proc is not None and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=3)
except Exception:
try:
proc.kill()
except Exception:
pass
self.started_at = 0.0
log_camera_event(f"audio stream stopped by {get_client_ip()}")
def status(self):
with self.lock:
active = bool(self.proc is not None and self.proc.poll() is None)
return {
"audio_enabled": CAMERA_AUDIO_ENABLED,
"active": active,
"device": self.device,
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "",
"last_error": self.last_error,
}
class CameraRecordingManager:
def __init__(self):
self.lock = threading.RLock()
self.proc = None
self.started_at = 0.0
self.file_path = ""
self.log_path = ""
self.last_error = ""
self.with_audio = False
self.video_device = ""
self.audio_device = ""
self.log_handle = None
def _refresh_locked(self):
if self.proc is not None and self.proc.poll() is not None:
try:
if self.log_handle:
self.log_handle.close()
except Exception:
pass
self.log_handle = None
self.proc = None
self.started_at = 0.0
def start(self, with_audio=True):
with self.lock:
self._refresh_locked()
if self.proc is not None and self.proc.poll() is None:
raise RuntimeError("יש כבר הקלטה פעילה. עצור אותה לפני התחלה חדשה.")
if not CAMERA_RECORDING_ENABLED:
raise RuntimeError("הקלטת מצלמה כבויה בהגדרת TRIPSERVER_ENABLE_CAMERA_RECORDING.")
ffmpeg = find_ffmpeg_executable()
if not ffmpeg:
raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להקליט וידאו/אודיו.")
video, audio, devices = selected_ffmpeg_devices()
if not video:
raise RuntimeError("לא נמצא התקן מצלמה ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_VIDEO_DEVICE.")
if with_audio and not audio:
raise RuntimeError("ביקשת הקלטה עם אודיו, אבל לא נמצא התקן מיקרופון ב־FFmpeg.")
os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
target = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.mp4")
log_path = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.ffmpeg.log")
input_name = dshow_input_name(video, audio if with_audio else "")
cmd = [
ffmpeg, "-hide_banner", "-y", "-f", "dshow", "-rtbufsize", "512M",
"-framerate", str(CAMERA_RECORD_FPS), "-video_size", f"{CAMERA_WIDTH}x{CAMERA_HEIGHT}",
"-i", input_name, "-t", str(CAMERA_RECORD_MAX_SECONDS),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
]
if with_audio:
cmd += ["-c:a", "aac", "-b:a", CAMERA_AUDIO_BITRATE]
else:
cmd += ["-an"]
cmd += ["-movflags", "+faststart", target]
logfh = open(log_path, "ab")
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=logfh, creationflags=creationflags)
self.log_handle = logfh
self.started_at = time.time()
self.file_path = target
self.log_path = log_path
self.last_error = ""
self.with_audio = bool(with_audio)
self.video_device = video
self.audio_device = audio if with_audio else ""
log_camera_event(f"recording started by {get_client_ip()} | file={target} | video={video} | audio={self.audio_device or 'no-audio'}")
return target
def stop(self):
with self.lock:
self._refresh_locked()
proc = self.proc
if proc is None:
return self.file_path or ""
try:
if proc.stdin:
proc.stdin.write(b"q")
proc.stdin.flush()
except Exception:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=10)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
if self.log_handle:
self.log_handle.close()
except Exception:
pass
self.log_handle = None
self.proc = None
self.started_at = 0.0
log_camera_event(f"recording stopped by {get_client_ip()} | file={self.file_path}")
return self.file_path
def status(self):
with self.lock:
self._refresh_locked()
active = bool(self.proc is not None and self.proc.poll() is None)
size = os.path.getsize(self.file_path) if self.file_path and os.path.exists(self.file_path) else 0
return {
"recording_enabled": CAMERA_RECORDING_ENABLED,
"active": active,
"file_path": self.file_path,
"file_name": os.path.basename(self.file_path) if self.file_path else "",
"file_size": size,
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "",
"elapsed_seconds": int(time.time() - self.started_at) if active and self.started_at else 0,
"with_audio": self.with_audio,
"video_device": self.video_device,
"audio_device": self.audio_device,
"log_path": self.log_path,
"last_error": self.last_error,
}
CAMERA_AUDIO_STREAM = CameraAudioStreamManager()
CAMERA_RECORDER = CameraRecordingManager()
def list_camera_recordings(limit=30):
try:
os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True)
items = []
for path in glob.glob(os.path.join(CAMERA_RECORDINGS_DIR, "*.mp4")):
try:
items.append({
"name": os.path.basename(path),
"path": path,
"size": os.path.getsize(path),
"mtime": os.path.getmtime(path),
})
except Exception:
pass
items.sort(key=lambda x: x.get("mtime", 0), reverse=True)
return items[:limit]
except Exception:
return []
def append_camera_setup_log(message):
os.makedirs(BASE_DIR, exist_ok=True)
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}\n"
try:
with open(CAMERA_SETUP_LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(line)
except Exception:
pass
return line
def read_camera_setup_log_tail(lines=80):
try:
lines = max(20, min(300, int(lines)))
except Exception:
lines = 80
if not os.path.exists(CAMERA_SETUP_LOG_PATH):
return ""
try:
with open(CAMERA_SETUP_LOG_PATH, "r", encoding="utf-8", errors="replace") as fh:
data = fh.readlines()
return "".join(data[-lines:])
except Exception as exc:
return f"שגיאה בקריאת לוג התקנה: {exc}"
def opencv_import_status():
try:
import cv2 # noqa: F401
return True, getattr(cv2, "__version__", "")
except Exception as exc:
return False, str(exc)
def camera_setup_status():
ok, info = opencv_import_status()
ffmpeg_path = find_ffmpeg_executable() or ""
with CAMERA_SETUP_LOCK:
state = dict(CAMERA_SETUP_STATE)
return {
"opencv": ok,
"opencv_info": info,
"ffmpeg": bool(ffmpeg_path),
"ffmpeg_path": ffmpeg_path,
"python_executable": sys.executable,
"pip_command": f'"{sys.executable}" -m pip install --upgrade {CAMERA_OPENCV_PACKAGE}',
"opencv_package": CAMERA_OPENCV_PACKAGE,
"setup_log_path": CAMERA_SETUP_LOG_PATH,
"marker_path": CAMERA_SETUP_MARKER_PATH,
"auto_install_enabled": CAMERA_AUTO_INSTALL_OPENCV,
"auto_install_delay_seconds": CAMERA_AUTO_INSTALL_DELAY_SECONDS,
"installer": state,
}
def ensure_pip_available():
"""Make pip available in the same Python interpreter when possible."""
check = subprocess.run([sys.executable, "-m", "pip", "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, shell=False)
if check.returncode == 0:
append_camera_setup_log("pip available: " + (check.stdout or check.stderr or "").strip())
return True
append_camera_setup_log("pip not available, trying ensurepip --upgrade")
try:
ep = subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, shell=False)
append_camera_setup_log("ensurepip return code: " + str(ep.returncode))
if ep.stdout:
append_camera_setup_log("ensurepip stdout:\n" + ep.stdout[-6000:])
if ep.stderr:
append_camera_setup_log("ensurepip stderr:\n" + ep.stderr[-6000:])
return ep.returncode == 0
except Exception as exc:
append_camera_setup_log("ensurepip exception: " + repr(exc))
return False
def write_camera_setup_marker(info):
try:
os.makedirs(BASE_DIR, exist_ok=True)
with open(CAMERA_SETUP_MARKER_PATH, "w", encoding="utf-8") as fh:
fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(info) + "\n")
except Exception:
pass
def run_opencv_install_worker(mode="manual"):
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתקין OpenCV...", "mode": mode})
append_camera_setup_log("OpenCV install requested | mode=" + str(mode))
append_camera_setup_log("Python executable: " + sys.executable)
if not ensure_pip_available():
msg = "pip לא זמין וגם ensurepip לא הצליח. אי אפשר להתקין OpenCV אוטומטית."
append_camera_setup_log("ERROR: " + msg)
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "last_message": msg, "mode": mode})
return
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", CAMERA_OPENCV_PACKAGE]
append_camera_setup_log("Command: " + " ".join(cmd))
ok = False
msg = ""
try:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS,
shell=False,
)
append_camera_setup_log("pip return code: " + str(completed.returncode))
if completed.stdout:
append_camera_setup_log("pip stdout:\n" + completed.stdout[-12000:])
if completed.stderr:
append_camera_setup_log("pip stderr:\n" + completed.stderr[-12000:])
ok, info = opencv_import_status()
if completed.returncode == 0 and ok:
msg = "OpenCV הותקן וזוהה: " + info
append_camera_setup_log("SUCCESS: " + msg)
write_camera_setup_marker(msg)
elif completed.returncode == 0 and not ok:
msg = "pip הסתיים בהצלחה אבל import cv2 עדיין נכשל: " + info
append_camera_setup_log("WARNING: " + msg)
else:
msg = "pip נכשל. בדוק את הלוג במסך ההתקנה."
append_camera_setup_log("ERROR: " + msg)
ok = False
except subprocess.TimeoutExpired:
msg = f"התקנת OpenCV עברה את מגבלת הזמן ({CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS} שניות)."
append_camera_setup_log("TIMEOUT: " + msg)
except Exception as exc:
msg = "שגיאה בהרצת pip: " + repr(exc)
append_camera_setup_log("EXCEPTION: " + msg)
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": bool(ok), "last_message": msg, "mode": mode})
def start_opencv_install_background(mode="manual"):
with CAMERA_SETUP_LOCK:
if CAMERA_SETUP_STATE.get("running"):
return False, "התקנת OpenCV כבר רצה כרגע."
CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתחיל התקנה...", "mode": mode})
t = threading.Thread(target=run_opencv_install_worker, kwargs={"mode": mode}, name="tripserver-opencv-installer", daemon=True)
t.start()
return True, "התקנת OpenCV התחילה ברקע. אפשר לרענן את מסך המצלמה/התקנה ולבדוק לוג."
def maybe_start_camera_auto_install(reason="startup"):
if not CAMERA_AUTO_INSTALL_OPENCV:
append_camera_setup_log("Auto install disabled by TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV")
return False, "התקנה אוטומטית כבויה."
ok, info = opencv_import_status()
if ok:
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info), "mode": "ready"})
write_camera_setup_marker("already available: " + str(info))
return False, "OpenCV כבר מותקן."
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE["auto_requested"] = True
def delayed_auto():
try:
if CAMERA_AUTO_INSTALL_DELAY_SECONDS > 0:
time.sleep(CAMERA_AUTO_INSTALL_DELAY_SECONDS)
ok2, info2 = opencv_import_status()
if ok2:
write_camera_setup_marker("available before auto install: " + str(info2))
with CAMERA_SETUP_LOCK:
CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info2), "mode": "ready"})
return
append_camera_setup_log("Auto install triggered | reason=" + str(reason) + " | cv2 import failed: " + str(info2))
start_opencv_install_background(mode="auto:" + str(reason))
except Exception as exc:
append_camera_setup_log("Auto install scheduler exception: " + repr(exc))
t = threading.Thread(target=delayed_auto, name="tripserver-opencv-auto-installer", daemon=True)
t.start()
return True, "התקנת OpenCV אוטומטית תתחיל ברקע."
def render_camera_setup_page():
allowed, reason = camera_access_allowed()
status = camera_setup_status()
inst = status.get("installer", {})
running = bool(inst.get("running"))
opencv_badge = "ok" if status.get("opencv") else "warn"
ffmpeg_badge = "ok" if status.get("ffmpeg") else "warn"
run_badge = "warn" if running else ("ok" if inst.get("ok") else "idle")
install_disabled = "disabled" if (not allowed or running or status.get("opencv")) else ""
allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>"
log_tail = html_lib.escape(read_camera_setup_log_tail(120)) or "אין עדיין לוג התקנה."
return f"""
<!DOCTYPE html><html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>התקנת רכיבי מצלמה</title>
<style>
body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#7f1d1d;color:#fecaca;border:1px solid #f87171}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:12px;overflow:auto;max-height:440px}}@media(max-width:700px){{body{{padding:13px}}h1{{font-size:28px}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin/camera">⬅ חזרה למצלמה</a><h1>🧩 התקנת רכיבי מצלמה</h1><p>OpenCV מותקן אוטומטית חד־פעמית ברקע כאשר פאנל האדמין עולה. FFmpeg לא מותקן כאן; הוא רק מזוהה מהנתיב שכבר קיים אצלך.</p><span class="badge {opencv_badge}">OpenCV: {'מותקן' if status.get('opencv') else 'חסר'}</span><span class="badge {ffmpeg_badge}">FFmpeg: {'זמין' if status.get('ffmpeg') else 'לא נמצא'}</span><span class="badge {run_badge}">Installer: {'רץ עכשיו' if running else ('הצלחה אחרונה' if inst.get('ok') else 'ממתין')}</span></div>{allowed_block}
<div class="grid"><div class="card"><h2>התקנה אוטומטית חד־פעמית</h2><p>אם OpenCV חסר, הפאנל מריץ לבד את הפקודה:</p><pre>{html_lib.escape(status.get('pip_command',''))}</pre><div class="note">אין כפתור התקנה נוסף. ההתקנה מופעלת ברקע אחרי עליית הפאנל, כדי שתוכל לעשות את זה מרחוק בלי CMD ידני.</div>{'<div class="good">OpenCV כבר מותקן וזמין. אין צורך להתקין שוב.</div>' if status.get('opencv') else ''}</div>
<div class="card"><h2>סטטוס</h2><p><b>Python:</b></p><pre>{html_lib.escape(status.get('python_executable',''))}</pre><p><b>OpenCV:</b> {html_lib.escape(str(status.get('opencv_info','')))}</p><p><b>FFmpeg:</b></p><pre>{html_lib.escape(status.get('ffmpeg_path') or 'לא נמצא')}</pre><p><b>הודעה אחרונה:</b><br>{html_lib.escape(str(inst.get('last_message') or 'אין'))}</p><p><b>התחיל:</b> {html_lib.escape(str(inst.get('started_at') or ''))}<br><b>הסתיים:</b> {html_lib.escape(str(inst.get('finished_at') or ''))}</p><p><a class="btn" href="/camera/setup">רענן</a><a class="btn" href="/camera/setup/status.json" target="_blank">JSON טכני</a></p></div></div>
<div class="card"><h2>לוג התקנה</h2><p>נתיב לוג: <code>{html_lib.escape(CAMERA_SETUP_LOG_PATH)}</code></p><pre>{log_tail}</pre></div></div></body></html>
"""
def camera_av_status():
video, audio, devices = selected_ffmpeg_devices()
return {
"ffmpeg": bool(find_ffmpeg_executable()),
"ffmpeg_path": find_ffmpeg_executable() or "",
"video_device": video,
"audio_device": audio,
"devices": devices,
"audio_stream": CAMERA_AUDIO_STREAM.status(),
"recording": CAMERA_RECORDER.status(),
"recordings_dir": CAMERA_RECORDINGS_DIR,
}
def camera_stream_generator():
started = time.time()
try:
while True:
if CAMERA_MAX_STREAM_SECONDS > 0 and (time.time() - started) > CAMERA_MAX_STREAM_SECONDS:
break
frame = CAMERA_MANAGER.read_jpeg()
yield b"--frame\r\nContent-Type: image/jpeg\r\nCache-Control: no-cache\r\n\r\n" + frame + b"\r\n"
time.sleep(CAMERA_FRAME_INTERVAL_SECONDS)
except GeneratorExit:
pass
except Exception as exc:
CAMERA_MANAGER.last_error = str(exc)
finally:
# Release the webcam when the browser closes/stops the stream so the LED turns off.
CAMERA_MANAGER.stop()
def render_admin_camera():
allowed, reason = camera_access_allowed()
engine = camera_engine_status()
av = camera_av_status()
cam_status = CAMERA_MANAGER.status()
rec_status = av.get("recording", {})
audio_status = av.get("audio_stream", {})
devices = av.get("devices", {}) or {}
allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>"
engine_block = ""
if not engine.get("opencv"):
engine_block = "<div class='warn'><b>OpenCV לא מותקן.</b><br>אפשר להתקין ישירות מהפאנל דרך הכפתור: <a class='btn' href='/camera/setup'>🧩 התקנת רכיבי מצלמה</a><pre>py -m pip install opencv-python</pre></div>"
if not av.get("ffmpeg"):
engine_block += "<div class='warn'><b>FFmpeg לא נמצא.</b><br>שידור אודיו והקלטת MP4 עם אודיו דורשים FFmpeg ב־PATH או ב־<code>C:\\ffmpeg\\bin\\ffmpeg.exe</code>.</div>"
active_text = "פעילה" if cam_status.get("active") else "כבויה"
active_cls = "ok" if cam_status.get("active") else "idle"
rec_text = "מקליט" if rec_status.get("active") else "לא מקליט"
rec_cls = "rec" if rec_status.get("active") else "idle"
audio_text = "פעיל" if audio_status.get("active") else "כבוי"
disabled_video = "disabled" if (not allowed or not engine.get("opencv")) else ""
disabled_av = "disabled" if (not allowed or not av.get("ffmpeg")) else ""
recordings = list_camera_recordings(12)
rec_rows = "".join(
f"<div class='file-row'><div><b>{html_lib.escape(item['name'])}</b><br><span>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span></div><a class='mini' href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח</a></div>"
for item in recordings
) or "<div class='muted'>אין עדיין הקלטות בתיקייה.</div>"
video_devices = ", ".join(devices.get("video", [])) or "לא זוהו דרך FFmpeg"
audio_devices = ", ".join(devices.get("audio", [])) or "לא זוהו דרך FFmpeg"
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>מצלמת חדר</title>
<style>
body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}h2{{margin:0 0 10px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:1.45fr .9fr;gap:16px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.cam-box{{background:#020617;border:1px solid #334155;border-radius:22px;overflow:hidden;min-height:280px;display:flex;align-items:center;justify-content:center}}#cameraView{{width:100%;height:auto;display:none;background:#020617}}audio{{width:100%;margin-top:10px}}.placeholder{{color:#94a3b8;text-align:center;padding:28px;line-height:1.7}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button.stop{{background:#dc2626}}button.gray,.btn.gray{{background:#475569}}button.snap{{background:#0f766e}}button.audio{{background:#7c3aed}}button.record{{background:#ea580c}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}.badge.rec{{background:#7c2d12;color:#fed7aa;border:1px solid #fb923c}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:10px;overflow:auto}}.row{{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid #263548;padding:9px 0;color:#cbd5e1}}.row b{{color:white}}.file-row{{display:flex;justify-content:space-between;gap:12px;align-items:center;border-bottom:1px solid #263548;padding:10px 0;color:#cbd5e1}}.file-row span,.muted{{color:#94a3b8}}.mini{{background:#1d4ed8;color:white;border-radius:10px;padding:8px 10px;font-weight:900;white-space:nowrap}}label{{display:block;color:#e2e8f0;margin-top:8px;font-weight:800}}input[type=checkbox]{{transform:scale(1.25);margin-left:8px}}@media(max-width:850px){{.grid{{grid-template-columns:1fr}}body{{padding:13px}}h1{{font-size:28px}}.file-row{{align-items:flex-start}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📷 מצלמת חדר</h1><p>צפייה חיה במצלמת המחשב, שידור אודיו נפרד, צילום רגעי והקלטת MP4 לתיקיית הקלטות מקומית. OpenCV מותקן אוטומטית חד־פעמית; המצלמה עצמה מופעלת רק בלחיצה שלך.</p><p><a class="btn gray" href="/camera/setup">סטטוס רכיבי מצלמה</a></p><span class="badge {active_cls}">וידאו: {active_text}</span><span class="badge {active_cls if audio_status.get('active') else 'idle'}">אודיו: {audio_text}</span><span class="badge {rec_cls}">הקלטה: {rec_text}</span></div>{allowed_block}{engine_block}
<div class="grid"><div><div class="card"><div class="cam-box"><img id="cameraView" alt="שידור מצלמה"><div id="placeholder" class="placeholder">המצלמה כבויה.<br>לחץ “הפעל וידאו” כדי להתחיל שידור חי.</div></div><div><button onclick="startCamera()" {disabled_video}>הפעל וידאו</button><button class="stop" onclick="stopCamera()">כבה וידאו</button><button class="snap" onclick="openSnapshot()" {disabled_video}>פתח צילום רגעי</button><button class="gray" onclick="location.reload()">רענן סטטוס</button></div></div>
<div class="card"><h2>🔊 אודיו חי</h2><p>האודיו מוזרם כנגן נפרד מהווידאו. בדפדפן בטלפון צריך ללחוץ ידנית על “הפעל אודיו”.</p><audio id="audioPlayer" controls preload="none"></audio><div><button class="audio" onclick="startAudio()" {disabled_av}>הפעל אודיו</button><button class="stop" onclick="stopAudio()">כבה אודיו</button></div></div>
<div class="card"><h2>⏺️ הקלטה למחשב</h2><p>הקלטות נשמרות מקומית בתיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p><form method="post" action="/camera/record/start"><label><input type="checkbox" name="with_audio" value="1" checked>כלול אודיו בהקלטה</label><button class="record" type="submit" {disabled_av}>התחל הקלטה</button></form><form method="post" action="/camera/record/stop"><button class="stop" type="submit">עצור הקלטה</button></form><div class="good">משך הקלטה מקסימלי להגנה מדיסק מלא: {CAMERA_RECORD_MAX_SECONDS//60} דקות. אפשר לשנות דרך <code>TRIPSERVER_CAMERA_RECORD_MAX_SECONDS</code>.</div></div>
<div class="card"><h2>🎞️ הקלטות אחרונות</h2>{rec_rows}<p><a class="btn gray" href="/camera/recordings">פתח רשימת הקלטות מלאה</a></p></div></div>
<div><div class="card"><h2>סטטוס טכני</h2><div class="row"><b>OpenCV</b><span>{'מותקן' if engine.get('opencv') else 'לא מותקן'}</span></div><div class="row"><b>FFmpeg</b><span>{'זמין' if av.get('ffmpeg') else 'לא נמצא'}</span></div><div class="row"><b>Camera index</b><span>{CAMERA_INDEX}</span></div><div class="row"><b>רזולוציה</b><span>{CAMERA_WIDTH}×{CAMERA_HEIGHT}</span></div><div class="row"><b>FPS שידור</b><span>{CAMERA_FPS}</span></div><div class="row"><b>FPS הקלטה</b><span>{CAMERA_RECORD_FPS}</span></div><div class="row"><b>וידאו FFmpeg</b><span>{html_lib.escape(av.get('video_device') or '')}</span></div><div class="row"><b>אודיו FFmpeg</b><span>{html_lib.escape(av.get('audio_device') or '')}</span></div><div class="row"><b>Frames</b><span>{cam_status.get('frames')}</span></div><div class="row"><b>קובץ הקלטה פעיל</b><span>{html_lib.escape(rec_status.get('file_name') or 'אין')}</span></div><div class="row"><b>גודל הקלטה</b><span>{format_bytes(rec_status.get('file_size') or 0)}</span></div><div class="row"><b>שגיאה אחרונה</b><span>{html_lib.escape(cam_status.get('last_error') or rec_status.get('last_error') or audio_status.get('last_error') or 'אין')}</span></div></div>
<div class="card"><h2>התקנים שזוהו</h2><p><b>וידאו:</b><br>{html_lib.escape(video_devices)}</p><p><b>אודיו:</b><br>{html_lib.escape(audio_devices)}</p><p class="muted">אם נבחר התקן לא נכון, אפשר להגדיר במערכת: <code>TRIPSERVER_FFMPEG_VIDEO_DEVICE</code> / <code>TRIPSERVER_FFMPEG_AUDIO_DEVICE</code>.</p></div>
<div class="note"><b>הערת שימוש:</b> מאחר שזה כולל אודיו והקלטה, הפאנל משאיר חיווי ברור כשהמצלמה/ההקלטה פעילה. כדאי לוודא שכל מי שנמצא בבית יודע שהמערכת קיימת ומתי היא פעילה.</div></div></div></div>
<script>
function startCamera(){{
const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder');
img.style.display='block'; ph.style.display='none'; img.src='/camera/stream?ts='+Date.now();
}}
async function stopCamera(){{
const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder');
img.src=''; img.style.display='none'; ph.style.display='block';
try{{await fetch('/camera/stop',{{method:'POST'}});}}catch(e){{}}
}}
function startAudio(){{ const a=document.getElementById('audioPlayer'); a.src='/camera/audio-stream?ts='+Date.now(); a.play().catch(()=>{{}}); }}
async function stopAudio(){{ const a=document.getElementById('audioPlayer'); a.pause(); a.src=''; try{{await fetch('/camera/audio-stop',{{method:'POST'}});}}catch(e){{}} }}
function openSnapshot(){{ window.open('/camera/snapshot.jpg?ts='+Date.now(),'_blank'); }}
window.addEventListener('beforeunload',()=>{{try{{navigator.sendBeacon('/camera/stop'); navigator.sendBeacon('/camera/audio-stop');}}catch(e){{}}}});
</script></body></html>
"""
def serve_app(folder):
file_name = find_html_file(FOLDERS[folder])
if not file_name:
return f"No HTML file found in {folder} folder", 404
return serve_file_no_cache(FOLDERS[folder], file_name)
@app.route("/")
def home():
return """
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>מרכז ניהול</title>
<style>
body{margin:0;font-family:Arial;background:linear-gradient(135deg,#081229,#102447);color:white;min-height:100vh}
.hero{text-align:center;padding:50px 20px 20px}
.hero h1{font-size:42px;margin:0}
.hero p{color:#d8e6ff;font-size:18px}
.grid{width:min(1100px,92%);margin:40px auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:22px}
.card{text-decoration:none;color:white;border-radius:28px;padding:28px;min-height:180px;box-shadow:0 10px 30px rgba(0,0,0,.25);transition:.2s transform,.2s box-shadow}
.card:hover{transform:translateY(-4px);box-shadow:0 16px 40px rgba(0,0,0,.35)}
.card h2{font-size:30px;margin:0 0 12px}
.card p{line-height:1.6;margin:0}
.icon{font-size:52px;margin-bottom:15px}
.admin{background:linear-gradient(135deg,#0f9b0f,#38ef7d)}
.money{background:linear-gradient(135deg,#5f2c82,#49a09d)}
.homebudget{background:linear-gradient(135deg,#11998e,#38ef7d)}
.japan{background:linear-gradient(135deg,#ff416c,#ff4b2b)}
.usa{background:linear-gradient(135deg,#0052d4,#4364f7,#6fb1fc)}
.footer{text-align:center;color:#b8c8e6;padding:25px}
.image-guide{margin:12px 0 16px;background:#0f172a;border:1px solid #334155;border-radius:14px;padding:12px;color:#e2e8f0}
.image-guide summary{cursor:pointer;font-weight:bold;color:#bfdbfe;margin-bottom:10px}
.image-guide-table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}
.image-guide-table th,.image-guide-table td{border-bottom:1px solid #334155;padding:9px;text-align:right;vertical-align:top}
.image-guide-table th{color:#93c5fd;background:#111827}
.image-guide-table td:first-child{white-space:nowrap;color:#fde68a}
.image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin-top:16px}
.image-upload-card{margin:0}
.image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
.image-upload-label{display:block;height:100%;cursor:pointer;background:#0f172a;border:1px solid #334155;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)}
.image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a}
.image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px}
.image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25}
.image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px}
.upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px}
@media(max-width:640px){.image-upload-grid{grid-template-columns:1fr}.image-title{font-size:16px}.image-desc{min-height:auto}}
.app-hero{background:radial-gradient(circle at top right,rgba(59,130,246,.35),transparent 35%),linear-gradient(135deg,#172554,#0f172a);border:1px solid #334155;border-radius:28px;padding:26px;margin-bottom:18px;box-shadow:0 20px 60px rgba(0,0,0,.25)}
.app-hero h1{text-align:right;margin:0 0 8px;font-size:36px;letter-spacing:-.8px}.app-hero p{margin:0;color:#cbd5e1;line-height:1.65}.top-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.pill-link{display:inline-flex;align-items:center;gap:8px;background:#1d4ed8;color:white!important;text-decoration:none;border-radius:999px;padding:10px 14px;font-weight:900;border:1px solid #60a5fa}.pill-link.secondary{background:#334155;border-color:#475569}.dashboard-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin:18px 0}.dash-card{background:linear-gradient(180deg,#1e293b,#111827);border:1px solid #334155;border-radius:22px;padding:16px;box-shadow:0 10px 32px rgba(0,0,0,.18)}.dash-card b{display:block;font-size:18px;margin-bottom:6px}.dash-card span{color:#cbd5e1;font-size:14px;line-height:1.5}.box{box-shadow:0 16px 44px rgba(0,0,0,.20);border:1px solid #334155}.box h2{font-size:25px;letter-spacing:-.4px}.box h3{color:#bfdbfe;margin-top:20px}.split-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.mini-panel{background:#0f172a;border:1px solid #334155;border-radius:16px;padding:14px}.mini-panel h3{margin-top:0}.danger-zone{background:linear-gradient(135deg,#451a1a,#1e293b)!important;border-color:#7f1d1d!important}.safe-zone{background:linear-gradient(135deg,#132f25,#1e293b)!important;border-color:#166534!important}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}select,input[type=text]{width:100%;box-sizing:border-box;padding:13px;background:#0f172a;color:white;border-radius:12px;border:1px solid #334155}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.small-danger{background:#991b1b!important}.cancel{background:#475569!important}.muted{color:#94a3b8;font-size:13px;line-height:1.6}.success-dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 14px #22c55e;margin-left:8px}.quick-nav{box-shadow:0 12px 32px rgba(0,0,0,.22)}@media(max-width:720px){.wrap{width:min(100%,94%);padding:18px 0}.app-hero{padding:20px}.app-hero h1{font-size:28px}.quick-nav{overflow:auto;flex-wrap:nowrap}.quick-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="hero">
<h1>מרכז ניהול</h1>
<p>מערכת מרכזית לניהול קבצים, תקציבים ולוחות זמנים</p>
</div>
<div class="grid">
<a class="card admin" href="/admin"><div class="icon">📁</div><h2>ניהול קבצים</h2><p>העלאת קבצים ישירות למחשב.</p></a>
<a class="card money" href="/Moneyapp/"><div class="icon">💰</div><h2>תקציב טיולים</h2><p>ניהול תקציב חופשות וסנכרון נתונים.</p></a>
<a class="card homebudget" href="/MoneyHome/"><div class="icon">🏠</div><h2>תקציב משפחתי</h2><p>ניהול תקציב חודשי, Limits, הוצאות בפועל וארכיון חודשים.</p></a>
<a class="card admin" href="/TripForm/"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת נתונים לטיול חדש.</p></a>
<a class="card admin" href="/admin/camera"><div class="icon">📷</div><h2>מצלמת חדר</h2><p>צפייה חיה במצלמת המחשב, אודיו והקלטות דרך הפאנל.</p></a>
<a class="card japan" href="/JAPAN/"><div class="icon">🇯🇵</div><h2>טיול יפן</h2><p>לוז, ניווטים ומסמכי הטיול.</p></a>
<a class="card usa" href="/USA/"><div class="icon">🇺🇸</div><h2>טיול ארה״ב</h2><p>טיול בר מצווה 2027.</p></a>
</div>
<div class="footer">TripServer</div>
</body>
</html>
"""
USA_IMAGE_UI_ITEMS = [('intro', '🇺🇸 ארה״ב 2027 — אמריקה על פול ווליום', 'Road Trip • Orlando • Miami • Cruise / 36 ימים של נופים, פארקים, קניות, קרוז ורגעים שגורמים להגיד: “רגע… לשם אנחנו באמת הולכים?” / מערב ארה״ב: סן פרנסיסקו , כביש 1, LA , קניונים, וגאס ויוסמיטי'), ('roadtrip-map', '🗺️ מסלול הרוד טריפ — הסיפור הגדול, מלמעלה', 'סן פרנסיסקו • כביש 1 • LA • וגאס • יוסמיטי / מפה אחת שמסבירה את כל הרעיון: מתחילים באוקיינוס, מטפסים לקניונים, נוגעים במדבר וחוזרים לטבע. / המסלול נותן את כל המנה: חופים, כבישים יפים, פארקים לאומיים, וגאס ויוסמיטי — בלי לנסות לדחוס כאן את כל הלו״ז.'), ('day-01', 'יום 1 — ✈️ נחיתה ישירה ב סן פרנסיסקו', 'סן פרנסיסקו / נוחתים בעיר שיודעת לפתוח טיול עם סטייל. / נחיתה, התארגנות וטעימה ראשונה מהעיר'), ('day-02', 'יום 2 — 🌉 סן פרנסיסקו + Alcatraz', 'סן פרנסיסקו / Alcatraz / יום של אייקונים: מפרץ, גשרים, אלקטרז ואווירה של סרט. / אלקטרז, תצפיות והעיר הכי פוטוגנית בצפון קליפורניה'), ('day-03', 'יום 3 — 🌊 סן פרנסיסקו → מונטריי', 'מונטריי / 17-Mile Drive / עוברים למוד רוד־טריפ אמיתי: ים, כביש ונופים שמתחילים לעבוד עלינו חזק. / נסיעה דרומה, מונטריי וקטע חוף מהמם'), ('day-04', 'יום 4 — 🐋 Monterey Bay Whale Watch + Big Sur', 'מונטריי / Big Sur / היום שבו האוקיינוס נותן הופעה, ואם לוויתן קופץ — זה כבר בונוס קולנועי. / שייט לווייתנים ואז נופי Big Sur וכביש 1'), ('extra-big-sur', 'Road Trip Bonus — 🌉 Big Sur – Bixby Bridge + McWay Falls', 'כביש 1 / קליפורניה / כאן קליפורניה כבר לא רק יפה — היא מתחילה ממש להשוויץ. / גשר Bixby והחופים הדרמטיים של Big Sur הם מהשיאים המצולמים של כביש 1'), ('day-05', 'יום 5 — 🎡 סנטה מוניקה → לוס אנג׳לס', 'Santa Monica / Los Angeles / נכנסים ל־ LA דרך סנטה מוניקה : חוף, טיילת, גלגל ענק ואווירת קליפורניה קלאסית. / Santa Monica Pier, חוף, טיילת וכניסה לאווירת לוס אנג׳לס'), ('day-06', 'יום 6 — 🎥 Warner Bros + Beverly Hills + Griffith', 'לוס אנג׳לס / LA במצב קלאסי: אולפנים, בוורלי הילס ותצפית של סוף סרט. / Warner Bros , Beverly Hills ו־ Griffith'), ('day-07', 'יום 7 — 🏜️ לוס אנג׳לס → Zion', 'מעבר למדבר / Utah / עוזבים עיר ונכנסים למרחבים. זה יום מעבר, אבל עם סוף ששווה את זה. / נסיעה ארוכה אל אזור Zion'), ('extra-diner', 'Road Trip Bonus — 🍔 Peggy Sue’s 50’s Diner', 'עצירת דרך אמריקאית / מדבר / באמצע כל הנופים הגדולים, מגיע רגע קטן ומושלם של אמריקה קלאסית: ניאון, מילקשייק ואווירת רוד־טריפ אמיתית. / עצירת דיינר שנותנת לטיול את הטעם של אמריקה הישנה והכיפית של הכביש'), ('day-08', 'יום 8 — 🏞️ Zion → Bryce → Page', 'Zion / Bryce / Page / יום טבע חזק במיוחד: צוקים, צבעים ומעברים שנראים לא אמיתיים. / Zion , Bryce והגעה ל־ Page'), ('day-09', 'יום 9 — 🧡 Antelope Canyon + Horseshoe Bend', 'Page Arizona / זה היום של “וואו, לאן אתם הולכים?” — בדיוק זה. / Antelope Canyon ו־ Horseshoe Bend'), ('day-10', 'יום 10 — 🏜️ Page → Grand Canyon South Rim', 'Grand Canyon / היום שבו המילה “גדול” מקבלת קנה מידה חדש. / נסיעה אל South Rim ומפגש עם הגרנד קניון'), ('day-11', 'יום 11 — 🎰 Grand Canyon → Las Vegas', 'לאס וגאס / מהטבע הכי גדול — לעיר הכי מוגזמת. מעבר חד, אבל כיפי. / נסיעה לווגאס וכניסה לאורות של הסטריפ'), ('day-12', 'יום 12 — 🛍️ Vegas Outlets + Sphere', 'לאס וגאס / וגאס יודעת לשלב שופינג, שואו ואורות ברמה לא סבירה. / אאוטלטים, Sphere והסטריפ'), ('day-13', 'יום 13 — ✨ Vegas Extra Day + Strip', 'לאס וגאס / עוד יום בווגאס, כי כנראה יום אחד לא מספיק כדי להגזים כמו שצריך. / קניות, Strip , מלונות ותצוגות ערב'), ('day-14', 'יום 14 — 🏔️ Las Vegas → Mammoth Lakes', 'Mammoth Lakes / נפרדים מהניאון וחוזרים לאוויר הרים כמו אנשים אחראיים. בערך. / נסיעה צפונה אל Mammoth Lakes'), ('extra-death-valley', 'Road Trip Bonus — 🔥 Death Valley', 'בין וגאס ל־Mammoth / זה המעבר הכי חד במסלול: מווגאס המנצנצת אל מדבר עצום, שקט ובלתי נתפס. / עמק המוות מוסיף למסלול מרחבים אינסופיים, תצפיות דרמטיות ותחושת קצה של המערב'), ('extra-mono-lake', 'Road Trip Bonus — 🏜️ Mono Lake / Eastern Sierra', 'בדרך אל יוסמיטי / פה הרוד־טריפ משנה טון שוב: מדבר, הרים, אגם דרמטי ושמיים ענקיים — הכל בפריים אחד. / אזור Eastern Sierra מוסיף תחושת מרחב אדירה לפני הכניסה ליוסמיטי'), ('day-15', 'יום 15 — 🚗 Mammoth → Yosemite Valley', 'יוסמיטי / אחד מימי ה־וואו של הטיול. יוסמיטי נכנסת לפריים. / דרך נופית אל Yosemite Valley'), ('day-16', 'יום 16 — 🌲 Yosemite Highlights + Mariposa Grove', 'יוסמיטי / Mariposa / יום של מפלים, גרניט ועצי סקויה שגורמים לך להרגיש בגודל של סיכה. / יוסמיטי קלאסי + Mariposa Grove'), ('day-17', 'יום 17 — ✈️ Yosemite → SFO → Orlando', 'מעבר לאורלנדו / סוגרים פרק מערב ופותחים פרק פארקים. המזוודות עוד לא יודעות מה מחכה להן. / נסיעה לשדה וטיסה לאורלנדו'), ('day-18', 'יום 18 — 🛍️ התאוששות + Disney Springs', 'אורלנדו / יום רך לפני שמתחילים לצרוח בפארקים. / התאוששות, Disney Springs ואווירה קלילה'), ('day-19', 'יום 19 — 🎢 Islands of Adventure', 'Universal Orlando / כאן מתחיל פרק האדרנלין הרשמי. אקספרס ביד, חיוך בפנים. / צ׳ק־אין Premier ו־ Islands of Adventure'), ('day-20', 'יום 20 — 🎬 Universal Studios Florida + CityWalk', 'Universal Studios / יום Universal קלאסי: מתקנים, אווירה ו־ CityWalk לסיום. / Universal Studios Florida וערב CityWalk'), ('day-21', 'יום 21 — ⛳ מנוחה + Hollywood Drive-In Mini Golf', 'אורלנדו / יום רגוע יותר, כי גם למשפחה עם אנרגיה צריך טעינה. / מנוחה, בריכה/קניות קלות ומיני גולף'), ('day-22', 'יום 22 — 🌌 Epic Universe + Express', 'Epic Universe / אחד הימים הכי מסקרנים בטיול — פארק חדש, גדול ומלא הייפ. / Epic Universe עם Express'), ('day-23', 'יום 23 — 🧗 פארק חבלים + בריכה / I‑Drive', 'אורלנדו / קצת אקשן, קצת בריכה, קצת אורלנדו בלי לחץ. / פארק חבלים, בריכה או International Drive'), ('day-24', 'יום 24 — 🌊 Volcano Bay + Express', 'Volcano Bay / מים, מגלשות ויום רענון שנראה כמו פרסומת לחופשה. / Volcano Bay עם Express'), ('day-25', 'יום 25 — 🎃 רגוע + Halloween Horror Nights', 'Universal HHN / היום מתחיל רגוע ונגמר בפנים לבנות. / מנוחה ביום ו־HHN בערב'), ('day-26', 'יום 26 — 🚗 Orlando → Sawgrass Mills → Miami', 'Sawgrass / Miami / מחליפים פארקים בשופינג, ואז ממשיכים ל מיאמי . / Sawgrass Mills והמשך ל מיאמי'), ('day-27', 'יום 27 — 🌴 Miami Chill Before Cruise', 'מיאמי / מיאמי מורידה הילוך בסטייל לפני הקרוז. / חוף, אווירה, התארגנות ונשימה עמוקה'), ('day-28', 'יום 28 — 🚢 החזרת רכב ועלייה לקרוז', 'נמל מיאמי / הרכב חוזר, האונייה מתחילה. החופשה עוברת למצב צף. / עלייה לקרוז ותחילת פרק הים'), ('day-29', 'יום 29 — 🌊 קרוז – יום ים 1', 'בלב הים / יום ים ראשון: בריכה, אוכל, סיפון ונוף בלי סוף. / יום חופשי על האונייה'), ('day-30', 'יום 30 — 🌊 קרוז – יום ים 2', 'בלב הים / עוד יום ים, כי למה לעצור משהו שעובד? / מתקני האונייה, אוכל ומנוחה'), ('day-31', 'יום 31 — 🏝️ St. Maarten', 'St. Maarten / הקריביים נכנסים לתמונה בצבעים שלא נראים חוקיים. / יום עגינה ב־ St. Maarten'), ('day-32', 'יום 32 — 🐬 St. Thomas + Dolphin Splash', 'St. Thomas / יום שיכול להפוך לזיכרון משפחתי חזק במיוחד. / St. Thomas ואטרקציית דולפינים'), ('day-33', 'יום 33 — 🌅 קרוז – יום ים 3', 'בלב הים / עוד קצת ים כדי לא להיגמל מהר מדי. / יום ים, סיפון, אוכל ונוף'), ('day-34', 'יום 34 — 🏖️ Perfect Day at CocoCay', 'CocoCay / שם מחייב — ובמקרה הזה הוא לגמרי מתכוון לזה. / CocoCay , חוף, מים טורקיז ופארק מים'), ('day-35', 'יום 35 — 🏨 ירידה מהקרוז + לילה ליד השדה', 'מיאמי / החופשה מתחילה להתקפל, אבל לא שוברים בבת אחת. / ירידה מהקרוז, התארגנות ולילה ליד השדה'), ('day-36', 'יום 36 — 🛫 טיסה חזרה לישראל', 'הביתה / היום הפחות אהוב, אבל עם אלבום מטורף בזיכרון. / עלייה לטיסה חזרה לישראל'), ('budget', '💰 תקציב — המספרים, בלי דרמה', 'תמונת מצב פיננסית / זה לא טיול קטן. זה פרויקט משפחתי עם נוף, פארקים, ים, שופינג וקרוז. / עלות כללית מוערכת: $81,297 ≈ ₪243,890'), ('summary', '🥂 סיום — אז לאן אתם באמת הולכים?', 'טיול חלום משפחתי / אם מי שרואה את זה אומר “וואו” — הוא פשוט צודק. / מערב ארה״ב, אורלנדו, מיאמי וקרוז — בטיול אחד')]
def render_usa_image_upload_cards():
cards = []
for image_key, title, desc in USA_IMAGE_UI_ITEMS:
safe_key = html_lib.escape(image_key, quote=True)
safe_title = html_lib.escape(title, quote=True)
safe_desc = html_lib.escape(desc, quote=True)
search_text = html_lib.escape((image_key + " " + title + " " + desc).lower(), quote=True)
cards.append("""
<form class="image-upload-card" data-search="%s" action="/upload_usa_image" method="post" enctype="multipart/form-data">
<input type="hidden" name="image_key" value="%s">
<input class="image-file-input" id="image-file-%s" type="file" name="file" accept=".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*" onchange="if(this.files && this.files.length){this.closest('form').querySelector('.upload-status').textContent='מעלה תמונה...';this.closest('form').submit();}">
<label for="image-file-%s" class="image-upload-label">
<span class="image-key">%s</span>
<span class="image-title">%s</span>
<span class="image-desc">%s</span>
<span class="upload-status">לחץ כאן ובחר תמונה מהטלפון</span>
</label>
</form>
""" % (search_text, safe_key, safe_key, safe_key, safe_key, safe_title, safe_desc))
return "\n".join(cards)
@app.route("/admin")
@app.route("/admin/")
def admin():
return """
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TripServer Admin</title>
<style>
:root{--bg:#07111f;--panel:#101c31;--panel2:#14233c;--line:#253650;--text:#f8fafc;--muted:#a8b3c7;--brand:#3b82f6;--brand2:#60a5fa;--good:#16a34a;--warn:#f59e0b;--bad:#dc2626;--glass:rgba(16,28,49,.74)}
*{box-sizing:border-box}body{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 85% -10%,rgba(59,130,246,.35),transparent 28%),radial-gradient(circle at 10% 10%,rgba(20,184,166,.18),transparent 24%),linear-gradient(180deg,#07111f,#0f172a 52%,#07111f);color:var(--text);min-height:100vh}a{color:inherit}.wrap{width:min(1180px,94%);margin:auto;padding:22px 0 48px}.app-shell{min-height:calc(100vh - 44px)}
.hero{display:grid;grid-template-columns:1fr auto;gap:16px;align-items:center;background:linear-gradient(135deg,rgba(30,64,175,.92),rgba(15,23,42,.88));border:1px solid rgba(147,197,253,.25);border-radius:30px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.30);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;inset:auto -60px -90px auto;width:260px;height:260px;border-radius:50%;background:rgba(96,165,250,.16)}.hero h1{margin:0 0 8px;font-size:clamp(28px,5vw,44px);letter-spacing:-1px}.hero p{margin:0;color:#dbeafe;line-height:1.65;max-width:760px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn-link,.back-home{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;white-space:nowrap}.btn-link:hover,.back-home:hover{border-color:var(--brand2);background:rgba(59,130,246,.22)}
.status-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:16px 0}.status-card{background:var(--glass);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 16px 40px rgba(0,0,0,.18)}.status-card b{display:block;font-size:16px;margin-bottom:5px}.status-card span{color:var(--muted);font-size:13px;line-height:1.45}.dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 16px #22c55e;margin-left:7px}.main-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:18px}.main-card{margin-top:0;border:1px solid var(--line);background:linear-gradient(180deg,rgba(30,41,59,.94),rgba(15,23,42,.98));border-radius:26px;padding:20px;text-align:right;cursor:pointer;color:white;box-shadow:0 20px 55px rgba(0,0,0,.24);min-height:160px;transition:.18s transform,.18s border-color,.18s background;position:relative;overflow:hidden}.main-card:after{content:"›";position:absolute;left:18px;bottom:10px;font-size:42px;color:rgba(191,219,254,.35);transform:rotate(180deg)}.main-card:hover{transform:translateY(-4px);border-color:var(--brand2);background:linear-gradient(180deg,rgba(37,99,235,.28),rgba(15,23,42,.98))}.main-card .icon{font-size:34px;margin-bottom:10px}.main-card h2{margin:0 0 8px;font-size:24px}.main-card p{margin:0;color:var(--muted);line-height:1.55}.main-card.danger-card{border-color:rgba(248,113,113,.45);background:linear-gradient(180deg,rgba(127,29,29,.65),rgba(15,23,42,.98))}.main-card.admin-card{border-color:rgba(251,191,36,.45)}
.screen{display:none;margin-top:18px}.screen.active{display:block}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 14px}.section-title h2{margin:0 0 6px;font-size:32px;letter-spacing:-.5px}.section-title p{margin:0;color:var(--muted);line-height:1.55}.section-actions{display:flex;gap:10px;flex-wrap:wrap}.back-btn{border:1px solid var(--line);background:#1e293b;color:white;border-radius:999px;padding:10px 14px;cursor:pointer;font-weight:900}.back-btn:hover{background:#334155}.sub-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.sub-card{background:linear-gradient(180deg,var(--panel),#0f172a);border:1px solid var(--line);border-radius:22px;padding:18px;cursor:pointer;min-height:126px;transition:.18s transform,.18s border-color}.sub-card:hover{transform:translateY(-3px);border-color:var(--brand2)}.sub-card h3{margin:0 0 8px;font-size:22px}.sub-card p{margin:0;color:var(--muted);line-height:1.55}.tool-panel{display:none;background:rgba(16,28,49,.92);border:1px solid var(--line);border-radius:26px;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.22);margin-top:16px}.tool-panel.active{display:block}.tool-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.tool-head h3{font-size:26px;margin:0 0 6px}.tool-head p{margin:0;color:var(--muted);line-height:1.55}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.upload-box{background:#0b1220;border:1px solid #24344d;border-radius:18px;padding:16px}.upload-box h4{margin:0 0 8px;font-size:18px}.upload-box p{color:var(--muted);line-height:1.55;margin:8px 0 0;font-size:14px}.note{color:var(--muted);font-size:14px;line-height:1.65}.warning{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.good{background:rgba(22,101,52,.30);border:1px solid rgba(74,222,128,.35);color:#bbf7d0;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.mini-note{background:#0f172a;border:1px solid var(--line);border-radius:14px;padding:12px;color:#cbd5e1;line-height:1.6;font-size:14px}input[type=file],select,input[type=text]{width:100%;padding:14px;background:#07111f;color:white;border-radius:14px;border:1px solid #334155;font-size:15px}input[type=file]::file-selector-button{border:0;border-radius:10px;background:#1d4ed8;color:white;padding:10px 12px;margin-left:10px;font-weight:900}button{border:0;background:#2563eb;color:white;padding:13px 17px;border-radius:14px;font-size:15px;font-weight:950;cursor:pointer;margin-top:12px;box-shadow:0 12px 30px rgba(37,99,235,.20)}button:hover{filter:brightness(1.08)}button.orange{background:#ea580c}button.danger{background:#dc2626}button.cancel{background:#475569}.preview-links{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.preview-links a{display:inline-flex;text-decoration:none;background:#172033;border:1px solid #334155;border-radius:12px;padding:10px 12px;font-weight:900;color:#dbeafe}.preview-links a:hover{border-color:var(--brand2)}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.danger-zone{background:linear-gradient(135deg,rgba(69,26,26,.72),rgba(16,28,49,.94))!important;border-color:rgba(248,113,113,.35)!important}.split-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.filter-bar{position:sticky;top:0;z-index:8;background:rgba(7,17,31,.92);backdrop-filter:blur(12px);border:1px solid var(--line);border-radius:18px;padding:12px;margin:10px 0 14px;display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}.jump-days{display:flex;gap:6px;flex-wrap:wrap;max-width:500px;justify-content:flex-end}.jump-days button{margin:0;padding:7px 9px;border-radius:10px;background:#1e293b;box-shadow:none;font-size:12px}.image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.image-upload-card{margin:0}.image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}.image-upload-label{display:block;height:100%;cursor:pointer;background:#0b1220;border:1px solid #283950;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)}.image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a}.image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px}.image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25}.image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px}.upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px}.breadcrumb{display:flex;gap:8px;align-items:center;color:#bfdbfe;font-size:14px;margin:16px 0 0}.breadcrumb button{margin:0;background:transparent;border:1px solid #334155;box-shadow:none;padding:7px 10px}.mobile-footer{display:none;position:fixed;bottom:12px;right:12px;left:12px;z-index:20}.mobile-footer button{width:100%;box-shadow:0 12px 40px rgba(0,0,0,.35)}
@media(max-width:860px){.hero{grid-template-columns:1fr;padding:18px}.hero h1{font-size:30px}.hero p{font-size:14px;line-height:1.55}.hero-actions{justify-content:flex-start}.status-strip{display:none}.main-grid{grid-template-columns:repeat(2,1fr);gap:10px}.main-card{min-height:128px;padding:14px;border-radius:22px}.main-card .icon{font-size:27px;margin-bottom:7px}.main-card h2{font-size:20px;margin-bottom:6px}.main-card p{font-size:12.5px;line-height:1.38}.sub-grid,.form-grid,.split-grid{grid-template-columns:1fr}.section-head,.tool-head{display:block}.section-actions{margin-top:12px}.filter-bar{grid-template-columns:1fr}.jump-days{justify-content:flex-start;max-width:none}.wrap{width:min(100%,94%);padding:14px 0 72px}.mobile-footer{display:none}.image-upload-grid{grid-template-columns:1fr}.image-desc{min-height:auto}}@media(max-width:340px){.main-grid{grid-template-columns:1fr}.main-card{min-height:120px}}
</style>
</head>
<body>
<div class="wrap app-shell">
<div class="hero">
<div>
<h1><span class="dot"></span>TripServer Admin</h1>
<p>מסך ניהול לפי היגיון עבודה: בוחרים תחום, נכנסים לתת־קטגוריה, ואז מעלים רק את מה שצריך. בלי גלילה אינסופית ובלי לחפש ידיים ורגליים.</p>
</div>
<div class="hero-actions">
<a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a>
<a class="btn-link" href="/admin/health">🩺 בדיקת מערכת</a>
<a class="btn-link" href="/admin/self-test">🧪 בדיקה עצמית מלאה</a>
<a class="btn-link" href="/admin/watchdog">🛡️ Watchdog</a>
<a class="btn-link" href="/admin/logs">📜 לוגים</a>
<a class="btn-link" href="/">🏠 ראשי</a>
</div>
</div>
<div id="screen-home" class="screen active">
<div class="status-strip">
<div class="status-card"><b>📁 העלאה בטוחה</b><span>שמירה זמנית, גיבוי, החלפה אטומית, גודל ו־SHA אחרי שמירה.</span></div>
<div class="status-card"><b>🔁 Restart לאדמין</b><span>עדכון admin מפעיל helper חיצוני, לוג ובדיקת פורט.</span></div>
<div class="status-card"><b>🧭 מבנה היררכי</b><span>מסך ראשי → תחום → תת־תחום → פעולה.</span></div>
<div class="status-card"><b>🖥️ מחשב</b><span>כיבוי/ריסטארט עם טיימר, אישור כתוב וביטול.</span></div>
<div class="status-card"><b>🛡️ Watchdog</b><span>בדיקה חיצונית כל דקה והפעלה אוטומטית אם האדמין נפל.</span></div>
<div class="status-card"><b>📜 לוגים</b><span>צפייה בלוגי restart ו־Watchdog מתוך הפאנל.</span></div>
</div>
<div class="main-grid">
<button class="main-card" onclick="showScreen('japan')"><div class="icon">🇯🇵</div><h2>יפן</h2><p>קובץ טיול, מצגות, תמונות ומוזיקה של יפן.</p></button>
<button class="main-card" onclick="showScreen('usa')"><div class="icon">🇺🇸</div><h2>ארצות הברית</h2><p>קובץ טיול, מצגת, TV, תמונות שקפים ומוזיקה.</p></button>
<button class="main-card" onclick="showScreen('budgets')"><div class="icon">💰</div><h2>תקציבים</h2><p>אפליקציית תקציב חופשה ואפליקציית תקציב חודשי/משפחתי.</p></button>
<button class="main-card" onclick="showScreen('tripform')"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת טיול חדש, שמירה כ־TripForm/index.html.</p></button>
<button class="main-card danger-card" onclick="showScreen('power')"><div class="icon">🖥️</div><h2>פעולות מחשב</h2><p>כיבוי, ריסטארט, טיימר וביטול פעולה מתוזמנת.</p></button>
<button class="main-card" onclick="showScreen('watchdog')"><div class="icon">🛡️</div><h2>Watchdog</h2><p>שמירת הפאנל חי: בדיקה כל דקה, שחרור פורט תקוע והפעלה מחדש אם נפל.</p></button>
<a class="main-card" href="/admin/camera" style="text-decoration:none"><div class="icon">📷</div><h2>מצלמת חדר</h2><p>הפעלת מצלמת החדר, אודיו והקלטות — רק בלחיצה ידנית.</p></a>
<a class="main-card" href="/admin/logs" style="text-decoration:none"><div class="icon">📜</div><h2>לוגים</h2><p>צפייה בלוגי אתחול פאנל ו־Watchdog מתוך הדפדפן.</p></a>
<button class="main-card admin-card" onclick="showScreen('admin-tools')"><div class="icon">⚙️</div><h2>ניהול האדמין</h2><p>העלאת admin.py/admin_server.py, restart ושחזור גיבוי.</p></button>
</div>
</div>
<div id="screen-japan" class="screen">
<div class="section-head"><div class="section-title"><h2>🇯🇵 יפן</h2><p>בחר קודם מה אתה מעדכן: קובץ הטיול, המצגות או המדיה.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/JAPAN/" target="_blank">פתח יפן</a></div></div>
<div class="sub-grid">
<div class="sub-card" onclick="showTool('japan','japan-trip')"><h3>📄 קובץ טיול יפן</h3><p>העלאת index.html או חבילת ZIP מלאה.</p></div>
<div class="sub-card" onclick="showTool('japan','japan-presentations')"><h3>🎬 מצגות יפן</h3><p>פתיח טיסה ומצגת יפן.</p></div>
<div class="sub-card" onclick="showTool('japan','japan-media')"><h3>🖼️🎵 מדיה יפן</h3><p>ZIP תמונות ושירי MP3.</p></div>
<div class="sub-card" onclick="window.open('/JAPAN/japan-experience.html','_blank')"><h3>🔎 בדיקה מהירה</h3><p>פתיחת מצגת יפן בחלון חדש.</p></div>
</div>
<div id="tool-japan-trip" class="tool-panel">
<div class="tool-head"><div><h3>📄 קובץ טיול יפן</h3><p>קובץ יחיד נשמר כ־JAPAN/index.html. ZIP מלא מעדכן גם מצגות ומדיה אם הן קיימות בחבילה.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>קובץ HTML ראשי</h4><form action="/upload/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול יפן</button></form><p>יישמר כ־C:\\TripServer\\JAPAN\\index.html</p></div><div class="upload-box"><h4>ZIP מלא יפן</h4><form action="/upload_trip_package/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של יפן</button></form><p>מתאים לחבילה עם index.html, japan-experience.html, japan-flight-intro.html ותיקיית media.</p></div></div>
</div>
<div id="tool-japan-presentations" class="tool-panel">
<div class="tool-head"><div><h3>🎬 מצגות יפן</h3><p>שמות היעד קבועים כדי שהקישורים מתוך קובץ הטיול לא יישברו.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>✈️ פתיח טיסה</h4><form action="/upload_presentation/flight_intro" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה פתיח טיסה</button></form><p>יישמר כ־japan-flight-intro.html</p></div><div class="upload-box"><h4>🎌 מצגת יפן</h4><form action="/upload_presentation/japan_experience" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת יפן</button></form><p>יישמר כ־japan-experience.html</p></div></div>
<div class="preview-links"><a href="/JAPAN/japan-flight-intro.html" target="_blank">בדיקת פתיח</a><a href="/JAPAN/japan-experience.html" target="_blank">בדיקת מצגת</a></div>
</div>
<div id="tool-japan-media" class="tool-panel">
<div class="tool-head"><div><h3>🖼️🎵 מדיה יפן</h3><p>תמונות ושירים נשמרים לנתיבים שהמצגת מחפשת בפועל. ספירה מדויקת של תמונות בשימוש נמצאת במסך סטטוס קבצים.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מצגת יפן</h4><form action="/upload_japan_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות יפן</button></form><p>הפאנל מסנן קובצי תמונה נתמכים. הספירה החשובה היא לא כמה קבצים יש בתיקייה, אלא כמה src מופיעים בפועל בתוך japan-experience.html. בחבילות/ZIP מלאים הוא לא משנה שמות קבצים כדי לא לשבור קישורים פנימיים במצגת.</p></div><div class="upload-box"><h4>🎵 שיר מצגת יפן</h4><form action="/upload_japan_music/japan_experience_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה japan-theme.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי בכל נתיבי ה־fallback של המצגת. המרה מלאה דורשת FFmpeg.</p></div><div class="upload-box"><h4>✈️ מוזיקת פתיח טיסה</h4><form action="/upload_japan_music/japan_flight_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה flight-intro.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי לנתיב מוזיקת פתיח הטיסה. המרה מלאה דורשת FFmpeg.</p></div></div>
</div>
</div>
<div id="screen-usa" class="screen">
<div class="section-head"><div class="section-title"><h2>🇺🇸 ארצות הברית</h2><p>אזור ארה״ב מחולק לקובץ טיול, מצגות, תמונות שקפים ומוזיקה/TV.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/USA/" target="_blank">פתח ארה״ב</a></div></div>
<div class="sub-grid">
<div class="sub-card" onclick="showTool('usa','usa-trip')"><h3>📄 קובץ טיול</h3><p>HTML ראשי, ZIP מלא וגרסת טלוויזיה.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-presentation')"><h3>🎬 מצגת</h3><p>מצגת עם סכומים וגרסת TV.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-images')"><h3>🖼️ תמונות שקפים</h3><p>ZIP מלא או העלאה לפי שקף/יום.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-music')"><h3>🎵 מוזיקה</h3><p>שיר חלק 1 ושיר חלק 2 של המצגת.</p></div>
</div>
<div id="tool-usa-trip" class="tool-panel">
<div class="tool-head"><div><h3>📄 קובץ טיול ארה״ב</h3><p>קובץ הטיול הראשי נשמר כ־USA/index.html. גרסת TV נשמרת בנפרד.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>HTML ראשי</h4><form action="/upload/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול ארה״ב</button></form><p>יישמר כ־C:\\TripServer\\USA\\index.html</p></div><div class="upload-box"><h4>ZIP מלא ארה״ב</h4><form action="/upload_trip_package/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של ארה״ב</button></form><p>מתאים לחבילת Netlify/שרת עם index.html, מצגת, media ותמונות.</p></div><div class="upload-box"><h4>גרסת טלוויזיה</h4><form action="/upload_usa_extra_html/usa_trip_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ ארה״ב TV</button></form><p>יישמר כ־usa2027-tv.html.</p></div></div>
</div>
<div id="tool-usa-presentation" class="tool-panel">
<div class="tool-head"><div><h3>🎬 מצגת ארה״ב</h3><p>המצגת הרגילה והמצגת המותאמת למסך טלוויזיה נשמרות בשמות קבועים.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>מצגת ארה״ב עם סכומים</h4><form action="/upload_usa_presentation/usa_presentation" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת ארה״ב</button></form><p>יישמר כ־usa2027-presentation.html</p></div><div class="upload-box"><h4>מצגת ארה״ב TV</h4><form action="/upload_usa_presentation/usa_presentation_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת TV</button></form><p>יישמר כ־usa2027-presentation-tv.html</p></div></div>
<div class="preview-links"><a href="/USA/usa2027-presentation.html" target="_blank">בדיקת מצגת</a><a href="/USA/usa2027-presentation-tv.html" target="_blank">בדיקת מצגת TV</a></div>
</div>
<div id="tool-usa-images" class="tool-panel">
<div class="tool-head"><div><h3>🖼️ תמונות מצגת ארה״ב</h3><p>התיאורים כאן מסונכרנים לסדר השקפים בפועל: Disney Springs, ימי ים בקרוז, St. Maarten, St. Thomas ו־CocoCay.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="good">העלאה בודדת ו־ZIP שומרים תמיד JPG אמיתי בשם השקף, עם דחיסה חכמה והקטנה עדינה עד 1920px בצלע הארוכה. העלאת ZIP מקבלת שמות כמו day-01.jpg עד day-36.jpg וגם intro/budget/summary/extra.</div>
<div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מלא</h4><form action="/upload_usa_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות ארה״ב</button></form><p>שמות נתמכים: intro, roadtrip-map, day-01 עד day-36, budget, summary ותמונות bonus.</p></div><div class="upload-box"><h4>בדיקת מצגת</h4><p>אחרי העלאת תמונות פתח את המצגת כדי לוודא שאין cache ישן.</p><div class="preview-links"><a href="/USA/usa2027-presentation.html?from=admin" target="_blank">פתח מצגת</a><a href="/admin/status" target="_blank">סטטוס תמונות</a></div></div></div>
<div class="filter-bar"><input id="imageSearch" type="text" placeholder="חיפוש שקף: יום 18, Disney Springs, קרוז, CocoCay..." oninput="filterImages()"><div class="jump-days"><button onclick="jumpImage('intro')">פתיחה</button><button onclick="jumpImage('day-01')">יום 1</button><button onclick="jumpImage('day-18')">יום 18</button><button onclick="jumpImage('day-28')">קרוז</button><button onclick="jumpImage('budget')">תקציב</button><button onclick="jumpImage('summary')">סיום</button></div></div>
<div class="image-upload-grid" id="usaImageGrid">
""" + render_usa_image_upload_cards() + """
</div>
</div>
<div id="tool-usa-music" class="tool-panel">
<div class="tool-head"><div><h3>🎵 מוזיקת מצגת ארה״ב</h3><p>שני קבצי MP3 אמיתיים בלבד, כדי שהדפדפן ינגן בלי קובץ מזויף.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>שיר חלק 1 — Road Trip</h4><form action="/upload_usa_music/usa_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 1</button></form><p>usa-roadtrip-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div><div class="upload-box"><h4>שיר חלק 2 — Orlando / Miami / Cruise</h4><form action="/upload_usa_music/usa_music_part2" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 2</button></form><p>usa-part2-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div></div>
</div>
</div>
<div id="screen-budgets" class="screen">
<div class="section-head"><div class="section-title"><h2>💰 תקציבים</h2><p>שתי אפליקציות נפרדות: תקציב חופשה ותקציב חודשי/משפחתי.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div>
<div class="form-grid"><div class="upload-box"><h4>💼 אפליקציית ניהול תקציב חופשה</h4><form action="/upload/Moneyapp" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה אפליקציית תקציב חופשה</button></form><p>יישמר כ־Moneyapp/index.html.</p><div class="preview-links"><a href="/Moneyapp/" target="_blank">פתח תקציב חופשה</a></div></div><div class="upload-box"><h4>🏠 אפליקציית תקציב חודשי / משפחתי</h4><form action="/upload/MoneyHome" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה תקציב חודשי</button></form><p>יישמר כ־MoneyHome/index.html.</p><div class="preview-links"><a href="/MoneyHome/" target="_blank">פתח תקציב חודשי</a></div></div></div>
</div>
<div id="screen-tripform" class="screen">
<div class="section-head"><div class="section-title"><h2>🧾 טופס פתיחת טיול</h2><p>טופס הקליטה ליצירת טיול חדש.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/TripForm/" target="_blank">פתח טופס</a></div></div>
<div class="upload-box"><h4>העלאת טופס HTML</h4><form action="/upload/TripForm" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה טופס פתיחת טיול</button></form><p>יישמר כ־TripForm/index.html.</p></div>
</div>
<div id="screen-power" class="screen">
<div class="section-head"><div class="section-title"><h2>🖥️ פעולות מחשב</h2><p>פעולות Windows אמיתיות. הן מוגנות באישור כתוב, טיימר וחסימת גישה מחוץ לרשת מקומית.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div>
<div class="tool-panel active danger-zone"><div class="warning"><strong>זהירות:</strong> כיבוי או ריסטארט מנתקים את השרת עד שהמחשב עולה מחדש. השתמש בטיימר ולא ב־0 שניות.</div><div class="split-grid"><div class="upload-box"><h4>⏻ כיבוי מחשב</h4><form action="/power/shutdown" method="post" onsubmit="return confirm('לקבוע כיבוי למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני כיבוי</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: כיבוי</label><input type="text" name="confirm_text" autocomplete="off" placeholder="כיבוי"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יכבה את המחשב</label><button class="danger">קבע כיבוי מחשב</button></form></div><div class="upload-box"><h4>🔄 ריסטארט למחשב</h4><form action="/power/reboot" method="post" onsubmit="return confirm('לקבוע ריסטארט למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני ריסטארט</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: הפעלה מחדש</label><input type="text" name="confirm_text" autocomplete="off" placeholder="הפעלה מחדש"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יפעיל את המחשב מחדש</label><button class="orange">קבע ריסטארט למחשב</button></form></div></div><form action="/power/cancel" method="post" onsubmit="return confirm('לבטל כיבוי/ריסטארט מתוזמן של Windows?')"><button class="cancel">בטל כיבוי/ריסטארט מתוזמן</button></form><p class="note">פעולות נרשמות ללוג C:\\TripServer\\admin_restart.log.</p></div>
</div>
<div id="screen-watchdog" class="screen">
<div class="section-head"><div class="section-title"><h2>🛡️ Watchdog</h2><p>שכבת ביטחון חיצונית שמחזירה את פאנל האדמין לאוויר אם הוא נפל.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/watchdog">מסך Watchdog מלא</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית</a></div></div>
<div class="tool-panel active safe-zone"><div class="good"><strong>מטרה:</strong> לא ללכת פיזית למחשב אם השרת נפל. Windows יריץ סקריפט קטן כל דקה, ואם /health.json לא עונה — הוא ישחרר פורט תקוע במידת הצורך ויפעיל מחדש את admin_server.py.</div><div class="form-grid"><div class="upload-box"><h4>התקנה / עדכון</h4><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין או לעדכן את משימת ה־Watchdog?')"><button>התקן / עדכן Watchdog</button></form><p>נכתב אל C:\\TripServer\\tripserver_admin_watchdog.py ומותקן כ־Task Scheduler בשם TripServerAdminWatchdog.</p></div><div class="upload-box"><h4>בדיקה והרצה</h4><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ Watchdog פעם אחת עכשיו?')"><button class="orange">הרץ Watchdog עכשיו</button></form><div class="preview-links"><a href="/admin/watchdog">פתח סטטוס Watchdog</a></div><p>הרצה חד־פעמית לא מחליפה קבצים. אם השרת חי היא רק תצא תקין.</p></div><div class="upload-box"><h4>הסרה</h4><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog המתוזמנת?')"><button class="danger">הסר Watchdog</button></form><p>מסיר רק את המשימה המתוזמנת. קובץ הסקריפט והלוג נשארים לצורך בדיקה.</p></div></div></div>
</div>
<div id="screen-admin-tools" class="screen">
<div class="section-head"><div class="section-title"><h2>⚙️ ניהול אפליקציית האדמין</h2><p>כאן מעדכנים את הקובץ שמפעיל את הפאנל עצמו.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/status">סטטוס</a><a class="btn-link" href="/admin/health">בדיקת מערכת</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית מלאה</a><a class="btn-link" href="/admin/watchdog">Watchdog</a><a class="btn-link" href="/admin/logs">לוגים</a></div></div>
<div class="tool-panel active"><div class="warning"><strong>פעולה רגישה:</strong> עדכון admin_server.py מחליף את המנוע שמריץ את הפאנל. לכן יש בדיקת קומפילציה, גיבוי, החלפה אטומית ו־restart אוטומטי.</div><div class="good">הזרימה: העלאה → בדיקת Python → בדיקת סימני TripServer → גיבוי → החלפה → helper חיצוני → restart → לוג.</div><div class="form-grid"><div class="upload-box"><h4>העלאת admin חדש</h4><form action="/upload_admin_server" method="post" enctype="multipart/form-data" onsubmit="return confirm('להעלות admin_server.py חדש ולהחליף את קובץ הפאנל הפעיל אחרי בדיקה וגיבוי?')"><input type="file" name="file" accept=".py,text/x-python,text/plain"><button class="orange">העלה admin_server.py חדש</button></form><p>אם אצלך הקובץ נקרא admin.py, שמור את הקובץ החדש בשם הזה בהחלפה הידנית הראשונה.</p></div><div class="upload-box"><h4>פעולות תחזוקה</h4><form action="/restart_admin_server" method="post" onsubmit="return confirm('זו בדיקת אתחול מתקדמת. להפעיל מחדש את הפאנל עכשיו?')"><button class="orange">בדיקת אתחול פאנל — מתקדם</button></form><form action="/restore_admin_server_backup" method="post" onsubmit="return confirm('לשחזר את הגיבוי האחרון של admin_server.py?')"><button class="danger">שחזר גיבוי אחרון</button></form><div class="preview-links"><a href="/admin/self-test">🧪 הרץ בדיקה עצמית מלאה</a><a href="/admin/logs">📜 צפה בלוגים</a></div><p><b>הערה:</b> לא חובה אחרי התקנת Watchdog. בדיקת אתחול מיועדת רק לבדיקה יזומה או אחרי עדכון/שחזור אדמין.<br>גיבויים: C:\\TripServer\\backups\\admin_server</p></div></div></div>
</div>
<div class="mobile-footer"><button onclick="showScreen('home')">🏠 חזרה למסך הראשי</button></div>
</div>
<script>
function showScreen(name){
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
const el=document.getElementById('screen-'+name);
if(el){el.classList.add('active'); location.hash=name==='home'?'':name; window.scrollTo({top:0,behavior:'smooth'});}
}
function hideTools(group){
document.querySelectorAll('#screen-'+group+' .tool-panel').forEach(t=>t.classList.remove('active'));
window.scrollTo({top:document.getElementById('screen-'+group).offsetTop-10,behavior:'smooth'});
}
function showTool(group, tool){
hideTools(group);
const el=document.getElementById('tool-'+tool);
if(el){el.classList.add('active'); setTimeout(()=>el.scrollIntoView({behavior:'smooth',block:'start'}),70); location.hash=tool;}
}
function filterImages(){
const q=(document.getElementById('imageSearch')?.value||'').toLowerCase().trim();
document.querySelectorAll('#usaImageGrid .image-upload-card').forEach(card=>{card.style.display=!q||card.dataset.search.includes(q)?'block':'none';});
}
function jumpImage(key){
const card=document.querySelector('#usaImageGrid input[value="'+key+'"]').closest('.image-upload-card');
if(card){card.scrollIntoView({behavior:'smooth',block:'center'}); card.querySelector('.image-upload-label').style.borderColor='#fbbf24'; setTimeout(()=>card.querySelector('.image-upload-label').style.borderColor='',1600);}
}
window.addEventListener('load',()=>{const h=(location.hash||'').replace('#',''); if(['japan','usa','budgets','tripform','power','watchdog','admin-tools'].includes(h)) showScreen(h); if(h.startsWith('usa-')){showScreen('usa'); setTimeout(()=>showTool('usa',h),80);} if(h.startsWith('japan-')){showScreen('japan'); setTimeout(()=>showTool('japan',h),80);}});
</script>
</body>
</html>
"""
def add_self_test_result(results, category, name, ok, details="", severity="critical"):
if ok:
status = "pass"
elif severity == "warning":
status = "warn"
else:
status = "fail"
results.append({
"category": category,
"name": name,
"status": status,
"severity": severity,
"details": str(details or ""),
})
def route_rule_matches_action(rule, action):
# Support both exact rules and Flask dynamic rules such as /upload/<folder>.
if rule == action:
return True
rule_parts = [part for part in (rule or "").strip("/").split("/") if part]
action_parts = [part for part in (action or "").strip("/").split("/") if part]
if len(rule_parts) != len(action_parts):
return False
for rule_part, action_part in zip(rule_parts, action_parts):
if rule_part.startswith("<") and rule_part.endswith(">"):
if not action_part:
return False
continue
if rule_part != action_part:
return False
return True
def collect_admin_form_actions(html_text):
return sorted(set(re.findall(r"<form[^>]+action=[\"\']([^\"\']+)[\"\']", html_text or "")))
def collect_duplicate_ids(html_text):
ids = re.findall(r"\bid=[\"\']([^\"\']+)[\"\']", html_text or "")
seen, duplicates = set(), []
for item in ids:
if item in seen and item not in duplicates:
duplicates.append(item)
seen.add(item)
return duplicates
def check_temp_write_permission():
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
test_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"self_test_{os.getpid()}_{int(time.time())}.tmp")
try:
with open(test_path, "w", encoding="utf-8") as fh:
fh.write("TripServer self test")
with open(test_path, "r", encoding="utf-8") as fh:
data = fh.read()
return data == "TripServer self test", test_path
finally:
try:
if os.path.exists(test_path):
os.remove(test_path)
except Exception:
pass
def run_self_test_suite(include_media_conversion=False):
"""Non-destructive self-test for the admin panel.
It checks code validity, routes/forms, folders, media engine availability,
presentation image mappings, power guards and restart-helper generation.
It intentionally does not upload real files, restart the server, shut down Windows,
or modify active trip files.
"""
started = time.time()
results = []
# 1) Python/code validity.
try:
py_compile.compile(ADMIN_SERVER_PATH, doraise=True)
add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", True, ADMIN_SERVER_PATH)
except Exception as exc:
add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", False, repr(exc))
try:
proc = subprocess.run([sys.executable, "-Werror", "-m", "py_compile", ADMIN_SERVER_PATH], capture_output=True, text=True, timeout=40)
add_self_test_result(results, "קוד", "קומפילציה עם Werror", proc.returncode == 0, (proc.stderr or proc.stdout or "OK")[-1200:])
except Exception as exc:
add_self_test_result(results, "קוד", "קומפילציה עם Werror", False, repr(exc), severity="warning")
try:
valid, info = validate_admin_server_upload(ADMIN_SERVER_PATH)
add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", bool(valid), info)
except Exception as exc:
add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", False, repr(exc))
try:
helper_source = build_restart_helper_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
ok, info = validate_restart_helper_source(helper_source)
add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", bool(ok), info)
except Exception as exc:
add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", False, repr(exc))
try:
wd_code = build_watchdog_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
wd_ok, wd_info = validate_watchdog_source(wd_code)
add_self_test_result(results, "Watchdog", "קוד Watchdog חיצוני עובר קומפילציה", wd_ok, wd_info)
wd_status = watchdog_status(include_log=False)
add_self_test_result(results, "Watchdog", "סטטוס Watchdog ניתן לקריאה", bool(wd_status.get("source_ok")), f"script_exists={wd_status.get('script_exists')} task_installed={wd_status.get('task_installed')}")
if os.name == "nt":
add_self_test_result(results, "Watchdog", "Task Scheduler נתמך במחשב זה", bool(wd_status.get("task_supported")), wd_status.get("task_status", ""), severity="warning")
else:
add_self_test_result(results, "Watchdog", "Task Scheduler בסביבה הנוכחית", False, "לא Windows — אצלך בשרת Windows זה אמור להיות נתמך.", severity="warning")
except Exception as exc:
add_self_test_result(results, "Watchdog", "בדיקת Watchdog", False, repr(exc))
# 2) Paths and write access.
for key, folder_path in sorted(FOLDERS.items()):
add_self_test_result(results, "נתיבים", f"תיקיית {key} קיימת", os.path.isdir(folder_path), folder_path)
try:
ok, path = check_temp_write_permission()
add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", ok, path)
except Exception as exc:
add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", False, repr(exc))
for label, path in [
("תיקיית גיבויי אדמין", ADMIN_BACKUP_DIR),
("תיקיית גיבויי העלאות", UPLOAD_BACKUP_DIR),
]:
try:
os.makedirs(path, exist_ok=True)
add_self_test_result(results, "נתיבים", label, os.path.isdir(path), path)
except Exception as exc:
add_self_test_result(results, "נתיבים", label, False, repr(exc))
# 3) Flask routes and HTML forms.
try:
rules = sorted(str(rule.rule) for rule in app.url_map.iter_rules())
expected_routes = [
"/", "/admin", "/admin/status", "/admin/health", "/admin/self-test", "/admin/self-test.json", "/admin/watchdog", "/admin/logs", "/admin/logs.json", "/admin/camera", "/camera/stream", "/camera/snapshot.jpg", "/camera/stop", "/camera/status.json", "/camera/audio-stream", "/camera/audio-stop", "/camera/record/start", "/camera/record/stop", "/camera/recordings",
"/health", "/health.json", "/upload_admin_server", "/restart_admin_server", "/restore_admin_server_backup", "/watchdog/install", "/watchdog/uninstall", "/watchdog/run-once",
"/upload/<folder>", "/upload_presentation/<presentation_key>", "/upload_usa_presentation/<presentation_key>",
"/upload_usa_image", "/upload_usa_images_zip", "/upload_usa_extra_html/<extra_key>",
"/upload_japan_music/<music_key>", "/upload_japan_images_zip", "/upload_trip_package/<trip>",
"/upload_usa_music/<music_key>", "/power/<action>", "/power/cancel",
]
missing_routes = [route for route in expected_routes if route not in rules]
add_self_test_result(results, "ממשק", "כל routes המרכזיים קיימים", not missing_routes, ", ".join(missing_routes) if missing_routes else f"{len(rules)} routes")
except Exception as exc:
rules = []
add_self_test_result(results, "ממשק", "קריאת routes מהאפליקציה", False, repr(exc))
try:
admin_html = admin()
actions = collect_admin_form_actions(admin_html)
missing_actions = []
for action in actions:
if not any(route_rule_matches_action(rule, action) for rule in rules):
missing_actions.append(action)
add_self_test_result(results, "ממשק", "כל טפסי הפאנל מחוברים ל־route קיים", not missing_actions, ", ".join(missing_actions) if missing_actions else f"{len(actions)} טפסים")
dup_ids = collect_duplicate_ids(admin_html)
add_self_test_result(results, "ממשק", "אין IDs כפולים במסך האדמין", not dup_ids, ", ".join(dup_ids) if dup_ids else "OK")
add_self_test_result(results, "ממשק", "כפתור בדיקה עצמית קיים בפאנל", "/admin/self-test" in admin_html, "נבדק href=/admin/self-test")
add_self_test_result(results, "ממשק", "כפתור Watchdog קיים בפאנל", "/admin/watchdog" in admin_html and "screen-watchdog" in admin_html, "נבדק href=/admin/watchdog ומסך screen-watchdog")
add_self_test_result(results, "ממשק", "כפתור לוגים קיים בפאנל", "/admin/logs" in admin_html, "נבדק href=/admin/logs")
add_self_test_result(results, "ממשק", "כפתור מצלמת חדר קיים בפאנל", "/admin/camera" in admin_html and "מצלמת חדר" in admin_html, "נבדק href=/admin/camera")
add_self_test_result(results, "ממשק", "כפתור אתחול מתקדם לא מוצג כפעולת חובה", "בדיקת אתחול פאנל — מתקדם" in admin_html and "לא חובה אחרי התקנת Watchdog" in admin_html, "נבדק ניסוח UX")
except Exception as exc:
add_self_test_result(results, "ממשק", "ניתוח HTML של הפאנל", False, repr(exc))
try:
status_html = render_admin_status()
add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", "סטטוס קבצים פעילים" in status_html, "OK")
except Exception as exc:
add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", False, repr(exc))
try:
health_html = render_admin_health()
add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", "בדיקת מערכת" in health_html, "OK")
except Exception as exc:
add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", False, repr(exc))
try:
logs_html = render_admin_logs()
add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", "מרכז לוגים" in logs_html and "לוג Watchdog" in logs_html, "OK")
except Exception as exc:
add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", False, repr(exc))
try:
camera_html = render_admin_camera()
add_self_test_result(results, "מצלמה", "מסך מצלמת חדר נבנה ללא שגיאה", "מצלמת חדר" in camera_html and "/camera/stream" in camera_html, "OK")
cam_engine = camera_engine_status()
add_self_test_result(results, "מצלמה", "OpenCV זמין להפעלת מצלמה", bool(cam_engine.get("opencv")), "התקנה אוטומטית ברקע פעילה; בדוק /camera/setup" if not cam_engine.get("opencv") else f"OpenCV {cam_engine.get('opencv_version','')}", severity="warning")
setup_html = render_camera_setup_page()
add_self_test_result(results, "מצלמה", "מסך סטטוס רכיבי מצלמה נבנה תקין", "התקנה אוטומטית" in setup_html and "pip install" in setup_html, "OK")
add_self_test_result(results, "מצלמה", "מצלמה מוגנת לרשת פרטית/Tailscale בלבד", is_private_or_loopback_ip("100.64.1.2") and not is_private_or_loopback_ip("8.8.8.8"), "Tailscale מותר, ציבורי חסום")
except Exception as exc:
add_self_test_result(results, "מצלמה", "בדיקת מסך/מנוע מצלמה", False, repr(exc))
# 4) Media engine.
try:
media_status = media_engine_status()
add_self_test_result(results, "מדיה", "Pillow זמין להמרת/דחיסת תמונות", bool(media_status.get("pillow")), "py -m pip install --upgrade Pillow" if not media_status.get("pillow") else "OK")
add_self_test_result(results, "מדיה", "FFmpeg זמין להמרת אודיו ל־MP3", bool(media_status.get("ffmpeg")), media_status.get("ffmpeg_path") or r"שים FFmpeg ב־C:\ffmpeg\bin\ffmpeg.exe")
except Exception as exc:
add_self_test_result(results, "מדיה", "בדיקת מנוע מדיה", False, repr(exc))
# Optional conversion mini-test: only in temp folder, never touches active media.
if include_media_conversion:
try:
from PIL import Image
source = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png")
target = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg")
Image.new("RGBA", (1600, 900), (30, 80, 150, 255)).save(source, "PNG")
media = convert_or_copy_image_to_jpg(source, target, "self_test.png")
ok = os.path.exists(target) and is_likely_jpeg_file(target)
add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", ok, media_details_text(media))
except Exception as exc:
add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", False, repr(exc), severity="warning")
finally:
for p in (os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png"), os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg")):
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
# 5) Presentation-specific checks.
try:
usa_count = len(USA_IMAGE_UI_ITEMS)
usa_keys = {item[0] for item in USA_IMAGE_UI_ITEMS}
add_self_test_result(results, "ארה״ב", "44 כרטיסיות תמונות ארה״ב קיימות", usa_count == 44, str(usa_count))
add_self_test_result(results, "ארה״ב", "כרטיסיות ארה״ב תואמות למפת שמות התמונות", usa_keys == set(USA_IMAGE_KEYS), f"ui={len(usa_keys)} keys={len(USA_IMAGE_KEYS)}")
ui_blob = "\n".join(" ".join(item) for item in USA_IMAGE_UI_ITEMS)
forbidden = [word for word in ["Nassau", "נסאו", "פארק דיסני"] if word in ui_blob]
add_self_test_result(results, "ארה״ב", "אין תיאורים שגויים ידועים בתמונות ארה״ב", not forbidden, ", ".join(forbidden) if forbidden else "OK")
required = ["Disney Springs", "קרוז – יום ים 1", "קרוז – יום ים 2", "קרוז – יום ים 3", "St. Maarten", "St. Thomas", "CocoCay"]
missing_words = [word for word in required if word not in ui_blob]
add_self_test_result(results, "ארה״ב", "תיאורי קרוז/אורלנדו מסונכרנים", not missing_words, ", ".join(missing_words) if missing_words else "OK")
except Exception as exc:
add_self_test_result(results, "ארה״ב", "בדיקת כרטיסיות ארה״ב", False, repr(exc))
try:
japan_usage = analyze_japan_presentation_images()
if not japan_usage.get("presentation_exists"):
add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", False, japan_usage.get("presentation_path", ""), severity="warning")
else:
add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", True, japan_usage.get("presentation_path", ""))
add_self_test_result(results, "יפן", "מצגת יפן משתמשת בתמונות בפועל", japan_usage.get("image_slides", 0) > 0, f"{japan_usage.get('image_slides', 0)} תמונות")
add_self_test_result(results, "יפן", "אין תמונות חיצוניות חסרות למצגת יפן", japan_usage.get("missing_external_images", 0) == 0, f"חסרות: {japan_usage.get('missing_external_images', 0)}")
add_self_test_result(results, "יפן", "ספירת תמונות יפן מוצגת כספירת שימוש ולא כספירת תיקייה", "תמונות בשימוש בפועל" in render_admin_status(), "OK")
except Exception as exc:
add_self_test_result(results, "יפן", "ניתוח מצגת יפן", False, repr(exc))
# 6) Upload target mapping and safety guards.
try:
main_targets = {key: os.path.join(path, "index.html") for key, path in FOLDERS.items()}
bad_targets = [f"{key}:{target}" for key, target in main_targets.items() if not target.lower().endswith("index.html")]
add_self_test_result(results, "העלאות", "קבצים ראשיים נשמרים תמיד כ־index.html", not bad_targets, ", ".join(bad_targets) if bad_targets else "OK")
except Exception as exc:
add_self_test_result(results, "העלאות", "מפת יעדי העלאה ראשיים", False, repr(exc))
try:
safe_examples = ["index.html", "media/images/a.jpg", "USA/index.html"]
unsafe_examples = ["../x.html", "a/../../x.html", "__MACOSX/a.jpg"]
ok_safe = all(safe_zip_relpath(x) is not None for x in safe_examples)
ok_unsafe = all(safe_zip_relpath(x) is None for x in unsafe_examples)
add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", ok_safe and ok_unsafe, "OK" if ok_safe and ok_unsafe else f"safe={ok_safe} unsafe={ok_unsafe}")
except Exception as exc:
add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", False, repr(exc))
try:
add_self_test_result(results, "אבטחה", "זיהוי IP מקומי/בית/Tailscale", is_private_or_loopback_ip("127.0.0.1") and is_private_or_loopback_ip("192.168.1.10") and is_private_or_loopback_ip("100.64.1.2") and is_private_or_loopback_ip("fd7a:115c:a1e0::1"), "OK")
add_self_test_result(results, "אבטחה", "כתובת ציבורית נחסמת לפעולות מחשב", not is_private_or_loopback_ip("8.8.8.8"), "OK")
add_self_test_result(results, "אבטחה", "טווח טיימר כיבוי/ריסטארט מוגבל", normalize_power_delay("0") == POWER_MIN_DELAY_SECONDS and normalize_power_delay("999999") == POWER_MAX_DELAY_SECONDS, f"{POWER_MIN_DELAY_SECONDS}-{POWER_MAX_DELAY_SECONDS} שניות")
except Exception as exc:
add_self_test_result(results, "אבטחה", "בדיקות אבטחת פעולות מחשב", False, repr(exc))
elapsed = round(time.time() - started, 3)
passed = sum(1 for item in results if item["status"] == "pass")
warned = sum(1 for item in results if item["status"] == "warn")
failed = sum(1 for item in results if item["status"] == "fail")
return {
"ok": failed == 0,
"passed": passed,
"warnings": warned,
"failed": failed,
"total": len(results),
"elapsed_seconds": elapsed,
"version": ADMIN_PANEL_VERSION,
"started_at": ADMIN_STARTED_AT,
"checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"results": results,
}
def render_admin_self_test():
include_conversion = (request.args.get("conversion") == "1")
payload = run_self_test_suite(include_media_conversion=include_conversion)
badge_class = "ok" if payload["ok"] else "bad"
badge_text = "הבדיקה עברה" if payload["ok"] else "נמצאו כשלים"
grouped = {}
for item in payload["results"]:
grouped.setdefault(item["category"], []).append(item)
def row_html(item):
status = item.get("status")
if status == "pass":
mark = "✅"
cls = "pass"
label = "תקין"
elif status == "warn":
mark = "⚠️"
cls = "warn"
label = "אזהרה"
else:
mark = "❌"
cls = "fail"
label = "כשל"
details = html_lib.escape(item.get("details") or "").replace("\n", "<br>")
return f"<tr class='{cls}'><td>{mark} {label}</td><td>{html_lib.escape(item.get('name',''))}</td><td>{details}</td></tr>"
groups_html = []
for category, items in grouped.items():
rows = "".join(row_html(item) for item in items)
groups_html.append(f"<section class='group'><h2>{html_lib.escape(category)}</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{rows}</tbody></table></section>")
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>בדיקה עצמית TripServer</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1d4ed8,#0f172a 62%,#111827);border:1px solid #334155;border-radius:26px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.25)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.65}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin-top:8px}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.bad{{background:#7f1d1d;color:#fecaca;border:1px solid #ef4444}}.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:14px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:14px}}.num{{font-size:30px;font-weight:950;color:#fde68a}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0}}.actions a{{display:inline-flex;background:#1e293b;border:1px solid #475569;border-radius:999px;padding:10px 13px;font-weight:900}}.group{{background:#111827;border:1px solid #334155;border-radius:22px;padding:16px;margin:14px 0}}.group h2{{margin:0 0 12px;color:#dbeafe}}table{{width:100%;border-collapse:collapse;overflow:hidden;border-radius:14px}}th,td{{padding:10px;border-bottom:1px solid #334155;text-align:right;vertical-align:top}}th{{background:#0b1220;color:#bfdbfe}}td:last-child{{direction:ltr;text-align:left;color:#cbd5e1;word-break:break-word}}tr.pass td:first-child{{color:#bbf7d0;font-weight:900}}tr.warn td:first-child{{color:#fde68a;font-weight:900}}tr.fail td:first-child{{color:#fecaca;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}table,thead,tbody,tr,th,td{{display:block}}thead{{display:none}}tr{{border:1px solid #334155;border-radius:14px;margin:10px 0;padding:8px}}td{{border:0;padding:7px}}td:last-child{{text-align:right;direction:rtl}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🧪 בדיקה עצמית מלאה</h1><p>בדיקה לא־הרסנית של קוד, routes, טפסים, נתיבים, מדיה, מצגות, אבטחה ו־restart helper. היא לא מבצעת העלאות אמיתיות, לא מאתחלת את השרת ולא מכבה את המחשב.</p><span class="badge {badge_class}">{badge_text}</span><div class="actions"><a href="/admin/self-test">הרץ שוב</a><a href="/admin/self-test?conversion=1">בדיקה כולל סימולציית המרת תמונה</a><a href="/admin/self-test.json" target="_blank">JSON טכני</a><a href="/admin/status">סטטוס קבצים</a><a href="/admin/health">Health</a></div></div><div class="summary"><div class="card"><div>סה״כ בדיקות</div><div class="num">{payload['total']}</div></div><div class="card"><div>עברו</div><div class="num">{payload['passed']}</div></div><div class="card"><div>אזהרות</div><div class="num">{payload['warnings']}</div></div><div class="card"><div>כשלים</div><div class="num">{payload['failed']}</div></div><div class="card"><div>משך</div><div class="num">{payload['elapsed_seconds']}s</div></div></div><div class="note">גרסה: {html_lib.escape(payload['version'])}<br>נבדק: {html_lib.escape(payload['checked_at'])}</div>{''.join(groups_html)}</div></body></html>
"""
def health_payload():
helper_ok, helper_info = validate_restart_helper_source(build_restart_helper_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR))
return {
"ok": True,
"service": "TripServer Admin",
"version": ADMIN_PANEL_VERSION,
"started_at": ADMIN_STARTED_AT,
"script": ADMIN_SERVER_PATH,
"host": ADMIN_HOST,
"port": ADMIN_PORT,
"usa_image_keys": len(USA_IMAGE_KEYS),
"japan_presentation_images": compact_japan_usage_for_health(),
"power_actions_enabled": POWER_ACTIONS_ENABLED,
"restart_helper_ok": helper_ok,
"restart_helper_status": helper_info,
"watchdog": watchdog_status(include_log=False, include_task=False),
"logs": {key: {"path": meta["path"], "exists": os.path.exists(meta["path"])} for key, meta in LOG_FILE_REGISTRY.items()},
"media_engine": media_engine_status(),
}
def render_admin_health():
payload = health_payload()
media = payload.get("media_engine", {})
pillow_ok = bool(media.get("pillow"))
ffmpeg_ok = bool(media.get("ffmpeg"))
ffmpeg_path = media.get("ffmpeg_path") or "לא נמצא"
power_ok = bool(payload.get("power_actions_enabled"))
japan_usage = payload.get("japan_presentation_images", {})
def badge(ok, text_ok="תקין", text_bad="חסר"):
cls = "ok" if ok else "bad"
txt = text_ok if ok else text_bad
return f'<span class="badge {cls}">{txt}</span>'
json_text = html_lib.escape(str(payload)).replace(", '", ",\n '").replace("{", "{\n ").replace("}", "\n}")
return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>בדיקת מערכת TripServer</title>
<style>
:root{{--bg:#07111f;--panel:#101c31;--line:#253650;--muted:#a8b3c7;--blue:#60a5fa;--green:#22c55e;--red:#ef4444;--yellow:#fbbf24}}
*{{box-sizing:border-box}}body{{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 80% -10%,rgba(59,130,246,.35),transparent 28%),linear-gradient(180deg,#07111f,#0f172a);color:white;min-height:100vh;padding:20px}}
.wrap{{width:min(980px,94vw);margin:auto}}.hero{{background:linear-gradient(135deg,rgba(30,64,175,.9),rgba(15,23,42,.92));border:1px solid rgba(147,197,253,.25);border-radius:28px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.32)}}
h1{{margin:0 0 8px;font-size:clamp(28px,5vw,42px)}}p{{color:#dbeafe;line-height:1.65;margin:8px 0}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}a{{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;color:white}}
a:hover{{border-color:var(--blue);background:rgba(59,130,246,.22)}}.grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:18px 0}}.card{{background:rgba(16,28,49,.9);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:0 16px 45px rgba(0,0,0,.22)}}.card h2{{margin:0 0 10px;font-size:22px}}.line{{display:flex;justify-content:space-between;gap:12px;border-top:1px solid #263852;padding-top:10px;margin-top:10px;color:#cbd5e1;direction:rtl}}.ltr{{direction:ltr;text-align:left;overflow-wrap:anywhere;color:#fde68a}}.badge{{display:inline-flex;border-radius:999px;padding:6px 10px;font-weight:900;font-size:13px}}.badge.ok{{background:rgba(22,101,52,.45);border:1px solid rgba(74,222,128,.45);color:#bbf7d0}}.badge.bad{{background:rgba(127,29,29,.45);border:1px solid rgba(248,113,113,.45);color:#fecaca}}.note{{background:rgba(15,23,42,.75);border:1px solid #334155;border-radius:18px;padding:14px;color:#cbd5e1;line-height:1.6}}pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:18px;padding:16px;overflow:auto;max-height:360px}}
@media(max-width:760px){{body{{padding:14px}}.grid{{grid-template-columns:1fr}}.hero{{padding:18px;border-radius:24px}}.card{{padding:15px}}.line{{display:block}}.ltr{{margin-top:5px}}}}
</style>
</head>
<body><div class="wrap">
<div class="hero">
<h1>🩺 בדיקת מערכת</h1>
<p>זה מסך קריא לבני אדם. נקודת הקצה הטכנית עדיין קיימת ב־<span class="ltr">/health?json=1</span> למקרה שצריך בדיקה אוטומטית.</p>
<div class="actions"><a href="/admin">⬅ חזרה לפאנל</a><a href="/admin/status">📊 סטטוס קבצים</a><a href="/health?json=1" target="_blank">JSON טכני</a><a href="/admin/self-test">🧪 בדיקה עצמית מלאה</a><a href="/admin/watchdog">🛡️ Watchdog</a><a href="/admin/logs">📜 לוגים</a></div>
</div>
<div class="grid">
<div class="card"><h2>מצב שרת</h2><div>{badge(True)}</div><div class="line"><b>גרסה</b><span>{html_lib.escape(ADMIN_PANEL_VERSION)}</span></div><div class="line"><b>עלה בתאריך</b><span>{html_lib.escape(ADMIN_STARTED_AT)}</span></div><div class="line"><b>פורט</b><span>{ADMIN_PORT}</span></div><div class="line"><b>Host</b><span>{html_lib.escape(ADMIN_HOST)}</span></div><div class="line"><b>Restart helper</b><span>{badge(payload.get("restart_helper_ok", False), "תקין", "תקול")}</span></div></div>
<div class="card"><h2>מנוע מדיה</h2><div>{badge(pillow_ok, "Pillow תקין", "Pillow חסר")} {badge(ffmpeg_ok, "FFmpeg תקין", "FFmpeg חסר")}</div><div class="line"><b>תמונות</b><span>המרה ודחיסה ל־JPG</span></div><div class="line"><b>אודיו</b><span>המרה ל־MP3</span></div><div class="line"><b>FFmpeg</b><span class="ltr">{html_lib.escape(ffmpeg_path)}</span></div></div>
<div class="card"><h2>פעולות מחשב</h2><div>{badge(power_ok, "פעיל", "כבוי")}</div><div class="line"><b>כיבוי / ריסטארט</b><span>{"מופעל בפאנל" if power_ok else "מנוטרל בהגדרות"}</span></div><div class="line"><b>הגנות</b><span>POST, רשת מקומית, אישור כתוב, טיימר וביטול</span></div></div>
<div class="card"><h2>כיסוי מצגת ארה״ב</h2><div>{badge(len(USA_IMAGE_KEYS) == 44, "44 שקפים", str(len(USA_IMAGE_KEYS)) + " שקפים")}</div><div class="line"><b>תמונות שקפים</b><span>{len(USA_IMAGE_KEYS)}</span></div><div class="line"><b>העלאה</b><span>JPG/PNG/WebP וכו׳ → JPG דחוס</span></div></div>
<div class="card"><h2>כיסוי מצגת יפן</h2><div>{badge(japan_usage.get('presentation_exists') and japan_usage.get('missing_external_images', 0) == 0, "תקין", "לבדיקה")}</div><div class="line"><b>תמונות בשימוש בפועל</b><span>{japan_usage.get('image_slides_total', 0)}</span></div><div class="line"><b>קבצים חיצוניים</b><span>{japan_usage.get('external_files_used', 0)}</span></div><div class="line"><b>תמונות מוטמעות</b><span>{japan_usage.get('embedded_images', 0)}</span></div><div class="line"><b>קבצים בתיקייה</b><span>{japan_usage.get('folder_images', 0)}</span></div><div class="line"><b>לא בשימוש / חסרות</b><span>{japan_usage.get('unused_folder_images', 0)} / {japan_usage.get('missing_external_images', 0)}</span></div></div>
</div>
<div class="note"><b>מה קרה קודם?</b><br>לחצן Health פתח את נקודת הקצה הטכנית והציג JSON שחור. זה לא כשל בשרת — להפך, לפי הנתונים אצלך FFmpeg ו־Pillow זמינים — אבל זה לא מתאים ככפתור במסך ראשי. בגרסה הזו הכפתור מוביל למסך בדיקה קריא, וה־JSON נשאר רק לבדיקה טכנית.</div>
<h2>מידע טכני מלא</h2><pre>{json_text}</pre>
</div></body></html>
"""
@app.route("/admin/self-test")
def admin_self_test():
return render_admin_self_test()
@app.route("/admin/self-test.json")
def admin_self_test_json():
include_conversion = (request.args.get("conversion") == "1")
return run_self_test_suite(include_media_conversion=include_conversion)
@app.route("/health")
def health():
accept = request.headers.get("Accept", "")
if request.args.get("json") != "1" and "text/html" in accept:
return redirect("/admin/health")
return health_payload()
@app.route("/health.json")
def health_json():
return health_payload()
@app.route("/admin/health")
def admin_health():
return render_admin_health()
@app.route("/admin/status")
def admin_status():
return render_admin_status()
@app.route("/admin/logs")
def admin_logs():
return render_admin_logs()
@app.route("/admin/logs.json")
def admin_logs_json():
return build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES))
@app.route("/camera/setup")
def camera_setup_page():
allowed, reason = camera_access_allowed()
if not allowed:
return admin_status_page("גישה נחסמה", reason, ok=False, back_url="/admin/camera"), 403
return render_camera_setup_page()
@app.route("/camera/setup/status.json")
def camera_setup_status_json():
allowed, reason = camera_access_allowed()
payload = camera_setup_status()
payload["access_allowed"] = allowed
payload["access_reason"] = reason
return payload
@app.route("/camera/setup/opencv", methods=["POST"])
def camera_setup_opencv():
allowed, reason = camera_access_allowed()
if not allowed:
return admin_status_page("התקנת OpenCV נחסמה", reason, ok=False, back_url="/camera/setup"), 403
confirm = (request.form.get("confirm") or "").strip()
if confirm != CAMERA_SETUP_CONFIRM_TEXT:
return admin_status_page("אישור חסר", f"כדי להתקין OpenCV צריך לכתוב בדיוק: {CAMERA_SETUP_CONFIRM_TEXT}", ok=False, back_url="/camera/setup"), 400
if camera_engine_status().get("opencv"):
return admin_status_page("OpenCV כבר מותקן", "אין צורך להריץ התקנה נוספת. אפשר לחזור למסך המצלמה.", ok=True, back_url="/admin/camera")
started, message = start_opencv_install_background(mode="manual")
return admin_status_page("התקנת OpenCV התחילה" if started else "התקנת OpenCV כבר רצה", message + "\n\nפתח את מסך סטטוס רכיבי מצלמה כדי לעקוב אחרי הלוג.", ok=started, back_url="/camera/setup", details=str(camera_setup_status()))
@app.route("/admin/camera")
def admin_camera():
return render_admin_camera()
@app.route("/camera/status.json")
def camera_status_json():
allowed, reason = camera_access_allowed()
payload = CAMERA_MANAGER.status()
payload.update(camera_engine_status())
payload["access_allowed"] = allowed
payload["access_reason"] = reason
return payload
@app.route("/camera/snapshot.jpg")
def camera_snapshot():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
if not camera_engine_status().get("opencv"):
return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503
try:
frame = CAMERA_MANAGER.read_jpeg()
response = make_response(frame)
response.headers["Content-Type"] = "image/jpeg"
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return response
except Exception as exc:
return f"שגיאת מצלמה: {exc}", 500
@app.route("/camera/stream")
def camera_stream():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
if not camera_engine_status().get("opencv"):
return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503
return Response(camera_stream_generator(), mimetype="multipart/x-mixed-replace; boundary=frame", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"})
@app.route("/camera/stop", methods=["POST"])
def camera_stop():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
CAMERA_MANAGER.stop()
return action_page("מצלמה כובתה", "המצלמה שוחררה והזרם הופסק. אם נורית המצלמה עדיין דולקת, סגור גם את דף השידור בדפדפן ורענן.", "/admin/camera")
@app.route("/camera/audio-stream")
def camera_audio_stream():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
if not find_ffmpeg_executable():
return "FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו.", 503
return Response(CAMERA_AUDIO_STREAM.stream_chunks(), mimetype="audio/mpeg", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"})
@app.route("/camera/audio-stop", methods=["POST"])
def camera_audio_stop():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
CAMERA_AUDIO_STREAM.stop()
return action_page("אודיו כובה", "שידור האודיו הופסק והמיקרופון שוחרר.", "/admin/camera")
@app.route("/camera/record/start", methods=["POST"])
def camera_record_start():
allowed, reason = camera_access_allowed()
if not allowed:
return admin_status_page("הקלטה נחסמה", reason, ok=False), 403
try:
with_audio = request.form.get("with_audio") == "1"
target = CAMERA_RECORDER.start(with_audio=with_audio)
return admin_status_page("הקלטה התחילה", f"ההקלטה נשמרת אל:\n{target}", ok=True, details=str(CAMERA_RECORDER.status()))
except Exception as exc:
CAMERA_RECORDER.last_error = str(exc)
return admin_status_page("הקלטה לא התחילה", str(exc), ok=False, details=str(camera_av_status())), 500
@app.route("/camera/record/stop", methods=["POST"])
def camera_record_stop():
allowed, reason = camera_access_allowed()
if not allowed:
return admin_status_page("עצירת הקלטה נחסמה", reason, ok=False), 403
try:
target = CAMERA_RECORDER.stop()
return admin_status_page("הקלטה נעצרה", f"קובץ ההקלטה:\n{target or 'לא היה קובץ פעיל'}", ok=True, details=str(CAMERA_RECORDER.status()), back_url="/admin/camera")
except Exception as exc:
return admin_status_page("שגיאה בעצירת הקלטה", str(exc), ok=False, back_url="/admin/camera"), 500
@app.route("/camera/recordings")
def camera_recordings_page():
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
rows = "".join(
f"<div style='border-bottom:1px solid #334155;padding:12px 0'><b>{html_lib.escape(item['name'])}</b><br><span style='color:#94a3b8'>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span><br><a href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח/נגן</a></div>"
for item in list_camera_recordings(100)
) or "<p>אין הקלטות.</p>"
return f"""<!DOCTYPE html><html lang='he' dir='rtl'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>הקלטות מצלמה</title><style>body{{margin:0;background:#07111f;color:white;font-family:Arial;padding:18px}}.wrap{{max-width:900px;margin:auto}}a{{color:#93c5fd}}.card{{background:#111827;border:1px solid #334155;border-radius:22px;padding:18px}}</style></head><body><div class='wrap'><a href='/admin/camera'>⬅ חזרה למצלמה</a><h1>🎞️ הקלטות מצלמה</h1><div class='card'><p>תיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p>{rows}</div></div></body></html>"""
@app.route("/camera/recordings/<path:filename>")
def camera_recording_file(filename):
allowed, reason = camera_access_allowed()
if not allowed:
return reason, 403
base = os.path.basename(filename or "")
if not base.lower().endswith(".mp4"):
return "קובץ לא מורשה", 403
return send_from_directory(CAMERA_RECORDINGS_DIR, base, as_attachment=False)
@app.route("/admin/watchdog")
def admin_watchdog():
return render_admin_watchdog()
@app.route("/watchdog/install", methods=["POST"])
def watchdog_install_route():
if not is_private_or_loopback_ip(get_client_ip()):
return admin_status_page("התקנת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403
try:
ok, message, details = install_watchdog_task()
return admin_status_page("Watchdog הותקן" if ok else "Watchdog לא הותקן", message, ok=ok, details=details + "\n\n" + str(watchdog_status(include_log=False)))
except Exception as exc:
return admin_status_page("שגיאה בהתקנת Watchdog", "לא הצלחתי להתקין את שכבת ה־Watchdog.", ok=False, details=repr(exc)), 500
@app.route("/watchdog/uninstall", methods=["POST"])
def watchdog_uninstall_route():
if not is_private_or_loopback_ip(get_client_ip()):
return admin_status_page("הסרת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403
try:
ok, message, details = uninstall_watchdog_task()
return admin_status_page("Watchdog הוסר" if ok else "Watchdog לא הוסר", message, ok=ok, details=details)
except Exception as exc:
return admin_status_page("שגיאה בהסרת Watchdog", "לא הצלחתי להסיר את משימת ה־Watchdog.", ok=False, details=repr(exc)), 500
@app.route("/watchdog/run-once", methods=["POST"])
def watchdog_run_once_route():
if not is_private_or_loopback_ip(get_client_ip()):
return admin_status_page("הרצת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403
try:
ok, message, details = run_watchdog_once_now()
return admin_status_page("Watchdog הורץ" if ok else "Watchdog נכשל", message, ok=ok, details=details)
except Exception as exc:
return admin_status_page("שגיאה בהרצת Watchdog", "לא הצלחתי להריץ את ה־Watchdog.", ok=False, details=repr(exc)), 500
@app.route("/upload_admin_server", methods=["POST"])
def upload_admin_server():
file = request.files.get("file")
if not file or file.filename == "":
return admin_status_page("לא נבחר קובץ", "בחר קובץ admin_server.py להעלאה.", ok=False), 400
original_name = file.filename or ""
if not original_name.lower().endswith(".py"):
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ Python עם סיומת .py", ok=False), 400
if request.content_length and request.content_length > ADMIN_MAX_UPLOAD_BYTES + 250000:
return admin_status_page("קובץ גדול מדי", f"הקובץ גדול מהמותר. המגבלה היא בערך {ADMIN_MAX_UPLOAD_BYTES:,} bytes.", ok=False), 413
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_server_upload_{stamp}.py")
try:
file.save(tmp_path)
valid, details = validate_admin_server_upload(tmp_path)
if not valid:
try:
os.remove(tmp_path)
except Exception:
pass
return admin_status_page("העלאת admin_server.py נדחתה", "הקובץ הקיים לא הוחלף. נמצאה בעיה בקובץ שהעלית.", ok=False, details=details), 400
backup_path = backup_current_admin_server("admin_server_backup")
os.replace(tmp_path, ADMIN_SERVER_PATH)
new_sha = file_sha256(ADMIN_SERVER_PATH)
message = (
"הקובץ החדש עבר בדיקת קומפילציה והחליף את admin_server.py הפעיל.\n"
"נוצר גיבוי לקובץ הקודם.\n\n"
"מנגנון אתחול אוטומטי הופעל עכשיו. רענן את הפאנל בעוד כמה שניות."
)
details = f"קובץ פעיל:\n{ADMIN_SERVER_PATH}\n\nSHA256 חדש:\n{new_sha}\n\nגיבוי:\n{backup_path}\n\nלוג אתחול:\n{ADMIN_RESTART_LOG_PATH}"
threading.Thread(target=restart_process_later, args=(backup_path, new_sha, "admin_upload"), daemon=True).start()
return admin_status_page("admin_server.py עודכן והאתחול הופעל", message, ok=True, details=details)
except Exception as exc:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
return admin_status_page("שגיאה בעדכון admin_server.py", "הקובץ הקיים לא הוחלף.", ok=False, details=repr(exc)), 500
@app.route("/restart_admin_server", methods=["POST"])
def restart_admin_server():
threading.Thread(target=restart_process_later, args=(None, None, "manual_panel_restart"), daemon=True).start()
return admin_status_page(
"הפאנל מופעל מחדש",
"האיפוס התחיל דרך מנגנון Restart חיצוני. המתן 5–10 שניות ואז רענן את הפאנל. אם לא עולה, בדוק את C:\\TripServer\\admin_restart.log.",
ok=True,
)
@app.route("/restore_admin_server_backup", methods=["POST"])
def restore_admin_server_backup():
backup_path = latest_admin_backup()
if not backup_path:
return admin_status_page("אין גיבוי לשחזור", "לא נמצא קובץ גיבוי בתיקיית הגיבויים.", ok=False), 404
try:
valid, details = validate_admin_server_upload(backup_path)
if not valid:
return admin_status_page("הגיבוי האחרון לא תקין", "הגיבוי לא שוחזר כי הוא לא עבר בדיקת תקינות.", ok=False, details=details), 400
safety_backup = backup_current_admin_server("admin_server_before_restore")
shutil.copy2(backup_path, ADMIN_SERVER_PATH)
msg = "הגיבוי האחרון שוחזר אל admin_server.py. לחץ בדיקת אתחול פאנל — מתקדם כדי להפעיל אותו."
det = f"שוחזר מתוך:\n{backup_path}\n\nגיבוי בטיחות לגרסה שהייתה פעילה לפני השחזור:\n{safety_backup}"
return admin_status_page("שחזור גיבוי הסתיים", msg, ok=True, details=det)
except Exception as exc:
return admin_status_page("שגיאה בשחזור גיבוי", "השחזור לא הושלם.", ok=False, details=repr(exc)), 500
@app.route("/upload/<folder>", methods=["POST"])
def upload(folder):
if folder not in FOLDERS:
return "Folder not found", 404
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if folder in APP_FOLDERS and file_ext(file.filename) not in {".html", ".htm"}:
return admin_status_page("קובץ לא תקין", "לאזור הזה ניתן להעלות רק קובץ HTML.", ok=False), 400
try:
filename, result = save_uploaded_file(folder, file)
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
return admin_status_page("העלאה הושלמה", f"הקובץ נשמר בהצלחה כ־{filename}. אם אתה רואה גרסה ישנה, פתח מחדש עם רענון מלא.", ok=True, details=details)
except Exception as exc:
return admin_status_page("שגיאה בהעלאה", "הקובץ לא נשמר. הקובץ הקודם נשאר במקומו אם היה קיים.", ok=False, details=repr(exc)), 500
@app.route("/upload_presentation/<presentation_key>", methods=["POST"])
def upload_presentation(presentation_key):
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if file_ext(file.filename) not in {".html", ".htm"}:
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת יפן.", ok=False), 400
saved = save_japan_presentation(presentation_key, file)
if not saved:
return "Presentation type not found", 404
filename, result = saved
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
return admin_status_page("מצגת יפן הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)
@app.route("/upload_usa_presentation/<presentation_key>", methods=["POST"])
def upload_usa_presentation(presentation_key):
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if file_ext(file.filename) not in {".html", ".htm"}:
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת ארה״ב.", ok=False), 400
saved = save_usa_presentation(presentation_key, file)
if not saved:
return "USA presentation type not found", 404
filename, result = saved
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
return admin_status_page("מצגת ארה״ב הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)
@app.route("/upload_usa_image", methods=["POST"])
def upload_usa_image():
image_key = request.form.get("image_key", "").strip()
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
try:
result = save_usa_presentation_image(image_key, file)
if not result:
return "USA image key not found", 404
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}\n\nמצב שמירה:\n{result['mode']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
details += "\n\n" + media_details_text(result.get("media"))
return admin_status_page("תמונת מצגת ארה״ב הועלתה", f"התמונה נשמרה בשם הקבוע: {result['rel']}", ok=True, details=details)
except Exception as exc:
return admin_status_page("שגיאה בהעלאת תמונה", "התמונה לא נשמרה. להמרה ודחיסה של PNG/WebP/BMP/TIFF צריך Pillow בשרת. JPG אמיתי יתקבל גם בלי Pillow.", ok=False, details=repr(exc)), 400
@app.route("/upload_usa_images_zip", methods=["POST"])
def upload_usa_images_zip():
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
original_name = file.filename or ""
if not original_name.lower().endswith(".zip"):
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ ZIP של תמונות מצגת.", ok=False), 400
if request.content_length and request.content_length > USA_IMAGES_ZIP_MAX_BYTES + 1024 * 1024:
return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {USA_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
try:
imported, skipped, missing = save_usa_presentation_images_zip(file)
details = "נשמרו בתיקיית התמונות:\n" + ("\n".join(imported) if imported else "לא נשמרו תמונות.")
if skipped:
details += "\n\nדולגו:\n" + "\n".join(skipped[:120])
if missing:
details += "\n\nלא נמצאו ב־ZIP, ולכן לא הוחלפו:\n" + "\n".join(missing[:120])
return admin_status_page(
"ZIP תמונות ארה״ב נטען",
f"נשמרו {len(imported)} תמונות למצגת. קבצים קיימים באותו שם הוחלפו.",
ok=True,
details=details,
)
except zipfile.BadZipFile:
return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
except ValueError as exc:
return admin_status_page("ZIP לא תקין", "הקובץ לא עומד במגבלות העלאה של הפאנל.", ok=False, details=str(exc)), 400
except Exception as exc:
return admin_status_page("שגיאה בהעלאת ZIP", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500
@app.route("/upload_usa_extra_html/<extra_key>", methods=["POST"])
def upload_usa_extra_html(extra_key):
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if file_ext(file.filename) not in {".html", ".htm"}:
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML.", ok=False), 400
saved = save_usa_extra_html(extra_key, file)
if not saved:
return "USA extra HTML type not found", 404
filename, result = saved
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
return admin_status_page("קובץ ארה״ב נוסף הועלה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)
@app.route("/upload_japan_music/<music_key>", methods=["POST"])
def upload_japan_music(music_key):
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
result = save_japan_music(music_key, file)
if not result:
return "Japan music type not found", 404
details = "נשמר אל:\n" + "\n".join([item["path"] for item in result["targets"]]) + f"\n\nSHA256 MP3 סופי:\n{result['sha256']}"
details += "\n\n" + media_details_text(result.get("media"))
return admin_status_page("מוזיקת יפן הועלתה", "הקובץ נשמר בכל הנתיבים שהמצגת מחפשת כ־MP3 אמיתי.", ok=True, details=details)
@app.route("/upload_japan_images_zip", methods=["POST"])
def upload_japan_images_zip():
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if not (file.filename or "").lower().endswith(".zip"):
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP תמונות יפן.", ok=False), 400
if request.content_length and request.content_length > JAPAN_IMAGES_ZIP_MAX_BYTES + 1024 * 1024:
return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {JAPAN_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
try:
imported, skipped, overwritten = save_japan_images_zip(file)
details = "יובאו:\n" + ("\n".join(imported[:180]) if imported else "לא יובאו תמונות.")
if overwritten:
details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:120])
if skipped:
details += "\n\nדולגו:\n" + "\n".join(skipped[:120])
return admin_status_page("ZIP תמונות יפן נטען", f"נשמרו {len(imported)} תמונות תחת JAPAN/media/images.", ok=True, details=details)
except zipfile.BadZipFile:
return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
except Exception as exc:
return admin_status_page("שגיאה בהעלאת ZIP יפן", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500
@app.route("/upload_trip_package/<trip>", methods=["POST"])
def upload_trip_package(trip):
trip = (trip or "").upper()
file = request.files.get("file")
if trip not in {"USA", "JAPAN"}:
return "Trip package type not found", 404
if not file or file.filename == "":
return "No file selected", 400
if not (file.filename or "").lower().endswith(".zip"):
return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP מלא של הטיול.", ok=False), 400
if request.content_length and request.content_length > TRIP_PACKAGE_ZIP_MAX_BYTES + 1024 * 1024:
return admin_status_page("קובץ גדול מדי", f"ה־ZIP גדול מהמגבלה: {TRIP_PACKAGE_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
try:
imported, skipped, overwritten = save_trip_package_zip(trip, file)
details = "יובאו:\n" + ("\n".join(imported[:200]) if imported else "לא יובאו קבצים.")
if overwritten:
details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:140])
if skipped:
details += "\n\nדולגו:\n" + "\n".join(skipped[:140])
return admin_status_page(f"ZIP מלא של {trip} נטען", f"נשמרו {len(imported)} קבצים בתיקיית {trip}.", ok=True, details=details)
except zipfile.BadZipFile:
return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
except Exception as exc:
return admin_status_page("שגיאה בהעלאת ZIP מלא", "לא ניתן היה לעבד את חבילת הטיול.", ok=False, details=repr(exc)), 500
@app.route("/upload_usa_music/<music_key>", methods=["POST"])
def upload_usa_music(music_key):
file = request.files.get("file")
if not file or file.filename == "":
return "No file selected", 400
if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
saved = save_usa_music(music_key, file)
if not saved:
return "USA music type not found", 404
filename, result = saved
details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
if result.get("backup"):
details += f"\n\nגיבוי קודם:\n{result['backup']}"
details += "\n\n" + media_details_text(result.get("media"))
return admin_status_page("מוזיקת ארה״ב הועלתה", f"הקובץ נשמר בנתיב הקבוע: {filename}", ok=True, details=details)
@app.route("/power/<action>", methods=["POST"])
def power_action(action):
action = (action or "").strip().lower()
if action not in POWER_CONFIRM_TEXT:
return admin_status_page("פעולת מחשב לא מוכרת", "הפעולה המבוקשת אינה קיימת.", ok=False), 404
allowed, reason = power_action_allowed()
if not allowed:
return admin_status_page("פעולת מחשב נחסמה", reason, ok=False), 403
if request.form.get("ack") != "1":
return admin_status_page("חסר אישור פעולה", "כדי למנוע לחיצה בטעות, צריך לסמן שאתה מבין את המשמעות של הפעולה.", ok=False), 400
expected = POWER_CONFIRM_TEXT[action]
confirm_text = (request.form.get("confirm_text") or "").strip()
if confirm_text != expected:
return admin_status_page("מילת האישור לא נכונה", f"כדי לבצע את הפעולה צריך להקליד בדיוק: {expected}", ok=False), 400
delay = normalize_power_delay(request.form.get("delay"))
try:
label, final_delay, output = run_power_command(action, delay)
details = f"פעולה:\n{label}\n\nתבוצע בעוד:\n{final_delay} שניות\n\nכתובת מבקשת:\n{get_client_ip()}\n\nפלט Windows:\n{output or 'OK'}"
return admin_status_page("פעולת מחשב נקבעה", f"{label} נקבע בהצלחה. אפשר לבטל לפני סוף הטיימר דרך כפתור הביטול בפאנל.", ok=True, details=details)
except Exception as exc:
return admin_status_page("שגיאה בהפעלת פקודת מחשב", "Windows לא קיבל את פקודת הכיבוי/ריסטארט.", ok=False, details=repr(exc)), 500
@app.route("/power/cancel", methods=["POST"])
def power_cancel():
allowed, reason = power_action_allowed()
if not allowed:
return admin_status_page("ביטול פעולת מחשב נחסם", reason, ok=False), 403
rc, output = cancel_power_command()
ok = (rc == 0)
title = "פעולת מחשב מתוזמנת בוטלה" if ok else "לא נמצאה פעולה מתוזמנת לביטול"
msg = "אם היה כיבוי/ריסטארט בהמתנה — הוא בוטל." if ok else "Windows החזיר שאין כרגע כיבוי/ריסטארט בהמתנה, או שלא ניתן לבטל."
return admin_status_page(title, msg, ok=ok, details=output or f"return code: {rc}")
@app.route("/JAPAN")
@app.route("/JAPAN/")
def japan():
return serve_app("JAPAN")
@app.route("/USA")
@app.route("/USA/")
def usa():
return serve_app("USA")
@app.route("/Moneyapp")
@app.route("/Moneyapp/")
def moneyapp():
return serve_app("Moneyapp")
@app.route("/MoneyHome")
@app.route("/MoneyHome/")
def moneyhome():
return serve_app("MoneyHome")
@app.route("/JAPAN/<path:path>")
def japan_static(path):
return serve_file_no_cache(FOLDERS["JAPAN"], path)
@app.route("/USA/<path:path>")
def usa_static(path):
return serve_file_no_cache(FOLDERS["USA"], path)
@app.route("/Moneyapp/<path:path>")
def money_static(path):
return serve_file_no_cache(FOLDERS["Moneyapp"], path)
@app.route("/TripForm")
@app.route("/TripForm/")
def tripform():
return serve_app("TripForm")
@app.route("/MoneyHome/<path:path>")
def moneyhome_static(path):
return serve_file_no_cache(FOLDERS["MoneyHome"], path)
@app.route("/TripForm/<path:path>")
def tripform_static(path):
return serve_file_no_cache(FOLDERS["TripForm"], path)
if __name__ == "__main__":
maybe_start_camera_auto_install("admin-startup")
app.run(host=ADMIN_HOST, port=ADMIN_PORT, threaded=True, use_reloader=False)