From d53a50cb233f2b314f4c4bda15390ded9702f3c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 19:37:04 +0000 Subject: [PATCH] feat: optional web dashboard for exploring the data (/explore) Add an optional dashboard for monitoring the collected data in a browser. It is enabled in the configuration and is separate from data collection: it only reads the stored data. explore: - enable: true - refresh: 10 - New module succulent/explore.py (Explorer, a Flask blueprint), registered by SucculentAPI only when enabled - Dashboard: latest value per column with its position between min and max, data quality (measurements, missing values, values outside min/max, typical interval), a chart per numeric column with min/max lines, and the latest measurements; time ranges with timestamps, auto-refresh, CSV export when export is enabled - Gallery for the image format - JSON endpoints /explore/api/data (range: 1h, 24h, 7d, 30d, all; long series are averaged) and /explore/api/images - Password prompt with a session cookie, so the password is never in the URL; scripts can use the Authorization/X-API-Key headers; optional secret_key in the configuration - No external scripts or fonts, so it works without internet access; strict Content-Security-Policy; device values are rendered as text - Light and dark mode, works at phone width - Document the dashboard (with a screenshot) and add tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VCvw8haRnmyrVKrLkz5Yf8 --- .github/images/explore.png | Bin 0 -> 121243 bytes README.md | 25 + docs/getting_started.rst | 27 +- succulent/api.py | 10 + succulent/explore.py | 535 ++++++++++++++++ succulent/explore_static/explore.css | 451 ++++++++++++++ succulent/explore_static/explore.js | 581 ++++++++++++++++++ succulent/explore_templates/explore.html | 53 ++ .../explore_templates/explore_login.html | 33 + tests/test_explore.py | 324 ++++++++++ 10 files changed, 2038 insertions(+), 1 deletion(-) create mode 100644 .github/images/explore.png create mode 100644 succulent/explore.py create mode 100644 succulent/explore_static/explore.css create mode 100644 succulent/explore_static/explore.js create mode 100644 succulent/explore_templates/explore.html create mode 100644 succulent/explore_templates/explore_login.html create mode 100644 tests/test_explore.py diff --git a/.github/images/explore.png b/.github/images/explore.png new file mode 100644 index 0000000000000000000000000000000000000000..0b8cf5daa2c6628fb7e63ed6cc39fd9333cfa30f GIT binary patch literal 121243 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 iwLpy^1nsucculent 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()