diff --git a/.github/images/explore.png b/.github/images/explore.png new file mode 100644 index 0000000..0b8cf5d Binary files /dev/null and b/.github/images/explore.png differ diff --git a/README.md b/README.md index 4239914..84e2d01 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ The current version of succulent comes packed with exciting features, including, - **Effortless data retrieval** from POST requests 📥 - **Versatile data storage options**, such as CSV, JSON, SQLite, XML, and even images 🗂️📊🖼️ - **Customisable boundaries for collected data**, allowing you to set minimum and maximum thresholds ⚙️ +- **Web dashboard for exploring the data** with charts, data quality and image gallery (optional) 📊 With succulent, the process of collecting, managing, and preprocessing data becomes a breeze, empowering you to focus on what truly matters—gaining valuable insights from your embedded devices, microcontrollers, and smartwatches. ⌚ So why waste precious time? ⏳ Dive into the world of succulent and unlock the true potential of your data! 💪📈 @@ -260,6 +261,30 @@ results: To export the data, send a GET request (or navigate) to [http://localhost:8080/export](http://localhost:8080/export). To export password-protected data, send the password as described above. The data will be downloaded in the format specified in the configuration file. +### Data exploration +succulent includes an optional web dashboard for monitoring the collected data in a browser. To enable it, define the following setting in the `configuration.yml` file in the root directory: +```yml +explore: + - enable: true # false by default + - refresh: 10 # auto-refresh interval in seconds (optional, 0 disables it) +``` + +Then navigate to [http://localhost:8080/explore](http://localhost:8080/explore). The dashboard shows: + +- the latest value of each column, with its position between `min` and `max`, +- data quality: the number of measurements, missing values, values outside `min`/`max` and the typical interval between measurements, +- a chart for each numeric column, with `min` and `max` as dashed lines (long ranges are averaged so the page stays fast), +- the latest measurements in a table, +- for the `image` format, a gallery of the uploaded images. + +With `timestamp: true`, you can choose the time range (1 h, 24 h, 7 d, 30 d or all). If export is enabled, the data can be downloaded as CSV from the dashboard. + +

succulent explore dashboard

+ +If a password is set, the dashboard asks for it once and keeps the session in a cookie, so the password never appears in the URL. Sessions last until the server restarts; to keep them across restarts, set a `secret_key` in the configuration. Scripts can read `/explore/api/data` with the `Authorization: Bearer password` or `X-API-Key: password` header instead. + +The dashboard only reads the stored data; data collection works exactly as without it. It needs no internet access (no external scripts or fonts), so it also works on a local network, and values sent by devices are always shown as text. + ## 📄 Cite us Fister Jr., Iztok, and Tadej Lahovnik. Succulent. 0.4.0, doi:[10.5281/zenodo.10402365](https://doi.org/10.5281/zenodo.10402365). diff --git a/docs/getting_started.rst b/docs/getting_started.rst index a2e23f5..c53eacf 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -139,4 +139,29 @@ To export the data, enable the export option in the configuration file: results: - export: true # false by default -To export the data, send a GET request (or navigate) to `http://localhost:8080/export `_. To export password-protected data, send the password as described above. The data will be downloaded in the format specified in the configuration file. \ No newline at end of file +To export the data, send a GET request (or navigate) to `http://localhost:8080/export `_. To export password-protected data, send the password as described above. The data will be downloaded in the format specified in the configuration file. + +Data exploration +~~~~~~~~~~~~~~~~ + +succulent includes an optional web dashboard for monitoring the collected data in a browser. To enable it, define the following setting in the ``configuration.yml`` file in the root directory: + +.. code:: yaml + + explore: + - enable: true # false by default + - refresh: 10 # auto-refresh interval in seconds (optional, 0 disables it) + +Then navigate to `http://localhost:8080/explore `_. The dashboard shows: + +- the latest value of each column, with its position between ``min`` and ``max``, +- data quality: the number of measurements, missing values, values outside ``min``/``max`` and the typical interval between measurements, +- a chart for each numeric column, with ``min`` and ``max`` as dashed lines (long ranges are averaged so the page stays fast), +- the latest measurements in a table, +- for the ``image`` format, a gallery of the uploaded images. + +With ``timestamp: true``, you can choose the time range (1 h, 24 h, 7 d, 30 d or all). If export is enabled, the data can be downloaded as CSV from the dashboard. + +If a password is set, the dashboard asks for it once and keeps the session in a cookie, so the password never appears in the URL. Sessions last until the server restarts; to keep them across restarts, set a ``secret_key`` in the configuration. Scripts can read ``/explore/api/data`` with the ``Authorization: Bearer password`` or ``X-API-Key: password`` header instead. + +The dashboard only reads the stored data; data collection works exactly as without it. It needs no internet access (no external scripts or fonts), so it also works on a local network, and values sent by devices are always shown as text. diff --git a/succulent/api.py b/succulent/api.py index 9c20631..58c3604 100644 --- a/succulent/api.py +++ b/succulent/api.py @@ -5,6 +5,7 @@ from werkzeug.exceptions import BadRequest, HTTPException from succulent.configuration import Configuration from succulent.processing import Processing +from succulent.explore import Explorer class SucculentRequest(Request): @@ -89,6 +90,15 @@ def __init__(self, config, host='0.0.0.0', port=8080, format='csv'): self.app.register_error_handler(HTTPException, self.http_error) self.app.register_error_handler(Exception, self.internal_error) + # Optional web dashboard (/explore), enabled in the configuration. + # It only reads the stored data and is separate from the API above. + if self.config.get('secret_key'): + self.app.secret_key = str(self.config['secret_key']) + self.explorer = None + if Explorer.enabled(self.config): + self.explorer = Explorer(self.processing, self.config.get('explore')) + self.explorer.register(self.app) + def index(self): """Generate index HTML page with information about the API. diff --git a/succulent/explore.py b/succulent/explore.py new file mode 100644 index 0000000..fee8a6e --- /dev/null +++ b/succulent/explore.py @@ -0,0 +1,535 @@ +"""Web dashboard for exploring the collected data (``/explore``). + +The dashboard is optional and separate from the data collection API: it is +only registered when ``explore`` is enabled in the configuration, it does not +change how data is collected or stored, and it only reads the stored data. + +Example configuration:: + + explore: + - enable: true + - refresh: 10 # auto-refresh interval in seconds (optional) +""" +import hmac +import math +import os +import secrets +import sqlite3 +from datetime import datetime, timedelta + +import numpy as np +import pandas as pd +from flask import (Blueprint, abort, jsonify, make_response, redirect, + render_template, request, send_from_directory, session, + url_for) +from werkzeug.utils import secure_filename + + +class Explorer: + """Dashboard for exploring the collected data in a web browser. + + Args: + processing (Processing): The processing instance of the API, used + for the format, columns, boundaries, password and storage + location. The data itself is only read. + config (dict): The ``explore`` section of the configuration. + + Attributes: + processing (Processing): The processing instance of the API. + refresh (int): Auto-refresh interval in seconds (0 disables it). + blueprint (Blueprint): Flask blueprint with the dashboard routes. + """ + + # Time ranges offered in the dashboard, mapped to their length + RANGES = { + '1h': timedelta(hours=1), + '24h': timedelta(hours=24), + '7d': timedelta(days=7), + '30d': timedelta(days=30), + 'all': None, + } + + # Maximum number of points per chart; longer series are averaged in + # equal buckets so the browser stays fast + MAX_POINTS = 1000 + + # Number of raw rows shown in the table of latest measurements + LATEST_ROWS = 10 + + # Session key set after a successful password prompt + SESSION_KEY = 'succulent_explore' + + # Data formats that store measurements in a table + TABLE_FORMATS = ['csv', 'json', 'sqlite', 'xml'] + + def __init__(self, processing, config=None): + self.processing = processing + self.refresh = 10 + + settings = self.settings(config) + if 'refresh' in settings: + refresh = settings['refresh'] + if not isinstance(refresh, (int, float)) or isinstance(refresh, bool) or refresh < 0: + raise ValueError('"refresh" in "explore" must be a number of seconds (0 disables it).') + self.refresh = int(refresh) + + self.blueprint = Blueprint( + 'explore', __name__, + template_folder='explore_templates', + static_folder='explore_static', + static_url_path='/explore/static') + + bp = self.blueprint + bp.add_url_rule('/explore', 'page', self.page, methods=['GET']) + bp.add_url_rule('/explore/login', 'login', self.login, methods=['GET', 'POST']) + bp.add_url_rule('/explore/logout', 'logout', self.logout, methods=['POST']) + bp.add_url_rule('/explore/api/data', 'api_data', self.api_data, methods=['GET']) + bp.add_url_rule('/explore/export', 'export', self.export, methods=['GET']) + bp.add_url_rule('/explore/api/images', 'api_images', self.api_images, methods=['GET']) + bp.add_url_rule('/explore/images/', 'image', self.image, methods=['GET']) + bp.after_request(self.security_headers) + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + + @staticmethod + def settings(config): + """Merge the ``explore`` section into one dictionary. + + Both the list style used by other sections + (``[{'enable': True}, {'refresh': 10}]``) and a plain mapping + (``{'enable': True, 'refresh': 10}``) are accepted. + + Args: + config (list or dict or None): The ``explore`` section. + + Raises: + ValueError: The section has an unexpected shape. + + Returns: + dict: The merged settings. + """ + if config is None: + return {} + if isinstance(config, dict): + return dict(config) + if isinstance(config, list) and all(isinstance(item, dict) for item in config): + merged = {} + for item in config: + merged.update(item) + return merged + raise ValueError('"explore" must be a list of settings, e.g. [{"enable": true}].') + + @classmethod + def enabled(cls, config): + """Check whether the dashboard is enabled in the configuration. + + Args: + config (dict): The whole configuration. + + Returns: + bool: True if ``explore`` has ``enable: true``. + """ + return cls.settings(config.get('explore')).get('enable') is True + + def register(self, app): + """Register the dashboard routes on a Flask application. + + A random secret key is set for the session cookie if the + application has none. Sessions then last until the server + restarts; set ``secret_key`` in the configuration to keep them. + + Args: + app (Flask): The Flask application. + + Returns: + None + """ + if not app.secret_key: + app.secret_key = secrets.token_hex(32) + app.config.setdefault('SESSION_COOKIE_SAMESITE', 'Lax') + app.config.setdefault('SESSION_COOKIE_HTTPONLY', True) + app.register_blueprint(self.blueprint) + + # ------------------------------------------------------------------ + # Access + # ------------------------------------------------------------------ + + def allowed(self): + """Check whether the current request may see the dashboard. + + Without a password everyone may. With a password, a browser needs + a session from the password prompt; scripts may instead send the + password in a header, as for ``/data``. + + Returns: + bool: True if access is allowed. + """ + if not self.processing.password: + return True + if session.get(self.SESSION_KEY) is True: + return True + return bool(request.headers.get('Authorization') or request.headers.get('X-API-Key')) \ + and self.processing.authorised(request) + + def login(self): + """Show the password prompt (GET) or check the password (POST). + + Returns: + Response: The prompt, or a redirect to the dashboard. + """ + if not self.processing.password: + return redirect(url_for('explore.page')) + + error = False + if request.method == 'POST': + supplied = request.form.get('password', '') + expected = str(self.processing.password).encode('utf-8') + if hmac.compare_digest(supplied.encode('utf-8'), expected): + session.clear() + session[self.SESSION_KEY] = True + return redirect(url_for('explore.page')) + error = True + + return render_template('explore_login.html', error=error), 401 if error else 200 + + def logout(self): + """End the dashboard session. + + Returns: + Response: A redirect to the password prompt. + """ + session.pop(self.SESSION_KEY, None) + return redirect(url_for('explore.login')) + + # ------------------------------------------------------------------ + # Pages + # ------------------------------------------------------------------ + + def page(self): + """Show the dashboard. + + Returns: + Response: The dashboard page, or a redirect to the prompt. + """ + if not self.allowed(): + return redirect(url_for('explore.login')) + + can_export = (self.processing.format in self.TABLE_FORMATS + and getattr(self.processing, 'enable_export', False)) + + return render_template( + 'explore.html', + format=self.processing.format, + refresh=self.refresh, + protected=bool(self.processing.password), + gallery=self.processing.format == 'image', + export_url=url_for('explore.export') if can_export else None) + + def export(self): + """Download the stored data as CSV from the dashboard. + + Available only when export is enabled in the configuration + (``results: - export: true``), like ``/export``; the dashboard + session is accepted instead of a password header. + + Returns: + Response: The CSV file. + """ + if not self.allowed(): + return jsonify({'message': 'Unauthorised access.'}), 401 + if (self.processing.format not in self.TABLE_FORMATS + or not getattr(self.processing, 'enable_export', False)): + return jsonify({'message': 'Exporting is not enabled in your current configuration.'}), 400 + + df = self.load() + if df.empty: + return jsonify({'message': 'No data available.'}), 400 + + response = make_response(df.to_csv(index=False)) + response.headers['Content-Disposition'] = 'attachment; filename=data.csv' + response.headers['Content-Type'] = 'text/csv' + return response + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + + def load(self): + """Read the stored measurements. + + Returns: + pandas.DataFrame: The stored data (empty if there is none yet). + """ + path = os.path.join(self.processing.directory, f'data.{self.processing.format}') + if not os.path.exists(path): + return pd.DataFrame() + + fmt = self.processing.format + if fmt == 'csv': + return pd.read_csv(path, sep=',') + if fmt == 'json': + return pd.read_json(path, orient='records') + if fmt == 'xml': + return pd.read_xml(path) + conn = sqlite3.connect(path) + try: + return pd.read_sql_query('SELECT * FROM data', conn) + finally: + conn.close() + + def bounds(self): + """Map each column with a boundary to its minimum and maximum. + + Returns: + dict: Column name mapped to ``{'min': ..., 'max': ...}``. + """ + result = {} + for item in self.processing.boundaries: + for column, bound in item.items(): + result[column] = { + 'min': float(bound['min']) if 'min' in bound else None, + 'max': float(bound['max']) if 'max' in bound else None, + } + return result + + @staticmethod + def clean(value): + """Convert a value to something JSON can represent. + + Args: + value: A value from a DataFrame. + + Returns: + The value as a JSON-safe Python type (missing values as None). + """ + if value is None: + return None + if isinstance(value, (pd.Timestamp, datetime)): + return value.strftime('%Y-%m-%d %H:%M:%S') + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating, float)): + return None if not math.isfinite(float(value)) else float(value) + if isinstance(value, (np.bool_,)): + return bool(value) + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass + return value + + def api_data(self): + """Return the data for the dashboard as JSON. + + Query parameters: + range (str): One of ``1h``, ``24h``, ``7d``, ``30d`` or ``all`` + (default ``24h``). Only used when timestamps are stored. + + Returns: + Response: JSON with the columns, statistics, chart series and + the latest measurements. + """ + if not self.allowed(): + return jsonify({'message': 'Unauthorised access.'}), 401 + if self.processing.format not in self.TABLE_FORMATS: + return jsonify({'message': f'No table data for the "{self.processing.format}" format.'}), 400 + + selected = request.args.get('range', '24h') + if selected not in self.RANGES: + return jsonify({'message': f'Invalid range: {selected} (use {", ".join(self.RANGES)}).'}), 400 + + df = self.load() + columns = list(self.processing.columns) + for column in columns: + if column not in df.columns: + df[column] = np.nan + has_time = self.processing.timestamp or 'timestamp' in df.columns + + times = None + if has_time and 'timestamp' in df.columns and len(df): + times = pd.to_datetime(df['timestamp'], errors='coerce') + df = df.assign(_time=times).sort_values('_time', kind='stable') + span = self.RANGES[selected] + if span is not None and df['_time'].notna().any(): + df = df[df['_time'] >= df['_time'].max() - span] + times = df['_time'] + + bounds = self.bounds() + info = [] + for column in columns: + numbers = pd.to_numeric(df[column], errors='coerce') + present = df[column].notna().sum() + numeric = present > 0 and numbers.notna().sum() >= 0.8 * present + item = {'name': column, 'numeric': bool(numeric), + 'min': bounds.get(column, {}).get('min'), + 'max': bounds.get(column, {}).get('max')} + + # Latest value, and the last value before it if it is missing + values = df[column] + item['last'] = self.clean(values.iloc[-1]) if len(values) else None + valid = values[values.notna()] + item['last_valid'] = self.clean(valid.iloc[-1]) if len(valid) else None + item['last_valid_time'] = (self.clean(times.loc[valid.index[-1]]) + if times is not None and len(valid) else None) + item['missing'] = int(values.isna().sum()) + + outside = 0 + if numeric: + if item['min'] is not None: + outside += int((numbers < item['min']).sum()) + if item['max'] is not None: + outside += int((numbers > item['max']).sum()) + item['outside'] = outside + info.append(item) + + # Chart series, averaged in buckets when there are many points + bucket = max(1, math.ceil(len(df) / self.MAX_POINTS)) + groups = np.arange(len(df)) // bucket + series = {'x': [], 'values': {}} + if len(df): + if times is not None: + series['x'] = [self.clean(t) for t in times.groupby(groups).first()] + else: + series['x'] = [int(i) + 1 for i in pd.Series(np.arange(len(df))).groupby(groups).first()] + for item in info: + if item['numeric']: + numbers = pd.to_numeric(df[item['name']], errors='coerce').reset_index(drop=True) + means = numbers.groupby(groups).mean() + series['values'][item['name']] = [self.clean(v) for v in means] + + # Latest raw rows, newest first + shown = ['timestamp'] if has_time and 'timestamp' in df.columns else [] + shown += columns + latest = [] + for _, row in df.tail(self.LATEST_ROWS).iloc[::-1].iterrows(): + latest.append({key: self.clean(row[key]) for key in shown}) + + # Typical interval between measurements + interval = None + if times is not None and times.notna().sum() > 1: + interval = float(times.dropna().diff().dropna().dt.total_seconds().median()) + + return jsonify({ + 'format': self.processing.format, + 'range': selected, + 'ranges': list(self.RANGES) if times is not None else [], + 'timestamp': times is not None, + 'refresh': self.refresh, + 'count': int(len(df)), + 'cells': int(len(df) * len(columns)), + 'bucket': bucket, + 'interval': interval, + 'last_time': self.clean(times.iloc[-1]) if times is not None and len(times) else None, + 'server_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'columns': info, + 'series': series, + 'table': {'columns': shown, 'rows': latest}, + }) + + # ------------------------------------------------------------------ + # Images + # ------------------------------------------------------------------ + + def images(self): + """List the stored images, newest first. + + Returns: + list: Dictionaries with ``name``, ``size`` and ``time``. + """ + directory = self.processing.directory + if not os.path.isdir(directory): + return [] + allowed = set(getattr(self.processing, 'allowed_extensions', [])) + files = [] + for name in os.listdir(directory): + path = os.path.join(directory, name) + extension = name.rpartition('.')[2].lower() + if os.path.isfile(path) and extension in allowed: + stat = os.stat(path) + files.append({'name': name, 'size': stat.st_size, 'mtime': stat.st_mtime}) + files.sort(key=lambda f: (f['mtime'], f['name']), reverse=True) + for f in files: + f['time'] = datetime.fromtimestamp(f.pop('mtime')).strftime('%Y-%m-%d %H:%M:%S') + return files + + def api_images(self): + """Return the list of stored images as JSON. + + Returns: + Response: JSON with the images and summary statistics. + """ + if not self.allowed(): + return jsonify({'message': 'Unauthorised access.'}), 401 + if self.processing.format != 'image': + return jsonify({'message': 'Images are only available for the "image" format.'}), 400 + + files = self.images() + now = datetime.now() + recent = [f for f in files + if now - datetime.strptime(f['time'], '%Y-%m-%d %H:%M:%S') <= timedelta(hours=24)] + for f in files: + f['url'] = url_for('explore.image', name=f['name']) + return jsonify({ + 'format': 'image', + 'refresh': self.refresh, + 'count': len(files), + 'count_24h': len(recent), + 'bytes': sum(f['size'] for f in files), + 'allowed_extensions': list(getattr(self.processing, 'allowed_extensions', [])), + 'server_time': now.strftime('%Y-%m-%d %H:%M:%S'), + 'images': files, + }) + + def image(self, name): + """Serve one stored image. + + Args: + name (str): The file name. + + Returns: + Response: The image file. + """ + if not self.allowed(): + abort(401) + if self.processing.format != 'image': + abort(404) + allowed = set(getattr(self.processing, 'allowed_extensions', [])) + if (secure_filename(name) != name + or name.rpartition('.')[2].lower() not in allowed + or not os.path.isfile(os.path.join(self.processing.directory, name))): + abort(404) + response = send_from_directory(os.path.abspath(self.processing.directory), name) + # Never run anything an uploaded file might contain (e.g. an SVG) + response.headers['Content-Security-Policy'] = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox" + return response + + # ------------------------------------------------------------------ + # Headers + # ------------------------------------------------------------------ + + @staticmethod + def security_headers(response): + """Add security headers to every dashboard response. + + Scripts and styles are only loaded from the server itself, so the + dashboard works without internet access and values sent by devices + can never run as code in the browser. + + Args: + response (Response): The response. + + Returns: + Response: The response with security headers. + """ + response.headers.setdefault( + 'Content-Security-Policy', + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; " + "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") + response.headers.setdefault('X-Content-Type-Options', 'nosniff') + response.headers.setdefault('Referrer-Policy', 'same-origin') + if request.path.startswith('/explore/api') or request.path == '/explore': + response.headers.setdefault('Cache-Control', 'no-store') + return response diff --git a/succulent/explore_static/explore.css b/succulent/explore_static/explore.css new file mode 100644 index 0000000..b345269 --- /dev/null +++ b/succulent/explore_static/explore.css @@ -0,0 +1,451 @@ +/* succulent explore dashboard. No external fonts or libraries, so it also + works on a local network without internet access. */ + +:root { + --bg: #F4F6F4; + --surface: #FFFFFF; + --surface-2: #F8FAF8; + --border: #DCE1DD; + --line: #E6EAE7; + --text: #1B1F1D; + --text-2: #3F4743; + --muted: #5B6460; + --faint: #8A938E; + --accent: #1F6B4A; + --accent-soft: #E4F2EA; + --accent-line: #BFDCCB; + --ok: #2E8B57; + --warn-bg: #FCEBDD; + --warn-text: #9A3A0C; + --warn: #D9661F; + --bound: #B4440E; + --bad-bg: #F8E1DE; + --bad-cell: #FBEDEB; + --bad-text: #8E2318; + --bad: #A1271B; + --muted-bg: #ECEFED; + --placeholder: #E7ECE8; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #121514; + --surface: #1A1F1D; + --surface-2: #1F2523; + --border: #2E3633; + --line: #29302D; + --text: #E8ECEA; + --text-2: #C7CEC9; + --muted: #9AA39E; + --faint: #7C8580; + --accent: #5FC08E; + --accent-soft: #1C3328; + --accent-line: #2F5A44; + --ok: #5FC08E; + --warn-bg: #3A2516; + --warn-text: #F2A774; + --warn: #E8834A; + --bound: #E8834A; + --bad-bg: #3B1E1B; + --bad-cell: #2E1A18; + --bad-text: #F4A39A; + --bad: #EF6B5C; + --muted-bg: #262D2A; + --placeholder: #232A27; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); +} + +[hidden] { display: none !important; } + +a { color: var(--accent); } + +.page { + max-width: 1360px; + margin: 0 auto; + padding: 28px 24px 56px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Header */ + +.top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.brand { display: flex; align-items: center; gap: 12px; } +.logo { color: var(--accent); flex: none; } + +.title { + margin: 0; + font-size: 22px; + font-weight: 650; + letter-spacing: -0.01em; +} +.title span { color: var(--muted); font-weight: 500; } + +.subtitle { margin: 0; font-size: 12px; color: var(--muted); } + +.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.controls form { margin: 0; } + +.live { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-2); +} + +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); flex: none; } +.dot.stale { background: var(--warn); } +.dot.none { background: var(--faint); } + +.segmented { + display: flex; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 3px; + gap: 2px; +} + +.segmented button { + min-height: 38px; + min-width: 52px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-2); + font: 500 13px var(--sans); + cursor: pointer; +} +.segmented button[aria-pressed="true"] { background: var(--text); color: var(--bg); } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-2); + font: 500 14px var(--sans); + text-decoration: none; + cursor: pointer; +} +.btn:hover { border-color: var(--faint); } +.btn.dark { background: var(--text); color: var(--bg); border-color: var(--text); } +.btn.toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); } +.btn.wide { width: 100%; } +.btn:focus-visible, .segmented button:focus-visible, input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.banner { + margin: 0; + padding: 12px 16px; + border-radius: 12px; + background: var(--bad-bg); + color: var(--bad-text); +} + +/* Content */ + +.content { display: flex; flex-direction: column; gap: 24px; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; +} + +.empty { + margin: 0; + padding: 48px 24px; + text-align: center; + color: var(--muted); + background: var(--surface); + border: 1px dashed var(--border); + border-radius: 14px; +} +.empty code { font-family: var(--mono); color: var(--text-2); } + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.kpi { padding: 18px 20px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.kpi-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.name { font-family: var(--mono); font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; } + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + background: var(--accent-soft); + color: var(--accent); +} +.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); } +.pill.warn { background: var(--warn-bg); color: var(--warn-text); } +.pill.warn::before { background: var(--warn); } +.pill.bad { background: var(--bad-bg); color: var(--bad-text); } +.pill.bad::before { background: var(--bad); } +.pill.muted { background: var(--muted-bg); color: var(--text-2); } +.pill.muted::before { background: var(--faint); } + +.value { + font-size: 40px; + font-weight: 650; + line-height: 1; + letter-spacing: -0.02em; + overflow-wrap: anywhere; +} +.value.bad { color: var(--bad-text); } +.value.text { font-size: 24px; } + +.note { margin: 0; font-size: 12px; color: var(--muted); } + +.range { display: flex; flex-direction: column; gap: 4px; } +.track { position: relative; height: 6px; border-radius: 3px; background: var(--line); } +.marker { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border-radius: 50%; + background: var(--ok); + border: 2px solid var(--surface); + box-shadow: 0 0 0 1px var(--border); +} +.marker.warn { background: var(--warn); } +.marker.bad { background: var(--bad); } +.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); } + +.quality { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; +} +.stat { background: var(--surface); padding: 14px 20px; display: flex; flex-direction: column; gap: 2px; } +.stat-label { font-size: 12px; color: var(--muted); } +.stat-value { font-size: 20px; font-weight: 650; } + +.charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr)); + gap: 16px; +} + +.chart { padding: 18px 20px 14px; display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.chart-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.chart-head h3 { margin: 0; font-family: var(--mono); font-size: 14px; font-weight: 500; } +.chart-head span { font-size: 12px; color: var(--muted); } + +.plot-row { display: flex; gap: 10px; } +/* Each label is 16px tall; the column is 16px taller than the plot and + shifted up by 8px, so every label is centred on its grid line. */ +.ticks { + height: 216px; + width: 40px; + flex: none; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + font-size: 11px; + line-height: 16px; + color: var(--muted); + margin: -8px 0; +} +.plot { position: relative; flex: 1; height: 200px; min-width: 0; } +.plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.grid-line { stroke: var(--line); } +.bound-line { stroke: var(--bound); stroke-dasharray: 6 5; } +.series { stroke: var(--accent); } + +.bound-label { + position: absolute; + right: 4px; + font-size: 11px; + font-weight: 500; + color: var(--bound); + background: var(--surface); + padding: 0 4px; +} +.bound-label.max { transform: translateY(-115%); } +.bound-label.min { transform: translateY(15%); } + +.outlier { + position: absolute; + width: 9px; + height: 9px; + margin: -6px 0 0 -6px; + border-radius: 50%; + background: var(--bad); + border: 2px solid var(--surface); +} + +.x-labels { display: flex; justify-content: space-between; margin-left: 50px; font-size: 11px; color: var(--muted); } + +.table-card { overflow: hidden; } +.table-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} +.table-head h2 { margin: 0; font-size: 18px; font-weight: 650; } +.table-head span { font-size: 12px; color: var(--muted); } +.table-head .red { color: var(--bad-text); font-weight: 500; } +.scroll { overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; } +th { + text-align: right; + padding: 10px 20px; + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + color: var(--muted); + background: var(--surface-2); + border-bottom: 1px solid var(--line); + white-space: nowrap; +} +td { + text-align: right; + padding: 10px 20px; + border-bottom: 1px solid var(--line); + font-variant-numeric: tabular-nums; +} +th:first-child, td:first-child { text-align: left; } +td.time { font-family: var(--mono); white-space: nowrap; color: var(--text-2); } +td.missing { color: var(--faint); } +td.bad { color: var(--bad-text); font-weight: 600; background: var(--bad-cell); } +tbody tr:last-child td { border-bottom: 0; } + +/* Gallery */ + +.latest { display: flex; flex-wrap: wrap; gap: 16px; } +.latest-image { flex: 999 1 640px; min-width: 0; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.latest-image img { width: 100%; max-height: 520px; object-fit: contain; border-radius: 10px; background: var(--placeholder); } +.caption { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 13px; } +.caption .file { font-family: var(--mono); overflow-wrap: anywhere; } +.caption .meta { color: var(--muted); } +.latest-info { flex: 1 1 280px; padding: 18px 20px; display: flex; flex-direction: column; gap: 10px; } +.latest-info h2, .section-head h2 { margin: 0; font-size: 18px; font-weight: 650; } +.latest-info p { margin: 0; color: var(--muted); font-size: 13px; } +.latest-info code { font-family: var(--mono); color: var(--text-2); } + +.section { display: flex; flex-direction: column; gap: 12px; } +.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.section-head span { font-size: 12px; color: var(--muted); } + +.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.tile { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + color: inherit; + text-decoration: none; +} +.tile:hover { border-color: var(--faint); } +.tile img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; background: var(--placeholder); } +.tile-meta { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 0 2px 2px; } +.tile-meta .file { font-family: var(--mono); color: var(--text-2); } +.tile-meta .meta { color: var(--muted); } + +/* Password prompt */ + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; +} + +.login { + width: 100%; + max-width: 456px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + display: flex; + flex-direction: column; + gap: 20px; +} +.login .title { font-size: 22px; } +.login h1 { margin: 0; font-size: 24px; font-weight: 650; line-height: 1.25; } +.stack-6 { display: flex; flex-direction: column; gap: 6px; } +.muted { margin: 0; color: var(--muted); } +.field { display: flex; flex-direction: column; gap: 8px; } +.field label { font-size: 13px; font-weight: 500; color: var(--text-2); } +.field input { + min-height: 44px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 10px; + font: 400 15px var(--sans); + color: var(--text); + background: var(--surface); +} +.alert { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 10px; + background: var(--bad-bg); + color: var(--bad-text); + font-size: 13px; +} +.hint { margin: 0; font-size: 12px; color: var(--muted); text-align: center; } + +@media (max-width: 560px) { + .page { padding: 20px 16px 40px; } + .value { font-size: 34px; } + .x-labels span:nth-child(2), .x-labels span:nth-child(4) { display: none; } +} diff --git a/succulent/explore_static/explore.js b/succulent/explore_static/explore.js new file mode 100644 index 0000000..734ea06 --- /dev/null +++ b/succulent/explore_static/explore.js @@ -0,0 +1,581 @@ +/* succulent explore dashboard. + * + * Every value comes from devices, so it is only ever inserted as text + * (textContent), never as HTML. No external libraries are used. + */ +(function () { + 'use strict'; + + var app = document.getElementById('app'); + var content = document.getElementById('content'); + var errorBox = document.getElementById('error'); + var subtitle = document.getElementById('subtitle'); + var live = document.getElementById('live'); + var liveDot = document.getElementById('live-dot'); + var liveText = document.getElementById('live-text'); + var rangesBox = document.getElementById('ranges'); + var autoButton = document.getElementById('auto'); + var autoText = document.getElementById('auto-text'); + + var config = { + format: app.dataset.format, + refresh: parseInt(app.dataset.refresh, 10) || 0, + gallery: app.dataset.gallery === 'true', + api: app.dataset.api, + images: app.dataset.images, + login: app.dataset.login + }; + + var RANGE_LABELS = { '1h': '1 h', '24h': '24 h', '7d': '7 d', '30d': '30 d', all: 'All' }; + var SVG = 'http://www.w3.org/2000/svg'; + + var state = { + range: readStored('range') || '24h', + auto: readStored('auto') !== 'false', + timer: null, + loading: false, + lastData: null, + fetchedAt: 0 + }; + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + function readStored(key) { + try { return window.localStorage.getItem('succulent-explore-' + key); } catch (e) { return null; } + } + + function store(key, value) { + try { window.localStorage.setItem('succulent-explore-' + key, value); } catch (e) { /* ignore */ } + } + + /* Create an element. `text` is always set as plain text. */ + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; + } + + function svgEl(tag, attrs) { + var node = document.createElementNS(SVG, tag); + Object.keys(attrs || {}).forEach(function (key) { node.setAttribute(key, attrs[key]); }); + return node; + } + + function append(parent) { + for (var i = 1; i < arguments.length; i++) { + if (arguments[i]) parent.appendChild(arguments[i]); + } + return parent; + } + + function isNumber(v) { return typeof v === 'number' && isFinite(v); } + + function fmt(v) { + if (v === null || v === undefined || v === '') return '—'; + if (!isNumber(v)) { + var n = Number(v); + if (typeof v === 'string' && v.trim() !== '' && isFinite(n)) v = n; else return String(v); + } + if (Math.abs(v) >= 100) return String(Math.round(v)); + return String(Number(v.toFixed(2))); + } + + /* Number of decimals used by a column (0–2), from its raw values. */ + function decimalsOf(values) { + var most = 0; + values.forEach(function (v) { + var n = toNumber(v); + if (n === null || Math.abs(n) >= 100) return; + var text = String(Number(n.toFixed(2))); + var dot = text.indexOf('.'); + if (dot >= 0) most = Math.max(most, text.length - dot - 1); + }); + return Math.min(most, 2); + } + + /* Format a value with the column's number of decimals. */ + function fmtCol(v, column) { + var n = toNumber(v); + if (n === null || !column || column.decimals === undefined) return fmt(v); + return Math.abs(n) >= 100 ? String(Math.round(n)) : n.toFixed(column.decimals); + } + + /* Round chart limits to 4 or 5 evenly spaced intervals with round values. */ + function niceScale(lo, hi) { + var span = hi - lo || Math.abs(hi) || 1; + var power = Math.pow(10, Math.floor(Math.log10(span / 5))); + var steps = [1, 2, 2.5, 5]; + for (var k = 0; k < 3; k++, power *= 10) { + for (var i = 0; i < steps.length; i++) { + var step = steps[i] * power; + var low = Math.floor(lo / step + 1e-9) * step; + for (var n = 4; n <= 5; n++) { + if (low + n * step >= hi - 1e-9) return { lo: low, hi: low + n * step, step: step, n: n }; + } + } + } + return { lo: lo, hi: hi, step: span / 4, n: 4 }; + } + + /* Format a tick value with as many decimals as the step needs. */ + function tick(v, step) { + var text = String(Number(step.toPrecision(6))); + var digits = text.indexOf('e-') >= 0 ? Number(text.split('e-')[1]) + 2 + : (text.indexOf('.') >= 0 ? text.length - text.indexOf('.') - 1 : 0); + return String(Number(v.toFixed(Math.min(digits, 8)))); + } + + function toNumber(v) { + if (isNumber(v)) return v; + if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v))) return Number(v); + return null; + } + + function parseTime(text) { + if (!text) return null; + var d = new Date(String(text).replace(' ', 'T')); + return isNaN(d.getTime()) ? null : d; + } + + function pad(n) { return String(n).padStart(2, '0'); } + + function clock(d) { return pad(d.getHours()) + ':' + pad(d.getMinutes()); } + + function shortDate(d) { return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.'; } + + function duration(seconds) { + if (!isNumber(seconds)) return '—'; + seconds = Math.max(0, Math.round(seconds)); + if (seconds < 60) return seconds + ' s'; + if (seconds < 3600) return Math.round(seconds / 60) + ' min'; + if (seconds < 86400) return (Math.round(seconds / 360) / 10) + ' h'; + return (Math.round(seconds / 8640) / 10) + ' d'; + } + + function bytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return Math.round(n / 1024) + ' kB'; + if (n < 1024 * 1024 * 1024) return (Math.round(n / 1024 / 102.4) / 10) + ' MB'; + return (Math.round(n / 1024 / 1024 / 102.4) / 10) + ' GB'; + } + + function showError(message) { + errorBox.textContent = message; + errorBox.hidden = !message; + } + + /* Seconds since `time` (server clock), advanced by the time since the fetch. */ + function ageSeconds(time, serverTime) { + var t = parseTime(time), s = parseTime(serverTime); + if (!t || !s) return null; + return (s - t) / 1000 + (Date.now() - state.fetchedAt) / 1000; + } + + // ------------------------------------------------------------------ + // Loading + // ------------------------------------------------------------------ + + function load() { + if (state.loading) return; + state.loading = true; + var url = config.gallery ? config.images : config.api + '?range=' + encodeURIComponent(state.range); + fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }) + .then(function (response) { + if (response.status === 401) { window.location.href = config.login; return null; } + return response.json().then(function (body) { + if (!response.ok) throw new Error(body && body.message ? body.message : 'Request failed (' + response.status + ').'); + return body; + }); + }) + .then(function (data) { + if (!data) return; + state.fetchedAt = Date.now(); + state.lastData = data; + showError(''); + if (config.gallery) renderGallery(data); else renderDashboard(data); + }) + .catch(function (err) { + showError('Could not load the data: ' + err.message + ' Retrying' + (state.auto && config.refresh ? ' automatically.' : ' when you refresh.')); + }) + .then(function () { state.loading = false; }); + } + + function schedule() { + if (state.timer) window.clearInterval(state.timer); + state.timer = null; + if (state.auto && config.refresh > 0) { + state.timer = window.setInterval(function () { + if (!document.hidden) load(); + }, config.refresh * 1000); + } + autoButton.setAttribute('aria-pressed', state.auto ? 'true' : 'false'); + autoText.textContent = state.auto ? 'Auto-refresh · ' + duration(config.refresh) : 'Auto-refresh paused'; + } + + function setupControls() { + if (config.refresh > 0) { + autoButton.hidden = false; + autoButton.addEventListener('click', function () { + state.auto = !state.auto; + store('auto', String(state.auto)); + schedule(); + if (state.auto) load(); + }); + schedule(); + } + document.addEventListener('visibilitychange', function () { + if (!document.hidden && state.auto && config.refresh > 0) load(); + }); + // Keep the "last measurement ... ago" text current between fetches + window.setInterval(updateLive, 1000); + } + + function renderRanges(ranges) { + rangesBox.textContent = ''; + rangesBox.hidden = !ranges.length; + ranges.forEach(function (key) { + var button = el('button', null, RANGE_LABELS[key] || key); + button.type = 'button'; + button.setAttribute('aria-pressed', key === state.range ? 'true' : 'false'); + button.addEventListener('click', function () { + if (state.range === key) return; + state.range = key; + store('range', key); + renderRanges(ranges); + load(); + }); + rangesBox.appendChild(button); + }); + } + + function updateLive() { + var data = state.lastData; + if (!data) return; + var time = config.gallery ? (data.images[0] && data.images[0].time) : data.last_time; + var label = config.gallery ? 'Last image ' : 'Last measurement '; + live.hidden = false; + if (!time) { + liveDot.className = 'dot none'; + liveText.textContent = data.count ? (data.count + ' measurements, no timestamps') : (config.gallery ? 'No images yet' : 'No measurements yet'); + return; + } + var age = ageSeconds(time, data.server_time); + var expected = isNumber(data.interval) ? data.interval : null; + var stale = expected ? age > Math.max(expected * 3, 60) : age > 3600; + liveDot.className = 'dot' + (stale ? ' stale' : ''); + liveText.textContent = label + duration(age) + ' ago'; + } + + // ------------------------------------------------------------------ + // Dashboard + // ------------------------------------------------------------------ + + function status(column) { + var value = toNumber(column.last); + var hasMin = isNumber(column.min), hasMax = isNumber(column.max); + if (column.last === null || column.last === undefined || column.last === '') return { text: 'No reading', tone: 'muted' }; + if (!column.numeric || value === null) return { text: 'Latest', tone: 'muted' }; + if (!hasMin && !hasMax) return { text: 'No limits', tone: 'muted' }; + if (hasMax && value > column.max) return { text: 'Above max', tone: 'bad' }; + if (hasMin && value < column.min) return { text: 'Below min', tone: 'bad' }; + if (hasMin && hasMax) { + var span = column.max - column.min; + if (value > column.max - span * 0.1) return { text: 'Near max', tone: 'warn' }; + if (value < column.min + span * 0.1) return { text: 'Near min', tone: 'warn' }; + } + return { text: 'In range', tone: 'ok' }; + } + + function renderCard(column) { + var s = status(column); + var card = el('div', 'card kpi'); + var head = append(el('div', 'kpi-head'), el('span', 'name', column.name), el('span', 'pill ' + (s.tone === 'ok' ? '' : s.tone), s.text)); + var value = el('div', 'value' + (s.tone === 'bad' ? ' bad' : '') + (column.numeric ? '' : ' text'), fmtCol(column.last, column)); + + var note; + var hasBounds = isNumber(column.min) || isNumber(column.max); + if (column.last === null && column.last_valid !== null) { + var t = parseTime(column.last_valid_time); + note = 'Missing in the last measurement · previous ' + fmtCol(column.last_valid, column) + (t ? ' at ' + clock(t) : ''); + } else if (!column.numeric) { + note = 'Text values'; + } else if (hasBounds) { + note = 'Limits from configuration.yml'; + } else { + note = 'No min/max set in configuration.yml'; + } + append(card, head, value, el('p', 'note', note)); + + var number = toNumber(column.last); + if (isNumber(column.min) && isNumber(column.max) && number !== null && column.max > column.min) { + var pos = Math.min(100, Math.max(0, (number - column.min) / (column.max - column.min) * 100)); + var marker = el('div', 'marker ' + (s.tone === 'ok' ? '' : s.tone)); + marker.style.left = pos.toFixed(1) + '%'; + append(card, append(el('div', 'range'), + append(el('div', 'track'), marker), + append(el('div', 'range-labels'), el('span', null, 'min ' + fmt(column.min)), el('span', null, 'max ' + fmt(column.max))))); + } + return card; + } + + function renderQuality(data) { + var missing = data.columns.reduce(function (sum, c) { return sum + c.missing; }, 0); + var outside = data.columns.reduce(function (sum, c) { return sum + c.outside; }, 0); + var rangeText = data.timestamp ? ' (' + (RANGE_LABELS[data.range] || data.range) + ')' : ''; + var items = [ + ['Measurements' + rangeText, String(data.count)], + ['Missing values', data.cells ? (Math.round(missing / data.cells * 1000) / 10) + ' %' : '—'], + ['Outside min/max', String(outside)], + ['Typical interval', duration(data.interval)] + ]; + var box = el('section', 'quality'); + box.setAttribute('aria-label', 'Data quality'); + items.forEach(function (item) { + append(box, append(el('div', 'stat'), el('span', 'stat-label', item[0]), el('span', 'stat-value', item[1]))); + }); + return box; + } + + function xLabel(x, data) { + if (!data.timestamp) return '#' + x; + var d = parseTime(x); + if (!d) return ''; + return data.range === '1h' || data.range === '24h' ? clock(d) : shortDate(d) + ' ' + clock(d); + } + + function renderChart(column, data) { + var values = data.series.values[column.name] || []; + var present = values.filter(isNumber); + var card = el('div', 'card chart'); + var outsideText = column.outside ? ' · ' + column.outside + ' outside' : ''; + var bounds = []; + if (isNumber(column.min)) bounds.push('min ' + fmt(column.min)); + if (isNumber(column.max)) bounds.push('max ' + fmt(column.max)); + append(card, append(el('div', 'chart-head'), + el('h3', null, column.name), + el('span', null, (bounds.length ? bounds.join(' · ') : 'no min/max') + outsideText))); + + if (!present.length) { + append(card, el('p', 'note', 'No numeric values in this range.')); + return card; + } + + var dataLo = Math.min.apply(null, present.concat(isNumber(column.min) ? [column.min] : [])); + var dataHi = Math.max.apply(null, present.concat(isNumber(column.max) ? [column.max] : [])); + var padding = (dataHi - dataLo) * 0.08 || Math.max(Math.abs(dataHi) * 0.1, 1); + // Do not go below zero for values that are never negative (e.g. light) + var scale = niceScale(dataLo >= 0 ? Math.max(0, dataLo - padding) : dataLo - padding, dataHi + padding); + var lo = scale.lo, hi = scale.hi; + var n = values.length; + var x = function (i) { return n > 1 ? i / (n - 1) * 1000 : 500; }; + var y = function (v) { return (1 - (v - lo) / (hi - lo)) * 300; }; + + var d = '', pen = false; + values.forEach(function (v, i) { + if (!isNumber(v)) { pen = false; return; } + d += (pen ? ' L' : ' M') + x(i).toFixed(1) + ' ' + y(v).toFixed(1); + if (!pen && (i === n - 1 || !isNumber(values[i + 1]))) { + // A single point between gaps: draw a short tick so it is visible + d += ' L' + (x(i) + 0.1).toFixed(1) + ' ' + y(v).toFixed(1); + } + pen = true; + }); + + var plot = el('div', 'plot'); + var svg = svgEl('svg', { viewBox: '0 0 1000 300', preserveAspectRatio: 'none', role: 'img', 'aria-label': column.name + ' over the selected range' }); + var grid = ''; + for (var g = 0; g <= scale.n; g++) grid += 'M0 ' + (g * 300 / scale.n) + ' L1000 ' + (g * 300 / scale.n) + ' '; + svg.appendChild(svgEl('path', { d: grid.trim(), fill: 'none', 'stroke-width': '1', 'vector-effect': 'non-scaling-stroke', 'class': 'grid-line' })); + var boundPath = ''; + if (isNumber(column.max)) boundPath += 'M0 ' + y(column.max).toFixed(1) + ' L1000 ' + y(column.max).toFixed(1) + ' '; + if (isNumber(column.min)) boundPath += 'M0 ' + y(column.min).toFixed(1) + ' L1000 ' + y(column.min).toFixed(1); + if (boundPath) svg.appendChild(svgEl('path', { d: boundPath.trim(), fill: 'none', 'stroke-width': '1.5', 'vector-effect': 'non-scaling-stroke', 'class': 'bound-line' })); + svg.appendChild(svgEl('path', { d: d.trim(), fill: 'none', 'stroke-width': '2', 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'vector-effect': 'non-scaling-stroke', 'class': 'series' })); + plot.appendChild(svg); + + [['max', column.max], ['min', column.min]].forEach(function (b) { + if (!isNumber(b[1])) return; + var label = el('span', 'bound-label ' + b[0], b[0] + ' ' + fmt(b[1])); + label.style.top = (y(b[1]) / 3).toFixed(1) + '%'; + plot.appendChild(label); + }); + + values.forEach(function (v, i) { + if (!isNumber(v)) return; + var out = (isNumber(column.max) && v > column.max) || (isNumber(column.min) && v < column.min); + if (!out) return; + var dot = el('span', 'outlier'); + dot.style.left = (x(i) / 10).toFixed(2) + '%'; + dot.style.top = (y(v) / 3).toFixed(2) + '%'; + dot.title = column.name + ' ' + fmtCol(v, column) + ' · ' + xLabel(data.series.x[i], data); + plot.appendChild(dot); + }); + + var ticks = el('div', 'ticks'); + for (var t = scale.n; t >= 0; t--) ticks.appendChild(el('span', null, tick(lo + t * scale.step, scale.step))); + append(card, append(el('div', 'plot-row'), ticks, plot)); + + var labels = el('div', 'x-labels'); + var xs = data.series.x; + [0, 0.25, 0.5, 0.75, 1].forEach(function (f) { + labels.appendChild(el('span', null, xs.length ? xLabel(xs[Math.round(f * (xs.length - 1))], data) : '')); + }); + card.appendChild(labels); + if (data.bucket > 1) card.appendChild(el('p', 'note', 'Averaged over ' + data.bucket + ' measurements per point.')); + return card; + } + + function renderTable(data) { + var byName = {}; + data.columns.forEach(function (c) { byName[c.name] = c; }); + var card = el('section', 'card table-card'); + card.setAttribute('aria-label', 'Latest measurements'); + var note = append(el('span', null, data.table.rows.length + ' of ' + data.count + ' · newest first · '), el('span', 'red', 'red'), document.createTextNode(' = outside min/max')); + append(card, append(el('div', 'table-head'), el('h2', null, 'Latest measurements'), note)); + + var table = el('table'); + var headRow = el('tr'); + data.table.columns.forEach(function (name) { + var th = el('th', null, name); + th.scope = 'col'; + headRow.appendChild(th); + }); + append(table, append(el('thead'), headRow)); + var body = el('tbody'); + data.table.rows.forEach(function (row) { + var tr = el('tr'); + data.table.columns.forEach(function (name) { + var v = row[name]; + var td = el('td', null, name === 'timestamp' ? fmt(v) : fmtCol(v, byName[name])); + if (name === 'timestamp') td.className = 'time'; + else if (v === null || v === undefined || v === '') td.className = 'missing'; + else { + var c = byName[name], num = toNumber(v); + if (c && num !== null && ((isNumber(c.max) && num > c.max) || (isNumber(c.min) && num < c.min))) td.className = 'bad'; + } + tr.appendChild(td); + }); + body.appendChild(tr); + }); + table.appendChild(body); + card.appendChild(append(el('div', 'scroll'), table)); + return card; + } + + function renderDashboard(data) { + subtitle.textContent = 'format ' + data.format + ' · ' + data.columns.length + ' columns · timestamp ' + (data.timestamp ? 'on' : 'off'); + data.columns.forEach(function (c) { + if (!c.numeric) return; + c.decimals = decimalsOf(data.table.rows.map(function (r) { return r[c.name]; }).concat([c.last, c.last_valid])); + }); + renderRanges(data.ranges); + updateLive(); + + var fragment = document.createDocumentFragment(); + if (!data.count) { + var empty = el('p', 'empty'); + append(empty, document.createTextNode(data.range !== 'all' && data.timestamp + ? 'No measurements in this range. Choose a longer range, or send data to ' + : 'No measurements yet. Send data to '), el('code', null, 'POST /measure'), document.createTextNode('.')); + fragment.appendChild(empty); + content.replaceChildren(fragment); + return; + } + + var cards = el('section', 'cards'); + cards.setAttribute('aria-label', 'Latest values'); + data.columns.forEach(function (c) { cards.appendChild(renderCard(c)); }); + fragment.appendChild(cards); + fragment.appendChild(renderQuality(data)); + + var numeric = data.columns.filter(function (c) { return c.numeric; }); + if (numeric.length) { + var charts = el('section', 'charts'); + charts.setAttribute('aria-label', 'Charts'); + numeric.forEach(function (c) { charts.appendChild(renderChart(c, data)); }); + fragment.appendChild(charts); + } + fragment.appendChild(renderTable(data)); + content.replaceChildren(fragment); + } + + // ------------------------------------------------------------------ + // Gallery + // ------------------------------------------------------------------ + + function renderGallery(data) { + subtitle.textContent = 'format image · ' + data.allowed_extensions.join(', '); + + var fragment = document.createDocumentFragment(); + var interval = null; + if (data.images.length > 1) { + var times = data.images.map(function (f) { return parseTime(f.time); }).filter(Boolean); + var diffs = []; + for (var i = 1; i < times.length; i++) diffs.push((times[i - 1] - times[i]) / 1000); + diffs.sort(function (a, b) { return a - b; }); + interval = diffs.length ? diffs[Math.floor(diffs.length / 2)] : null; + } + data.interval = interval; + updateLive(); + + var stats = el('section', 'quality'); + stats.setAttribute('aria-label', 'Upload summary'); + [['Images (24 h)', String(data.count_24h)], ['Images in total', String(data.count)], + ['Typical interval', duration(interval)], ['Storage used', bytes(data.bytes)]].forEach(function (item) { + append(stats, append(el('div', 'stat'), el('span', 'stat-label', item[0]), el('span', 'stat-value', item[1]))); + }); + fragment.appendChild(stats); + + if (!data.images.length) { + var empty = el('p', 'empty'); + append(empty, document.createTextNode('No images yet. Upload one to '), el('code', null, 'POST /measure'), document.createTextNode('.')); + fragment.appendChild(empty); + content.replaceChildren(fragment); + return; + } + + var latest = data.images[0]; + var image = el('img'); + image.src = latest.url; + image.alt = 'Latest image, ' + latest.name; + var latestCard = append(el('div', 'card latest-image'), image, + append(el('div', 'caption'), el('span', 'file', latest.name), el('span', 'meta', latest.time + ' · ' + bytes(latest.size)))); + var info = append(el('div', 'card latest-info'), el('h2', null, 'Latest image'), + el('p', null, 'Uploaded ' + latest.time + '.')); + var accepted = el('p'); + append(accepted, document.createTextNode('Accepted types: '), el('code', null, data.allowed_extensions.join(', '))); + info.appendChild(accepted); + fragment.appendChild(append(el('section', 'latest'), latestCard, info)); + + var rest = data.images.slice(1); + if (rest.length) { + var section = el('section', 'section'); + section.setAttribute('aria-label', 'Earlier images'); + append(section, append(el('div', 'section-head'), el('h2', null, 'Earlier images'), el('span', null, rest.length + ' · newest first'))); + var tiles = el('div', 'tiles'); + rest.forEach(function (file) { + var tile = el('a', 'card tile'); + tile.href = file.url; + tile.target = '_blank'; + tile.rel = 'noopener'; + var img = el('img'); + img.src = file.url; + img.alt = file.name; + img.loading = 'lazy'; + var t = parseTime(file.time); + append(tile, img, append(el('div', 'tile-meta'), + el('span', 'file', t ? shortDate(t) + ' ' + clock(t) : file.time), el('span', 'meta', bytes(file.size)))); + tiles.appendChild(tile); + }); + section.appendChild(tiles); + fragment.appendChild(section); + } + content.replaceChildren(fragment); + } + + setupControls(); + load(); +})(); diff --git a/succulent/explore_templates/explore.html b/succulent/explore_templates/explore.html new file mode 100644 index 0000000..6432382 --- /dev/null +++ b/succulent/explore_templates/explore.html @@ -0,0 +1,53 @@ + + + + + +succulent explore + + + +
+
+
+ +
+

succulent / explore

+

format {{ format }}

+
+
+
+ + + + {% if export_url %} + + + Export CSV + + {% endif %} + {% if protected %} +
+ +
+ {% endif %} +
+
+ +
+

Loading…

+
+
+ + + diff --git a/succulent/explore_templates/explore_login.html b/succulent/explore_templates/explore_login.html new file mode 100644 index 0000000..935b2c5 --- /dev/null +++ b/succulent/explore_templates/explore_login.html @@ -0,0 +1,33 @@ + + + + + +succulent explore · password + + + + + + diff --git a/tests/test_explore.py b/tests/test_explore.py new file mode 100644 index 0000000..4bc98f9 --- /dev/null +++ b/tests/test_explore.py @@ -0,0 +1,324 @@ +import io +import json +import os +import shutil +import tempfile +import unittest +from datetime import datetime, timedelta + +import pandas as pd +import yaml + +from succulent.api import SucculentAPI +from succulent.explore import Explorer + + +PNG = b'\x89PNG\r\n\x1a\n' + b'\x00' * 32 + + +def strict_json(response): + """Parse a response like JavaScript's JSON.parse (no NaN/Infinity).""" + def reject(constant): + raise ValueError(f'Invalid JSON constant: {constant}') + return json.loads(response.get_data(as_text=True), parse_constant=reject) + + +class ExploreTestCase(unittest.TestCase): + """Base class: builds an API from a configuration dictionary.""" + + def make_api(self, config, format='csv'): + handle, path = tempfile.mkstemp(suffix='.yml') + with os.fdopen(handle, 'w') as f: + yaml.safe_dump(config, f) + self.addCleanup(os.remove, path) + api = SucculentAPI(config=path, format=format) + self.addCleanup(shutil.rmtree, api.processing.directory, True) + shutil.rmtree(api.processing.directory, ignore_errors=True) + return api, api.app.test_client() + + +TABLE_CONFIG = { + 'timestamp': True, + 'data': [ + {'name': 'temperature', 'min': 15, 'max': 30}, + {'name': 'humidity'}, + {'name': 'status'}, + ], + 'results': [{'enable': True}, {'export': True}], + 'explore': [{'enable': True}, {'refresh': 5}], +} + + +class TestExploreConfiguration(ExploreTestCase): + + def test_disabled_by_default(self): + config = dict(TABLE_CONFIG) + del config['explore'] + api, client = self.make_api(config) + self.assertIsNone(api.explorer) + response = client.get('/explore') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_json(), {'message': 'Not Found.'}) + + def test_enable_false(self): + config = dict(TABLE_CONFIG, explore=[{'enable': False}]) + api, client = self.make_api(config) + self.assertEqual(client.get('/explore').status_code, 404) + + def test_mapping_style_and_refresh(self): + config = dict(TABLE_CONFIG, explore={'enable': True, 'refresh': 0}) + api, client = self.make_api(config) + self.assertEqual(api.explorer.refresh, 0) + self.assertIn(b'data-refresh="0"', client.get('/explore').data) + + def test_invalid_settings(self): + for value in [[{'enable': True}, {'refresh': -1}], [{'enable': True}, {'refresh': 'fast'}], 'yes']: + with self.subTest(value=value): + with self.assertRaises(ValueError): + Explorer.settings(value) if isinstance(value, str) else Explorer(None, value) + + def test_existing_endpoints_unchanged(self): + api, client = self.make_api(TABLE_CONFIG) + self.assertEqual(client.post('/measure', json={'temperature': 20}).status_code, 200) + self.assertEqual(client.get('/data').status_code, 200) + self.assertEqual(client.get('/').status_code, 200) + + +class TestExplorePage(ExploreTestCase): + + def setUp(self): + self.api, self.client = self.make_api(TABLE_CONFIG) + + def test_page_and_assets(self): + response = self.client.get('/explore') + self.assertEqual(response.status_code, 200) + self.assertTrue(response.content_type.startswith('text/html')) + html = response.get_data(as_text=True) + self.assertIn('/explore/static/explore.js', html) + self.assertIn('/explore/export', html) + self.assertNotIn('style=', html) + + for asset in ['explore.js', 'explore.css']: + with self.subTest(asset=asset): + self.assertEqual(self.client.get(f'/explore/static/{asset}').status_code, 200) + + def test_security_headers(self): + response = self.client.get('/explore') + policy = response.headers['Content-Security-Policy'] + self.assertIn("script-src 'self'", policy) + self.assertIn("style-src 'self'", policy) + self.assertNotIn('unsafe-inline', policy) + self.assertEqual(response.headers['X-Content-Type-Options'], 'nosniff') + self.assertEqual(response.headers['Cache-Control'], 'no-store') + + def test_no_export_button_when_export_disabled(self): + config = dict(TABLE_CONFIG, results=[{'enable': True}]) + api, client = self.make_api(config) + self.assertNotIn('/explore/export', client.get('/explore').get_data(as_text=True)) + self.assertEqual(client.get('/explore/export').status_code, 400) + + +class TestExploreData(ExploreTestCase): + + def setUp(self): + self.api, self.client = self.make_api(TABLE_CONFIG) + + def write_rows(self, rows): + os.makedirs(self.api.processing.directory, exist_ok=True) + pd.DataFrame(rows).to_csv(os.path.join(self.api.processing.directory, 'data.csv'), index=False) + + def test_no_data(self): + data = strict_json(self.client.get('/explore/api/data')) + self.assertEqual(data['count'], 0) + self.assertEqual(data['table']['rows'], []) + self.assertEqual([c['name'] for c in data['columns']], ['temperature', 'humidity', 'status']) + + def test_summary(self): + # /measure rejects values outside min/max, so the out-of-range row is + # written directly (as for data stored before the limits were set) + now = datetime.now() + stamp = lambda minutes: (now - timedelta(minutes=minutes)).strftime('%Y-%m-%d %H:%M:%S') + self.write_rows([ + {'temperature': 20, 'humidity': 40, 'status': 'ok', 'timestamp': stamp(20)}, + {'temperature': 35, 'humidity': 41, 'status': 'ok', 'timestamp': stamp(10)}, + ]) + body = {'temperature': 22, 'status': ''} + self.assertEqual(self.client.post('/measure', json=body).status_code, 200) + + response = self.client.get('/explore/api/data') + self.assertEqual(response.status_code, 200) + data = strict_json(response) + columns = {c['name']: c for c in data['columns']} + + self.assertEqual(data['count'], 3) + self.assertTrue(data['timestamp']) + self.assertEqual(data['refresh'], 5) + self.assertEqual(columns['temperature']['min'], 15.0) + self.assertEqual(columns['temperature']['max'], 30.0) + self.assertEqual(columns['temperature']['outside'], 1) + self.assertTrue(columns['temperature']['numeric']) + self.assertFalse(columns['status']['numeric']) + + # Missing in the last measurement: last is null, previous value is kept + self.assertIsNone(columns['humidity']['last']) + self.assertEqual(columns['humidity']['last_valid'], 41) + self.assertEqual(columns['humidity']['missing'], 1) + + # Device values are returned as plain data (rendered as text by the page) + self.assertEqual(columns['status']['last'], '') + + self.assertEqual(data['series']['values']['humidity'], [40, 41, None]) + self.assertNotIn('status', data['series']['values']) + self.assertEqual(data['table']['columns'], ['timestamp', 'temperature', 'humidity', 'status']) + self.assertEqual([r['temperature'] for r in data['table']['rows']], [22, 35, 20]) + + def test_ranges(self): + now = datetime.now().replace(microsecond=0) + self.write_rows([ + {'temperature': 20, 'humidity': 1, 'status': 'a', + 'timestamp': (now - timedelta(days=10)).strftime('%Y-%m-%d %H:%M:%S')}, + {'temperature': 21, 'humidity': 2, 'status': 'b', + 'timestamp': (now - timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')}, + {'temperature': 22, 'humidity': 3, 'status': 'c', + 'timestamp': (now - timedelta(minutes=10)).strftime('%Y-%m-%d %H:%M:%S')}, + ]) + counts = {r: strict_json(self.client.get(f'/explore/api/data?range={r}'))['count'] + for r in ['1h', '24h', '7d', '30d', 'all']} + self.assertEqual(counts, {'1h': 1, '24h': 2, '7d': 2, '30d': 3, 'all': 3}) + + data = strict_json(self.client.get('/explore/api/data?range=all')) + self.assertEqual(data['ranges'], ['1h', '24h', '7d', '30d', 'all']) + + response = self.client.get('/explore/api/data?range=year') + self.assertEqual(response.status_code, 400) + self.assertIn('Invalid range', response.get_json()['message']) + + def test_without_timestamps(self): + config = dict(TABLE_CONFIG, timestamp=False) + api, client = self.make_api(config) + client.post('/measure', json={'temperature': 20}) + client.post('/measure', json={'temperature': 21}) + data = strict_json(client.get('/explore/api/data')) + self.assertFalse(data['timestamp']) + self.assertEqual(data['ranges'], []) + self.assertEqual(data['series']['x'], [1, 2]) + self.assertEqual(data['table']['columns'], ['temperature', 'humidity', 'status']) + + def test_long_series_are_averaged(self): + self.api.explorer.MAX_POINTS = 10 + start = datetime.now() - timedelta(hours=10) + self.write_rows([{'temperature': i, 'humidity': 1, 'status': 'x', + 'timestamp': (start + timedelta(minutes=i)).strftime('%Y-%m-%d %H:%M:%S')} + for i in range(100)]) + data = strict_json(self.client.get('/explore/api/data?range=all')) + self.assertEqual(data['count'], 100) + self.assertEqual(data['bucket'], 10) + self.assertEqual(len(data['series']['values']['temperature']), 10) + self.assertEqual(data['series']['values']['temperature'][0], 4.5) + self.assertEqual(len(data['table']['rows']), 10) + self.assertEqual(data['interval'], 60.0) + + def test_export(self): + self.client.post('/measure', json={'temperature': 20}) + response = self.client.get('/explore/export') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers['Content-Type'], 'text/csv') + self.assertTrue(response.get_data(as_text=True).startswith('temperature,humidity,status,timestamp')) + + def test_image_endpoints_not_available(self): + self.assertEqual(self.client.get('/explore/api/images').status_code, 400) + + +class TestExplorePassword(ExploreTestCase): + + def setUp(self): + self.api, self.client = self.make_api(dict(TABLE_CONFIG, password='s3cret')) + self.client.post('/measure', json={'temperature': 20}, headers={'X-API-Key': 's3cret'}) + + def test_requires_login(self): + response = self.client.get('/explore') + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers['Location'].endswith('/explore/login')) + self.assertEqual(self.client.get('/explore/api/data').status_code, 401) + self.assertEqual(self.client.get('/explore/export').status_code, 401) + + def test_query_password_is_not_accepted(self): + # The password must not end up in URLs (browser history, logs) + self.assertEqual(self.client.get('/explore/api/data?password=s3cret').status_code, 401) + + def test_wrong_password(self): + response = self.client.post('/explore/login', data={'password': 'wrong'}) + self.assertEqual(response.status_code, 401) + self.assertIn('Wrong password', response.get_data(as_text=True)) + self.assertEqual(self.client.get('/explore/api/data').status_code, 401) + + def test_login_and_logout(self): + self.assertEqual(self.client.get('/explore/login').status_code, 200) + response = self.client.post('/explore/login', data={'password': 's3cret'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.client.get('/explore').status_code, 200) + self.assertEqual(self.client.get('/explore/api/data').status_code, 200) + + self.client.post('/explore/logout') + self.assertEqual(self.client.get('/explore/api/data').status_code, 401) + + def test_header_for_scripts(self): + for headers in [{'X-API-Key': 's3cret'}, {'Authorization': 'Bearer s3cret'}]: + with self.subTest(headers=headers): + self.assertEqual(self.client.get('/explore/api/data', headers=headers).status_code, 200) + self.assertEqual(self.client.get('/explore/api/data', headers={'X-API-Key': 'x'}).status_code, 401) + + def test_numeric_password(self): + api, client = self.make_api(dict(TABLE_CONFIG, password=1234)) + self.assertEqual(client.post('/explore/login', data={'password': '1234'}).status_code, 302) + + def test_secret_key_from_config(self): + api, client = self.make_api(dict(TABLE_CONFIG, password='x', secret_key='fixed-key')) + self.assertEqual(api.app.secret_key, 'fixed-key') + + +class TestExploreImages(ExploreTestCase): + + def setUp(self): + self.api, self.client = self.make_api( + {'data': [{'key': 'image'}], 'explore': [{'enable': True}]}, format='image') + + def upload(self, name): + return self.client.post('/measure', data={'image': (io.BytesIO(PNG), name)}, + content_type='multipart/form-data') + + def test_gallery(self): + self.assertIn(b'data-gallery="true"', self.client.get('/explore').data) + + data = strict_json(self.client.get('/explore/api/images')) + self.assertEqual(data['count'], 0) + + self.assertEqual(self.upload('a.png').status_code, 200) + self.assertEqual(self.upload('b.png').status_code, 200) + + data = strict_json(self.client.get('/explore/api/images')) + self.assertEqual(data['count'], 2) + self.assertEqual(data['count_24h'], 2) + self.assertEqual(data['bytes'], 2 * len(PNG)) + + image = self.client.get(data['images'][0]['url']) + self.assertEqual(image.status_code, 200) + self.assertEqual(image.data, PNG) + self.assertIn('sandbox', image.headers['Content-Security-Policy']) + + self.assertEqual(self.client.get('/explore/api/data').status_code, 400) + + def test_only_stored_images_are_served(self): + self.upload('a.png') + os.makedirs(self.api.processing.directory, exist_ok=True) + with open(os.path.join(self.api.processing.directory, 'notes.txt'), 'w') as f: + f.write('secret') + for name in ['notes.txt', '..%2Fapi.py', 'missing.png']: + with self.subTest(name=name): + self.assertEqual(self.client.get(f'/explore/images/{name}').status_code, 404) + names = [f['name'] for f in strict_json(self.client.get('/explore/api/images'))['images']] + self.assertNotIn('notes.txt', names) + + +if __name__ == '__main__': + unittest.main()