From 7094d050300703c5f33adcd65df545e80b5e3215 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Sat, 3 Oct 2026 20:24:37 +0200 Subject: [PATCH] css --- server/README.md | 10 +- server/app.py | 55 ++++--- server/run-prod.sh | 18 +-- server/static/retro.css | 288 ++++++++++++++++++++++++++++++++++ server/templates/docs.html | 32 ++-- server/templates/home.html | 48 ++++-- server/templates/login.html | 99 +++++++++--- server/templates/sensors.html | 166 ++++++++++++++------ 8 files changed, 581 insertions(+), 135 deletions(-) create mode 100644 server/static/retro.css diff --git a/server/README.md b/server/README.md index 176b223..35bc430 100644 --- a/server/README.md +++ b/server/README.md @@ -49,8 +49,10 @@ python seed.py ## Users There is no public registration. All accounts are created by an administrator. -Pages (`/`, `/home`, `/sensors`, `/api/docs`) and `GET /api/*` require login. -`POST /api/*` stays open so devices can push data without a browser session. +Pages (`/`, `/home`, `/sensors`, `/api/docs`) and dashboard data endpoints +(`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`) require login. +`GET /api/sensors` (list, needed by devices to resolve name to id) and +`POST /api/*` stay open so devices can push data without a browser session. Set a secret key for sessions (required in production): @@ -68,10 +70,10 @@ with the CLI command (flags, env vars, or interactive prompts): alembic upgrade head # Option 1: flags -flask --app app create-admin --username admin --password 'sekret' +flask --app app create-admin --username admin --password 'secret' # Option 2: environment variables (e.g. on first deploy) -ADMIN_USERNAME=admin ADMIN_PASSWORD=sekret flask --app app create-admin +ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin # Option 3: interactive (asks for username/password) flask --app app create-admin diff --git a/server/app.py b/server/app.py index 6fcd03f..25cd5c9 100644 --- a/server/app.py +++ b/server/app.py @@ -1,4 +1,5 @@ import os +from datetime import datetime, timedelta, timezone import click from flask import Flask, jsonify, redirect, render_template, request, url_for @@ -34,7 +35,7 @@ def create_app(): @login_manager.unauthorized_handler def unauthorized(): - # Dla API zwróć JSON zamiast przekierowania na stronę logowania. + # Return JSON for API calls instead of redirecting to the login page. if request.path.startswith("/api/"): return jsonify({"error": "authentication required"}), 401 next_page = request.path @@ -46,7 +47,7 @@ def create_app(): def _is_safe_next(target: str | None) -> bool: return bool(target) and target.startswith("/") and not target.startswith("//") - # --- Strony --- + # --- Pages --- @app.route("/") def index(): @@ -69,7 +70,7 @@ def create_app(): if not _is_safe_next(next_page): next_page = url_for("home") return redirect(next_page) - error = "Nieprawidłowa nazwa użytkownika lub hasło." + error = "Invalid username or password." return render_template("login.html", error=error, next=request.args.get("next", "")) @app.route("/logout") @@ -86,21 +87,24 @@ def create_app(): @app.route("/sensors") @login_required def sensors_page(): - return render_template("sensors.html") + return render_template("sensors.html", user=current_user) @app.route("/api/docs") @login_required def api_docs(): - return render_template("docs.html") + return render_template("docs.html", user=current_user) - # --- API: odczyt (wymaga zalogowania, sesja z przeglądarki wystarcza) --- + # --- API: device endpoints (intentionally public) --- + # The sensor firmware has no browser session, but it needs the sensor + # list to resolve a name to an id before pushing readouts. @app.route("/api/sensors") - @login_required def api_sensors(): sensors = Sensor.query.order_by(Sensor.name).all() return jsonify([{"id": s.id, "name": s.name} for s in sensors]) + # --- API: dashboard reads (login required, the browser session cookie is enough) --- + @app.route("/api/sensors//types") @login_required def api_sensor_types(sensor_id): @@ -132,6 +136,19 @@ def create_app(): @login_required def api_sensor_readouts_hourly(sensor_id): type_ = request.args.get("type") + range_ = request.args.get("range", "all") + # Timestamps are stored as naive UTC, so the cutoff must be naive UTC too. + now = datetime.now(timezone.utc).replace(tzinfo=None) + if range_ == "today": + cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif range_ == "week": + cutoff = now - timedelta(days=7) + elif range_ == "month": + cutoff = now - timedelta(days=30) + elif range_ == "all": + cutoff = None + else: + return jsonify({"error": "range must be one of: today, week, month, all"}), 400 query = ( db.session.query( func.strftime("%Y-%m-%dT%H:00:00", SensorReadout.timestamp).label("hour"), @@ -139,6 +156,8 @@ def create_app(): ) .filter(SensorReadout.sensor_id == sensor_id) ) + if cutoff is not None: + query = query.filter(SensorReadout.timestamp >= cutoff) if type_: query = query.filter(SensorReadout.type == type_) rows = ( @@ -150,7 +169,7 @@ def create_app(): [{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows] ) - # --- API: zapis (celowo bez logowania — wywołują je urządzenia/sensory) --- + # --- API: device writes (intentionally public — called by devices/sensors) --- @app.route("/api/sensors", methods=["POST"]) def api_create_sensor(): @@ -206,17 +225,17 @@ def create_app(): } ), 201 - # --- Komenda do utworzenia pierwszego admina --- - # Użycie: - # flask --app app create-admin --username admin --password 'sekret' - # ADMIN_USERNAME=admin ADMIN_PASSWORD=sekret flask --app app create-admin - # flask --app app create-admin (zapyta interaktywnie) + # --- Command for creating the first admin --- + # Usage: + # flask --app app create-admin --username admin --password 'secret' + # ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin + # flask --app app create-admin (asks interactively) @app.cli.command("create-admin") - @click.option("--username", default=None, help="Nazwa administratora") - @click.option("--password", default=None, help="Hasło administratora") + @click.option("--username", default=None, help="Administrator username") + @click.option("--password", default=None, help="Administrator password") def create_admin(username, password): - """Utwórz lub zaktualizuj konto administratora (is_admin=True).""" + """Create or update an administrator account (is_admin=True).""" username = username or os.environ.get("ADMIN_USERNAME") if not username: username = click.prompt("Admin username") @@ -235,13 +254,13 @@ def create_app(): user.set_password(password) user.is_admin = True db.session.commit() - click.echo(f"Zaktualizowano istniejącego użytkownika '{username}' (nadano is_admin=True).") + click.echo(f"Updated existing user '{username}' (granted is_admin=True).") else: user = User(username=username, is_admin=True) user.set_password(password) db.session.add(user) db.session.commit() - click.echo(f"Utworzono administratora '{username}'.") + click.echo(f"Created administrator '{username}'.") return app diff --git a/server/run-prod.sh b/server/run-prod.sh index 259162a..4c13c2f 100755 --- a/server/run-prod.sh +++ b/server/run-prod.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# Uruchomienie produkcyjne (gunicorn) ze stabilnym SECRET_KEY. +# Production startup (gunicorn) with a stable SECRET_KEY. # -# Kolejność źródeł klucza: -# 1. zmienna środowiskowa SECRET_KEY (ma pierwszeństwo), -# 2. plik .secret_key obok skryptu (tworzony raz przy pierwszym starcie), -# 3. w przeciwnym razie generowany jest losowy klucz i zapisywany do .secret_key. +# Key resolution order: +# 1. SECRET_KEY environment variable (takes precedence), +# 2. .secret_key file next to this script (created once on first start), +# 3. otherwise a random key is generated and saved to .secret_key. # -# Stabilny klucz ma znaczenie: podpisuje ciasteczka sesji, więc jego zmiana -# przy każdym restarcie wylogowałaby wszystkich użytkowników. +# A stable key matters: it signs the session cookies, so changing it +# on every restart would log out all users. set -euo pipefail cd "$(dirname "$0")" @@ -20,12 +20,12 @@ if [ -z "${SECRET_KEY:-}" ]; then SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" printf '%s' "$SECRET_KEY" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" - echo "Wygenerowano nowy SECRET_KEY, zapisano w $SECRET_FILE" >&2 + echo "Generated a new SECRET_KEY, saved to $SECRET_FILE" >&2 fi export SECRET_KEY fi -# Migracje przed startem, żeby schemat był zgodny z kodem. +# Migrate before starting, so the schema matches the code. if [ -x ".venv/bin/alembic" ]; then .venv/bin/alembic upgrade head elif command -v alembic >/dev/null 2>&1; then diff --git a/server/static/retro.css b/server/static/retro.css new file mode 100644 index 0000000..49b1889 --- /dev/null +++ b/server/static/retro.css @@ -0,0 +1,288 @@ +/* Yggdrasil retro theme — Midnight Commander vibes. + * Midnight-blue background, pixel frames, hard shadows, zero rounding. + * VT323 font (falls back to monospace if the CDN is unreachable). + */ +:root { + --mc-blue: #0000aa; + --mc-deep: #000066; + --mc-fg: #d7d7d7; + --mc-dim: #a8a8a8; + --mc-bright: #ffffff; + --mc-cyan: #55ffff; + --mc-cyan-dim: #00aaaa; + --mc-yellow: #ffff55; + --mc-red: #aa0000; + --mc-red-bright: #ff5555; + --mc-black: #000000; +} + +* { box-sizing: border-box; border-radius: 0 !important; } + +html { background: var(--mc-black); } + +body { + background: var(--mc-blue); + color: var(--mc-fg); + font-family: "VT323", "Courier New", Courier, monospace; + font-size: 22px; + line-height: 1.35; + max-width: 900px; + margin: 0 auto; + padding: 1rem 1rem 4rem; + min-height: 100vh; +} + +/* Subtle CRT effect — scanlines over the whole page. */ +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.10) 0 1px, + transparent 1px 3px + ); +} + +::selection { background: var(--mc-cyan); color: var(--mc-black); } + +.hidden { display: none !important; } + +/* --- Headings --- */ +h1 { + color: var(--mc-yellow); + text-transform: uppercase; + letter-spacing: 3px; + font-size: 2.2rem; + text-shadow: 3px 3px 0 var(--mc-black); + border-bottom: 3px double var(--mc-dim); + padding-bottom: 0.3em; +} + +h2 { color: var(--mc-bright); letter-spacing: 2px; } + +@keyframes mcblink { 50% { opacity: 0; } } + +/* --- Menu bar (navigation) --- */ +nav { + background: var(--mc-black); + border: 2px solid var(--mc-dim); + padding: 0.3em 0.5em; + margin-bottom: 1rem; +} +nav a { + color: var(--mc-cyan); + text-decoration: none; + padding: 0 0.6em; + margin-right: 0.2em; +} +nav a:hover { background: var(--mc-cyan); color: var(--mc-black); } + +/* --- Panels and dialogs --- */ +.panel { + background: var(--mc-deep); + border: 3px double var(--mc-bright); + box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45); + padding: 1rem 1.2rem; + margin-top: 1rem; +} + +.dialog { + position: relative; + max-width: 460px; + margin: 10vh auto 0; + background: var(--mc-blue); + border: 3px double var(--mc-bright); + box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.5); + padding: 1.5rem 1.5rem 1.2rem; +} +.dialog-title { + position: absolute; + top: -0.75em; + left: 50%; + transform: translateX(-50%); + background: var(--mc-blue); + color: var(--mc-yellow); + padding: 0 1em; + letter-spacing: 2px; + text-transform: uppercase; + white-space: nowrap; +} + +/* --- Forms --- */ +form { display: flex; flex-direction: column; gap: 0.8rem; } +label { color: var(--mc-bright); letter-spacing: 1px; } + +/* Terminal-style field wrapper for the block caret (see login.html). + * The overlay block is positioned by JS; without JS it stays hidden. */ +.term-field { position: relative; } +.caret-block { + display: none; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 1ch; + height: 1.15em; + background: var(--mc-yellow); + box-shadow: 0 0 6px rgba(255, 255, 85, 0.6); + pointer-events: none; + animation: mcblink 1.06s steps(1) infinite; +} +.term-field.active .caret-block { display: block; } +/* Hide the native thin caret only when the JS block takes over. */ +input.js-caret { caret-color: transparent; } + +input, select { + font: inherit; + background: var(--mc-black); + color: var(--mc-bright); + border: 2px solid var(--mc-dim); + padding: 0.3em 0.5em; + width: 100%; +} +input:focus, select:focus { + outline: none; + border-color: var(--mc-yellow); +} +select { width: 100%; margin-top: 0.3rem; } +option { background: var(--mc-black); color: var(--mc-bright); } + +button { + font: inherit; + text-transform: uppercase; + letter-spacing: 2px; + background: var(--mc-dim); + color: var(--mc-black); + border: none; + box-shadow: 4px 4px 0 var(--mc-black); + padding: 0.4em 1.2em; + cursor: pointer; +} +button:hover { background: var(--mc-cyan); } +button:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--mc-black); } +button:disabled { opacity: 0.5; cursor: default; } + +#refresh-button { margin-top: 0.6rem; } + +.error { + background: var(--mc-red); + color: var(--mc-bright); + border: 2px solid var(--mc-bright); + box-shadow: 4px 4px 0 var(--mc-black); + padding: 0.4em 0.6em; +} + +/* --- Tables --- */ +table { width: 100%; margin-top: 0.5rem; border-collapse: collapse; } +th, td { + border: 1px solid var(--mc-dim); + padding: 0.3em 0.6em; + text-align: left; +} +th { background: var(--mc-cyan-dim); color: var(--mc-black); text-transform: uppercase; letter-spacing: 1px; } +tbody tr:hover { background: var(--mc-cyan); color: var(--mc-black); } + +/* --- Links inside content --- */ +a { color: var(--mc-cyan); } +a:hover { background: var(--mc-cyan); color: var(--mc-black); text-decoration: none; } + +/* --- MC-style menu (home page) --- */ +.mc-menu { list-style: none; padding: 0; margin: 1rem 0; } +.mc-menu li { margin: 0.2em 0; } +.mc-menu a { + display: block; + text-decoration: none; + padding: 0.2em 0.6em; +} +.mc-menu a::before { content: "[*] "; color: var(--mc-yellow); } +.mc-menu a:hover::before { color: var(--mc-black); } +.mc-key { + display: inline-block; + background: var(--mc-black); + color: var(--mc-cyan); + border: 1px solid var(--mc-cyan-dim); + padding: 0 0.4em; + margin-right: 0.5em; +} +a:hover .mc-key { background: var(--mc-black); color: var(--mc-black); } + +.hint { color: var(--mc-dim); } + +/* Yellow highlight for usernames, roles and other key words. */ +.hl { color: var(--mc-yellow); } + +/* --- API docs --- */ +.endpoint { + background: var(--mc-deep); + border: 3px double var(--mc-bright); + box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.45); + padding: 1rem 1.2rem; + margin: 1rem 0; +} +.method { + display: inline-block; + padding: 0.1em 0.6em; + margin-right: 0.5em; + font-size: 0.9em; + letter-spacing: 1px; +} +.get { background: var(--mc-cyan); color: var(--mc-black); } +.post { background: var(--mc-yellow); color: var(--mc-black); } +.path { font-size: 1.1rem; color: var(--mc-bright); } +.desc { margin: 0.5rem 0; } +.section-label { + font-size: 0.85em; + color: var(--mc-dim); + margin: 0.75rem 0 0.25rem; + text-transform: uppercase; + letter-spacing: 2px; +} +pre { + background: var(--mc-black); + color: var(--mc-cyan); + border: 2px solid var(--mc-dim); + padding: 0.7rem; + overflow-x: auto; + font-size: 0.9em; +} +code { font-family: inherit; } +.note { + background: var(--mc-black); + border-left: 6px solid var(--mc-yellow); + padding: 0.5rem 0.75rem; + margin: 1rem 0; +} + +/* --- Bottom status bar --- */ +.statusbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: var(--mc-black); + color: var(--mc-dim); + border-top: 2px solid var(--mc-dim); + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.25em 1em; + font-size: 0.95em; + z-index: 100; +} +.statusbar b { + background: var(--mc-cyan-dim); + color: var(--mc-bright); + padding: 0 0.4em; + margin-right: 0.3em; +} +.statusbar a { text-decoration: none; } + +/* --- Chart --- */ +.chart-box { margin-top: 1rem; } +canvas { + background: var(--mc-black); + border: 3px double var(--mc-bright); + padding: 0.5rem; +} diff --git a/server/templates/docs.html b/server/templates/docs.html index c972b92..e91883b 100644 --- a/server/templates/docs.html +++ b/server/templates/docs.html @@ -3,27 +3,17 @@ - API Documentation - Sensor Readouts - + API Documentation - Yggdrasil + + + +

