aboutsummaryrefslogtreecommitdiffstats
path: root/demo
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2025-05-02 16:37:29 +0300
committerJan Tuomi <jan@jantuomi.fi>2025-05-02 16:37:29 +0300
commitef72b74ec34e95a38890d542cda298dd6565d5ad (patch)
tree63fe96b24a8534e8dd2d88b1ca3d69b21c502d3e /demo
parentdaca0e6b68d32d885face00d68df5b5cb00e098b (diff)
Start replacing browser with a chat demo
Diffstat (limited to 'demo')
-rw-r--r--demo/app.py242
-rw-r--r--demo/requirements.txt11
-rw-r--r--demo/static/InclusiveSans-Regular.ttfbin0 -> 57124 bytes
-rw-r--r--demo/static/htmp.js38
-rw-r--r--demo/static/styles.css140
-rw-r--r--demo/templates/base.html.j219
-rw-r--r--demo/templates/frag_aside.html.j227
-rw-r--r--demo/templates/frag_aside_link.html.j29
-rw-r--r--demo/templates/frag_error.html.j216
-rw-r--r--demo/templates/frag_form_delete.html.j27
-rw-r--r--demo/templates/frag_form_find.html.j27
-rw-r--r--demo/templates/frag_form_range.html.j221
-rw-r--r--demo/templates/frag_form_upsert.html.j25
-rw-r--r--demo/templates/frag_results.html.j218
-rw-r--r--demo/templates/page_error.html.j25
-rw-r--r--demo/templates/page_main.html.j211
16 files changed, 576 insertions, 0 deletions
diff --git a/demo/app.py b/demo/app.py
new file mode 100644
index 0000000..be79432
--- /dev/null
+++ b/demo/app.py
@@ -0,0 +1,242 @@
+from dotenv import load_dotenv
+load_dotenv()
+
+import os
+import sys
+import traceback
+import tempfile
+from typing import cast
+from flask import Flask, render_template, request
+from flask_compress import Compress # type: ignore
+
+import log_db
+from log_db import DB, Bound, Value
+
+app = Flask(__name__)
+Compress(app)
+
+BASE_URL = os.environ["BASE_URL"]
+DB_DIR = os.environ["DB_DIR"] if "DB_DIR" in os.environ else tempfile.TemporaryDirectory().name
+
+print("""\n"""
+ f"""BASE_URL: {BASE_URL}\n"""
+ f"""DB_DIR: {DB_DIR}\n""")
+
+db_fields = ["id", "name"]
+db_types = ["int", "string"]
+
+db = DB \
+ .configure() \
+ .data_dir(DB_DIR) \
+ .fields(db_fields) \
+ .primary_key("id") \
+ .secondary_keys(["name"]) \
+ .initialize()
+
+def error(e: str, code: int):
+ error_text = f"HTTP {code}: {e}"
+ #return render_template("page_error.html.j2", error = error_text)
+
+ htmp_target = request.form.get("htmp") or request.args.get("htmp")
+ if htmp_target:
+ return render_template("frag_error.html.j2", error = error_text, container = htmp_target)
+ else:
+ return render_template("page_error.html.j2", error = error_text)
+
+@app.errorhandler(Exception)
+def error_handler(e: Exception):
+ if hasattr(e, "code") and 400 >= getattr(e, "code") < 500:
+ return error(str(e), getattr(e, "code"))
+ else:
+ traceback.print_exception(e, file=sys.stderr)
+ return error("Internal Server Error", 500)
+
+@app.get("/")
+def index_default():
+ return index("find")
+
+@app.get("/<op>")
+def index(op: str):
+ if op not in ["find", "range", "upsert", "delete"]:
+ return error("Invalid operation", 400)
+
+ rows = db.range_by("id", Bound.unbounded(), Bound.unbounded(), limit=100)
+ rows = [[value_to_str(v) for v in row] for row in rows]
+
+ htmp_target = request.form.get("htmp") or request.args.get("htmp")
+ if htmp_target:
+ return render_template(f"frag_form_{op}.html.j2")
+ else:
+ return render_template('page_main.html.j2',
+ selected_form = f"frag_form_{op}.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+
+@app.post("/find")
+def query_find():
+ field = request.form.get("field")
+ if not field: raise ValueError("Field is required")
+
+ values = request.form.get("values")
+ if not values: raise ValueError("Values are required")
+
+ field_index = db_fields.index(field)
+ if field_index == -1: raise ValueError(f"Field '{field}' not found")
+
+ try:
+ values = [cast_to_value(field, v) for v in values.split("\n")]
+ values = [v for v in values if v is not None]
+ except ValueError as e:
+ return error(str(e), 400)
+
+ tagged_rows = db.batch_find_by(field, values, limit=100)
+ rows = [[value_to_str(v) for v in row] for (_, row) in tagged_rows]
+
+ htmp_target = request.form.get("htmp") or request.args.get("htmp")
+ if htmp_target:
+ return render_template("frag_results.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+ else:
+ return render_template('page_main.html.j2',
+ selected_form = "frag_form_find.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+
+@app.post("/range")
+def query_range():
+ field = request.form.get("field")
+ if not field: raise ValueError("Field is required")
+
+ from_type = request.form.get("from_type")
+ if not from_type: raise ValueError("From type is required")
+
+ to_type = request.form.get("to_type")
+ if not to_type: raise ValueError("To type is required")
+
+ field_index = db_fields.index(field)
+ if field_index == -1: raise ValueError(f"Field '{field}' not found")
+
+ match from_type:
+ case "unbounded":
+ bound_lower = Bound.unbounded()
+ case "included":
+ from_value = request.form.get("from_value")
+ try:
+ if not from_value: raise ValueError("From value is required")
+ from_value = cast_to_value(field, from_value)
+ except ValueError as e:
+ return error(str(e), 400)
+ bound_lower = Bound.included(cast(Value, from_value))
+ case "excluded":
+ from_value = request.form.get("from_value")
+ try:
+ if not from_value: raise ValueError("From value is required")
+ from_value = cast_to_value(field, from_value)
+ except ValueError as e:
+ return error(str(e), 400)
+ bound_lower = Bound.excluded(cast(Value, from_value))
+ case _:
+ return error("Invalid from type", 400)
+
+ match to_type:
+ case "unbounded":
+ bound_upper = Bound.unbounded()
+ case "included":
+ to_value = request.form.get("to_value")
+ try:
+ if not to_value: raise ValueError("To value is required")
+ to_value = cast_to_value(field, to_value)
+ except ValueError as e:
+ return error(str(e), 400)
+ bound_upper = Bound.included(cast(Value, to_value))
+ case "excluded":
+ to_value = request.form.get("to_value")
+ try:
+ if not to_value: raise ValueError("To value is required")
+ to_value = cast_to_value(field, to_value)
+ except ValueError as e:
+ return error(str(e), 400)
+ bound_upper = Bound.excluded(cast(Value, to_value))
+ case _:
+ return error("Invalid to type", 400)
+
+ tagged_rows = db.range_by(field, bound_lower, bound_upper, limit=100)
+ rows = [[value_to_str(v) for v in row] for row in tagged_rows]
+
+ htmp_target = request.form.get("htmp") or request.args.get("htmp")
+ if htmp_target:
+ return render_template("frag_results.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+ else:
+ return render_template('page_main.html.j2',
+ selected_form = "frag_form_range.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+
+@app.post("/delete")
+def query_delete():
+ field = request.form.get("field")
+ if not field: raise ValueError("Field is required")
+
+ value = request.form.get("value")
+ if not value: raise ValueError("Value is required")
+
+ field_index = db_fields.index(field)
+ if field_index == -1: raise ValueError(f"Field '{field}' not found")
+
+ try:
+ value = cast_to_value(field, value)
+ except ValueError as e:
+ return error(str(e), 400)
+
+ rows = db.delete_by(field, cast(Value, value))
+ rows = [[value_to_str(v) for v in row] for row in rows]
+
+ htmp_target = request.form.get("htmp") or request.args.get("htmp")
+ if htmp_target:
+ return render_template("frag_results.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+ else:
+ return render_template('page_main.html.j2',
+ selected_form = "frag_form_delete.html.j2",
+ field_names = ["id", "name"],
+ rows = rows,
+ )
+
+
+# Utils
+
+def cast_to_value(field: str, str_value: str) -> Value | None:
+ str_value = str_value.strip()
+ if str_value == "": return None
+ if str_value[0] == "\"":
+ if str_value[-1] != "\"": raise ValueError(f"Invalid string: {str_value}")
+ str_value = str_value[1:-1]
+
+ field_index = db_fields.index(field)
+ if field_index == -1: raise ValueError(f"Field '{field}' not found")
+
+ type = db_types[field_index]
+
+ match type:
+ case "int": return Value.int(int(str_value))
+ case "string": return Value.string(str_value)
+ case _: raise ValueError(f"Unsupported type: {type}")
+
+def value_to_str(value: Value) -> str:
+ match value.kind():
+ case log_db.VALUE_INT: return f"{str(value.as_int())} (int)"
+ case log_db.VALUE_STRING: return f"\"{value.as_string()}\" (string)"
+ case log_db.VALUE_DECIMAL: return f"{value.as_decimal()} (decimal)"
+ case log_db.VALUE_BYTES: return f"{value.as_bytes()} (bytes)"
+ case log_db.VALUE_NULL: return "null"
+ case _: raise ValueError(f"Unsupported value kind: {value.kind()}")
diff --git a/demo/requirements.txt b/demo/requirements.txt
new file mode 100644
index 0000000..b6b9bff
--- /dev/null
+++ b/demo/requirements.txt
@@ -0,0 +1,11 @@
+blinker==1.9.0
+Brotli==1.1.0
+click==8.1.8
+Flask==3.1.0
+Flask-Compress==1.17
+itsdangerous==2.2.0
+Jinja2==3.1.5
+MarkupSafe==3.0.2
+python-dotenv==1.0.1
+Werkzeug==3.1.3
+zstandard==0.23.0
diff --git a/demo/static/InclusiveSans-Regular.ttf b/demo/static/InclusiveSans-Regular.ttf
new file mode 100644
index 0000000..3a30e79
--- /dev/null
+++ b/demo/static/InclusiveSans-Regular.ttf
Binary files differ
diff --git a/demo/static/htmp.js b/demo/static/htmp.js
new file mode 100644
index 0000000..27d963b
--- /dev/null
+++ b/demo/static/htmp.js
@@ -0,0 +1,38 @@
+const iframe = document.createElement("iframe");
+iframe.name = "htmp";
+iframe.hidden = true;
+iframe.addEventListener("load", function () {
+ setTimeout(() => {
+ const cd = this.contentDocument;
+ const cw = this.contentWindow;
+ // If the server responds with an entire HTML document, replace the current document with it.
+ // To check whether the response is an entire page we check the presence of this very snippet.
+ if (cd.querySelector("iframe[name='htmp']")) {
+ document.documentElement.replaceWith(cd.documentElement);
+ // If the server responds with a fragment, replace the target element with it.
+ } else {
+ document
+ .querySelector(cw.location.hash || null)
+ ?.replaceWith(...cd.body.childNodes);
+ const url = new URL(cw.location.href);
+ url.searchParams.delete("htmp");
+ url.hash = "";
+ history.replaceState({}, "", url.toString());
+ }
+ });
+});
+document.body.appendChild(iframe);
+
+document.querySelectorAll("form[htmp]").forEach(function (el) {
+ const r = el.attributes.replace.value;
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = "htmp";
+ input.value = r;
+ el.appendChild(input);
+
+ const action = new URL(el.action);
+ action.hash = r;
+ el.action = action.toString();
+ el.target = "htmp";
+});
diff --git a/demo/static/styles.css b/demo/static/styles.css
new file mode 100644
index 0000000..82bed74
--- /dev/null
+++ b/demo/static/styles.css
@@ -0,0 +1,140 @@
+:root {
+ --blue: rgb(30, 99, 248);
+ --red: #d42f2f;
+ --yellow: #ff9800;
+ --black: rgb(0, 0, 0);
+ --white: rgb(255, 255, 255);
+ --gray: #666;
+ --light-gray-1: #f2f2f2;
+ --light-gray-2: #f8f6ff;
+ --gold-trans: #ffcd0455;
+
+ --font-family: "Inclusive Sans", sans-serif;
+}
+
+@font-face {
+ font-family: "Inclusive Sans";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/static/InclusiveSans-Regular.ttf) format("woff2");
+}
+
+html {
+ font-family: var(--font-family) !important;
+ font-size: 16px;
+}
+
+body {
+ padding: 10px;
+ margin: 0 auto;
+ line-height: 24px;
+}
+
+#container {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+a,
+.contrast {
+ color: var(--blue);
+}
+
+h1 {
+ margin-bottom: 40px;
+}
+
+h1 a {
+ color: var(--black);
+ text-decoration: none;
+}
+
+h1:hover a {
+ border-bottom: 2px dotted var(--blue);
+ text-decoration: none;
+}
+
+textarea,
+input {
+ font-family: var(--font-family);
+ font-size: 16px;
+}
+
+button,
+input[type="submit"] {
+ cursor: pointer;
+ font-size: 16px;
+ border-radius: 4px;
+ text-shadow: none;
+ box-shadow: none;
+ padding: 5px 8px;
+ border: 0;
+ background-color: var(--blue);
+ color: var(--white);
+
+ margin-top: 20px;
+}
+
+button.link,
+input[type="submit"].link {
+ background: none;
+ color: var(--blue);
+ padding: 0;
+ margin: 0;
+ text-decoration: underline;
+ font-family: "Inclusive Sans";
+}
+
+button.red,
+input[type="submit"].red {
+ background-color: var(--red);
+}
+
+button.blue,
+input[type="submit"].blue {
+ background-color: var(--blue);
+}
+
+button.yellow,
+input[type="submit"].yellow {
+ background-color: var(--yellow);
+}
+
+input[type="text"],
+input[type*="date"] {
+ font-size: 16px;
+ color: var(--black);
+}
+
+input[type="text"]:disabled,
+input[type*="date"]:disabled {
+ color: var(--gray);
+}
+
+aside {
+ width: 250px;
+}
+
+main {
+ flex: 1;
+ overflow-y: scroll;
+ max-height: 100%;
+}
+
+table {
+ width: 100%;
+}
+
+th,
+td {
+ min-width: 100px;
+ text-align: left;
+ padding: 7px 5px;
+}
+
+tr:nth-of-type(odd) td {
+ background-color: var(--light-gray-2);
+}
diff --git a/demo/templates/base.html.j2 b/demo/templates/base.html.j2
new file mode 100644
index 0000000..47b3f6c
--- /dev/null
+++ b/demo/templates/base.html.j2
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="preload" href="/static/InclusiveSans-Regular.ttf" as="font" type="font/woff2" crossorigin="anonymous">
+ <link rel="stylesheet" href="/static/styles.css"></link>
+ {% block head_meta %}
+ <title>Database browser</title>
+ <meta name="description" content="Database browser for log_db">
+ {% endblock %}
+</head>
+<body>
+ <h1><a href="/">Database browser</a></h1>
+ {% block content %}{% endblock %}
+
+ <script src="/static/htmp.js"></script>
+</body>
+</html>
diff --git a/demo/templates/frag_aside.html.j2 b/demo/templates/frag_aside.html.j2
new file mode 100644
index 0000000..6c5658f
--- /dev/null
+++ b/demo/templates/frag_aside.html.j2
@@ -0,0 +1,27 @@
+{#
+Params
+ selected_form Form fragment to include
+#}
+
+<aside id="aside">
+ <h3>Read operations</h3>
+ <ul>
+ {% with href="/find", text="Find" %}
+ <li>{% include "frag_aside_link.html.j2" %}</li>
+ {% endwith %}
+ {% with href="/range", text="Range" %}
+ <li>{% include "frag_aside_link.html.j2" %}</li>
+ {% endwith %}
+ </ul>
+ <h3>Write operations</h3>
+ <ul>
+ {% with href="/upsert", text="Upsert" %}
+ <li>{% include "frag_aside_link.html.j2" %}</li>
+ {% endwith %}
+ {% with href="/delete", text="Delete" %}
+ <li>{% include "frag_aside_link.html.j2" %}</li>
+ {% endwith %}
+ </ul>
+
+ {% include selected_form %}
+</aside>
diff --git a/demo/templates/frag_aside_link.html.j2 b/demo/templates/frag_aside_link.html.j2
new file mode 100644
index 0000000..9cd6041
--- /dev/null
+++ b/demo/templates/frag_aside_link.html.j2
@@ -0,0 +1,9 @@
+{#
+Params
+ text Text to render in the link
+ href URL to link to
+#}
+
+<form method="GET" action="{{ href }}" htmp replace=form_query>
+ <button class="link" type="submit">{{ text }}</button>
+</form>
diff --git a/demo/templates/frag_error.html.j2 b/demo/templates/frag_error.html.j2
new file mode 100644
index 0000000..c5034cd
--- /dev/null
+++ b/demo/templates/frag_error.html.j2
@@ -0,0 +1,16 @@
+{#
+Params
+ error Error message to display
+ container (optional) id for a div to wrap the error message in
+#}
+
+{% if container %}
+<div id="{{ container }}">
+{% endif %}
+
+<h2>Error</h2>
+{{ error }}
+
+{% if container %}
+</div>
+{% endif %}
diff --git a/demo/templates/frag_form_delete.html.j2 b/demo/templates/frag_form_delete.html.j2
new file mode 100644
index 0000000..22306c1
--- /dev/null
+++ b/demo/templates/frag_form_delete.html.j2
@@ -0,0 +1,7 @@
+<form id="form_query" action="/delete" method="post" htmp replace=results>
+ <label for="field">Field</label>
+ <input type="text" name="field" id="field" required>
+ <label for="value">Value</label>
+ <input type="text" name="value" id="value" required>
+ <button type="submit">Run query</button>
+</form>
diff --git a/demo/templates/frag_form_find.html.j2 b/demo/templates/frag_form_find.html.j2
new file mode 100644
index 0000000..cadd44b
--- /dev/null
+++ b/demo/templates/frag_form_find.html.j2
@@ -0,0 +1,7 @@
+<form id="form_query" action="/find" method="post" htmp replace=results>
+ <label for="field">Field</label>
+ <input type="text" name="field" id="field" required>
+ <label for="values">Values (one per line)</label>
+ <textarea name="values" id="values" required></textarea>
+ <button type="submit">Run query</button>
+</form>
diff --git a/demo/templates/frag_form_range.html.j2 b/demo/templates/frag_form_range.html.j2
new file mode 100644
index 0000000..84b8024
--- /dev/null
+++ b/demo/templates/frag_form_range.html.j2
@@ -0,0 +1,21 @@
+<form id="form_query" action="/range" method="post" htmp replace=results>
+ <label for="field">Field</label>
+ <input type="text" name="field" id="field" required>
+ <label for="from_type">Lower bound type</label>
+ <select id="from_type" name="from_type" required>
+ <option value="unbounded">Unbounded</option>
+ <option value="included">Inclusive</option>
+ <option value="excluded">Exclusive</option>
+ </select>
+ <label for="values">Lower bound value</label>
+ <input type="text" name="from_value" id="from_value">
+ <label for="to_type">Upper bound type</label>
+ <select id="to_type" name="to_type" required>
+ <option value="unbounded">Unbounded</option>
+ <option value="included">Inclusive</option>
+ <option value="excluded">Exclusive</option>
+ </select>
+ <label for="values">Upper bound value</label>
+ <input type="text" name="to_value" id="to_value">
+ <button type="submit">Run query</button>
+</form>
diff --git a/demo/templates/frag_form_upsert.html.j2 b/demo/templates/frag_form_upsert.html.j2
new file mode 100644
index 0000000..68820f5
--- /dev/null
+++ b/demo/templates/frag_form_upsert.html.j2
@@ -0,0 +1,5 @@
+<form id="form_query" action="/upsert" method="post" htmp replace=results>
+ <label for="values">Values (one per line)</label>
+ <textarea name="values" id="values" required></textarea>
+ <button type="submit">Run query</button>
+</form>
diff --git a/demo/templates/frag_results.html.j2 b/demo/templates/frag_results.html.j2
new file mode 100644
index 0000000..bfb54e9
--- /dev/null
+++ b/demo/templates/frag_results.html.j2
@@ -0,0 +1,18 @@
+<table id="results">
+<thead>
+ <tr>
+ {% for field_name in field_names %}
+ <th>{{ field_name }}</th>
+ {% endfor %}
+ </tr>
+</thead>
+<tbody>
+ {% for row in rows %}
+ <tr>
+ {% for field in row %}
+ <td>{{ field }}</td>
+ {% endfor %}
+ </tr>
+ {% endfor %}
+</tbody>
+</table>
diff --git a/demo/templates/page_error.html.j2 b/demo/templates/page_error.html.j2
new file mode 100644
index 0000000..3233283
--- /dev/null
+++ b/demo/templates/page_error.html.j2
@@ -0,0 +1,5 @@
+{% extends "base.html.j2" %}
+
+{% block content %}
+{% include "frag_error.html.j2" %}
+{% endblock %}
diff --git a/demo/templates/page_main.html.j2 b/demo/templates/page_main.html.j2
new file mode 100644
index 0000000..907275f
--- /dev/null
+++ b/demo/templates/page_main.html.j2
@@ -0,0 +1,11 @@
+{% extends "base.html.j2" %}
+
+{% block content %}
+<div id="container">
+ {% include "frag_aside.html.j2" %}
+
+ <main id="main">
+ {% include "frag_results.html.j2" %}
+ </main>
+</div>
+{% endblock %}