Compare commits
No commits in common. "LuzLobo2" and "main" have entirely different histories.
53
.gitignore
vendored
53
.gitignore
vendored
@ -1,53 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
ENV/
|
|
||||||
env/
|
|
||||||
|
|
||||||
# Packaging
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
*.egg-info/
|
|
||||||
|
|
||||||
# Logs and runtime files
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
run_web.pid
|
|
||||||
|
|
||||||
# Screenshots and generated images
|
|
||||||
screenshots/
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# IDE/editor
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# Cache and test
|
|
||||||
.pytest_cache/
|
|
||||||
htmlcov/
|
|
||||||
.coverage
|
|
||||||
|
|
||||||
# SQLite DBs
|
|
||||||
*.sqlite3
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# pip wheel cache
|
|
||||||
pip-wheel-metadata/
|
|
||||||
|
|
||||||
# Ignore local secrets or config
|
|
||||||
secrets.yaml
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
|
||||||
109
api_client.py
109
api_client.py
@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Simple CLI to call the running Space API (no external deps).
|
|
||||||
Usage:
|
|
||||||
python api_client.py status [--url URL]
|
|
||||||
python api_client.py start [--url URL]
|
|
||||||
python api_client.py stop [--url URL]
|
|
||||||
python api_client.py screenshot [--url URL] [--out FILE]
|
|
||||||
|
|
||||||
Defaults URL: http://localhost:5000
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from urllib import request, error, parse
|
|
||||||
|
|
||||||
DEFAULT_URL = 'http://localhost:5000'
|
|
||||||
|
|
||||||
|
|
||||||
def get(url):
|
|
||||||
try:
|
|
||||||
with request.urlopen(url, timeout=5) as resp:
|
|
||||||
return resp.read(), resp.getcode(), resp.info().get_content_type()
|
|
||||||
except error.HTTPError as e:
|
|
||||||
return e.read(), e.code, None
|
|
||||||
except Exception as e:
|
|
||||||
print('Request error:', e)
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
|
|
||||||
def post(url):
|
|
||||||
req = request.Request(url, method='POST')
|
|
||||||
try:
|
|
||||||
with request.urlopen(req, timeout=5) as resp:
|
|
||||||
return resp.read(), resp.getcode()
|
|
||||||
except error.HTTPError as e:
|
|
||||||
return e.read(), e.code
|
|
||||||
except Exception as e:
|
|
||||||
print('Request error:', e)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_status(base_url):
|
|
||||||
data, code, ctype = get(f"{base_url.rstrip('/')}/api/status")
|
|
||||||
if data is None:
|
|
||||||
print('No response')
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
j = json.loads(data.decode())
|
|
||||||
print(json.dumps(j, indent=2))
|
|
||||||
except Exception:
|
|
||||||
print(data.decode(errors='ignore'))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(base_url):
|
|
||||||
data, code = post(f"{base_url.rstrip('/')}/api/start")
|
|
||||||
print('HTTP', code)
|
|
||||||
if data:
|
|
||||||
print(data.decode(errors='ignore'))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_stop(base_url):
|
|
||||||
data, code = post(f"{base_url.rstrip('/')}/api/stop")
|
|
||||||
print('HTTP', code)
|
|
||||||
if data:
|
|
||||||
print(data.decode(errors='ignore'))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_screenshot(base_url, out):
|
|
||||||
data, code, ctype = get(f"{base_url.rstrip('/')}/api/screenshot")
|
|
||||||
if data is None or code != 200:
|
|
||||||
print('Failed to get screenshot, HTTP', code)
|
|
||||||
if data:
|
|
||||||
print(data.decode(errors='ignore'))
|
|
||||||
return 2
|
|
||||||
with open(out, 'wb') as f:
|
|
||||||
f.write(data)
|
|
||||||
print('Saved screenshot to', out)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument('--url', '-u', default=DEFAULT_URL, help='Base URL of the API')
|
|
||||||
sub = p.add_subparsers(dest='cmd')
|
|
||||||
sub.add_parser('status')
|
|
||||||
sub.add_parser('start')
|
|
||||||
sub.add_parser('stop')
|
|
||||||
ss = sub.add_parser('screenshot')
|
|
||||||
ss.add_argument('--out', '-o', default='latest.jpg')
|
|
||||||
args = p.parse_args(argv)
|
|
||||||
if not args.cmd:
|
|
||||||
p.print_help()
|
|
||||||
return 1
|
|
||||||
if args.cmd == 'status':
|
|
||||||
return cmd_status(args.url)
|
|
||||||
if args.cmd == 'start':
|
|
||||||
return cmd_start(args.url)
|
|
||||||
if args.cmd == 'stop':
|
|
||||||
return cmd_stop(args.url)
|
|
||||||
if args.cmd == 'screenshot':
|
|
||||||
return cmd_screenshot(args.url, args.out)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
raise SystemExit(main(sys.argv[1:]))
|
|
||||||
187
app.py
187
app.py
@ -1,187 +0,0 @@
|
|||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import logging
|
|
||||||
from flask import Flask, jsonify, send_file, render_template
|
|
||||||
import cv2
|
|
||||||
from spacer import get_latest_screenshot, process_image, get_brightness
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
BASE_DIR = os.path.dirname(__file__)
|
|
||||||
SCREENSHOT_DIR = os.environ.get('MOTION_DIR', os.path.join(BASE_DIR, 'screenshots'))
|
|
||||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
|
||||||
MIN_MOTION_CONTOUR_AREA = int(os.environ.get('MIN_MOTION_AREA', 4000))
|
|
||||||
CAPTURE_DEVICE = int(os.environ.get('CAPTURE_DEVICE', 0))
|
|
||||||
# default one snapshot every 10 minutes (600 seconds). Override with SNAPSHOT_INTERVAL env var.
|
|
||||||
SNAPSHOT_INTERVAL_SECONDS = int(os.environ.get('SNAPSHOT_INTERVAL', 600))
|
|
||||||
# Brightness threshold (0-255) used to decide open/closed state from a photo
|
|
||||||
BRIGHTNESS_THRESHOLD = int(os.environ.get('BRIGHTNESS_THRESHOLD', 50))
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
# ensure logger prints info messages
|
|
||||||
app.logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
state = {
|
|
||||||
'last_image': None,
|
|
||||||
'last_status': 'unknown',
|
|
||||||
'last_brightness': None,
|
|
||||||
'running': False,
|
|
||||||
'monitor_thread': None,
|
|
||||||
}
|
|
||||||
state_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def save_frame(frame):
|
|
||||||
# use timezone-aware UTC datetime to avoid DeprecationWarning
|
|
||||||
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
|
||||||
filename = f'shot_{ts}.jpg'
|
|
||||||
path = os.path.join(SCREENSHOT_DIR, filename)
|
|
||||||
cv2.imwrite(path, frame)
|
|
||||||
app.logger.info('Saved snapshot: %s', path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def motion_monitor():
|
|
||||||
cap = cv2.VideoCapture(CAPTURE_DEVICE)
|
|
||||||
if not cap.isOpened():
|
|
||||||
app.logger.error('Could not open video device %s', CAPTURE_DEVICE)
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = False
|
|
||||||
return
|
|
||||||
|
|
||||||
app.logger.info('Periodic snapshot monitor started on device %s', CAPTURE_DEVICE)
|
|
||||||
last_snapshot = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
with state_lock:
|
|
||||||
if not state.get('running', False):
|
|
||||||
break
|
|
||||||
|
|
||||||
ret, frame = cap.read()
|
|
||||||
if not ret:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - last_snapshot >= SNAPSHOT_INTERVAL_SECONDS:
|
|
||||||
path = save_frame(frame)
|
|
||||||
brightness = get_brightness(path)
|
|
||||||
if brightness is not None:
|
|
||||||
status = 'open' if brightness >= BRIGHTNESS_THRESHOLD else 'closed'
|
|
||||||
else:
|
|
||||||
status = process_image(path)
|
|
||||||
with state_lock:
|
|
||||||
state['last_image'] = path
|
|
||||||
state['last_status'] = status
|
|
||||||
state['last_brightness'] = brightness
|
|
||||||
app.logger.info(
|
|
||||||
'Periodic snapshot: %s brightness=%.1f status=%s',
|
|
||||||
path, brightness if brightness is not None else -1.0, status
|
|
||||||
)
|
|
||||||
last_snapshot = current_time
|
|
||||||
|
|
||||||
time.sleep(0.2)
|
|
||||||
finally:
|
|
||||||
cap.release()
|
|
||||||
app.logger.info('Periodic snapshot monitor stopped')
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = False
|
|
||||||
state['monitor_thread'] = None
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return render_template('index.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/status')
|
|
||||||
def api_status():
|
|
||||||
with state_lock:
|
|
||||||
last_image = state['last_image'] or get_latest_screenshot(SCREENSHOT_DIR)
|
|
||||||
# prefer a recent brightness-derived status if available, otherwise fallback
|
|
||||||
last_status = state.get('last_status') if state.get('last_status') != 'unknown' else process_image(last_image)
|
|
||||||
last_brightness = state.get('last_brightness')
|
|
||||||
# if we don't have brightness yet attempt to compute it for the latest image
|
|
||||||
if last_brightness is None and last_image:
|
|
||||||
last_brightness = get_brightness(last_image)
|
|
||||||
if last_brightness is not None:
|
|
||||||
last_status = 'open' if last_brightness >= BRIGHTNESS_THRESHOLD else 'closed'
|
|
||||||
image_url = '/api/screenshot' if last_image else None
|
|
||||||
return jsonify({
|
|
||||||
'status': last_status,
|
|
||||||
'brightness': last_brightness,
|
|
||||||
'image': image_url,
|
|
||||||
'screenshot_dir': SCREENSHOT_DIR,
|
|
||||||
'brightness_threshold': BRIGHTNESS_THRESHOLD
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/screenshot')
|
|
||||||
def api_screenshot():
|
|
||||||
path = get_latest_screenshot(SCREENSHOT_DIR)
|
|
||||||
if not path:
|
|
||||||
return jsonify({'error': 'no screenshot available'}), 404
|
|
||||||
return send_file(path, mimetype='image/jpeg')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/start', methods=['POST'])
|
|
||||||
def api_start():
|
|
||||||
with state_lock:
|
|
||||||
# if a monitor thread exists and is alive, report running
|
|
||||||
thr = state.get('monitor_thread')
|
|
||||||
if thr is not None and thr.is_alive():
|
|
||||||
state['running'] = True
|
|
||||||
return jsonify({'running': True}), 200
|
|
||||||
# start a new monitor thread
|
|
||||||
state['running'] = True
|
|
||||||
t = threading.Thread(target=motion_monitor, daemon=True)
|
|
||||||
state['monitor_thread'] = t
|
|
||||||
t.start()
|
|
||||||
return jsonify({'running': True}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/stop', methods=['POST'])
|
|
||||||
def api_stop():
|
|
||||||
# request monitor to stop and join the thread briefly
|
|
||||||
thr = None
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = False
|
|
||||||
thr = state.get('monitor_thread')
|
|
||||||
if thr is not None:
|
|
||||||
# give the thread a short time to exit
|
|
||||||
thr.join(timeout=3.0)
|
|
||||||
with state_lock:
|
|
||||||
if state.get('monitor_thread') is thr:
|
|
||||||
state['monitor_thread'] = None
|
|
||||||
return jsonify({'running': False}), 200
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Console (no-web) mode: run monitor in foreground and print status
|
|
||||||
CONSOLE_MODE = os.environ.get('CONSOLE_MODE', 'false').lower() in ('1', 'true', 'yes')
|
|
||||||
if CONSOLE_MODE:
|
|
||||||
print('Starting in CONSOLE_MODE — monitor will run in foreground. Press Ctrl+C to stop.')
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = True
|
|
||||||
try:
|
|
||||||
# run monitor in the main thread (blocking) so logs appear live in console
|
|
||||||
motion_monitor()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print('Interrupted, stopping monitor...')
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = False
|
|
||||||
finally:
|
|
||||||
print('Monitor stopped')
|
|
||||||
else:
|
|
||||||
# Start monitor only if AUTO_START is set (web mode)
|
|
||||||
AUTO_START = os.environ.get('AUTO_START', 'false').lower() in ('1', 'true', 'yes')
|
|
||||||
if AUTO_START:
|
|
||||||
with state_lock:
|
|
||||||
state['running'] = True
|
|
||||||
t = threading.Thread(target=motion_monitor, daemon=True)
|
|
||||||
state['monitor_thread'] = t
|
|
||||||
t.start()
|
|
||||||
# run the Flask webserver
|
|
||||||
app.run(host='0.0.0.0', port=5000)
|
|
||||||
78
run_web.fish
78
run_web.fish
@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env fish
|
|
||||||
# Run the app in web mode with recommended env vars (fish shell)
|
|
||||||
# Usage: ./run_web.fish
|
|
||||||
|
|
||||||
# create venv if missing
|
|
||||||
if not test -d venv
|
|
||||||
python3 -m venv venv
|
|
||||||
end
|
|
||||||
|
|
||||||
# activate venv
|
|
||||||
source venv/bin/activate.fish
|
|
||||||
|
|
||||||
# ensure dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# recommended env vars (adjust if needed)
|
|
||||||
set -x SNAPSHOT_INTERVAL 600 # seconds (600 = 10 minutes)
|
|
||||||
set -x BRIGHTNESS_THRESHOLD 50 # 0-255
|
|
||||||
set -x AUTO_START false # true to auto-start monitor inside web server
|
|
||||||
# ensure CONSOLE_MODE is unset so the Flask server will run
|
|
||||||
set -e CONSOLE_MODE
|
|
||||||
|
|
||||||
# logging
|
|
||||||
set -x LOG_DIR logs
|
|
||||||
mkdir -p $LOG_DIR
|
|
||||||
set -x LOG_FILE $LOG_DIR/run_web.log
|
|
||||||
set -x PID_FILE $LOG_DIR/run_web.pid
|
|
||||||
|
|
||||||
# If a previous PID file exists, try to stop that process first
|
|
||||||
if test -f $PID_FILE
|
|
||||||
set OLD_PID (cat $PID_FILE)
|
|
||||||
if test -n "$OLD_PID"
|
|
||||||
if ps -p $OLD_PID > /dev/null 2>&1
|
|
||||||
echo "Stopping previous process pid $OLD_PID"
|
|
||||||
kill $OLD_PID 2>/dev/null || kill -9 $OLD_PID 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
end
|
|
||||||
rm -f $PID_FILE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Also check for any process listening on port 5000 and kill it to free the port
|
|
||||||
if type lsof >/dev/null 2>&1
|
|
||||||
set PORT_PIDS (lsof -ti:5000 2>/dev/null)
|
|
||||||
if test -n "$PORT_PIDS"
|
|
||||||
echo "Killing processes on port 5000: $PORT_PIDS"
|
|
||||||
for p in $PORT_PIDS
|
|
||||||
kill $p 2>/dev/null || kill -9 $p 2>/dev/null
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# fallback using ss and awk if lsof not available
|
|
||||||
if type ss >/dev/null 2>&1
|
|
||||||
for line in (ss -ltnp 2>/dev/null | grep ':5000' | awk '{print $NF}' | sed -n 's/.*pid=\([0-9]*\).*/\1/p')
|
|
||||||
if test -n "$line"
|
|
||||||
echo "Killing process pid $line listening on port 5000"
|
|
||||||
kill $line 2>/dev/null || kill -9 $line 2>/dev/null
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
echo "Starting web app in background; logs: $LOG_FILE"
|
|
||||||
# Use bash to ensure POSIX redirection works reliably
|
|
||||||
bash -lc "python app.py > '$LOG_FILE' 2>&1 & echo \$! > '$PID_FILE'"
|
|
||||||
|
|
||||||
# wait briefly for process to start
|
|
||||||
sleep 1
|
|
||||||
if test -f $PID_FILE
|
|
||||||
set PID (cat $PID_FILE)
|
|
||||||
echo "Started pid: $PID"
|
|
||||||
else
|
|
||||||
echo "Failed to start process; see $LOG_FILE for details"
|
|
||||||
end
|
|
||||||
|
|
||||||
# tail the log so the user can see live output (Ctrl+C to stop tail but process keeps running)
|
|
||||||
echo "Tailing log (press Ctrl+C to stop tail, process will continue running in background)."
|
|
||||||
tail -n 200 -f $LOG_FILE
|
|
||||||
72
spacer.py
72
spacer.py
@ -1,53 +1,35 @@
|
|||||||
import os
|
import os
|
||||||
import glob
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import glob
|
||||||
|
|
||||||
|
def get_latest_screenshot(directory):
|
||||||
def get_latest_screenshot(directory, ext='jpg'):
|
""" Get the latest screenshot file from the given directory. """
|
||||||
"""Return the latest file path with the given extension in directory or None if none found."""
|
list_of_files = glob.glob(os.path.join(directory, '*.jpg')) # You can change the file extension if needed
|
||||||
if not directory:
|
latest_file = max(list_of_files, key=os.path.getctime)
|
||||||
return None
|
return latest_file
|
||||||
if not os.path.isdir(directory):
|
|
||||||
return None
|
|
||||||
pattern = os.path.join(directory, f'*.{ext}')
|
|
||||||
files = glob.glob(pattern)
|
|
||||||
if not files:
|
|
||||||
return None
|
|
||||||
return max(files, key=os.path.getctime)
|
|
||||||
|
|
||||||
|
|
||||||
def process_image(image_path):
|
def process_image(image_path):
|
||||||
"""Return 'open', 'closed' or 'unknown' based on average brightness of an image.
|
""" Process the image to decide if it's dark or light. """
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
# Resize image to 2x2
|
||||||
|
img = img.resize((2, 2))
|
||||||
|
|
||||||
The function is defensive and returns 'unknown' if the path is missing or an error occurs.
|
# Get average color of the pixels
|
||||||
"""
|
average_color = sum(img.getdata(), (0, 0, 0))
|
||||||
if not image_path or not os.path.isfile(image_path):
|
average_color = [color / 4 for color in average_color] # Since the image is 2x2
|
||||||
return 'unknown'
|
|
||||||
try:
|
|
||||||
with Image.open(image_path) as img:
|
|
||||||
img = img.convert('RGB')
|
|
||||||
img = img.resize((2, 2))
|
|
||||||
pixels = list(img.getdata())
|
|
||||||
if not pixels:
|
|
||||||
return 'unknown'
|
|
||||||
avg = [sum(ch) / len(pixels) for ch in zip(*pixels)]
|
|
||||||
luminance = sum(avg) / 3.0
|
|
||||||
return 'closed' if luminance < 128 else 'open'
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
# Determine if the image is dark or light
|
||||||
|
if sum(average_color) / 3 < 128: # Average RGB values less than 128 is considered dark
|
||||||
|
return 'closed'
|
||||||
|
else:
|
||||||
|
return 'open'
|
||||||
|
|
||||||
def get_brightness(image_path):
|
# Directory where the screenshots are stored
|
||||||
"""Return average brightness (0-255) as float, or None on error."""
|
screenshot_directory = '/var/lib/motion/'
|
||||||
if not image_path or not os.path.isfile(image_path):
|
|
||||||
return None
|
# Get the latest screenshot
|
||||||
try:
|
latest_screenshot = get_latest_screenshot(screenshot_directory)
|
||||||
with Image.open(image_path) as img:
|
|
||||||
img = img.convert('L')
|
# Process the image and determine hackspace status
|
||||||
img = img.resize((16, 16))
|
hackspace_status = process_image(latest_screenshot)
|
||||||
pixels = list(img.getdata())
|
print(f"The hackspace is {hackspace_status}.")
|
||||||
if not pixels:
|
|
||||||
return None
|
|
||||||
return float(sum(pixels) / len(pixels))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background: #f4f6f8;
|
|
||||||
color: #222;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
|
|
||||||
width: 420px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.status.open { background: #28a745; }
|
|
||||||
.status.closed { background: #dc3545; }
|
|
||||||
.status.unknown { background: #6c757d; }
|
|
||||||
img#latest {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: #111;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.controls button {
|
|
||||||
margin: 6px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.controls button#start { background: #007bff; color: white; }
|
|
||||||
.controls button#stop { background: #ffc107; color: #222; }
|
|
||||||
pre#info { text-align: left; background: #f0f0f0; padding: 8px; border-radius: 6px; font-size: 12px; }
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Hackspace Status</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Hackspace Status</h1>
|
|
||||||
<div id="status" class="status unknown">Loading...</div>
|
|
||||||
<img id="latest" src="" alt="Latest screenshot" />
|
|
||||||
<div class="controls">
|
|
||||||
<button id="start">Start Monitor</button>
|
|
||||||
<button id="stop">Stop Monitor</button>
|
|
||||||
</div>
|
|
||||||
<pre id="info"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/status');
|
|
||||||
const j = await r.json();
|
|
||||||
const s = document.getElementById('status');
|
|
||||||
s.textContent = (j.status || 'unknown').toUpperCase();
|
|
||||||
s.className = 'status ' + (j.status || 'unknown');
|
|
||||||
const img = document.getElementById('latest');
|
|
||||||
if (j.image) {
|
|
||||||
img.src = j.image + '?_=' + Date.now();
|
|
||||||
} else {
|
|
||||||
img.src = '';
|
|
||||||
}
|
|
||||||
document.getElementById('info').textContent = JSON.stringify(j, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.getElementById('start').addEventListener('click', async () => {
|
|
||||||
await fetch('/api/start', {method: 'POST'});
|
|
||||||
});
|
|
||||||
document.getElementById('stop').addEventListener('click', async () => {
|
|
||||||
await fetch('/api/stop', {method: 'POST'});
|
|
||||||
});
|
|
||||||
setInterval(refresh, 30000);
|
|
||||||
refresh();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Reference in New Issue
Block a user