Sensor Readouts — API Documentation

Base URL

@@ -127,5 +117,9 @@ curl "http://localhost:5000/api/sensors/1/readouts?type=temperature" Note: All request and response bodies use application/json. + +
+ {{ user.username }}@yggdrasil:~/api$ +
diff --git a/server/templates/home.html b/server/templates/home.html index b69b231..abf544b 100644 --- a/server/templates/home.html +++ b/server/templates/home.html @@ -1,22 +1,48 @@ - + - Strona domowa - + Yggdrasil — Home + + + -

Witaj, {{ user.username }}!

-

Jesteś zalogowany{% if user.is_admin %} jako administrator{% endif %}.

+

Yggdrasil

+
+

Welcome, {{ user.username }}! You are logged in{% if user.is_admin %} as administrator{% endif %}.

+

Select an item from the menu:

+ +

Tip: press 1, 2 or 0 to navigate.

+
+
+ {{ user.username }}@yggdrasil:~$ +
+ diff --git a/server/templates/login.html b/server/templates/login.html index 0567176..4fe96d8 100644 --- a/server/templates/login.html +++ b/server/templates/login.html @@ -1,34 +1,81 @@ - + - Logowanie - + Login — Yggdrasil + + + -

Logowanie

- {% if error %} -

{{ error }}

- {% endif %} -
- -
-
- -
-
-
- -
- -
+
+
[ Login ]
+

Yggdrasil

+ {% if error %} +

! {{ error }}

+ {% endif %} +
+ +
+
+
+ + +
+
+
+
+
+ + +
+
+ +
+
+ diff --git a/server/templates/sensors.html b/server/templates/sensors.html index ed995cd..0a239b6 100644 --- a/server/templates/sensors.html +++ b/server/templates/sensors.html @@ -3,24 +3,17 @@ - Sensor Readouts + Sensor Readouts — Yggdrasil - + + +

Sensor Readouts

@@ -38,8 +31,20 @@ + + +
+ {{ user.username }}@yggdrasil:~/sensors$ +
+