css
This commit is contained in:
+6
-4
@@ -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
|
||||
|
||||
+37
-18
@@ -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/<int:sensor_id>/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
|
||||
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+13
-19
@@ -3,27 +3,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Documentation - Sensor Readouts</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; line-height: 1.5; }
|
||||
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||
h2 { margin-top: 2rem; color: #333; }
|
||||
.endpoint { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 1rem 1.25rem; margin: 1rem 0; }
|
||||
.method { display: inline-block; font-weight: bold; padding: 0.15rem 0.5rem; border-radius: 3px; color: #fff; margin-right: 0.5rem; font-size: 0.85rem; }
|
||||
.get { background: #2563eb; }
|
||||
.post { background: #16a34a; }
|
||||
.path { font-family: monospace; font-size: 1rem; }
|
||||
.desc { margin: 0.5rem 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; font-size: 0.9rem; }
|
||||
th { background: #f0f0f0; }
|
||||
pre { background: #1e1e1e; color: #d4d4d4; padding: 0.75rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; }
|
||||
code { font-family: monospace; }
|
||||
.note { background: #fef9c3; border-left: 3px solid #eab308; padding: 0.5rem 0.75rem; margin: 1rem 0; font-size: 0.9rem; }
|
||||
.section-label { font-size: 0.8rem; color: #888; margin: 0.75rem 0 0.25rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
</style>
|
||||
<title>API Documentation - Yggdrasil</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{{ url_for('home') }}">Home</a>
|
||||
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||
<a href="{{ url_for('logout') }}">Log out</a>
|
||||
</nav>
|
||||
<h1>Sensor Readouts — API Documentation</h1>
|
||||
|
||||
<h2>Base URL</h2>
|
||||
@@ -127,5 +117,9 @@ curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"</code></pre
|
||||
<div class="note">
|
||||
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
|
||||
</div>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span>{{ user.username }}@yggdrasil:~/api$</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+37
-11
@@ -1,22 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Strona domowa</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
|
||||
nav a { margin-right: 1rem; }
|
||||
</style>
|
||||
<title>Yggdrasil — Home</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{{ url_for('home') }}">Dom</a>
|
||||
<a href="{{ url_for('sensors_page') }}">Sensory</a>
|
||||
<a href="{{ url_for('home') }}">Home</a>
|
||||
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||
<a href="{{ url_for('api_docs') }}">API</a>
|
||||
<a href="{{ url_for('logout') }}">Wyloguj</a>
|
||||
<a href="{{ url_for('logout') }}">Log out</a>
|
||||
</nav>
|
||||
<h1>Witaj, {{ user.username }}!</h1>
|
||||
<p>Jesteś zalogowany{% if user.is_admin %} jako administrator{% endif %}.</p>
|
||||
<h1>Yggdrasil</h1>
|
||||
<div class="panel">
|
||||
<p>Welcome, <span class="hl">{{ user.username }}</span>! You are logged in{% if user.is_admin %} as <b class="hl">administrator</b>{% endif %}.</p>
|
||||
<p>Select an item from the menu:</p>
|
||||
<ul class="mc-menu">
|
||||
<li><a href="{{ url_for('sensors_page') }}"><span class="mc-key">1</span>Sensors — charts and readouts</a></li>
|
||||
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">2</span>API — documentation</a></li>
|
||||
<li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
|
||||
</ul>
|
||||
<p class="hint">Tip: press <b>1</b>, <b>2</b> or <b>0</b> to navigate.</p>
|
||||
</div>
|
||||
<footer class="statusbar">
|
||||
<span>{{ user.username }}@yggdrasil:~$</span>
|
||||
</footer>
|
||||
<script>
|
||||
// Numbered menu hotkeys, Midnight Commander style.
|
||||
// Ignored while typing in a form field.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
|
||||
const targets = {
|
||||
'1': "{{ url_for('sensors_page') }}",
|
||||
'2': "{{ url_for('api_docs') }}",
|
||||
'0': "{{ url_for('logout') }}"
|
||||
};
|
||||
if (targets[e.key]) window.location.href = targets[e.key];
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+64
-17
@@ -1,34 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logowanie</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 400px; margin: 4rem auto; padding: 0 1rem; }
|
||||
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
label { font-weight: bold; }
|
||||
input { padding: 0.5rem; font-size: 1rem; }
|
||||
button { padding: 0.6rem; font-size: 1rem; cursor: pointer; }
|
||||
.error { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 4px; }
|
||||
</style>
|
||||
<title>Login — Yggdrasil</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Logowanie</h1>
|
||||
<div class="dialog">
|
||||
<div class="dialog-title">[ Login ]</div>
|
||||
<h1>Yggdrasil</h1>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
<p class="error">! {{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('login', next=next) if next else url_for('login') }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div>
|
||||
<label for="username">Użytkownik:</label><br>
|
||||
<input type="text" id="username" name="username" required autocomplete="username" style="width: 100%; box-sizing: border-box;">
|
||||
<label for="username">Username:</label><br>
|
||||
<div class="term-field">
|
||||
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
|
||||
<span class="caret-block" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Hasło:</label><br>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" style="width: 100%; box-sizing: border-box;">
|
||||
<label for="password">Password:</label><br>
|
||||
<div class="term-field">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<span class="caret-block" aria-hidden="true"></span>
|
||||
</div>
|
||||
<button type="submit">Zaloguj</button>
|
||||
</div>
|
||||
<button type="submit">[ OK ] Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
// Block caret, Midnight Commander style.
|
||||
// The native caret is hidden (only when this script runs) and a
|
||||
// blinking block is drawn exactly where the text cursor is.
|
||||
// Measurement is exact because the font is monospace.
|
||||
// Without JS the fields keep their normal native caret.
|
||||
function attachBlockCaret(input) {
|
||||
const field = input.closest('.term-field');
|
||||
const block = field.querySelector('.caret-block');
|
||||
input.classList.add('js-caret');
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
|
||||
function place() {
|
||||
const cs = getComputedStyle(input);
|
||||
ctx.font = cs.font;
|
||||
const pos = (input.selectionStart !== null && input.selectionStart !== undefined)
|
||||
? input.selectionStart
|
||||
: input.value.length;
|
||||
let text = input.value.slice(0, pos);
|
||||
if (input.type === 'password') text = '\u2022'.repeat(text.length);
|
||||
const padLeft = parseFloat(cs.paddingLeft) || 0;
|
||||
const borderLeft = parseFloat(cs.borderLeftWidth) || 0;
|
||||
const x = borderLeft + padLeft + ctx.measureText(text).width - (input.scrollLeft || 0);
|
||||
block.style.left = Math.max(borderLeft + padLeft, x) + 'px';
|
||||
}
|
||||
|
||||
function show() {
|
||||
field.classList.add('active');
|
||||
requestAnimationFrame(place);
|
||||
}
|
||||
function hide() { field.classList.remove('active'); }
|
||||
|
||||
['input', 'change', 'keyup', 'click', 'select', 'focus'].forEach((ev) => {
|
||||
input.addEventListener(ev, show);
|
||||
});
|
||||
input.addEventListener('blur', hide);
|
||||
window.addEventListener('load', () => setTimeout(place, 300)); // late autofill
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(place);
|
||||
if (document.activeElement === input) show();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.term-field input').forEach(attachBlockCaret);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,24 +3,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sensor Readouts</title>
|
||||
<title>Sensor Readouts — Yggdrasil</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
|
||||
select, table { width: 100%; margin-top: 0.5rem; }
|
||||
select { padding: 0.4rem; }
|
||||
table { border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
label { font-weight: bold; }
|
||||
.hidden { display: none; }
|
||||
.chart-box { margin-top: 1rem; }
|
||||
</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{{ url_for('home') }}">Dom</a>
|
||||
<a href="{{ url_for('logout') }}">Wyloguj</a>
|
||||
<a href="{{ url_for('home') }}">Home</a>
|
||||
<a href="{{ url_for('api_docs') }}">API</a>
|
||||
<a href="{{ url_for('logout') }}">Log out</a>
|
||||
</nav>
|
||||
<h1>Sensor Readouts</h1>
|
||||
|
||||
@@ -38,8 +31,20 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="range-section" class="hidden">
|
||||
<label for="range-select">Range:</label>
|
||||
<select id="range-select">
|
||||
<option value="today">Today</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="all" selected>All</option>
|
||||
</select>
|
||||
<button id="refresh-button" type="button">[ Refresh ]</button>
|
||||
</div>
|
||||
|
||||
<div id="chart-section" class="hidden chart-box">
|
||||
<canvas id="chart"></canvas>
|
||||
<p id="chart-error" class="error hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="readouts-section" class="hidden">
|
||||
@@ -54,15 +59,32 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span>{{ user.username }}@yggdrasil:~/sensors$</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const sensorSelect = document.getElementById('sensor-select');
|
||||
const typeSelect = document.getElementById('type-select');
|
||||
const typeSection = document.getElementById('type-section');
|
||||
const rangeSelect = document.getElementById('range-select');
|
||||
const rangeSection = document.getElementById('range-section');
|
||||
const chartSection = document.getElementById('chart-section');
|
||||
const chartError = document.getElementById('chart-error');
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const readoutsSection = document.getElementById('readouts-section');
|
||||
const readoutsBody = document.getElementById('readouts-body');
|
||||
let chart = null;
|
||||
|
||||
// The page must keep working (dropdown + table) even if the
|
||||
// Chart.js CDN is unreachable — the chart is then simply skipped.
|
||||
if (window.Chart) {
|
||||
Chart.defaults.color = '#d7d7d7';
|
||||
Chart.defaults.borderColor = 'rgba(215,215,215,0.18)';
|
||||
Chart.defaults.font.family = "'VT323', 'Courier New', monospace";
|
||||
Chart.defaults.font.size = 20;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw' });
|
||||
@@ -91,6 +113,7 @@
|
||||
sensorSelect.addEventListener('change', async () => {
|
||||
const sensorId = sensorSelect.value;
|
||||
typeSection.classList.add('hidden');
|
||||
rangeSection.classList.add('hidden');
|
||||
chartSection.classList.add('hidden');
|
||||
readoutsSection.classList.add('hidden');
|
||||
typeSelect.innerHTML = '<option value="">-- select type --</option>';
|
||||
@@ -106,19 +129,23 @@
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
typeSection.classList.remove('hidden');
|
||||
rangeSection.classList.remove('hidden');
|
||||
});
|
||||
|
||||
typeSelect.addEventListener('change', async () => {
|
||||
async function loadChart() {
|
||||
const sensorId = sensorSelect.value;
|
||||
const type = typeSelect.value;
|
||||
const range = rangeSelect.value;
|
||||
chartSection.classList.add('hidden');
|
||||
readoutsSection.classList.add('hidden');
|
||||
readoutsBody.innerHTML = '';
|
||||
chartError.classList.add('hidden');
|
||||
|
||||
if (!type) return;
|
||||
if (!sensorId || !type || !window.Chart) return;
|
||||
|
||||
try {
|
||||
// fetch hourly data for chart
|
||||
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}`);
|
||||
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}&range=${range}`);
|
||||
if (!hourly.length) throw new Error('no data for this range');
|
||||
|
||||
const labels = hourly.map(r => formatChartLabel(r.timestamp));
|
||||
const values = hourly.map(r => r.value);
|
||||
|
||||
@@ -131,11 +158,13 @@
|
||||
datasets: [{
|
||||
label: type,
|
||||
data: values,
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37,99,235,0.1)',
|
||||
borderColor: '#55ffff',
|
||||
backgroundColor: 'rgba(85,255,255,0.12)',
|
||||
pointBackgroundColor: '#ffff55',
|
||||
pointBorderColor: '#55ffff',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -148,6 +177,23 @@
|
||||
}
|
||||
});
|
||||
chartSection.classList.remove('hidden');
|
||||
// The chart was created while the container was hidden,
|
||||
// so force a re-measure now that it is visible.
|
||||
chart.resize();
|
||||
} catch (err) {
|
||||
chartError.textContent = '! Chart error: ' + err.message;
|
||||
chartError.classList.remove('hidden');
|
||||
chartSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTable() {
|
||||
const sensorId = sensorSelect.value;
|
||||
const type = typeSelect.value;
|
||||
readoutsSection.classList.add('hidden');
|
||||
readoutsBody.innerHTML = '';
|
||||
|
||||
if (!sensorId || !type) return;
|
||||
|
||||
// fetch raw readouts for table
|
||||
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
|
||||
@@ -157,8 +203,32 @@
|
||||
readoutsBody.appendChild(tr);
|
||||
});
|
||||
readoutsSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
typeSelect.addEventListener('change', async () => {
|
||||
if (!typeSelect.value) {
|
||||
chartSection.classList.add('hidden');
|
||||
readoutsSection.classList.add('hidden');
|
||||
readoutsBody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
await loadChart();
|
||||
await loadTable();
|
||||
});
|
||||
|
||||
refreshButton.addEventListener('click', async () => {
|
||||
if (!sensorSelect.value || !typeSelect.value || refreshButton.disabled) return;
|
||||
refreshButton.disabled = true;
|
||||
try {
|
||||
await loadChart();
|
||||
await loadTable();
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
rangeSelect.addEventListener('change', loadChart);
|
||||
|
||||
loadSensors();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user