basic views
This commit is contained in:
parent
df922fcdba
commit
c2afc3654c
9 changed files with 339 additions and 22 deletions
27
a5/db.py
27
a5/db.py
|
|
@ -11,6 +11,8 @@ class Stats:
|
||||||
co2_min: sqlite3.Row | None
|
co2_min: sqlite3.Row | None
|
||||||
co2_max: sqlite3.Row | None
|
co2_max: sqlite3.Row | None
|
||||||
|
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class DB:
|
class DB:
|
||||||
"""Class for handling database interactions"""
|
"""Class for handling database interactions"""
|
||||||
|
|
@ -19,7 +21,7 @@ class DB:
|
||||||
CREATE_SQL = """
|
CREATE_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS measurements
|
CREATE TABLE IF NOT EXISTS measurements
|
||||||
(
|
(
|
||||||
timestamp integer not null primary key,
|
timestamp real not null primary key,
|
||||||
tvoc real not null,
|
tvoc real not null,
|
||||||
co2 real not null
|
co2 real not null
|
||||||
);
|
);
|
||||||
|
|
@ -36,7 +38,7 @@ CREATE TABLE IF NOT EXISTS measurements
|
||||||
cursor.execute(self.CREATE_SQL)
|
cursor.execute(self.CREATE_SQL)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def store(self, timestamp: int, tvoc: float, co2: float) -> int | None:
|
def store(self, timestamp: float, tvoc: float, co2: float) -> int | None:
|
||||||
"""Adds a measurement"""
|
"""Adds a measurement"""
|
||||||
with closing(self._conn.cursor()) as cursor:
|
with closing(self._conn.cursor()) as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
|
|
@ -49,10 +51,10 @@ CREATE TABLE IF NOT EXISTS measurements
|
||||||
|
|
||||||
def get_stats(self) -> Stats:
|
def get_stats(self) -> Stats:
|
||||||
"""Returns measurement statistics"""
|
"""Returns measurement statistics"""
|
||||||
|
stats = Stats()
|
||||||
with closing(self._conn.cursor()) as cursor:
|
with closing(self._conn.cursor()) as cursor:
|
||||||
# this could probably be made into a single query, but the database being sqlite results in much lower latency with multiple selects
|
# this could probably be made into a single query, but the database being sqlite results in much lower latency with multiple selects
|
||||||
# returned object could also be better typed
|
# returned object could also be better typed
|
||||||
stats = Stats()
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
# sql
|
# sql
|
||||||
"SELECT tvoc, timestamp FROM measurements ORDER BY tvoc DESC LIMIT 1"
|
"SELECT tvoc, timestamp FROM measurements ORDER BY tvoc DESC LIMIT 1"
|
||||||
|
|
@ -73,18 +75,27 @@ CREATE TABLE IF NOT EXISTS measurements
|
||||||
"SELECT co2, timestamp FROM measurements ORDER BY co2 ASC LIMIT 1"
|
"SELECT co2, timestamp FROM measurements ORDER BY co2 ASC LIMIT 1"
|
||||||
)
|
)
|
||||||
stats.co2_min = cursor.fetchone()
|
stats.co2_min = cursor.fetchone()
|
||||||
return stats
|
stats.count = self.count()
|
||||||
|
return stats
|
||||||
|
|
||||||
def get_page(self, page: int, page_size: int = 20) -> List[sqlite3.Row]:
|
def get_page(self, page: int, page_size: int = 20) -> List[sqlite3.Row]:
|
||||||
"""Gets a paged list of all measurements. Page is 0 indexed."""
|
"""Gets a paged list of all measurements. Page is 0 indexed."""
|
||||||
with closing(self._conn.cursor()) as cur:
|
with closing(self._conn.cursor()) as cursor:
|
||||||
cur = self._conn.cursor()
|
cursor.execute(
|
||||||
cur.execute(
|
|
||||||
# sql
|
# sql
|
||||||
"SELECT timestamp, tvoc, co2 FROM measurements ORDER BY timestamp DESC LIMIT ?, ?",
|
"SELECT timestamp, tvoc, co2 FROM measurements ORDER BY timestamp DESC LIMIT ?, ?",
|
||||||
(page_size, page * page_size),
|
(page_size, page * page_size),
|
||||||
)
|
)
|
||||||
return cur.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
"""Counts number of measurements"""
|
||||||
|
with closing(self._conn.cursor()) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
# sql
|
||||||
|
"SELECT COUNT(*) as count FROM MEASUREMENTS"
|
||||||
|
)
|
||||||
|
return cursor.fetchone()["count"]
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Closes the DB connection"""
|
"""Closes the DB connection"""
|
||||||
|
|
|
||||||
46
a5/main.py
46
a5/main.py
|
|
@ -1,46 +1,64 @@
|
||||||
from flask import Flask, g, redirect, render_template, request, url_for
|
from time import time
|
||||||
from werkzeug.wrappers.response import Response
|
|
||||||
|
from flask import Flask, g, render_template, request
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
from flask_htmx import HTMX
|
||||||
|
|
||||||
from db import DB
|
from db import DB
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
htmx = HTMX(app)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/report", methods=["POST"])
|
@app.route("/api/report", methods=["POST"])
|
||||||
def report() -> str | Response:
|
def report() -> ResponseReturnValue:
|
||||||
"""Adds a measurement"""
|
"""Adds a measurement"""
|
||||||
timestamp = request.form["timestamp"]
|
# timestamp = request.json["timestamp"]
|
||||||
tvoc = request.form["tvoc"]
|
tvoc = request.json["tvoc"]
|
||||||
co2 = request.form["co2"]
|
co2 = request.json["co2"]
|
||||||
try:
|
try:
|
||||||
timestamp = int(timestamp)
|
# timestamp = int(timestamp)
|
||||||
tvoc = float(tvoc)
|
tvoc = float(tvoc)
|
||||||
co2 = float(co2)
|
co2 = float(co2)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Invalid data", 400
|
return "Invalid data", 400
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
timestamp = time()
|
||||||
db.store(timestamp, tvoc, co2)
|
db.store(timestamp, tvoc, co2)
|
||||||
return "ok", 200
|
return "ok", 200
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stats", methods=["GET"])
|
@app.route("/stats", methods=["GET"])
|
||||||
def stats() -> Response:
|
def stats() -> ResponseReturnValue:
|
||||||
|
"""Gets overall statistics as well as few recent measurements"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
return db.get_stats()
|
stats = db.get_stats()
|
||||||
|
recents = db.get_page(0, 10)
|
||||||
|
template = "page/index.html.j2"
|
||||||
|
if htmx:
|
||||||
|
template = "block/index.html.j2"
|
||||||
|
return render_template(template, stats=stats, list=recents)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/measurements", methods=["GET"])
|
@app.route("/measurements", methods=["GET"])
|
||||||
def measurements() -> Response:
|
def measurements() -> ResponseReturnValue:
|
||||||
|
"""Lists measurements"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
page = request.get["page"]
|
|
||||||
page_size = 20
|
page_size = 20
|
||||||
|
page = request.args["page"]
|
||||||
try:
|
try:
|
||||||
page = int(page)
|
page = int(page)
|
||||||
if page < 0:
|
if page < 0:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Invalid page", 400
|
return "Invalid page", 400
|
||||||
return db.get_page(page, page_size)
|
data = db.get_page(page, page_size)
|
||||||
|
count = db.count()
|
||||||
|
has_next_page = count > page * page_size
|
||||||
|
template = "page/measurements.html.j2"
|
||||||
|
if htmx:
|
||||||
|
template = "block/measurements.html.j2"
|
||||||
|
return render_template(template, has_next_page=has_next_page, page=page, list=data)
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> DB:
|
def get_db() -> DB:
|
||||||
|
|
|
||||||
1
a5/static/icon-right.svg
Normal file
1
a5/static/icon-right.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" data-astro-cid-6ygtcg62="true" data-icon="solar:arrow-right-linear"> <symbol id="ai:solar:arrow-right-linear" viewBox="0 0 24 24"><path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 12h16m0 0l-6-6m6 6l-6 6"></path></symbol><use href="#ai:solar:arrow-right-linear"></use> </svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
164
a5/static/styles.css
Normal file
164
a5/static/styles.css
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
:root {
|
||||||
|
--font-mono: "Fira Code VF", "Fira Code", monospace;
|
||||||
|
--font-sans: "Fira Sans", sans-serif;
|
||||||
|
--primary-dark: #242038;
|
||||||
|
--primary: #9067c6;
|
||||||
|
--bg-dark: #100d11;
|
||||||
|
--bg: #110f1a;
|
||||||
|
--bg-success: #7ceec8;
|
||||||
|
--gray-700: oklch(37.3% 0.034 259.733);
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to bottom, var(--primary), var(--bg));
|
||||||
|
opacity: 0.06;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
z-index: 100;
|
||||||
|
/*background: var(--bg);*/
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
position: fixed;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 70rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: auto;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-spacer {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .links {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.text-gray-500 {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
isolation: isolate;
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-border {
|
||||||
|
border: 1px solid var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn::before {
|
||||||
|
z-index: -1;
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover::before {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 70rem;
|
||||||
|
margin: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card > img {
|
||||||
|
height: 5rem;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .contents {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
border: 1px solid var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link::after {
|
||||||
|
content: "";
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background-image: url("/static/icon-right.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-out {
|
||||||
|
border: 1px solid var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-out::after {
|
||||||
|
content: "";
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background-image: url("/static/icon-link.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
49
a5/templates/base.html.j2
Normal file
49
a5/templates/base.html.j2
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
>
|
||||||
|
<meta name="description"
|
||||||
|
content="{% block description %}Sensor driven website{% endblock %}"
|
||||||
|
>
|
||||||
|
<title>Sensor - {% block title %}{% endblock %}</title>
|
||||||
|
|
||||||
|
<link rel="preconnect"
|
||||||
|
href="https://fonts.googleapis.com"
|
||||||
|
>
|
||||||
|
<link rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossorigin
|
||||||
|
>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
>
|
||||||
|
<link href="/static/styles.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-gradient"></div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="font-bold">
|
||||||
|
Sensor
|
||||||
|
<span class="text-gray-500">></span>
|
||||||
|
{{ self.title() }}
|
||||||
|
</a>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/" class="btn">/</a>
|
||||||
|
<a href="/measurements" class="btn">/measurements</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="nav-spacer"></div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
a5/templates/block/index.html.j2
Normal file
31
a5/templates/block/index.html.j2
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<div hx-get="/" hx-trigger="every 2s" hx-target="main"></div>
|
||||||
|
|
||||||
|
<h1>Overview</h1>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="contents">
|
||||||
|
Count {{ stats.count }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<h2>Recent measurements</h2>
|
||||||
|
|
||||||
|
{% for row in list %}
|
||||||
|
<article class="card">
|
||||||
|
<div class="contents">
|
||||||
|
<header>@ {{row['timestamp']}}</header>
|
||||||
|
CO2: {{row['co2']}}
|
||||||
|
TVOC: {{row['tvoc']}}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<article class="card">
|
||||||
|
<div class="contents">
|
||||||
|
<header>No measurements yet.</header>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/measurements" class="btn btn-link">View measurements</a>
|
||||||
|
</div>
|
||||||
31
a5/templates/block/measurements.html.j2
Normal file
31
a5/templates/block/measurements.html.j2
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<div hx-get="/measurements" hx-trigger="every 2s" hx-target="main"></div>
|
||||||
|
|
||||||
|
<h1>Measurements</h1>
|
||||||
|
<p>Page {{page}}</p>
|
||||||
|
|
||||||
|
{% for row in list %}
|
||||||
|
<article class="card">
|
||||||
|
<div class="contents">
|
||||||
|
<header>@ {{row['timestamp']}}</header>
|
||||||
|
CO2: {{row['co2']}}
|
||||||
|
TVOC: {{row['tvoc']}}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<article class="card">
|
||||||
|
<div class="contents">
|
||||||
|
<header>No measurements yet.</header>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if has_next_page %}
|
||||||
|
<a href="/measurements?page={{page+1}}" class="btn btn-link">Previous page</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if page > 0 %}
|
||||||
|
<a href="/measurements?page={{page-1}}" class="btn btn-link">Next page</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/" class="btn btn-link">View statistics</a>
|
||||||
|
</div>
|
||||||
6
a5/templates/page/index.html.j2
Normal file
6
a5/templates/page/index.html.j2
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "../base.html.j2" %}
|
||||||
|
{% block title %}Overview{% endblock %}
|
||||||
|
{% block description %}Overview of a CO2 and TVOC sensor data.{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "../block/index.html.j2" %}
|
||||||
|
{% endblock %}
|
||||||
6
a5/templates/page/measurements.html.j2
Normal file
6
a5/templates/page/measurements.html.j2
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "../base.html.j2" %}
|
||||||
|
{% block title %}Measurements{% endblock %}
|
||||||
|
{% block description %}List of measurements{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "../block/measurements.html.j2" %}
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue