#!/usr/bin/env python3 """ Benchmark the read-only WROLPi API endpoints, hot and cold. Read-only here means "does not mutate the library": every GET endpoint, plus the POST `.../search` and `.../estimates` endpoints the React UI uses to render its pages (the Videos page, for example, is rendered from `POST /api/videos/search`). Usage examples: # Hot only (no SSH needed), 3 passes, against a host profile from scripts/api_perf_hosts.json # (copy scripts/api_perf_hosts.example.json to create it). python3 scripts/api_perf.py --host docker # Cold pass (restart the API over SSH, then run every endpoint once) followed by 3 hot passes. python3 scripts/api_perf.py --host docker --cold # Additionally restart before EACH of the "page" endpoints so each gets a truly cold number. python3 scripts/api_perf.py --host decay --cold --cold-each # Ad-hoc host. python3 scripts/api_perf.py --url https://192.0.2.10:8443 Results are written as CSV to ./perf_results/_.csv, one row per request, and a summary table is printed at the end. Only the standard library is used so this runs anywhere. """ import argparse import csv import datetime import json import shlex import ssl import statistics import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Optional # ---------------------------------------------------------------------------------------------- # Hosts # ---------------------------------------------------------------------------------------------- HOSTS_FILE = Path(__file__).with_name('api_perf_hosts.json') # gitignored; see api_perf_hosts.example.json def load_hosts() -> dict: """Host profiles live in a gitignored JSON file so LAN addresses/usernames stay out of the repo. Each profile: url, ssh (user@host or null), ssh_opts (list), restart (shell command run over ssh), drop_caches (shell command or null when the ssh user cannot do it).""" if not HOSTS_FILE.exists(): return {} return json.loads(HOSTS_FILE.read_text()) HOSTS = load_hosts() # ---------------------------------------------------------------------------------------------- # Endpoint catalog # ---------------------------------------------------------------------------------------------- # Each entry: name, method, path, optional json body, optional `page` (which UI page issues it on # load; used for grouping and for --cold-each), optional `needs` (an id discovered at runtime, # substituted into the path as {video_id}, etc.). # # Deliberately excluded (read-only but not useful to time, or slow by design): # GET /api/events/feed (long-poll), GET /api/files/zip/download (streams a zip), # GET /api/extensions/ (static file), GET /api/upgrade/check (network), # GET /api/ai/services//logs, GET /api/ai/files/read, GET /api/ai/help/, # GET /api/echo (used as the readiness probe instead). CATALOG = [ # --- root --- dict(name='echo', method='GET', path='/api/echo'), dict(name='settings', method='GET', path='/api/settings', page='app'), dict(name='status', method='GET', path='/api/status', page='app'), dict(name='statistics', method='GET', path='/api/statistics', page='home'), dict(name='downloaders', method='GET', path='/api/downloaders'), dict(name='download', method='GET', path='/api/download', page='downloads'), dict(name='tag', method='GET', path='/api/tag', page='app'), dict(name='tag/recent', method='GET', path='/api/tag/recent'), dict(name='extensions', method='GET', path='/api/extensions'), dict(name='search_suggestions', method='POST', path='/api/search_suggestions', body={'search_str': '{q}'}, page='search'), dict(name='search_file_estimates', method='POST', path='/api/search_file_estimates', body={'search_str': '{q}'}, page='search'), dict(name='search_other_estimates', method='POST', path='/api/search_other_estimates', body={'tag_names': []}, page='search'), # --- config --- dict(name='config', method='GET', path='/api/config/'), dict(name='config/backups', method='GET', path='/api/config/backups'), # --- files --- dict(name='files/worker_status', method='GET', path='/api/files/worker_status'), dict(name='files/bulk_tag/progress', method='GET', path='/api/files/bulk_tag/progress'), dict(name='files (root listing)', method='POST', path='/api/files/', body={'directories': []}, page='files'), dict(name='files/search (no query)', method='POST', path='/api/files/search', body={'offset': 0, 'limit': 20}, page='search'), dict(name='files/search (query)', method='POST', path='/api/files/search', body={'offset': 0, 'limit': 20, 'search_str': '{q}'}, page='search'), dict(name='files/search (common word "the")', method='POST', path='/api/files/search', body={'offset': 0, 'limit': 20, 'search_str': 'the'}), dict(name='files/search (query, deep)', method='POST', path='/api/files/search', body={'offset': 0, 'limit': 20, 'search_str': '{q}', 'deep': True}), dict(name='files/search (page 50)', method='POST', path='/api/files/search', body={'offset': 1000, 'limit': 20, 'search_str': '{q}'}), dict(name='files/search_directories', method='POST', path='/api/files/search_directories', body={'path': 'vid'}), # --- collections --- dict(name='collections', method='GET', path='/api/collections/', page='collections'), dict(name='collections/', method='GET', path='/api/collections/{collection_id}', needs='collection_id'), dict(name='collections/search', method='POST', path='/api/collections/search', body={}), dict(name='collections/reorganize/channels', method='GET', path='/api/collections/reorganize/channels'), dict(name='collections/reorganize/domains', method='GET', path='/api/collections/reorganize/domains'), # --- videos --- dict(name='videos/statistics', method='GET', path='/api/videos/statistics', page='videos-stats'), dict(name='videos/download-defaults', method='GET', path='/api/videos/download-defaults'), dict(name='videos/cookies/status', method='GET', path='/api/videos/cookies/status'), dict(name='videos/suggested-user-agent', method='GET', path='/api/videos/suggested-user-agent'), dict(name='videos/channels', method='GET', path='/api/videos/channels', page='videos'), dict(name='videos/channels/', method='GET', path='/api/videos/channels/{channel_id}', needs='channel_id', page='channel'), dict(name='videos/channels/search', method='POST', path='/api/videos/channels/search', body={'tag_names': []}), dict(name='videos/search (page 1, newest)', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': '-published_datetime'}, page='videos'), dict(name='videos/search (page 1, -view_count)', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': '-view_count'}), dict(name='videos/search (page 50, newest)', method='POST', path='/api/videos/search', body={'offset': 1176, 'limit': 24, 'order_by': '-published_datetime'}), dict(name='videos/search (query, rank)', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': 'rank', 'search_str': '{q}'}, page='videos-search'), dict(name='videos/search (common word "the")', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': 'rank', 'search_str': 'the'}), dict(name='videos/search (query, deep)', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': 'rank', 'search_str': '{q}', 'deep': True}), dict(name='videos/search (channel)', method='POST', path='/api/videos/search', body={'offset': 0, 'limit': 24, 'order_by': '-published_datetime', 'channel_id': '{channel_id}'}, needs='channel_id', page='channel'), dict(name='videos/', method='GET', path='/api/videos/{video_id}', needs='video_id', page='video'), dict(name='videos//comments', method='GET', path='/api/videos/{video_id}/comments', needs='video_id', page='video'), dict(name='videos//captions', method='GET', path='/api/videos/{video_id}/captions', needs='video_id'), # --- archive --- dict(name='archive/statistics', method='GET', path='/api/archive/statistics', page='archives-stats'), dict(name='archive/browsers', method='GET', path='/api/archive/browsers'), dict(name='archive/search (page 1)', method='POST', path='/api/archive/search', body={'offset': 0, 'limit': 20, 'order_by': '-published_datetime'}, page='archives'), dict(name='archive/search (query)', method='POST', path='/api/archive/search', body={'offset': 0, 'limit': 20, 'search_str': '{q}'}, page='archives-search'), dict(name='archive/search (query, deep)', method='POST', path='/api/archive/search', body={'offset': 0, 'limit': 20, 'search_str': '{q}', 'deep': True}), dict(name='archive/', method='GET', path='/api/archive/{archive_id}', needs='archive_id', page='archive'), # --- docs --- dict(name='docs/statistics', method='GET', path='/api/docs/statistics'), dict(name='docs/search (page 1)', method='POST', path='/api/docs/search', body={'limit': 20}, page='docs'), dict(name='docs/search (query)', method='POST', path='/api/docs/search', body={'limit': 20, 'search_str': '{q}'}), dict(name='docs/', method='GET', path='/api/docs/{doc_id}', needs='doc_id'), # --- zim --- dict(name='zim', method='GET', path='/api/zim/', page='zim'), dict(name='zim/subscribe', method='GET', path='/api/zim/subscribe'), dict(name='zim/outdated', method='GET', path='/api/zim/outdated'), dict(name='zim/search', method='POST', path='/api/zim/search', body={'search_str': '{q}', 'limit': 10}, page='search'), dict(name='zim/search_estimates', method='POST', path='/api/zim/search_estimates', body={'search_str': '{q}'}, page='search'), # --- map --- dict(name='map/files', method='GET', path='/api/map/files', page='map'), dict(name='map/subscribe', method='GET', path='/api/map/subscribe'), dict(name='map/pins', method='GET', path='/api/map/pins', page='map'), dict(name='map/search', method='GET', path='/api/map/search?q={q}&limit=20&offset=0'), dict(name='map/search/estimate', method='GET', path='/api/map/search/estimate?q={q}'), dict(name='map/search/status', method='GET', path='/api/map/search/status'), # --- inventory --- dict(name='inventory', method='GET', path='/api/inventory/', page='inventory'), dict(name='inventory/catalog', method='GET', path='/api/inventory/catalog'), # --- flasher --- dict(name='flasher/configs', method='GET', path='/api/flasher/configs'), dict(name='flasher/search', method='POST', path='/api/flasher/search', body={}), # --- ai (MCP-facing read endpoints) --- dict(name='ai/status', method='GET', path='/api/ai/status'), dict(name='ai/services', method='GET', path='/api/ai/services'), dict(name='ai/disks', method='GET', path='/api/ai/disks'), dict(name='ai/manage/catalog', method='GET', path='/api/ai/manage/catalog'), dict(name='ai/collections', method='GET', path='/api/ai/collections'), dict(name='ai/inventories', method='GET', path='/api/ai/inventories'), dict(name='ai/zims', method='GET', path='/api/ai/zims'), dict(name='ai/files/search', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'limit': 5}), dict(name='ai/files/search video', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'kind': 'video', 'limit': 5}), dict(name='ai/files/search archive', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'kind': 'archive', 'limit': 5}), dict(name='ai/files/search doc', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'kind': 'doc', 'limit': 5}), dict(name='ai/zims/search', method='POST', path='/api/ai/zims/search', body={'search_str': '{q}', 'limit': 5}), dict(name='ai/files/