Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
# Dockerfile for the python devcontainer
# Dockerfile for the collectd-web Python devcontainer
# Perl CGI packages removed - backend ported to Python (see collectd_web/)
FROM mcr.microsoft.com/devcontainers/python:3

# Install collectd
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
collectd \
librrds-perl \
libjson-perl \
libhtml-parser-perl \
libcgi-pm-perl \
libipc-run-perl \
rrdtool \
librrd-dev \
fonts-recommended \
python3-dotenv \
dpkg-dev \
debhelper \
devscripts \
build-essential \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
74 changes: 36 additions & 38 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -1,57 +1,55 @@
name: test-perl
name: test-python

on:
push:
branches: [ master ]
branches: [master]
pull_request:
branches: [ master ]
branches: [master]

jobs:
test:
name: pytest (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.1
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']

- name: Set up Perl
uses: shogo82148/actions-setup-perl@v1.37.0
with:
perl-version: '5.36'
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v6.1.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: '3.x'
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y librrds-perl \
libjson-perl \
libhtml-parser-perl \
libcgi-pm-perl \
libipc-run-perl

- name: Run tests
run: |
export PERL5LIB=$PERL5LIB:/usr/share/perl5
prove -lrv t > tap.out
cat tap.out
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
rrdtool \
librrd-dev \
pkg-config

- name: Convert TAP to Markdown inline
run: |
python3 ./scripts/tap2md.py tap.out > results.md
- name: Install Python dependencies
run: pip install -r requirements.txt

- name: Show Markdown Test Results as step summary
run: cat results.md >> $GITHUB_STEP_SUMMARY
- name: Run pytest
run: |
pytest tests/ -v \
--tb=short \
--junitxml=test-results.xml

- name: Upload Markdown Test Results
uses: actions/upload-artifact@v6.0.0
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-markdown
path: results.md
name: test-results-py${{ matrix.python-version }}
path: test-results.xml

- name: Upload TAP Test Results
uses: actions/upload-artifact@v6.0.0
- name: Publish test summary
if: always()
uses: test-summary/action@v2
with:
name: test-results-tap
path: tap.out
paths: test-results.xml
5 changes: 5 additions & 0 deletions collectd_web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""collectd-web Python backend package."""

from collectd_web.config import load_config

__all__ = ["load_config"]
149 changes: 149 additions & 0 deletions collectd_web/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Flask application for collectd-web.

Replaces the Python CGI shim (runserver.py + CGIHTTPRequestHandler) with a
proper WSGI app. All URLs are backward-compatible with the original Perl CGI
scripts so that index.html requires no changes.
"""
from __future__ import annotations

import json
import os
import time
from pathlib import Path

from flask import Flask, Response, jsonify, request, send_file, send_from_directory

from collectd_web.config import load_config
from collectd_web.graph_defs import GRAPH_DEFS
from collectd_web.graph_engine import find_files_for_host, find_hosts, generate_graph

# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------

def create_app(config_path: str | None = None) -> Flask:
"""Create and configure the Flask application."""
app = Flask(
__name__,
static_folder=None, # we handle static files manually
)

cfg = load_config(config_path)
app.config['COLLECTD_CONFIG'] = cfg

# Root of the repository (one level above this package)
repo_root = Path(__file__).parent.parent

# ------------------------------------------------------------------
# /cgi-bin/collection.modified.cgi (all actions)
# ------------------------------------------------------------------
@app.route('/cgi-bin/collection.modified.cgi')
def collection_cgi() -> Response:
config = app.config['COLLECTD_CONFIG']
data_dirs: list[str] = config.get('data_dirs', [])
action = request.args.get('action', '')

# --- hostlist_json ---
if action == 'hostlist_json':
hosts = find_hosts(data_dirs)
return jsonify(sorted(hosts))

# --- pluginlist_json ---
if action == 'pluginlist_json':
host = request.args.get('host', '')
if not host:
return jsonify({'error': 'host parameter required'}), 400
files = find_files_for_host(host, data_dirs)
return jsonify(sorted(files.keys()))

# --- graphs_json ---
if action == 'graphs_json':
host = request.args.get('host', '')
plugin = request.args.get('plugin', '')
if not host or not plugin:
return jsonify({'error': 'host and plugin required'}), 400
files = find_files_for_host(host, data_dirs)
plugin_data = files.get(plugin, {})
return jsonify(plugin_data)

# --- show_graph / show_custom_graph ---
if action in ('show_graph', 'show_custom_graph'):
host = request.args.get('host')
plugin = request.args.get('plugin')
type_name = request.args.get('type')
if not host or not plugin or not type_name:
return Response('Missing host, plugin or type', status=400)

plugin_instance = request.args.get('plugin_instance') or None
type_instance = request.args.get('type_instance') or None
timespan = request.args.get('timespan', 'day')
start = request.args.get('start') or None
end = request.args.get('end') or None
enable_caching = 'enable-caching' in request.args
output_format = request.args.get('output', 'PNG').upper()

png = generate_graph(
host=host,
plugin=plugin,
plugin_instance=plugin_instance,
type_name=type_name,
type_instance=type_instance,
data_dirs=data_dirs,
timespan=timespan,
start=start,
end=end,
output_format=output_format,
enable_caching=enable_caching,
)
if png is None:
return Response('Graph not found', status=404)

headers = {}
if enable_caching:
headers['Cache-Control'] = 'maxage=3600'
headers['Pragma'] = 'public'
return Response(
png,
mimetype='image/png',
headers=headers,
)

return Response('Unknown action', status=400)

# ------------------------------------------------------------------
# /cgi-bin/graphdefs.cgi — graph definitions as JSON
# ------------------------------------------------------------------
@app.route('/cgi-bin/graphdefs.cgi')
def graphdefs_cgi() -> Response:
return jsonify({'graph_defs': GRAPH_DEFS})

# ------------------------------------------------------------------
# /cgi-bin/time.cgi — timezone offset JSON
# ------------------------------------------------------------------
@app.route('/cgi-bin/time.cgi')
def time_cgi() -> Response:
offset = -time.timezone / 3600.0
if time.daylight and time.localtime().tm_isdst:
offset = -time.altzone / 3600.0
return jsonify({'tz': round(offset, 1)})

# ------------------------------------------------------------------
# Static files — index.html, media/, mobile/
# ------------------------------------------------------------------
@app.route('/')
def index() -> Response:
return send_file(repo_root / 'index.html')

@app.route('/media/<path:filename>')
def media(filename: str) -> Response:
return send_from_directory(repo_root / 'media', filename)

@app.route('/mobile/<path:filename>')
def mobile(filename: str) -> Response:
return send_from_directory(repo_root / 'mobile', filename)

@app.route('/docs/<path:filename>')
def docs(filename: str) -> Response:
return send_from_directory(repo_root / 'docs', filename)

return app
89 changes: 89 additions & 0 deletions collectd_web/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Config reader for /etc/collectd/collection.conf.

File format (same as the original Perl implementation):

DataDir: "/path/to/rrd/data"
DataDir: "/another/path"
LibDir: "/usr/share/collectd"
UriPrefix: "/collectd-web"

Rules:
- Lines starting with # or blank lines are ignored.
- Keys are case-insensitive.
- String values are wrapped in double-quotes (backslash escapes supported).
- Numeric values are bare integers.
- DataDir may appear multiple times (accumulates into a list).
"""

from __future__ import annotations

import os
import platform
import re
from pathlib import Path
from typing import Any

_DEFAULT_CONFIG_PATH_LINUX = Path("/etc/collectd/collection.conf")
_DEFAULT_CONFIG_PATH_FREEBSD = Path("/usr/local/etc/collectd-web.conf")

_RE_STRING = re.compile(r'^([A-Za-z]+):\s*"((?:[^"\\]|\\.)*)"\s*$')
_RE_NUMBER = re.compile(r'^([A-Za-z]+):\s*([0-9]+)\s*$')


def default_config_path() -> Path:
"""Return the platform-appropriate default config file path."""
if platform.system().lower() == "freebsd":
return _DEFAULT_CONFIG_PATH_FREEBSD
return _DEFAULT_CONFIG_PATH_LINUX


def load_config(path: str | Path | None = None) -> dict[str, Any]:
"""Parse a collection.conf file and return a config dict.

Returns:
{
"data_dirs": [str, ...], # one or more RRD data directories
"lib_dir": str | None, # optional library directory
"uri_prefix": str | None, # optional URI prefix
}
"""
if path is None:
path = Path(os.environ.get("COLLECTD_WEB_CONFIG", str(default_config_path())))
path = Path(path)

config: dict[str, Any] = {
"data_dirs": [],
"lib_dir": None,
"uri_prefix": None,
}

if not path.exists():
return config

with path.open(encoding="utf-8") as fh:
for raw_line in fh:
line = raw_line.strip()
if not line or line.startswith("#"):
continue

m = _RE_STRING.match(line)
if m:
key = m.group(1).lower()
value: Any = m.group(2).replace("\\\\", "\\").replace('\\"', '"')
value = value.rstrip("/")
else:
m = _RE_NUMBER.match(line)
if m:
key = m.group(1).lower()
value = int(m.group(2))
else:
continue

if key == "datadir":
config["data_dirs"].append(value)
elif key == "libdir":
config["lib_dir"] = value
elif key == "uriprefix":
config["uri_prefix"] = value

return config
Loading
Loading