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.
+
+
+
+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
+
+
+
+