feat: v3.4.0 — OpenPanel plugin public release (42 tools)

OpenPanel product analytics plugin fully reviewed, tested, and published.
Works with both self-hosted and cloud (openpanel.dev) instances.

- 42 tools: event tracking, data export, analytics, project/client management
- All tools use public REST APIs (Track, Export, Insights, Manage)
- Client modes: write (tracking), read (analytics), root (full access)
- Service page with description, setup notes, WordPress plugin download
- Dynamic URL hints in Add Site form
- 62 unit tests
- ENABLED_PLUGINS default now includes openpanel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 17:48:12 +02:00
parent 68c84b3ece
commit 9905a28eb0
33 changed files with 1672 additions and 3207 deletions

View File

@@ -5,6 +5,38 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.4.0] — 2026-03-31
### OpenPanel Plugin — Public Release (Track F.10)
OpenPanel product analytics plugin fully reviewed, tested, and published for public use. Works with both self-hosted and cloud (openpanel.dev) instances.
#### Added
- **OpenPanel Plugin** (42 tools): Complete rewrite using public REST APIs — no tRPC/session dependency
- **Track API** (11 tools): track_event, page_view, screen_view, identify_user, set_user_properties, increment/decrement_property, create_group, assign_group, track_revenue, track_batch
- **Export API** (10 tools): export_events, export_events_csv, export_chart_data, get_event_count, get_unique_users, get_page_views, get_top_pages, get_top_referrers, get_geo_data, get_device_data
- **Insights API** (2 tools): get_overview_report, get_realtime_stats
- **Profile API** (3 tools): get_profile_events, get_profile_sessions, export_profile_data
- **Manage API** (10 tools): list/get/create/update/delete projects and clients
- **System** (6 tools): health_check, get_instance_info, get_usage_stats, get_storage_stats, test_connection, get_rate_limit_status
- **Service Page descriptions**: Per-plugin description and notes section on service detail pages
- **Dynamic URL hints**: Add Site form shows per-plugin URL guidance (e.g., OpenPanel API URL vs dashboard URL)
- **WordPress plugin download**: OpenPanel WordPress plugin bundled at `/static/plugins/openpanel-self-hosted.zip`
- **62 unit tests** for OpenPanel plugin (`tests/test_openpanel_plugin.py`)
#### Changed
- `ENABLED_PLUGINS` default: `wordpress,woocommerce,supabase,openpanel` (was: `wordpress,woocommerce,supabase`)
- OpenPanel health check uses `GET /healthcheck` (was: non-existent `/api/v1/oauth/token`)
- OpenPanel credential fields: added Project ID and Organization ID (optional)
#### Removed
- OpenPanel `alias_user` tool (explicitly unsupported by OpenPanel API)
- OpenPanel dashboard handler (18 tools) — no public API available, was placeholder stubs
- OpenPanel funnel handler (8 tools) — no public API available, was placeholder stubs
- tRPC client dependency — all tools now use public REST APIs
---
## [3.3.0] — 2026-03-31 ## [3.3.0] — 2026-03-31
### Platform Hardening & Admin Unification (Track F.1F.8) ### Platform Hardening & Admin Unification (Track F.1F.8)
@@ -22,7 +54,7 @@ Major quality release: plugin visibility control, UI/UX polish, unified admin pa
- **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth - **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth
#### Fixed #### Fixed
- **Connect Page** (F.2): WordPress/SEO amber info box now shown only for WordPress/WooCommerce sites (was shown for all plugin types) - **Connect Page** (F.2): WordPress/SEO amber info box shown only for WordPress/WooCommerce sites (was shown for all plugin types)
- **Sidebar Version** (F.2): Fixed "v" displaying when version string is empty - **Sidebar Version** (F.2): Fixed "v" displaying when version string is empty
- **Auth Page Language** (F.2): Auth page defaults to English regardless of Accept-Language header - **Auth Page Language** (F.2): Auth page defaults to English regardless of Accept-Language header
- **Donation Link** (F.2): Moved from home page to sidebar for consistent visibility - **Donation Link** (F.2): Moved from home page to sidebar for consistent visibility

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 596 tools total. The tool count stays constant regardless of how many sites are configured. **MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 565 tools total. The tool count stays constant regardless of how many sites are configured.
## Quick Setup ## Quick Setup
@@ -193,15 +193,15 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
## Live Instances ## Live Instances
- **Platform**: `mcp.palebluedot.live` — Live MCP Hub with OAuth login - **Platform**: `mcp.example.com` — Live MCP Hub with OAuth login
- **Blog**: `blog.palebluedot.live` — WordPress test site + project blog - **Blog**: `blog.example.com` — WordPress test site + project blog
- **Deployment**: Coolify / Docker Compose, port 8000 - **Deployment**: Coolify / Docker Compose, port 8000
## Current Development (Track F) ## Current Development (Track F)
v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan. v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan.
**Active plugins for public users**: WordPress, WooCommerce, Supabase (configurable via `ENABLED_PLUGINS` env var) **Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel (configurable via `ENABLED_PLUGINS` env var)
## Gotchas ## Gotchas
@@ -209,7 +209,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary - `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python - `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- Only 3 plugins are tested for public use: WordPress, WooCommerce, Supabase. Others are admin-only or disabled. - 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login - OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
- User sites stored in SQLite (`core/database.py`), admin sites still from env vars - User sites stored in SQLite (`core/database.py`), admin sites still from env vars
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`) - Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
@@ -225,4 +225,4 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL` - Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
- OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL` - OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL`
- User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500` - User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500`
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase` (default) - Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel` (default)

View File

@@ -112,14 +112,14 @@ You should see the login page. Log in with your `MASTER_API_KEY` or via **GitHub
### Try It Now (No Setup Required) ### Try It Now (No Setup Required)
**Don't want to self-host?** Try the demo instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)**: **Don't want to self-host?** Use the hosted instance at **[mcp.example.com](https://mcp.example.com)**:
1. Log in with **GitHub** or **Google** 1. Log in with **GitHub** or **Google**
2. Add your sites via the dashboard (My Sites → Add Service) 2. Add your sites via the dashboard (My Sites → Add Service)
3. Go to **Connect** page — generate config for your AI client 3. Go to **Connect** page — generate config for your AI client
4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code 4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code
Your personal MCP endpoint: `https://mcp.palebluedot.live/u/{your-user-id}/{alias}/mcp` Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mcp`
--- ---

View File

@@ -10,12 +10,12 @@ import json
import logging import logging
import os import os
import secrets import secrets
import bcrypt
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -850,12 +850,9 @@ async def get_all_projects(
is_admin = False is_admin = False
current_user_id = None current_user_id = None
if user_session: if user_session:
if ( if hasattr(user_session, "user_type") and user_session.user_type == "master":
hasattr(user_session, "user_type") is_admin = True
and user_session.user_type == "master" elif isinstance(user_session, dict) and user_session.get("role") == "admin":
or isinstance(user_session, dict)
and user_session.get("role") == "admin"
):
is_admin = True is_admin = True
elif isinstance(user_session, dict) and "user_id" in user_session: elif isinstance(user_session, dict) and "user_id" in user_session:
current_user_id = user_session["user_id"] current_user_id = user_session["user_id"]
@@ -3292,6 +3289,46 @@ async def get_service_page_data(plugin_type: str) -> dict | None:
from core.plugin_visibility import is_plugin_public from core.plugin_visibility import is_plugin_public
# Per-plugin descriptions and notes
service_descriptions = {
"openpanel": {
"en": (
"OpenPanel product analytics management. "
"Supports event tracking, data export, analytics reports, "
"and project/client management via public REST APIs. "
"Works with both self-hosted and cloud (openpanel.dev) instances."
),
"fa": (
"مدیریت آنالیتیکس محصول OpenPanel. "
"پشتیبانی از ردیابی رویداد، خروجی داده، گزارش‌های آنالیتیکس، "
"و مدیریت پروژه/کلاینت از طریق APIهای عمومی REST. "
"هم نسخه خودمیزبان و هم نسخه cloud (openpanel.dev) پشتیبانی می‌شود."
),
"notes_en": [
"<strong>Site URL must be the API URL</strong>, not the dashboard URL. "
"Cloud: <code>https://api.openpanel.dev</code> — "
"Self-hosted: your API service URL (e.g., <code>https://analytics.example.com</code>).",
"Client must have <strong>root</strong> mode for full access (tracking + export + manage). "
"Use <strong>read</strong> mode for analytics only, or <strong>write</strong> mode for tracking only.",
"If you have WordPress, install the <a href='/static/plugins/openpanel-self-hosted.zip' "
"class='text-primary-400 hover:underline'>OpenPanel WordPress plugin</a> "
"to automatically send analytics data to your OpenPanel instance.",
],
"notes_fa": [
"<strong>آدرس سایت باید آدرس API باشد</strong>، نه داشبورد. "
"نسخه Cloud: <code>https://api.openpanel.dev</code> — "
"خودمیزبان: آدرس سرویس API شما (مثلاً <code>https://analytics.example.com</code>).",
"برای دسترسی کامل، کلاینت باید حالت <strong>root</strong> داشته باشد. "
"از حالت <strong>read</strong> برای آنالیتیکس و <strong>write</strong> برای ردیابی استفاده کنید.",
"اگر وردپرس دارید، افزونه <a href='/static/plugins/openpanel-self-hosted.zip' "
"class='text-primary-400 hover:underline'>OpenPanel WordPress</a> "
"را نصب کنید تا داده‌های آنالیتیکس به‌صورت خودکار به OpenPanel ارسال شود.",
],
},
}
desc_data = service_descriptions.get(plugin_type, {})
return { return {
"plugin_type": plugin_type, "plugin_type": plugin_type,
"display_name": display_name, "display_name": display_name,
@@ -3299,6 +3336,7 @@ async def get_service_page_data(plugin_type: str) -> dict | None:
"tools_count": len(tools), "tools_count": len(tools),
"credential_fields": credential_fields, "credential_fields": credential_fields,
"is_public": is_plugin_public(plugin_type), "is_public": is_plugin_public(plugin_type),
"description": desc_data,
} }

View File

@@ -249,16 +249,15 @@ ENDPOINT_CONFIGS = {
}, },
max_tools=80, max_tools=80,
), ),
# OpenPanel endpoint - Product Analytics (73 tools) - Phase H # OpenPanel endpoint - Product Analytics (42 tools) - Phase H
EndpointType.OPENPANEL: EndpointConfig( EndpointType.OPENPANEL: EndpointConfig(
path="/openpanel", path="/openpanel",
name="OpenPanel Analytics", name="OpenPanel Analytics",
description="OpenPanel product analytics management (events, export, funnels, dashboards)", description="OpenPanel self-hosted product analytics (events, export, insights, project/client management)",
endpoint_type=EndpointType.OPENPANEL, endpoint_type=EndpointType.OPENPANEL,
plugin_types=["openpanel"], plugin_types=["openpanel"],
require_master_key=False, require_master_key=False,
allowed_scopes={"read", "write", "admin"}, allowed_scopes={"read", "write", "admin"},
# Blacklist system and admin tools
tool_blacklist={ tool_blacklist={
"manage_api_keys_create", "manage_api_keys_create",
"manage_api_keys_delete", "manage_api_keys_delete",
@@ -266,7 +265,7 @@ ENDPOINT_CONFIGS = {
"oauth_register_client", "oauth_register_client",
"oauth_revoke_client", "oauth_revoke_client",
}, },
max_tools=80, max_tools=50,
), ),
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I # Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
EndpointType.APPWRITE: EndpointConfig( EndpointType.APPWRITE: EndpointConfig(

View File

@@ -15,7 +15,7 @@ Usage:
import os import os
# Default plugins available to public (OAuth) users # Default plugins available to public (OAuth) users
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase"} DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel"}
def _parse_plugins(val: str) -> set[str]: def _parse_plugins(val: str) -> set[str]:

View File

@@ -3,7 +3,7 @@
Usage: Usage:
from core.settings import get_setting from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase") enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel")
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10")) max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
""" """
@@ -17,7 +17,7 @@ _cached_plugins: set[str] | None = None
# Default values for all managed settings # Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = { SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase", "ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel",
"MAX_SITES_PER_USER": "10", "MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30", "USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500", "USER_RATE_LIMIT_PER_HR": "500",

View File

@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Client ID", "label": "Client ID",
"type": "text", "type": "text",
"required": True, "required": True,
"hint": "OpenPanel admin panel → API section", "hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
}, },
{ {
"name": "client_secret", "name": "client_secret",
@@ -166,6 +166,21 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"required": True, "required": True,
"hint": "Generated with your Client ID", "hint": "Generated with your Client ID",
}, },
{
"name": "project_id",
"label": "Project ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools",
},
{
"name": "organization_id",
"label": "Organization ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/",
"advanced": True,
},
], ],
"appwrite": [ "appwrite": [
{ {
@@ -215,7 +230,7 @@ _HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"gitea": {"path": "/api/v1/user", "method": "GET"}, "gitea": {"path": "/api/v1/user", "method": "GET"},
"n8n": {"path": "/healthz", "method": "GET"}, "n8n": {"path": "/healthz", "method": "GET"},
"supabase": {"path": "/rest/v1/", "method": "GET"}, "supabase": {"path": "/rest/v1/", "method": "GET"},
"openpanel": {"path": "/api/v1/oauth/token", "method": "POST"}, "openpanel": {"path": "/healthcheck", "method": "GET"},
"appwrite": {"path": "/v1/health", "method": "GET"}, "appwrite": {"path": "/v1/health", "method": "GET"},
"directus": {"path": "/server/health", "method": "GET"}, "directus": {"path": "/server/health", "method": "GET"},
} }
@@ -342,23 +357,13 @@ async def validate_site_connection(
elif plugin_type == "directus": elif plugin_type == "directus":
headers["Authorization"] = f"Bearer {credentials.get('token', '')}" headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
elif plugin_type == "openpanel": elif plugin_type == "openpanel":
# OpenPanel uses token exchange — just check that the URL is reachable headers["openpanel-client-id"] = credentials.get("client_id", "")
pass headers["openpanel-client-secret"] = credentials.get("client_secret", "")
try: try:
timeout = aiohttp.ClientTimeout(total=15) timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
if method == "POST" and plugin_type == "openpanel": if method == "POST":
async with session.post(
check_url,
json={
"clientId": credentials.get("client_id", ""),
"clientSecret": credentials.get("client_secret", ""),
},
) as resp:
status_code = resp.status
resp_text = await resp.text()
elif method == "POST":
async with session.post(check_url, headers=headers) as resp: async with session.post(check_url, headers=headers) as resp:
status_code = resp.status status_code = resp.status
resp_text = await resp.text() resp_text = await resp.text()

View File

@@ -31,6 +31,31 @@
</a> </a>
</div> </div>
<!-- Description & Notes -->
{% if service.description %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{% if lang == 'fa' and service.description.fa %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed" dir="rtl">{{ service.description.fa }}</p>
{% elif service.description.en %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ service.description.en }}</p>
{% endif %}
{% set notes_key = 'notes_fa' if lang == 'fa' else 'notes_en' %}
{% if service.description[notes_key] %}
<ul class="mt-4 space-y-2{% if lang == 'fa' %} text-right{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
{% for note in service.description[notes_key] %}
<li class="flex items-start gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 text-primary-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ note|safe }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
<!-- Overview Cards --> <!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Tools Count --> <!-- Tools Count -->

View File

@@ -37,6 +37,7 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required placeholder="https://example.com" <input type="url" id="url" name="url" required placeholder="https://example.com"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"> class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
</div> </div>
<!-- Alias --> <!-- Alias -->
@@ -73,9 +74,23 @@
<script> <script>
const pluginFields = {{ plugin_fields_json| safe }}; const pluginFields = {{ plugin_fields_json| safe }};
const urlHints = {
'openpanel': 'OpenPanel API URL — Cloud: https://api.openpanel.dev | Self-hosted: your API service URL (e.g., https://analytics.example.com)',
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
};
function updateFields() { function updateFields() {
const ptype = document.getElementById('plugin_type').value; const ptype = document.getElementById('plugin_type').value;
const container = document.getElementById('credential-fields'); const container = document.getElementById('credential-fields');
const urlHintEl = document.getElementById('url-hint');
// Update URL hint
if (urlHints[ptype]) {
urlHintEl.textContent = urlHints[ptype];
urlHintEl.classList.remove('hidden');
} else {
urlHintEl.classList.add('hidden');
}
if (!ptype || !pluginFields[ptype]) { if (!ptype || !pluginFields[ptype]) {
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>'; container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';

View File

@@ -46,7 +46,7 @@ For each WordPress site you want to manage:
The fastest way to try MCP Hub — no installation needed: The fastest way to try MCP Hub — no installation needed:
1. Visit **[mcp.palebluedot.live](https://mcp.palebluedot.live)** 1. Visit **[mcp.example.com](https://mcp.example.com)**
2. Log in with **GitHub** or **Google** 2. Log in with **GitHub** or **Google**
3. Add your sites via the dashboard (**My Sites → Add Service**) 3. Add your sites via the dashboard (**My Sites → Add Service**)
4. Go to **Connect** page to generate your AI client config 4. Go to **Connect** page to generate your AI client config
@@ -54,7 +54,7 @@ The fastest way to try MCP Hub — no installation needed:
Your personal MCP endpoint: Your personal MCP endpoint:
``` ```
https://mcp.palebluedot.live/u/{your-user-id}/{alias}/mcp https://mcp.example.com/u/{your-user-id}/{alias}/mcp
``` ```
**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication. **WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.

View File

@@ -32,8 +32,8 @@ DASHBOARD_SESSION_SECRET=
# ============================================ # ============================================
# Comma-separated list of plugins visible to public (OAuth) users. # Comma-separated list of plugins visible to public (OAuth) users.
# Admin users always see all plugins regardless of this setting. # Admin users always see all plugins regardless of this setting.
# Default (if not set): wordpress,woocommerce,supabase # Default (if not set): wordpress,woocommerce,supabase,openpanel
# ENABLED_PLUGINS=wordpress,woocommerce,supabase # ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel
# ============================================ # ============================================
# ADMIN SYSTEM (Track F.4) # ADMIN SYSTEM (Track F.4)

View File

@@ -1,11 +1,8 @@
""" """
OpenPanel Plugin - Product Analytics Management OpenPanel Plugin - Product Analytics Management.
Complete OpenPanel Self-Hosted management through REST API. Self-hosted OpenPanel management through public REST APIs (42 tools).
Provides tools for event tracking, data export, funnels, Event tracking, data export, analytics, project & client management.
dashboards, user profiles, and analytics reports.
For Self-Hosted instances deployed on Coolify.
""" """
from plugins.openpanel.client import OpenPanelClient from plugins.openpanel.client import OpenPanelClient

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,36 @@
""" """
OpenPanel Handlers OpenPanel Handlers — 42 tools across 7 handlers.
Phase H.1: Core (25 tools) All tools use public REST APIs (no tRPC/session dependency).
- events.py: Event tracking (9 tools - alias_user removed)
- export.py: Data export (10 tools)
- system.py: Health & stats (6 tools)
Phase H.2: Analytics (24 tools) Track API (/track) — write mode:
- reports.py: Analytics reports (8 tools) - events.py: Event tracking, groups (11 tools)
- funnels.py: Funnel analysis (8 tools)
- profiles.py: User profiles (8 tools)
Phase H.3: Management (24 tools) Export API (/export) — read mode:
- projects.py: Project management (8 tools) - export.py: Data export & analytics (10 tools)
- dashboards.py: Dashboard management (10 tools)
- clients.py: API client management (6 tools)
Total: 73 tools Insights API (/insights) — read mode:
- reports.py: Overview & realtime stats (2 tools)
Profile API — read mode:
- profiles.py: Profile events & data export (3 tools)
Manage API (/manage) — root mode:
- projects.py: Project CRUD (5 tools)
- clients.py: Client CRUD (5 tools)
System:
- system.py: Health & instance info (6 tools)
Removed (no public API):
- dashboards.py (tRPC-only)
- funnels.py (tRPC-only)
""" """
from plugins.openpanel.handlers import ( from plugins.openpanel.handlers import (
clients, clients,
dashboards,
events, events,
export, export,
funnels,
profiles, profiles,
projects, projects,
reports, reports,
@@ -36,9 +42,7 @@ __all__ = [
"export", "export",
"system", "system",
"reports", "reports",
"funnels",
"profiles", "profiles",
"projects", "projects",
"dashboards",
"clients", "clients",
] ]

View File

@@ -1,4 +1,8 @@
"""Clients Handler - OpenPanel API client/key management (6 tools)""" """Clients Handler - OpenPanel API client management (5 tools).
Uses Manage API (GET/POST/PATCH/DELETE /manage/clients).
Requires 'root' mode client.
"""
import json import json
from typing import Any from typing import Any
@@ -7,107 +11,82 @@ from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (6 tools)""" """Return tool specifications for ToolGenerator (5 tools)."""
return [ return [
{ {
"name": "list_clients", "name": "list_clients",
"method_name": "list_clients", "method_name": "list_clients",
"description": "List all API clients for a project.", "description": "List all API clients via Manage API. Requires 'root' mode client.",
"schema": { "schema": {"type": "object", "properties": {}},
"type": "object", "scope": "admin",
"properties": {"project_id": {"type": "string", "description": "Project ID"}},
"required": ["project_id"],
},
"scope": "read",
}, },
{ {
"name": "get_client", "name": "get_client",
"method_name": "get_client", "method_name": "get_client",
"description": "Get API client details.", "description": "Get API client details via Manage API. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "Project ID"}, "client_id": {"type": "string", "description": "Client ID"},
"client_id": {"type": "string", "description": "API Client ID"},
}, },
"required": ["project_id", "client_id"], "required": ["client_id"],
}, },
"scope": "read", "scope": "admin",
}, },
{ {
"name": "create_client", "name": "create_client",
"method_name": "create_client", "method_name": "create_client",
"description": "Create a new API client for tracking or export.", "description": "Create a new API client via Manage API. Modes: 'write' (tracking only), 'read' (export/analytics), 'root' (full access). Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "Project ID"}, "name": {"type": "string", "description": "Client name"},
"name": { "project_id": {"type": "string", "description": "Project to associate with"},
"type": "string",
"description": "Client name (e.g., 'Web Tracker', 'Backend Export')",
},
"mode": { "mode": {
"type": "string", "type": "string",
"enum": ["write", "read", "root"], "enum": ["write", "read", "root"],
"description": "Client mode: write (tracking), read (export), root (full access)", "description": "Client mode: write (tracking), read (export/analytics), root (full access)",
}, "default": "write",
"cors_domains": {
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
"description": "Allowed domains for CORS (write mode)",
}, },
}, },
"required": ["project_id", "name", "mode"], "required": ["name", "project_id"],
},
"scope": "admin",
},
{
"name": "update_client",
"method_name": "update_client",
"description": "Update an API client via Manage API. Requires 'root' mode client.",
"schema": {
"type": "object",
"properties": {
"client_id": {"type": "string", "description": "Client ID to update"},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New client name",
},
"mode": {
"anyOf": [
{"type": "string", "enum": ["write", "read", "root"]},
{"type": "null"},
],
"description": "New client mode",
},
},
"required": ["client_id"],
}, },
"scope": "admin", "scope": "admin",
}, },
{ {
"name": "delete_client", "name": "delete_client",
"method_name": "delete_client", "method_name": "delete_client",
"description": "Delete an API client.", "description": "Delete an API client via Manage API. WARNING: Any integrations using this client will stop working. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "Project ID"},
"client_id": {"type": "string", "description": "Client ID to delete"}, "client_id": {"type": "string", "description": "Client ID to delete"},
}, },
"required": ["project_id", "client_id"], "required": ["client_id"],
},
"scope": "admin",
},
{
"name": "regenerate_client_secret",
"method_name": "regenerate_client_secret",
"description": "Regenerate the secret for an API client.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"client_id": {"type": "string", "description": "Client ID"},
},
"required": ["project_id", "client_id"],
},
"scope": "admin",
},
{
"name": "update_client_mode",
"method_name": "update_client_mode",
"description": "Update API client permissions/mode.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"client_id": {"type": "string", "description": "Client ID"},
"mode": {
"type": "string",
"enum": ["write", "read", "root"],
"description": "New mode",
},
"cors_domains": {
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
"description": "New CORS domains",
},
},
"required": ["project_id", "client_id", "mode"],
}, },
"scope": "admin", "scope": "admin",
}, },
@@ -115,20 +94,21 @@ def get_tool_specifications() -> list[dict[str, Any]]:
# ===================== # =====================
# Client Functions (6) # Client Functions (5)
# ===================== # =====================
async def list_clients(client: OpenPanelClient, project_id: str) -> str: async def list_clients(client: OpenPanelClient) -> str:
"""List all API clients""" """List all API clients via GET /manage/clients."""
try: try:
result = await client.list_clients()
clients = (
result
if isinstance(result, list)
else result.get("data", []) if isinstance(result, dict) else []
)
return json.dumps( return json.dumps(
{ {"success": True, "count": len(clients), "clients": clients},
"success": True,
"project_id": project_id,
"note": "Client listing requires dashboard tRPC API. Use OpenPanel dashboard to view clients.",
"message": "Client list request processed",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -136,117 +116,76 @@ async def list_clients(client: OpenPanelClient, project_id: str) -> str:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_client(client: OpenPanelClient, project_id: str, client_id: str) -> str: async def get_client(client: OpenPanelClient, client_id: str) -> str:
"""Get API client details""" """Get client details via GET /manage/clients/:id."""
try: try:
return json.dumps( result = await client.get_client(client_id)
{ return json.dumps({"success": True, "client": result}, indent=2, ensure_ascii=False)
"success": True,
"project_id": project_id,
"client_id": client_id,
"note": "Client details require dashboard tRPC API. Use OpenPanel dashboard for full view.",
"message": "Client details request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def create_client( async def create_client(
client: OpenPanelClient, client: OpenPanelClient,
project_id: str,
name: str, name: str,
mode: str,
cors_domains: list[str] | None = None,
) -> str:
"""Create a new API client"""
try:
client_config = {"name": name, "mode": mode, "cors_domains": cors_domains}
mode_descriptions = {
"write": "Can send events (tracking)",
"read": "Can read/export data",
"root": "Full access (tracking + export + management)",
}
return json.dumps(
{
"success": True,
"project_id": project_id,
"client": client_config,
"mode_description": mode_descriptions.get(mode, "Unknown mode"),
"note": "Client creation requires dashboard tRPC API. Use OpenPanel dashboard to create clients.",
"message": f"Client '{name}' configuration created with {mode} mode",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_client(client: OpenPanelClient, project_id: str, client_id: str) -> str:
"""Delete an API client"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"client_id": client_id,
"note": "Client deletion requires dashboard tRPC API. Use OpenPanel dashboard to delete clients.",
"warning": "Deleting a client will invalidate all requests using its credentials.",
"message": "Client deletion request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def regenerate_client_secret(client: OpenPanelClient, project_id: str, client_id: str) -> str:
"""Regenerate client secret"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"client_id": client_id,
"note": "Secret regeneration requires dashboard tRPC API. Use OpenPanel dashboard to regenerate.",
"warning": "Regenerating secret will invalidate the current secret immediately.",
"message": "Secret regeneration request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_client_mode(
client: OpenPanelClient,
project_id: str, project_id: str,
client_id: str, mode: str = "write",
mode: str,
cors_domains: list[str] | None = None,
) -> str: ) -> str:
"""Update client permissions""" """Create a new API client via POST /manage/clients."""
try: try:
updates = {"mode": mode, "cors_domains": cors_domains} data: dict[str, Any] = {"name": name, "projectId": project_id, "mode": mode}
result = await client.create_client(data)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": project_id, "message": f"Client '{name}' created with {mode} mode",
"client_id": client_id, "client": result,
"updates": updates, "note": "Save the client_secret — it cannot be retrieved later.",
"note": "Client mode update requires dashboard tRPC API. Use OpenPanel dashboard to modify.",
"message": f"Client mode update to {mode} configuration created",
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_client(
client: OpenPanelClient,
client_id: str,
name: str | None = None,
mode: str | None = None,
) -> str:
"""Update a client via PATCH /manage/clients/:id."""
try:
data: dict[str, Any] = {}
if name:
data["name"] = name
if mode:
data["mode"] = mode
if not data:
return json.dumps(
{"success": False, "error": "No fields to update. Provide name or mode."},
indent=2,
ensure_ascii=False,
)
result = await client.update_client(client_id, data)
return json.dumps(
{"success": True, "message": f"Client '{client_id}' updated", "client": result},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_client(client: OpenPanelClient, client_id: str) -> str:
"""Delete a client via DELETE /manage/clients/:id."""
try:
result = await client.delete_client(client_id)
return json.dumps(
{"success": True, "message": f"Client '{client_id}' deleted", "result": result},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)

View File

@@ -1,467 +0,0 @@
"""Dashboards Handler - OpenPanel dashboard management (10 tools)"""
import json
from typing import Any
from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (10 tools)"""
return [
{
"name": "list_dashboards",
"method_name": "list_dashboards",
"description": "List all dashboards for a project.",
"schema": {
"type": "object",
"properties": {"project_id": {"type": "string", "description": "Project ID"}},
"required": ["project_id"],
},
"scope": "read",
},
{
"name": "get_dashboard",
"method_name": "get_dashboard",
"description": "Get dashboard details including all charts.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
},
"required": ["project_id", "dashboard_id"],
},
"scope": "read",
},
{
"name": "create_dashboard",
"method_name": "create_dashboard",
"description": "Create a new custom dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"name": {"type": "string", "description": "Dashboard name"},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Dashboard description",
},
},
"required": ["project_id", "name"],
},
"scope": "write",
},
{
"name": "update_dashboard",
"method_name": "update_dashboard",
"description": "Update dashboard properties.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New description",
},
},
"required": ["project_id", "dashboard_id"],
},
"scope": "write",
},
{
"name": "delete_dashboard",
"method_name": "delete_dashboard",
"description": "Delete a dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID to delete"},
},
"required": ["project_id", "dashboard_id"],
},
"scope": "write",
},
{
"name": "add_chart",
"method_name": "add_chart",
"description": "Add a new chart to a dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar",
"area",
"pie",
"map",
"histogram",
"funnel",
"retention",
"metric",
],
"description": "Type of chart",
},
"title": {"type": "string", "description": "Chart title"},
"events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"segment": {"type": "string"},
},
"required": ["name"],
},
"description": "Events to include in chart",
},
"breakdowns": {
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
"description": "Breakdown dimensions",
},
},
"required": ["project_id", "dashboard_id", "chart_type", "title", "events"],
},
"scope": "write",
},
{
"name": "update_chart",
"method_name": "update_chart",
"description": "Update an existing chart configuration.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
"chart_id": {"type": "string", "description": "Chart ID to update"},
"title": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New title",
},
"chart_type": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New chart type",
},
"events": {
"anyOf": [{"type": "array"}, {"type": "null"}],
"description": "New events configuration",
},
},
"required": ["project_id", "dashboard_id", "chart_id"],
},
"scope": "write",
},
{
"name": "delete_chart",
"method_name": "delete_chart",
"description": "Remove a chart from a dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
"chart_id": {"type": "string", "description": "Chart ID to delete"},
},
"required": ["project_id", "dashboard_id", "chart_id"],
},
"scope": "write",
},
{
"name": "duplicate_dashboard",
"method_name": "duplicate_dashboard",
"description": "Create a copy of an existing dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID to duplicate"},
"new_name": {"type": "string", "description": "Name for the new dashboard"},
},
"required": ["project_id", "dashboard_id", "new_name"],
},
"scope": "write",
},
{
"name": "share_dashboard",
"method_name": "share_dashboard",
"description": "Generate a shareable link for a dashboard.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"dashboard_id": {"type": "string", "description": "Dashboard ID"},
"public": {
"type": "boolean",
"description": "Make dashboard publicly accessible",
"default": False,
},
"expires_in_days": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Link expiration in days (null for no expiration)",
},
},
"required": ["project_id", "dashboard_id"],
},
"scope": "write",
},
]
# =====================
# Dashboard Functions (10)
# =====================
async def list_dashboards(client: OpenPanelClient, project_id: str) -> str:
"""List all dashboards"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"note": "Dashboard listing requires dashboard tRPC API. Use OpenPanel dashboard to view dashboards.",
"message": "Dashboard list request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_dashboard(client: OpenPanelClient, project_id: str, dashboard_id: str) -> str:
"""Get dashboard details"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"note": "Dashboard details require dashboard tRPC API. Use OpenPanel dashboard for full view.",
"message": "Dashboard request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def create_dashboard(
client: OpenPanelClient, project_id: str, name: str, description: str | None = None
) -> str:
"""Create a new dashboard"""
try:
dashboard_config = {"name": name, "description": description}
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard": dashboard_config,
"note": "Dashboard creation requires dashboard tRPC API. Use OpenPanel dashboard to create.",
"message": f"Dashboard '{name}' configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_dashboard(
client: OpenPanelClient,
project_id: str,
dashboard_id: str,
name: str | None = None,
description: str | None = None,
) -> str:
"""Update dashboard properties"""
try:
updates = {}
if name:
updates["name"] = name
if description:
updates["description"] = description
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"updates": updates,
"note": "Dashboard updates require dashboard tRPC API. Use OpenPanel dashboard to modify.",
"message": "Dashboard update configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_dashboard(client: OpenPanelClient, project_id: str, dashboard_id: str) -> str:
"""Delete a dashboard"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"note": "Dashboard deletion requires dashboard tRPC API. Use OpenPanel dashboard to delete.",
"message": "Dashboard deletion request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def add_chart(
client: OpenPanelClient,
project_id: str,
dashboard_id: str,
chart_type: str,
title: str,
events: list[dict[str, Any]],
breakdowns: list[str] | None = None,
) -> str:
"""Add a chart to dashboard"""
try:
chart_config = {
"chart_type": chart_type,
"title": title,
"events": events,
"breakdowns": breakdowns,
}
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"chart": chart_config,
"note": "Chart creation requires dashboard tRPC API. Use OpenPanel dashboard to add charts.",
"message": f"Chart '{title}' configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_chart(
client: OpenPanelClient,
project_id: str,
dashboard_id: str,
chart_id: str,
title: str | None = None,
chart_type: str | None = None,
events: list[dict[str, Any]] | None = None,
) -> str:
"""Update chart configuration"""
try:
updates = {}
if title:
updates["title"] = title
if chart_type:
updates["chart_type"] = chart_type
if events:
updates["events"] = events
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"chart_id": chart_id,
"updates": updates,
"note": "Chart updates require dashboard tRPC API. Use OpenPanel dashboard to modify charts.",
"message": "Chart update configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_chart(
client: OpenPanelClient, project_id: str, dashboard_id: str, chart_id: str
) -> str:
"""Remove chart from dashboard"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"chart_id": chart_id,
"note": "Chart deletion requires dashboard tRPC API. Use OpenPanel dashboard to remove charts.",
"message": "Chart deletion request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def duplicate_dashboard(
client: OpenPanelClient, project_id: str, dashboard_id: str, new_name: str
) -> str:
"""Duplicate a dashboard"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"new_name": new_name,
"note": "Dashboard duplication requires dashboard tRPC API. Use OpenPanel dashboard to clone.",
"message": f"Dashboard duplication request for '{new_name}'",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def share_dashboard(
client: OpenPanelClient,
project_id: str,
dashboard_id: str,
public: bool = False,
expires_in_days: int | None = None,
) -> str:
"""Generate shareable link"""
try:
share_config = {"public": public, "expires_in_days": expires_in_days}
return json.dumps(
{
"success": True,
"project_id": project_id,
"dashboard_id": dashboard_id,
"share_config": share_config,
"note": "Dashboard sharing requires dashboard tRPC API. Use OpenPanel dashboard to share.",
"message": "Dashboard share configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)

View File

@@ -1,4 +1,4 @@
"""Events Handler - OpenPanel event tracking operations (10 tools)""" """Events Handler - OpenPanel event tracking operations (11 tools)"""
import json import json
from typing import Any from typing import Any
@@ -7,7 +7,7 @@ from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (10 tools)""" """Return tool specifications for ToolGenerator (11 tools)"""
return [ return [
{ {
"name": "track_event", "name": "track_event",
@@ -222,7 +222,56 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"scope": "write", "scope": "write",
}, },
# NOTE: alias_user removed - not supported on most self-hosted OpenPanel instances {
"name": "create_group",
"method_name": "create_group",
"description": "Create or update a group (e.g., company, workspace, team). Groups can be associated with events and profiles.",
"schema": {
"type": "object",
"properties": {
"group_id": {
"type": "string",
"description": "Unique group identifier",
},
"group_type": {
"type": "string",
"description": "Group category (e.g., 'company', 'workspace', 'team')",
},
"name": {
"type": "string",
"description": "Display name for the group",
},
"properties": {
"anyOf": [{"type": "object"}, {"type": "null"}],
"description": 'Custom group properties (e.g., {"plan": "enterprise", "size": 50})',
},
},
"required": ["group_id", "group_type", "name"],
},
"scope": "write",
},
{
"name": "assign_group",
"method_name": "assign_group",
"description": "Assign a user to one or more groups. If no profile_id is provided, falls back to device ID.",
"schema": {
"type": "object",
"properties": {
"group_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of group IDs to assign the user to",
"minItems": 1,
},
"profile_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "User profile ID (falls back to device ID if not provided)",
},
},
"required": ["group_ids"],
},
"scope": "write",
},
{ {
"name": "track_revenue", "name": "track_revenue",
"method_name": "track_revenue", "method_name": "track_revenue",
@@ -566,32 +615,55 @@ async def decrement_property(
) )
async def alias_user(client: OpenPanelClient, profile_id: str, alias: str) -> str: async def create_group(
"""Create an alias to link two profile IDs""" client: OpenPanelClient,
group_id: str,
group_type: str,
name: str,
properties: dict[str, Any] | None = None,
) -> str:
"""Create or update a group"""
try: try:
result = await client.alias_user(profile_id=profile_id, alias=alias) result = await client.track_group(
group_id=group_id, group_type=group_type, name=name, properties=properties
)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"profile_id": profile_id, "group_id": group_id,
"alias": alias, "group_type": group_type,
"message": f"Alias '{alias}' linked to profile '{profile_id}'", "name": name,
"message": f"Group '{name}' ({group_type}) created/updated",
"response": result, "response": result,
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
except Exception as e: except Exception as e:
error_str = str(e) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
hint = ""
if "not supported" in error_str.lower() or "400" in error_str:
hint = " (Note: Alias feature may not be available in all OpenPanel configurations. This is a server-side limitation.)" async def assign_group(
client: OpenPanelClient,
group_ids: list[str],
profile_id: str | None = None,
) -> str:
"""Assign a user to groups"""
try:
result = await client.assign_group(group_ids=group_ids, profile_id=profile_id)
return json.dumps( return json.dumps(
{"success": False, "error": error_str + hint, "profile_id": profile_id, "alias": alias}, {
"success": True,
"group_ids": group_ids,
"profile_id": profile_id,
"message": f"User assigned to {len(group_ids)} group(s)",
"response": result,
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def track_revenue( async def track_revenue(

View File

@@ -1,7 +1,13 @@
"""Export Handler - OpenPanel data export operations (10 tools) """Export Handler - OpenPanel data export and analytics operations (10 tools).
Uses REST APIs:
- Export API (GET /export/events, /export/charts) for raw data export
- Insights API (GET /insights/:projectId/*) for analytics queries
Note: project_id is optional if configured in environment variables. Note: project_id is optional if configured in environment variables.
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used. When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
Requires 'read' or 'root' mode client for Export API.
""" """
import json import json
@@ -12,12 +18,12 @@ from plugins.openpanel.handlers.utils import get_project_id as _get_project_id
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (10 tools)""" """Return tool specifications for ToolGenerator (10 tools)."""
return [ return [
{ {
"name": "export_events", "name": "export_events",
"method_name": "export_events", "method_name": "export_events",
"description": "Export raw event data with filters and pagination. Returns individual event records. Note: project_id is optional if configured in environment.", "description": "Export raw event data with filters and pagination. Returns individual event records. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -56,11 +62,22 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"anyOf": [ "anyOf": [
{ {
"type": "array", "type": "array",
"items": {"type": "string", "enum": ["profile", "meta"]}, "items": {
"type": "string",
"enum": [
"profile",
"meta",
"properties",
"region",
"device",
"referrer",
"revenue",
],
},
}, },
{"type": "null"}, {"type": "null"},
], ],
"description": "Additional data to include (profile, meta)", "description": "Additional data to include (profile, meta, properties, region, device, referrer, revenue)",
}, },
}, },
"required": [], "required": [],
@@ -70,7 +87,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "export_events_csv", "name": "export_events_csv",
"method_name": "export_events_csv", "method_name": "export_events_csv",
"description": "Export events as CSV-formatted data for spreadsheet analysis. Note: project_id is optional if configured in environment.", "description": "Export events as CSV-formatted data for spreadsheet analysis. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -103,7 +120,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "export_chart_data", "name": "export_chart_data",
"method_name": "export_chart_data", "method_name": "export_chart_data",
"description": "Export aggregated chart data with time series and breakdowns. Note: project_id is optional if configured in environment.", "description": "Export aggregated chart data with time series and breakdowns. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -150,7 +167,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common values: 30min, lastHour, today, yesterday, 1d, 3d, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, lastMonth, yearToDate, lastYear, all. Can also use custom ranges like '2024-01-01 to 2024-12-31'", "description": "Date range (30min, today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"breakdowns": { "breakdowns": {
@@ -188,7 +205,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_event_count", "name": "get_event_count",
"method_name": "get_event_count", "method_name": "get_event_count",
"description": "Get total event count with optional filters. Note: project_id is optional if configured in environment.", "description": "Get total event count with optional filters via Export API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -202,7 +219,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
}, },
@@ -213,7 +230,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_unique_users", "name": "get_unique_users",
"method_name": "get_unique_users", "method_name": "get_unique_users",
"description": "Get unique user/visitor count for a time period. Note: project_id is optional if configured in environment.", "description": "Get unique user/visitor count for a time period via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -223,7 +240,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
}, },
@@ -234,7 +251,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_page_views", "name": "get_page_views",
"method_name": "get_page_views", "method_name": "get_page_views",
"description": "Get page view statistics over time. Note: project_id is optional if configured in environment.", "description": "Get page view statistics via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -244,15 +261,9 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"interval": {
"type": "string",
"enum": ["hour", "day", "week", "month"],
"description": "Time interval",
"default": "day",
},
}, },
"required": [], "required": [],
}, },
@@ -261,7 +272,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_top_pages", "name": "get_top_pages",
"method_name": "get_top_pages", "method_name": "get_top_pages",
"description": "Get top pages by view count. Note: project_id is optional if configured in environment.", "description": "Get top pages by view count via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -271,7 +282,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"limit": { "limit": {
@@ -287,7 +298,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_top_referrers", "name": "get_top_referrers",
"method_name": "get_top_referrers", "method_name": "get_top_referrers",
"description": "Get top traffic sources/referrers. Note: project_id is optional if configured in environment.", "description": "Get top traffic sources/referrers via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -297,7 +308,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"limit": { "limit": {
@@ -313,7 +324,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_geo_data", "name": "get_geo_data",
"method_name": "get_geo_data", "method_name": "get_geo_data",
"description": "Get geographic distribution of users/visitors. Note: project_id is optional if configured in environment.", "description": "Get geographic distribution of visitors via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -323,7 +334,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"breakdown": { "breakdown": {
@@ -345,7 +356,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_device_data", "name": "get_device_data",
"method_name": "get_device_data", "method_name": "get_device_data",
"description": "Get device/browser/OS breakdown of users. Note: project_id is optional if configured in environment.", "description": "Get device/browser/OS breakdown of visitors via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -355,7 +366,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d", "default": "30d",
}, },
"breakdown": { "breakdown": {
@@ -393,11 +404,11 @@ async def export_events(
page: int = 1, page: int = 1,
includes: list[str] | None = None, includes: list[str] | None = None,
) -> str: ) -> str:
"""Export raw event data""" """Export raw event data via GET /export/events."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.export_events( result = await client.export_events(
project_id=effective_project_id, project_id=pid,
event=event, event=event,
profile_id=profile_id, profile_id=profile_id,
start=start, start=start,
@@ -406,18 +417,17 @@ async def export_events(
limit=limit, limit=limit,
includes=includes, includes=includes,
) )
meta = result.get("meta", {}) if isinstance(result, dict) else {}
events_count = len(result.get("data", [])) if isinstance(result, dict) else 0 data = result.get("data", []) if isinstance(result, dict) else []
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"count": events_count, "count": len(data),
"page": page, "total": meta.get("totalCount", len(data)),
"limit": limit, "page": meta.get("current", page),
"filters": {"event": event, "profile_id": profile_id, "start": start, "end": end}, "pages": meta.get("pages", 1),
"data": result, "data": data,
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -434,20 +444,18 @@ async def export_events_csv(
end: str | None = None, end: str | None = None,
limit: int = 1000, limit: int = 1000,
) -> str: ) -> str:
"""Export events as CSV-formatted data""" """Export events as CSV-formatted data."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.export_events( result = await client.export_events(
project_id=effective_project_id, event=event, start=start, end=end, limit=limit project_id=pid, event=event, start=start, end=end, limit=limit
) )
events = result.get("data", []) if isinstance(result, dict) else [] events = result.get("data", []) if isinstance(result, dict) else []
if not events: if not events:
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"count": 0, "count": 0,
"csv": "", "csv": "",
"message": "No events found", "message": "No events found",
@@ -455,28 +463,22 @@ async def export_events_csv(
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
# Build CSV
headers = ["timestamp", "name", "profile_id"] headers = ["timestamp", "name", "profile_id"]
csv_lines = [",".join(headers)] csv_lines = [",".join(headers)]
for ev in events:
for event_data in events:
row = [ row = [
str(event_data.get("timestamp", "")), str(ev.get("createdAt", ev.get("timestamp", ""))),
str(event_data.get("name", "")), str(ev.get("name", "")),
str(event_data.get("profileId", "")), str(ev.get("profileId", "")),
] ]
csv_lines.append(",".join(row)) csv_lines.append(",".join(row))
csv_content = "\n".join(csv_lines)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"count": len(events), "count": len(events),
"format": "csv", "format": "csv",
"csv": csv_content, "csv": "\n".join(csv_lines),
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -494,22 +496,22 @@ async def export_chart_data(
breakdowns: list[str] | None = None, breakdowns: list[str] | None = None,
previous: bool = False, previous: bool = False,
) -> str: ) -> str:
"""Export aggregated chart data""" """Export aggregated chart data via GET /export/charts."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
bd = [{"name": b} for b in breakdowns] if breakdowns else None
result = await client.export_charts( result = await client.export_charts(
project_id=effective_project_id, project_id=pid,
events=events, events=events,
interval=interval, interval=interval,
date_range=date_range, date_range=date_range,
breakdowns=breakdowns, breakdowns=bd,
previous=previous, previous=previous,
) )
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"events": [e.get("name") for e in events], "events": [e.get("name") for e in events],
"interval": interval, "interval": interval,
"date_range": date_range, "date_range": date_range,
@@ -529,29 +531,33 @@ async def get_event_count(
event: str | None = None, event: str | None = None,
date_range: str = "30d", date_range: str = "30d",
) -> str: ) -> str:
"""Get total event count""" """Get total event count via Export charts API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
events_config = [{"name": event if event else "*", "segment": "event"}] events_config = [{"name": event if event else "*", "segment": "event"}]
result = await client.export_charts( result = await client.export_charts(
project_id=effective_project_id, project_id=pid,
events=events_config, events=events_config,
interval="day", interval="day",
date_range=date_range, date_range=date_range,
) )
# Extract total from chart response
# Sum up the counts
total = 0 total = 0
if isinstance(result, dict) and "data" in result: if isinstance(result, dict):
for point in result.get("data", []): series = result.get("series", [])
total += point.get("count", 0) if series:
for s in series:
for point in s.get("data", []):
total += point.get("count", 0)
# Alternative: check metrics
metrics = result.get("metrics", {})
if metrics.get("current", {}).get("value"):
total = metrics["current"]["value"]
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"event": event if event else "all events", "event": event or "all events",
"date_range": date_range, "date_range": date_range,
"total_count": total, "total_count": total,
}, },
@@ -565,47 +571,12 @@ async def get_event_count(
async def get_unique_users( async def get_unique_users(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d" client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d"
) -> str: ) -> str:
"""Get unique user count using tRPC overview.stats""" """Get unique user count via Insights metrics API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_overview_stats(project_id=pid, date_range=date_range)
# Use overview.stats to get visitor count
result = await client.get_overview_stats(
project_id=effective_project_id, date_range=date_range
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC overview.stats endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract unique visitors from overview stats
unique_users = 0
if isinstance(result, dict):
# Try different possible field names for visitors
unique_users = (
result.get("visitors", 0)
or result.get("uniqueVisitors", 0)
or result.get("current", {}).get("visitors", 0)
)
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "date_range": date_range, "stats": result},
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"unique_users": unique_users,
"raw_stats": result if isinstance(result, dict) else None,
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -617,51 +588,13 @@ async def get_page_views(
client: OpenPanelClient, client: OpenPanelClient,
project_id: str | None = None, project_id: str | None = None,
date_range: str = "30d", date_range: str = "30d",
interval: str = "day",
) -> str: ) -> str:
"""Get page view statistics using tRPC overview.stats""" """Get page view statistics via Insights metrics API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_overview_stats(project_id=pid, date_range=date_range)
# Use overview.stats to get pageview count
result = await client.get_overview_stats(
project_id=effective_project_id, date_range=date_range
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"interval": interval,
"error": result.get("error"),
"note": "tRPC overview.stats endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract pageview count from overview stats
total_page_views = 0
if isinstance(result, dict):
# Try different possible field names for pageviews
total_page_views = (
result.get("pageviews", 0)
or result.get("pageViews", 0)
or result.get("current", {}).get("pageviews", 0)
)
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "date_range": date_range, "stats": result},
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"interval": interval,
"total_page_views": total_page_views,
"raw_stats": result if isinstance(result, dict) else None,
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -672,44 +605,12 @@ async def get_page_views(
async def get_top_pages( async def get_top_pages(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10 client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10
) -> str: ) -> str:
"""Get top pages by view count using tRPC overview.topPages""" """Get top pages via Insights pages API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_top_pages(project_id=pid, date_range=date_range, limit=limit)
# Use the new tRPC overview.topPages endpoint
result = await client.get_top_pages(
project_id=effective_project_id, date_range=date_range, mode="page"
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC overview.topPages endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Transform tRPC response to our format
pages = []
if isinstance(result, list):
pages = result[:limit]
elif isinstance(result, dict) and "data" in result:
pages = result.get("data", [])[:limit]
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "date_range": date_range, "data": result},
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"count": len(pages),
"top_pages": pages,
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -720,54 +621,12 @@ async def get_top_pages(
async def get_top_referrers( async def get_top_referrers(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10 client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10
) -> str: ) -> str:
"""Get top traffic sources using chart.chart with referrer breakdown""" """Get top traffic sources via Insights breakdown API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_top_sources(project_id=pid, date_range=date_range, limit=limit)
# Use chart.chart with referrer breakdown
result = await client.get_top_sources(
project_id=effective_project_id, date_range=date_range, limit=limit
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract referrers from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {referrer: [...]}}]}
referrers = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get("referrer", [])
for item in breakdown_data[:limit]:
referrers.append(
{
"referrer": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "date_range": date_range, "data": result},
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"count": len(referrers),
"top_referrers": referrers,
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -782,55 +641,19 @@ async def get_geo_data(
breakdown: str = "country", breakdown: str = "country",
limit: int = 10, limit: int = 10,
) -> str: ) -> str:
"""Get geographic distribution using chart.chart with country/city/region breakdown""" """Get geographic distribution via Insights breakdown API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
# Use chart.chart with geographic breakdown
result = await client.get_top_locations( result = await client.get_top_locations(
project_id=effective_project_id, date_range=date_range, breakdown=breakdown, limit=limit project_id=pid, date_range=date_range, breakdown=breakdown, limit=limit
) )
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"breakdown": breakdown,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract locations from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {country: [...]}}]}
locations = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get(breakdown, [])
for item in breakdown_data[:limit]:
locations.append(
{
"location": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"date_range": date_range, "date_range": date_range,
"breakdown": breakdown, "breakdown": breakdown,
"count": len(locations), "data": result,
"locations": locations,
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -846,64 +669,26 @@ async def get_device_data(
breakdown: str = "device", breakdown: str = "device",
limit: int = 10, limit: int = 10,
) -> str: ) -> str:
"""Get device/browser/OS breakdown using chart.chart with appropriate breakdown""" """Get device/browser/OS breakdown via Insights breakdown API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
# Use appropriate chart.chart breakdown based on breakdown type
if breakdown == "browser": if breakdown == "browser":
result = await client.get_top_browsers( result = await client.get_top_browsers(
project_id=effective_project_id, date_range=date_range, limit=limit project_id=pid, date_range=date_range, limit=limit
) )
elif breakdown == "os": elif breakdown == "os":
result = await client.get_top_os( result = await client.get_top_os(project_id=pid, date_range=date_range, limit=limit)
project_id=effective_project_id, date_range=date_range, limit=limit else:
)
else: # device (default)
result = await client.get_top_devices( result = await client.get_top_devices(
project_id=effective_project_id, date_range=date_range, limit=limit project_id=pid, date_range=date_range, limit=limit
) )
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"breakdown": breakdown,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract device data from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {device: [...]}}]}
devices = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get(breakdown, [])
for item in breakdown_data[:limit]:
devices.append(
{
"name": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": effective_project_id, "project_id": pid,
"date_range": date_range, "date_range": date_range,
"breakdown": breakdown, "breakdown": breakdown,
"count": len(devices), "data": result,
"devices": devices,
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,

View File

@@ -1,388 +0,0 @@
"""Funnels Handler - OpenPanel funnel analytics (8 tools)"""
import json
from typing import Any
from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (8 tools)"""
return [
{
"name": "list_funnels",
"method_name": "list_funnels",
"description": "List all funnels defined for a project.",
"schema": {
"type": "object",
"properties": {"project_id": {"type": "string", "description": "Project ID"}},
"required": ["project_id"],
},
"scope": "read",
},
{
"name": "get_funnel",
"method_name": "get_funnel",
"description": "Get funnel details and current conversion data.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_id": {"type": "string", "description": "Funnel ID"},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["project_id", "funnel_id"],
},
"scope": "read",
},
{
"name": "create_funnel",
"method_name": "create_funnel",
"description": "Create a new funnel to track user journey through steps.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"name": {"type": "string", "description": "Funnel name"},
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Step name"},
"event": {
"type": "string",
"description": "Event name for this step",
},
"filters": {
"type": "array",
"description": "Optional filters for this step",
},
},
"required": ["name", "event"],
},
"description": "Funnel steps in sequence",
"minItems": 2,
"maxItems": 10,
},
"window_days": {
"type": "integer",
"description": "Conversion window in days",
"default": 14,
"maximum": 90,
},
},
"required": ["project_id", "name", "steps"],
},
"scope": "write",
},
{
"name": "update_funnel",
"method_name": "update_funnel",
"description": "Update an existing funnel's configuration.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_id": {"type": "string", "description": "Funnel ID"},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New funnel name",
},
"steps": {
"anyOf": [{"type": "array"}, {"type": "null"}],
"description": "New funnel steps",
},
"window_days": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "New conversion window",
},
},
"required": ["project_id", "funnel_id"],
},
"scope": "write",
},
{
"name": "delete_funnel",
"method_name": "delete_funnel",
"description": "Delete a funnel.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_id": {"type": "string", "description": "Funnel ID to delete"},
},
"required": ["project_id", "funnel_id"],
},
"scope": "write",
},
{
"name": "get_funnel_conversion",
"method_name": "get_funnel_conversion",
"description": "Get detailed conversion rates for each funnel step.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_id": {"type": "string", "description": "Funnel ID"},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["project_id", "funnel_id"],
},
"scope": "read",
},
{
"name": "get_funnel_breakdown",
"method_name": "get_funnel_breakdown",
"description": "Get funnel breakdown by segment (country, device, etc.).",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_id": {"type": "string", "description": "Funnel ID"},
"breakdown_by": {
"type": "string",
"enum": ["country", "device", "browser", "os", "referrer"],
"description": "Dimension to break down by",
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["project_id", "funnel_id", "breakdown_by"],
},
"scope": "read",
},
{
"name": "compare_funnels",
"method_name": "compare_funnels",
"description": "Compare conversion rates between multiple funnels.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"funnel_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Funnel IDs to compare",
"minItems": 2,
"maxItems": 5,
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["project_id", "funnel_ids"],
},
"scope": "read",
},
]
# =====================
# Funnel Functions (8)
# =====================
async def list_funnels(client: OpenPanelClient, project_id: str) -> str:
"""List all funnels for a project"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"note": "Funnel listing requires dashboard tRPC API. Use OpenPanel dashboard to view funnels.",
"message": "Funnel list request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_funnel(
client: OpenPanelClient, project_id: str, funnel_id: str, date_range: str = "30d"
) -> str:
"""Get funnel details with conversion data"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_id": funnel_id,
"date_range": date_range,
"note": "Funnel details require dashboard tRPC API. Use OpenPanel dashboard to view funnel data.",
"message": "Funnel data request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def create_funnel(
client: OpenPanelClient,
project_id: str,
name: str,
steps: list[dict[str, Any]],
window_days: int = 14,
) -> str:
"""Create a new funnel"""
try:
# Validate steps
if len(steps) < 2:
return json.dumps(
{"success": False, "error": "Funnel must have at least 2 steps"},
indent=2,
ensure_ascii=False,
)
funnel_config = {"name": name, "steps": steps, "window_days": window_days}
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel": funnel_config,
"note": "Funnel creation requires dashboard tRPC API. Use OpenPanel dashboard to create funnels.",
"message": f"Funnel '{name}' configuration created with {len(steps)} steps",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_funnel(
client: OpenPanelClient,
project_id: str,
funnel_id: str,
name: str | None = None,
steps: list[dict[str, Any]] | None = None,
window_days: int | None = None,
) -> str:
"""Update an existing funnel"""
try:
updates = {}
if name:
updates["name"] = name
if steps:
updates["steps"] = steps
if window_days:
updates["window_days"] = window_days
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_id": funnel_id,
"updates": updates,
"note": "Funnel updates require dashboard tRPC API. Use OpenPanel dashboard to modify funnels.",
"message": "Funnel update configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_funnel(client: OpenPanelClient, project_id: str, funnel_id: str) -> str:
"""Delete a funnel"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_id": funnel_id,
"note": "Funnel deletion requires dashboard tRPC API. Use OpenPanel dashboard to delete funnels.",
"message": "Funnel deletion request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_funnel_conversion(
client: OpenPanelClient, project_id: str, funnel_id: str, date_range: str = "30d"
) -> str:
"""Get funnel conversion rates"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_id": funnel_id,
"date_range": date_range,
"note": "Conversion data requires dashboard tRPC API. Use OpenPanel dashboard for detailed conversion analysis.",
"message": "Funnel conversion request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_funnel_breakdown(
client: OpenPanelClient,
project_id: str,
funnel_id: str,
breakdown_by: str,
date_range: str = "30d",
) -> str:
"""Get funnel breakdown by segment"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_id": funnel_id,
"breakdown_by": breakdown_by,
"date_range": date_range,
"note": "Funnel breakdown requires dashboard tRPC API. Use OpenPanel dashboard for segmented analysis.",
"message": f"Funnel breakdown by {breakdown_by} request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def compare_funnels(
client: OpenPanelClient, project_id: str, funnel_ids: list[str], date_range: str = "30d"
) -> str:
"""Compare multiple funnels"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"funnel_ids": funnel_ids,
"date_range": date_range,
"note": "Funnel comparison requires dashboard tRPC API. Use OpenPanel dashboard for comparison views.",
"message": f"Comparison request for {len(funnel_ids)} funnels processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)

View File

@@ -1,4 +1,7 @@
"""Profiles Handler - OpenPanel user profile management (8 tools)""" """Profiles Handler - OpenPanel user profile operations (3 tools).
Uses Export API to retrieve profile events and data.
"""
import json import json
from typing import Any from typing import Any
@@ -7,81 +10,12 @@ from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (8 tools)""" """Return tool specifications for ToolGenerator (3 tools)."""
return [ return [
{
"name": "list_profiles",
"method_name": "list_profiles",
"description": "List user profiles with optional filtering and pagination.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"limit": {
"type": "integer",
"description": "Number of profiles to return",
"default": 50,
"maximum": 100,
},
"offset": {
"type": "integer",
"description": "Offset for pagination",
"default": 0,
},
"sort_by": {
"type": "string",
"enum": ["last_seen", "first_seen", "event_count"],
"description": "Sort order",
"default": "last_seen",
},
},
"required": ["project_id"],
},
"scope": "read",
},
{
"name": "get_profile",
"method_name": "get_profile",
"description": "Get detailed information about a specific user profile.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"profile_id": {"type": "string", "description": "User profile ID"},
},
"required": ["project_id", "profile_id"],
},
"scope": "read",
},
{
"name": "search_profiles",
"method_name": "search_profiles",
"description": "Search user profiles by property value.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"property": {
"type": "string",
"description": "Property to search (e.g., 'email', 'name', 'plan')",
},
"value": {"type": "string", "description": "Value to search for"},
"operator": {
"type": "string",
"enum": ["is", "contains", "startsWith", "endsWith"],
"description": "Search operator",
"default": "contains",
},
"limit": {"type": "integer", "description": "Maximum results", "default": 50},
},
"required": ["project_id", "property", "value"],
},
"scope": "read",
},
{ {
"name": "get_profile_events", "name": "get_profile_events",
"method_name": "get_profile_events", "method_name": "get_profile_events",
"description": "Get events for a specific user profile.", "description": "Get events for a specific user profile via Export API. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -95,7 +29,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"type": "integer", "type": "integer",
"description": "Number of events", "description": "Number of events",
"default": 50, "default": 50,
"maximum": 200, "maximum": 1000,
}, },
}, },
"required": ["project_id", "profile_id"], "required": ["project_id", "profile_id"],
@@ -105,7 +39,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_profile_sessions", "name": "get_profile_sessions",
"method_name": "get_profile_sessions", "method_name": "get_profile_sessions",
"description": "Get sessions for a specific user profile.", "description": "Get sessions for a specific user profile via Export API. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -115,57 +49,17 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"type": "integer", "type": "integer",
"description": "Number of sessions", "description": "Number of sessions",
"default": 20, "default": 20,
"maximum": 50, "maximum": 100,
}, },
}, },
"required": ["project_id", "profile_id"], "required": ["project_id", "profile_id"],
}, },
"scope": "read", "scope": "read",
}, },
{
"name": "delete_profile",
"method_name": "delete_profile",
"description": "Delete a user profile and all associated data (GDPR compliance).",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"profile_id": {"type": "string", "description": "User profile ID to delete"},
"confirm": {
"type": "boolean",
"description": "Confirm deletion (required)",
"default": False,
},
},
"required": ["project_id", "profile_id", "confirm"],
},
"scope": "admin",
},
{
"name": "merge_profiles",
"method_name": "merge_profiles",
"description": "Merge two user profiles into one (for duplicate resolution).",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"primary_profile_id": {
"type": "string",
"description": "Primary profile ID to keep",
},
"secondary_profile_id": {
"type": "string",
"description": "Secondary profile ID to merge and delete",
},
},
"required": ["project_id", "primary_profile_id", "secondary_profile_id"],
},
"scope": "admin",
},
{ {
"name": "export_profile_data", "name": "export_profile_data",
"method_name": "export_profile_data", "method_name": "export_profile_data",
"description": "Export all data for a user profile (GDPR data portability).", "description": "Export all data for a user profile (GDPR data portability). Returns events and profile data. Requires 'read' or 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -186,104 +80,10 @@ def get_tool_specifications() -> list[dict[str, Any]]:
# ===================== # =====================
# Profile Functions (8) # Profile Functions (3)
# ===================== # =====================
async def list_profiles(
client: OpenPanelClient,
project_id: str,
limit: int = 50,
offset: int = 0,
sort_by: str = "last_seen",
) -> str:
"""List user profiles"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"pagination": {"limit": limit, "offset": offset, "sort_by": sort_by},
"note": "Profile listing requires dashboard tRPC API. Use OpenPanel dashboard to view profiles.",
"message": "Profile list request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_profile(client: OpenPanelClient, project_id: str, profile_id: str) -> str:
"""Get profile details"""
try:
# Try to get events for this profile via export API
try:
result = await client.export_events(
project_id=project_id, profile_id=profile_id, limit=1, includes=["profile"]
)
if isinstance(result, dict) and result.get("data"):
return json.dumps(
{
"success": True,
"project_id": project_id,
"profile_id": profile_id,
"has_events": True,
"note": "Full profile details require dashboard tRPC API.",
"message": "Profile found with associated events",
},
indent=2,
ensure_ascii=False,
)
except Exception:
pass # Profile event fetch is optional; fall through to generic response
return json.dumps(
{
"success": True,
"project_id": project_id,
"profile_id": profile_id,
"note": "Profile details require dashboard tRPC API. Use OpenPanel dashboard for full profile view.",
"message": "Profile request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def search_profiles(
client: OpenPanelClient,
project_id: str,
property: str,
value: str,
operator: str = "contains",
limit: int = 50,
) -> str:
"""Search profiles by property"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"search": {
"property": property,
"value": value,
"operator": operator,
"limit": limit,
},
"note": "Profile search requires dashboard tRPC API. Use OpenPanel dashboard for profile search.",
"message": "Profile search request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_profile_events( async def get_profile_events(
client: OpenPanelClient, client: OpenPanelClient,
project_id: str, project_id: str,
@@ -291,22 +91,22 @@ async def get_profile_events(
event: str | None = None, event: str | None = None,
limit: int = 50, limit: int = 50,
) -> str: ) -> str:
"""Get events for a profile""" """Get events for a profile via Export API."""
try: try:
result = await client.export_events( result = await client.export_events(
project_id=project_id, profile_id=profile_id, event=event, limit=limit project_id=project_id, profile_id=profile_id, event=event, limit=limit
) )
data = result.get("data", []) if isinstance(result, dict) else []
events_count = len(result.get("data", [])) if isinstance(result, dict) else 0 meta = result.get("meta", {}) if isinstance(result, dict) else {}
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": project_id, "project_id": project_id,
"profile_id": profile_id, "profile_id": profile_id,
"event_filter": event, "event_filter": event,
"count": events_count, "count": len(data),
"data": result, "total": meta.get("totalCount", len(data)),
"data": data,
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -318,75 +118,19 @@ async def get_profile_events(
async def get_profile_sessions( async def get_profile_sessions(
client: OpenPanelClient, project_id: str, profile_id: str, limit: int = 20 client: OpenPanelClient, project_id: str, profile_id: str, limit: int = 20
) -> str: ) -> str:
"""Get sessions for a profile""" """Get sessions for a profile via Export API."""
try: try:
# Get session events
result = await client.export_events( result = await client.export_events(
project_id=project_id, profile_id=profile_id, event="session_start", limit=limit project_id=project_id, profile_id=profile_id, event="session_start", limit=limit
) )
data = result.get("data", []) if isinstance(result, dict) else []
sessions = result.get("data", []) if isinstance(result, dict) else []
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": project_id, "project_id": project_id,
"profile_id": profile_id, "profile_id": profile_id,
"sessions_count": len(sessions), "sessions_count": len(data),
"sessions": sessions, "sessions": data,
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_profile(
client: OpenPanelClient, project_id: str, profile_id: str, confirm: bool = False
) -> str:
"""Delete a user profile (GDPR)"""
try:
if not confirm:
return json.dumps(
{
"success": False,
"error": "Deletion not confirmed. Set confirm=true to proceed.",
"warning": "This will permanently delete all user data.",
},
indent=2,
ensure_ascii=False,
)
return json.dumps(
{
"success": True,
"project_id": project_id,
"profile_id": profile_id,
"confirmed": confirm,
"note": "Profile deletion requires dashboard tRPC API. Use OpenPanel dashboard for GDPR deletion.",
"message": "Profile deletion request processed (GDPR)",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def merge_profiles(
client: OpenPanelClient, project_id: str, primary_profile_id: str, secondary_profile_id: str
) -> str:
"""Merge two profiles"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"primary_profile_id": primary_profile_id,
"secondary_profile_id": secondary_profile_id,
"note": "Profile merging requires dashboard tRPC API. Use OpenPanel dashboard for profile management.",
"message": "Profile merge request processed",
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -398,30 +142,23 @@ async def merge_profiles(
async def export_profile_data( async def export_profile_data(
client: OpenPanelClient, project_id: str, profile_id: str, format: str = "json" client: OpenPanelClient, project_id: str, profile_id: str, format: str = "json"
) -> str: ) -> str:
"""Export all profile data (GDPR)""" """Export all profile data (GDPR compliance)."""
try: try:
# Export events for this profile
result = await client.export_events( result = await client.export_events(
project_id=project_id, profile_id=profile_id, limit=1000, includes=["profile", "meta"] project_id=project_id,
profile_id=profile_id,
limit=1000,
includes=["profile", "meta", "properties"],
) )
events = result.get("data", []) if isinstance(result, dict) else [] events = result.get("data", []) if isinstance(result, dict) else []
export_data = {
"profile_id": profile_id,
"project_id": project_id,
"events_count": len(events),
"events": events,
}
if format == "csv": if format == "csv":
# Build CSV
csv_lines = ["timestamp,event_name,properties"] csv_lines = ["timestamp,event_name,properties"]
for event in events: for ev in events:
csv_lines.append( csv_lines.append(
f"{event.get('timestamp', '')},{event.get('name', '')},{json.dumps(event.get('properties', {}))}" f"{ev.get('createdAt', ev.get('timestamp', ''))},{ev.get('name', '')},"
f"{json.dumps(ev.get('properties', {}))}"
) )
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
@@ -429,7 +166,6 @@ async def export_profile_data(
"format": "csv", "format": "csv",
"events_count": len(events), "events_count": len(events),
"csv": "\n".join(csv_lines), "csv": "\n".join(csv_lines),
"message": "Profile data exported (GDPR compliance)",
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
@@ -441,8 +177,7 @@ async def export_profile_data(
"profile_id": profile_id, "profile_id": profile_id,
"format": "json", "format": "json",
"events_count": len(events), "events_count": len(events),
"data": export_data, "data": {"profile_id": profile_id, "project_id": project_id, "events": events},
"message": "Profile data exported (GDPR compliance)",
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,

View File

@@ -1,4 +1,8 @@
"""Projects Handler - OpenPanel project management (8 tools)""" """Projects Handler - OpenPanel project management (5 tools).
Uses Manage API (GET/POST/PATCH/DELETE /manage/projects).
Requires 'root' mode client for write operations.
"""
import json import json
from typing import Any from typing import Any
@@ -7,22 +11,24 @@ from plugins.openpanel.client import OpenPanelClient
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (8 tools)""" """Return tool specifications for ToolGenerator (5 tools)."""
return [ return [
{ {
"name": "list_projects", "name": "list_projects",
"method_name": "list_projects", "method_name": "list_projects",
"description": "List all OpenPanel projects.", "description": "List all projects via Manage API. Requires 'root' mode client.",
"schema": {"type": "object", "properties": {}}, "schema": {"type": "object", "properties": {}},
"scope": "read", "scope": "admin",
}, },
{ {
"name": "get_project", "name": "get_project",
"method_name": "get_project", "method_name": "get_project",
"description": "Get project details including settings and statistics.", "description": "Get project details via Manage API. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": {"project_id": {"type": "string", "description": "Project ID"}}, "properties": {
"project_id": {"type": "string", "description": "Project ID"},
},
"required": ["project_id"], "required": ["project_id"],
}, },
"scope": "read", "scope": "read",
@@ -30,19 +36,14 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "create_project", "name": "create_project",
"method_name": "create_project", "method_name": "create_project",
"description": "Create a new OpenPanel project.", "description": "Create a new project via Manage API. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": {"type": "string", "description": "Project name"}, "name": {"type": "string", "description": "Project name"},
"domain": { "domain": {
"anyOf": [{"type": "string"}, {"type": "null"}], "anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Primary domain for the project", "description": "Project domain (e.g., 'example.com')",
},
"timezone": {
"type": "string",
"description": "Project timezone",
"default": "UTC",
}, },
}, },
"required": ["name"], "required": ["name"],
@@ -52,22 +53,18 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "update_project", "name": "update_project",
"method_name": "update_project", "method_name": "update_project",
"description": "Update project settings.", "description": "Update a project via Manage API. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "Project ID"}, "project_id": {"type": "string", "description": "Project ID to update"},
"name": { "name": {
"anyOf": [{"type": "string"}, {"type": "null"}], "anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New project name", "description": "New project name",
}, },
"domain": { "domain": {
"anyOf": [{"type": "string"}, {"type": "null"}], "anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New primary domain", "description": "New domain",
},
"timezone": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New timezone",
}, },
}, },
"required": ["project_id"], "required": ["project_id"],
@@ -77,85 +74,35 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "delete_project", "name": "delete_project",
"method_name": "delete_project", "method_name": "delete_project",
"description": "Delete a project and all its data.", "description": "Delete a project via Manage API. WARNING: Permanently deletes all project data. Requires 'root' mode client.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "Project ID to delete"}, "project_id": {"type": "string", "description": "Project ID to delete"},
"confirm": {"type": "boolean", "description": "Confirm deletion (required)"},
},
"required": ["project_id", "confirm"],
},
"scope": "admin",
},
{
"name": "get_project_stats",
"method_name": "get_project_stats",
"description": "Get project statistics (events, users, storage).",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, yearToDate, all",
"default": "30d",
},
}, },
"required": ["project_id"], "required": ["project_id"],
}, },
"scope": "read",
},
{
"name": "get_project_settings",
"method_name": "get_project_settings",
"description": "Get project configuration settings.",
"schema": {
"type": "object",
"properties": {"project_id": {"type": "string", "description": "Project ID"}},
"required": ["project_id"],
},
"scope": "read",
},
{
"name": "update_project_settings",
"method_name": "update_project_settings",
"description": "Update project configuration.",
"schema": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project ID"},
"settings": {
"type": "object",
"description": "Settings to update",
"properties": {
"cors_domains": {"type": "array", "items": {"type": "string"}},
"ip_anonymization": {"type": "boolean"},
"data_retention_days": {"type": "integer"},
},
},
},
"required": ["project_id", "settings"],
},
"scope": "admin", "scope": "admin",
}, },
] ]
# ===================== # =====================
# Project Functions (8) # Project Functions (5)
# ===================== # =====================
async def list_projects(client: OpenPanelClient) -> str: async def list_projects(client: OpenPanelClient) -> str:
"""List all projects""" """List all projects via GET /manage/projects."""
try: try:
result = await client.list_projects()
projects = (
result
if isinstance(result, list)
else result.get("data", []) if isinstance(result, dict) else []
)
return json.dumps( return json.dumps(
{ {"success": True, "count": len(projects), "projects": projects},
"success": True,
"note": "Project listing requires dashboard tRPC API. Use OpenPanel dashboard to view projects.",
"message": "Project list request processed",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -164,36 +111,23 @@ async def list_projects(client: OpenPanelClient) -> str:
async def get_project(client: OpenPanelClient, project_id: str) -> str: async def get_project(client: OpenPanelClient, project_id: str) -> str:
"""Get project details""" """Get project details via GET /manage/projects/:id."""
try: try:
return json.dumps( result = await client.get_project(project_id)
{ return json.dumps({"success": True, "project": result}, indent=2, ensure_ascii=False)
"success": True,
"project_id": project_id,
"note": "Project details require dashboard tRPC API. Use OpenPanel dashboard for full project view.",
"message": "Project details request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def create_project( async def create_project(client: OpenPanelClient, name: str, domain: str | None = None) -> str:
client: OpenPanelClient, name: str, domain: str | None = None, timezone: str = "UTC" """Create a new project via POST /manage/projects."""
) -> str:
"""Create a new project"""
try: try:
project_config = {"name": name, "domain": domain, "timezone": timezone} data: dict[str, Any] = {"name": name}
if domain:
data["domain"] = domain
result = await client.create_project(data)
return json.dumps( return json.dumps(
{ {"success": True, "message": f"Project '{name}' created", "project": result},
"success": True,
"project": project_config,
"note": "Project creation requires dashboard tRPC API. Use OpenPanel dashboard to create projects.",
"message": f"Project '{name}' configuration created",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -206,55 +140,23 @@ async def update_project(
project_id: str, project_id: str,
name: str | None = None, name: str | None = None,
domain: str | None = None, domain: str | None = None,
timezone: str | None = None,
) -> str: ) -> str:
"""Update project settings""" """Update a project via PATCH /manage/projects/:id."""
try: try:
updates = {} data: dict[str, Any] = {}
if name: if name:
updates["name"] = name data["name"] = name
if domain: if domain:
updates["domain"] = domain data["domain"] = domain
if timezone: if not data:
updates["timezone"] = timezone
return json.dumps(
{
"success": True,
"project_id": project_id,
"updates": updates,
"note": "Project updates require dashboard tRPC API. Use OpenPanel dashboard to modify projects.",
"message": "Project update configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def delete_project(client: OpenPanelClient, project_id: str, confirm: bool = False) -> str:
"""Delete a project"""
try:
if not confirm:
return json.dumps( return json.dumps(
{ {"success": False, "error": "No fields to update. Provide name or domain."},
"success": False,
"error": "Deletion not confirmed. Set confirm=true to proceed.",
"warning": "This will permanently delete all project data including events, users, and settings.",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
result = await client.update_project(project_id, data)
return json.dumps( return json.dumps(
{ {"success": True, "message": f"Project '{project_id}' updated", "project": result},
"success": True,
"project_id": project_id,
"confirmed": confirm,
"note": "Project deletion requires dashboard tRPC API. Use OpenPanel dashboard to delete projects.",
"message": "Project deletion request processed",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -262,81 +164,12 @@ async def delete_project(client: OpenPanelClient, project_id: str, confirm: bool
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_project_stats( async def delete_project(client: OpenPanelClient, project_id: str) -> str:
client: OpenPanelClient, project_id: str, date_range: str = "30d" """Delete a project via DELETE /manage/projects/:id."""
) -> str:
"""Get project statistics"""
try: try:
# Get basic stats via export API result = await client.delete_project(project_id)
metrics = {}
# Total events
events_config = [{"name": "*", "segment": "event"}]
events_result = await client.export_charts(
project_id=project_id, events=events_config, interval="day", date_range=date_range
)
total_events = 0
if isinstance(events_result, dict) and "data" in events_result:
for point in events_result.get("data", []):
total_events += point.get("count", 0)
metrics["total_events"] = total_events
# Unique users
users_config = [{"name": "*", "segment": "user"}]
users_result = await client.export_charts(
project_id=project_id, events=users_config, interval="day", date_range=date_range
)
if isinstance(users_result, dict) and "data" in users_result:
data = users_result.get("data", [])
metrics["unique_users"] = data[-1].get("count", 0) if data else 0
return json.dumps( return json.dumps(
{ {"success": True, "message": f"Project '{project_id}' deleted", "result": result},
"success": True,
"project_id": project_id,
"date_range": date_range,
"stats": metrics,
"message": "Project stats retrieved",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_project_settings(client: OpenPanelClient, project_id: str) -> str:
"""Get project settings"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"note": "Project settings require dashboard tRPC API. Use OpenPanel dashboard for settings.",
"message": "Project settings request processed",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def update_project_settings(
client: OpenPanelClient, project_id: str, settings: dict[str, Any]
) -> str:
"""Update project settings"""
try:
return json.dumps(
{
"success": True,
"project_id": project_id,
"settings": settings,
"note": "Settings update requires dashboard tRPC API. Use OpenPanel dashboard to configure.",
"message": "Project settings update configuration created",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )

View File

@@ -1,6 +1,6 @@
"""Reports Handler - OpenPanel analytics reports (8 tools) """Reports Handler - OpenPanel analytics reports (2 tools).
Note: project_id is optional if configured in environment variables. Uses Insights API for overview stats and live visitors.
""" """
import json import json
@@ -11,12 +11,12 @@ from plugins.openpanel.handlers.utils import get_project_id as _get_project_id
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (8 tools)""" """Return tool specifications for ToolGenerator (2 tools)."""
return [ return [
{ {
"name": "get_overview_report", "name": "get_overview_report",
"method_name": "get_overview_report", "method_name": "get_overview_report",
"description": "Get overview statistics including total events, users, sessions, and page views. Note: project_id is optional if configured in environment.", "description": "Get overview statistics including visitors, page views, sessions, bounce rate, and duration via Insights API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -26,116 +26,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"date_range": { "date_range": {
"type": "string", "type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all", "description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
},
"required": [],
},
"scope": "read",
},
{
"name": "get_retention_report",
"method_name": "get_retention_report",
"description": "Get user retention analysis showing how many users return over time. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"start_event": {
"type": "string",
"description": "Event that marks user acquisition (e.g., 'signup_completed')",
"default": "session_start",
},
"return_event": {
"type": "string",
"description": "Event that marks user return (e.g., 'app_opened')",
"default": "session_start",
},
"period": {
"type": "string",
"enum": ["day", "week", "month"],
"description": "Retention period granularity",
"default": "week",
},
"cohorts": {
"type": "integer",
"description": "Number of cohorts to analyze",
"default": 8,
"maximum": 12,
},
},
"required": [],
},
"scope": "read",
},
{
"name": "get_cohort_report",
"method_name": "get_cohort_report",
"description": "Get cohort analysis grouping users by acquisition date and tracking their behavior. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"cohort_type": {
"type": "string",
"enum": ["first_seen", "signup", "custom"],
"description": "How to define cohorts",
"default": "first_seen",
},
"cohort_event": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Custom event for cohort definition (when cohort_type is 'custom')",
},
"measure_event": {
"type": "string",
"description": "Event to measure for each cohort",
"default": "session_start",
},
"date_range": {
"type": "string",
"description": "Date range. Common: 7d, 14d, 30d, 60d, 90d, 6m, 12m, yearToDate, all",
"default": "6m",
},
},
"required": [],
},
"scope": "read",
},
{
"name": "get_paths_report",
"method_name": "get_paths_report",
"description": "Get user flow/paths analysis showing common navigation patterns. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"start_event": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Starting event for path analysis",
},
"end_event": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Ending event for path analysis",
},
"max_steps": {
"type": "integer",
"description": "Maximum path steps to analyze",
"default": 5,
"maximum": 10,
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d", "default": "30d",
}, },
}, },
@@ -146,7 +37,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{ {
"name": "get_realtime_stats", "name": "get_realtime_stats",
"method_name": "get_realtime_stats", "method_name": "get_realtime_stats",
"description": "Get real-time visitor statistics for the last 30 minutes. Note: project_id is optional if configured in environment.", "description": "Get real-time active visitor count via Insights live API.",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -159,250 +50,23 @@ def get_tool_specifications() -> list[dict[str, Any]]:
}, },
"scope": "read", "scope": "read",
}, },
{
"name": "get_ab_test_results",
"method_name": "get_ab_test_results",
"description": "Get A/B test results comparing variants by conversion rate. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"test_name": {"type": "string", "description": "Name of the A/B test"},
"conversion_event": {
"type": "string",
"description": "Event that marks conversion",
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["test_name", "conversion_event"],
},
"scope": "read",
},
{
"name": "create_scheduled_report",
"method_name": "create_scheduled_report",
"description": "Create a scheduled report to be sent via email. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"name": {"type": "string", "description": "Report name"},
"report_type": {
"type": "string",
"enum": ["overview", "events", "funnels", "retention"],
"description": "Type of report",
},
"schedule": {
"type": "string",
"enum": ["daily", "weekly", "monthly"],
"description": "Report schedule",
},
"recipients": {
"type": "array",
"items": {"type": "string", "format": "email"},
"description": "Email recipients",
},
},
"required": ["name", "report_type", "schedule", "recipients"],
},
"scope": "write",
},
{
"name": "export_report_pdf",
"method_name": "export_report_pdf",
"description": "Export a report as PDF format. Note: project_id is optional if configured in environment.",
"schema": {
"type": "object",
"properties": {
"project_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project ID (optional if configured in env)",
},
"report_type": {
"type": "string",
"enum": ["overview", "events", "funnels", "retention", "paths"],
"description": "Type of report to export",
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, all",
"default": "30d",
},
},
"required": ["report_type"],
},
"scope": "read",
},
] ]
# ===================== # =====================
# Report Functions (8) # Report Functions (2)
# ===================== # =====================
async def get_overview_report( async def get_overview_report(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d" client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d"
) -> str: ) -> str:
"""Get overview statistics using tRPC overview.stats endpoint""" """Get overview statistics via Insights metrics API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_overview_stats(project_id=pid, date_range=date_range)
# Use the new tRPC overview.stats endpoint
result = await client.get_overview_stats(
project_id=effective_project_id, date_range=date_range
)
# Check for errors
if "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC overview.stats endpoint may require authentication or the project may not exist",
},
indent=2,
ensure_ascii=False,
)
# Transform tRPC response to our format
# tRPC overview.stats returns: {current: {...}, previous: {...}}
current = result.get("current", result)
metrics = {
"unique_visitors": current.get("uniqueVisitors", 0),
"page_views": current.get("screenViews", 0),
"sessions": current.get("sessions", 0),
"bounce_rate": current.get("bounceRate", 0),
"session_duration": current.get("sessionDuration", 0),
"revenue": current.get("revenue", 0),
}
# Add previous period comparison if available
previous = result.get("previous", {})
if previous:
metrics["previous_period"] = {
"unique_visitors": previous.get("uniqueVisitors", 0),
"page_views": previous.get("screenViews", 0),
"sessions": previous.get("sessions", 0),
}
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "date_range": date_range, "stats": result},
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"overview": metrics,
"raw": result,
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_retention_report(
client: OpenPanelClient,
project_id: str | None = None,
start_event: str = "session_start",
return_event: str = "session_start",
period: str = "week",
cohorts: int = 8,
) -> str:
"""Get user retention analysis"""
try:
effective_project_id = _get_project_id(client, project_id)
# Note: Full retention analysis requires tRPC API access
# This provides a simplified version using export API
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"retention_config": {
"start_event": start_event,
"return_event": return_event,
"period": period,
"cohorts": cohorts,
},
"note": "Full retention analysis requires dashboard tRPC API. Use OpenPanel dashboard for detailed retention charts.",
"message": "Retention report configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_cohort_report(
client: OpenPanelClient,
project_id: str | None = None,
cohort_type: str = "first_seen",
cohort_event: str | None = None,
measure_event: str = "session_start",
date_range: str = "6m",
) -> str:
"""Get cohort analysis"""
try:
effective_project_id = _get_project_id(client, project_id)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"cohort_config": {
"cohort_type": cohort_type,
"cohort_event": cohort_event,
"measure_event": measure_event,
"date_range": date_range,
},
"note": "Full cohort analysis requires dashboard tRPC API. Use OpenPanel dashboard for detailed cohort charts.",
"message": "Cohort report configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_paths_report(
client: OpenPanelClient,
project_id: str | None = None,
start_event: str | None = None,
end_event: str | None = None,
max_steps: int = 5,
date_range: str = "30d",
) -> str:
"""Get user flow/paths analysis"""
try:
effective_project_id = _get_project_id(client, project_id)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"paths_config": {
"start_event": start_event,
"end_event": end_event,
"max_steps": max_steps,
"date_range": date_range,
},
"note": "Full path analysis requires dashboard tRPC API. Use OpenPanel dashboard for user flow visualization.",
"message": "Paths report configuration created",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
@@ -411,125 +75,12 @@ async def get_paths_report(
async def get_realtime_stats(client: OpenPanelClient, project_id: str | None = None) -> str: async def get_realtime_stats(client: OpenPanelClient, project_id: str | None = None) -> str:
"""Get real-time visitor statistics using chart.chart with 30min range""" """Get real-time active visitor count via Insights live API."""
try: try:
effective_project_id = _get_project_id(client, project_id) pid = _get_project_id(client, project_id)
result = await client.get_live_visitors(project_id=pid)
# Use chart.chart with 30min range for realtime data
result = await client.get_live_visitors(project_id=effective_project_id)
active_users = result.get("count", 0) if isinstance(result, dict) else 0
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
return json.dumps( return json.dumps(
{ {"success": True, "project_id": pid, "realtime": result},
"success": True,
"project_id": effective_project_id,
"realtime": {
"active_visitors_30min": active_users,
"timestamp": "now",
"period": "30min",
},
"message": "Real-time stats retrieved via chart.chart with 30min range",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def get_ab_test_results(
client: OpenPanelClient,
test_name: str,
conversion_event: str,
project_id: str | None = None,
date_range: str = "30d",
) -> str:
"""Get A/B test results"""
try:
effective_project_id = _get_project_id(client, project_id)
# A/B test results typically tracked via properties
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"test_name": test_name,
"ab_test_config": {"conversion_event": conversion_event, "date_range": date_range},
"note": "A/B test analysis requires tracking variants via event properties. Use OpenPanel dashboard for detailed variant comparison.",
"message": "A/B test configuration retrieved",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def create_scheduled_report(
client: OpenPanelClient,
name: str,
report_type: str,
schedule: str,
recipients: list[str],
project_id: str | None = None,
) -> str:
"""Create a scheduled report"""
try:
effective_project_id = _get_project_id(client, project_id)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"scheduled_report": {
"name": name,
"report_type": report_type,
"schedule": schedule,
"recipients": recipients,
},
"note": "Scheduled reports require dashboard tRPC API. Configure via OpenPanel dashboard.",
"message": f"Scheduled report '{name}' configuration created",
},
indent=2,
ensure_ascii=False,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def export_report_pdf(
client: OpenPanelClient,
report_type: str,
project_id: str | None = None,
date_range: str = "30d",
) -> str:
"""Export report as PDF"""
try:
effective_project_id = _get_project_id(client, project_id)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"export_config": {
"report_type": report_type,
"format": "pdf",
"date_range": date_range,
},
"note": "PDF export requires dashboard functionality. Use OpenPanel dashboard to export reports.",
"message": f"PDF export configuration for {report_type} report",
},
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )

View File

@@ -132,45 +132,15 @@ async def get_instance_info(client: OpenPanelClient) -> str:
async def get_usage_stats(client: OpenPanelClient, project_id: str, date_range: str = "30d") -> str: async def get_usage_stats(client: OpenPanelClient, project_id: str, date_range: str = "30d") -> str:
"""Get usage statistics for a project""" """Get usage statistics for a project via Insights metrics API."""
try: try:
# Get event count result = await client.get_overview_stats(project_id=project_id, date_range=date_range)
events_config = [{"name": "*", "segment": "event"}]
events_result = await client.export_charts(
project_id=project_id, events=events_config, interval="day", date_range=date_range
)
# Get unique users
users_config = [{"name": "*", "segment": "user"}]
users_result = await client.export_charts(
project_id=project_id, events=users_config, interval="day", date_range=date_range
)
# Calculate totals
total_events = 0
if isinstance(events_result, dict) and "data" in events_result:
for point in events_result.get("data", []):
total_events += point.get("count", 0)
total_users = 0
if isinstance(users_result, dict) and "data" in users_result:
data = users_result.get("data", [])
if data:
total_users = data[-1].get("count", 0)
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"project_id": project_id, "project_id": project_id,
"date_range": date_range, "date_range": date_range,
"stats": { "stats": result,
"total_events": total_events,
"unique_users": total_users,
"events_per_user": (
round(total_events / total_users, 2) if total_users > 0 else 0
),
},
"message": f"Usage stats for {date_range}",
}, },
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,

View File

@@ -1,10 +1,17 @@
""" """
OpenPanel Plugin - Product Analytics Management OpenPanel Plugin - Product Analytics Management.
Complete OpenPanel Self-Hosted management through REST API. Self-hosted OpenPanel management through public REST APIs.
Provides tools for event tracking, data export, and analytics. Provides event tracking, data export, analytics, and project/client management.
For Self-Hosted instances deployed on Coolify. APIs used:
- Track API (/track) — Event ingestion (write mode)
- Export API (/export) — Raw data export (read mode)
- Insights API (/insights) — Analytics queries (read mode)
- Manage API (/manage) — Project & client CRUD (root mode)
- Health API (/healthcheck) — Instance health
For Self-Hosted instances deployed on Coolify or Docker.
""" """
from typing import Any from typing import Any
@@ -16,30 +23,17 @@ from plugins.openpanel.client import OpenPanelClient
class OpenPanelPlugin(BasePlugin): class OpenPanelPlugin(BasePlugin):
""" """
OpenPanel Analytics Plugin - Comprehensive product analytics. OpenPanel Analytics Plugin — 42 tools across 7 handlers.
Provides complete OpenPanel management capabilities including: All tools use public REST APIs (no tRPC/session dependency).
- Event tracking (track, identify, increment, decrement)
- Data export (events, charts, CSV)
- Analytics reports (page views, referrers, geo, devices)
- System operations (health, stats)
Phase H.1: Core (25 tools) Events (11): track, identify, increment, decrement, group, assign_group, batch, revenue
- Events Handler: 9 tools (alias_user removed - not supported on self-hosted) Export (10): events, charts, CSV, counts, top pages/referrers/geo/devices
- Export Handler: 10 tools Reports (2): overview stats, realtime visitors
- System Handler: 6 tools Profiles (3): profile events, sessions, GDPR export
Projects (5): list, get, create, update, delete
Phase H.2: Analytics (24 tools) Clients (5): list, get, create, update, delete
- Reports Handler: 8 tools System (6): health, instance info, usage stats, storage, connection test, rate limits
- Funnels Handler: 8 tools
- Profiles Handler: 8 tools
Phase H.3: Management (24 tools)
- Projects Handler: 8 tools
- Dashboards Handler: 10 tools
- Clients Handler: 6 tools
Total: 73 tools
""" """
@staticmethod @staticmethod
@@ -61,63 +55,42 @@ class OpenPanelPlugin(BasePlugin):
- url: OpenPanel instance URL - url: OpenPanel instance URL
- client_id: Client ID for authentication - client_id: Client ID for authentication
- client_secret: Client Secret for authentication - client_secret: Client Secret for authentication
- project_id: OpenPanel project ID (for Export/Read APIs) - project_id: OpenPanel project ID (for Export/Insights APIs)
- organization_id: Organization/Workspace ID (for multi-tenant setups) - organization_id: Organization/Workspace ID (optional)
project_id: Optional MCP project ID (auto-generated if not provided) project_id: Optional MCP project ID (auto-generated if not provided)
""" """
super().__init__(config, project_id=project_id) super().__init__(config, project_id=project_id)
# Get OpenPanel project_id and organization_id from config
openpanel_project_id = config.get("project_id") openpanel_project_id = config.get("project_id")
openpanel_organization_id = config.get("organization_id") openpanel_organization_id = config.get("organization_id")
# Get session cookie for tRPC API access (optional)
# This is needed for analytics queries as tRPC uses session-based auth
session_cookie = config.get("session_cookie")
# Create OpenPanel API client
self.client = OpenPanelClient( self.client = OpenPanelClient(
base_url=config["url"], base_url=config["url"],
client_id=config["client_id"], client_id=config["client_id"],
client_secret=config["client_secret"], client_secret=config["client_secret"],
project_id=openpanel_project_id, project_id=openpanel_project_id,
organization_id=openpanel_organization_id, organization_id=openpanel_organization_id,
session_cookie=session_cookie,
) )
# Store for reference
self.openpanel_project_id = openpanel_project_id self.openpanel_project_id = openpanel_project_id
self.openpanel_organization_id = openpanel_organization_id self.openpanel_organization_id = openpanel_organization_id
self.has_session = bool(session_cookie)
@staticmethod @staticmethod
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
""" """
Return all tool specifications for ToolGenerator. Return all tool specifications for ToolGenerator (42 tools).
This method is called by ToolGenerator to create unified tools This method is called by ToolGenerator to create unified tools
with site parameter routing. with site parameter routing.
Returns:
List of tool specification dictionaries (26 tools in Phase H.1)
""" """
specs = [] specs = []
specs.extend(handlers.events.get_tool_specifications()) # 11 tools
# Phase H.1: Core (26 tools)
specs.extend(handlers.events.get_tool_specifications()) # 10 tools
specs.extend(handlers.export.get_tool_specifications()) # 10 tools specs.extend(handlers.export.get_tool_specifications()) # 10 tools
specs.extend(handlers.system.get_tool_specifications()) # 6 tools specs.extend(handlers.system.get_tool_specifications()) # 6 tools
specs.extend(handlers.reports.get_tool_specifications()) # 2 tools
# Phase H.2: Analytics (24 tools) specs.extend(handlers.profiles.get_tool_specifications()) # 3 tools
specs.extend(handlers.reports.get_tool_specifications()) # 8 tools specs.extend(handlers.projects.get_tool_specifications()) # 5 tools
specs.extend(handlers.funnels.get_tool_specifications()) # 8 tools specs.extend(handlers.clients.get_tool_specifications()) # 5 tools
specs.extend(handlers.profiles.get_tool_specifications()) # 8 tools
# Phase H.3: Management (24 tools)
specs.extend(handlers.projects.get_tool_specifications()) # 8 tools
specs.extend(handlers.dashboards.get_tool_specifications()) # 10 tools
specs.extend(handlers.clients.get_tool_specifications()) # 6 tools
return specs return specs
def __getattr__(self, name: str): def __getattr__(self, name: str):
@@ -139,10 +112,8 @@ class OpenPanelPlugin(BasePlugin):
handlers.export, handlers.export,
handlers.system, handlers.system,
handlers.reports, handlers.reports,
handlers.funnels,
handlers.profiles, handlers.profiles,
handlers.projects, handlers.projects,
handlers.dashboards,
handlers.clients, handlers.clients,
] ]

View File

@@ -483,6 +483,8 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def search_users( async def search_users(
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50 client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
) -> str: ) -> str:

View File

@@ -77,12 +77,8 @@ class WPCLIManager:
try: try:
# First, test if we have Docker socket access # First, test if we have Docker socket access
test_process = await asyncio.create_subprocess_exec( test_process = await asyncio.create_subprocess_exec(
"docker", "docker", "version", "--format", "{{.Server.Version}}",
"version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"--format",
"{{.Server.Version}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
test_stdout, test_stderr = await asyncio.wait_for( test_stdout, test_stderr = await asyncio.wait_for(
@@ -103,15 +99,10 @@ class WPCLIManager:
# Now check for our specific container using exact name match # Now check for our specific container using exact name match
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"docker", "docker", "ps", "--all",
"ps", "--filter", f"name=^{self.container_name}$",
"--all", "--format", "{{.Names}}|{{.Status}}",
"--filter", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
f"name=^{self.container_name}$",
"--format",
"{{.Names}}|{{.Status}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -126,13 +117,8 @@ class WPCLIManager:
if not output: if not output:
# Container not found - get list of available containers for helpful error # Container not found - get list of available containers for helpful error
list_process = await asyncio.create_subprocess_exec( list_process = await asyncio.create_subprocess_exec(
"docker", "docker", "ps", "--all", "--format", "{{.Names}}",
"ps", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"--all",
"--format",
"{{.Names}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
list_stdout, _ = await list_process.communicate() list_stdout, _ = await list_process.communicate()
available = list_stdout.decode().strip().split("\n") if list_stdout else [] available = list_stdout.decode().strip().split("\n") if list_stdout else []
@@ -184,14 +170,9 @@ class WPCLIManager:
try: try:
# Try to run wp --version # Try to run wp --version
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"docker", "docker", "exec", self.container_name,
"exec", "wp", "--version", "--allow-root",
self.container_name, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"wp",
"--version",
"--allow-root",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -310,9 +291,7 @@ class WPCLIManager:
) )
# 4. Build docker exec command (split command into args to prevent shell injection) # 4. Build docker exec command (split command into args to prevent shell injection)
cmd_parts = ( cmd_parts = ["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
)
self.logger.info(f"Executing: {' '.join(cmd_parts)}") self.logger.info(f"Executing: {' '.join(cmd_parts)}")
@@ -603,29 +582,17 @@ class WPCLIManager:
try: try:
# Try GNU stat first, fall back to BSD stat # Try GNU stat first, fall back to BSD stat
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"docker", "docker", "exec", self.container_name,
"exec", "stat", "-c", "%s", export_path,
self.container_name, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"stat",
"-c",
"%s",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
if process.returncode != 0: if process.returncode != 0:
# BSD stat fallback # BSD stat fallback
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"docker", "docker", "exec", self.container_name,
"exec", "stat", "-f", "%z", export_path,
self.container_name, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
"stat",
"-f",
"%z",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "mcphub-server" name = "mcphub-server"
version = "3.3.0" version = "3.4.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [ authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"} {name = "MCP Hub", email = "contact@mcphub.dev"}

View File

@@ -66,8 +66,6 @@ from core.dashboard.routes import (
api_get_config, api_get_config,
api_list_keys, api_list_keys,
api_list_sites, api_list_sites,
# K.5: Settings routes
api_save_setting,
api_test_site, api_test_site,
api_update_site, api_update_site,
# E.2: OAuth Social Login routes # E.2: OAuth Social Login routes
@@ -109,6 +107,8 @@ from core.dashboard.routes import (
# F.3: Service pages # F.3: Service pages
dashboard_service_page, dashboard_service_page,
dashboard_services_list, dashboard_services_list,
# K.5: Settings routes
api_save_setting,
dashboard_settings_page, dashboard_settings_page,
# E.3: Site Management pages # E.3: Site Management pages
dashboard_sites_add, dashboard_sites_add,

View File

@@ -1,5 +1,6 @@
"""Tests for Admin System Unification (F.4).""" """Tests for Admin System Unification (F.4)."""
from core.dashboard.auth import ( from core.dashboard.auth import (
DashboardSession, DashboardSession,
get_session_display_info, get_session_display_info,

View File

@@ -0,0 +1,767 @@
"""Tests for OpenPanel Plugin (plugins/openpanel/).
Unit tests covering client initialization, tool specifications,
handler delegation, API request building, and health checks.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.openpanel.client import OpenPanelClient
from plugins.openpanel.plugin import OpenPanelPlugin
# --- OpenPanelClient Tests ---
class TestOpenPanelClientInit:
"""Test OpenPanelClient initialization."""
def test_valid_initialization(self):
"""Should initialize with valid credentials."""
client = OpenPanelClient(
base_url="https://analytics.example.com",
client_id="test-client-id",
client_secret="test-client-secret",
)
assert client.base_url == "https://analytics.example.com"
assert client.client_id == "test-client-id"
assert client.client_secret == "test-client-secret"
assert client.default_project_id is None
def test_trailing_slash_stripped(self):
"""Should strip trailing slash from base URL."""
client = OpenPanelClient(
base_url="https://analytics.example.com/",
client_id="cid",
client_secret="csec",
)
assert client.base_url == "https://analytics.example.com"
def test_project_id_stored(self):
"""Should store optional project_id."""
client = OpenPanelClient(
base_url="https://analytics.example.com",
client_id="cid",
client_secret="csec",
project_id="proj123",
)
assert client.default_project_id == "proj123"
def test_organization_id_stored(self):
"""Should store optional organization_id."""
client = OpenPanelClient(
base_url="https://analytics.example.com",
client_id="cid",
client_secret="csec",
organization_id="org456",
)
assert client.default_organization_id == "org456"
class TestOpenPanelClientHeaders:
"""Test request header generation."""
def test_auth_headers(self):
"""Should include client_id and client_secret in headers."""
client = OpenPanelClient(
base_url="https://a.com", client_id="my-id", client_secret="my-secret"
)
headers = client._get_headers()
assert headers["openpanel-client-id"] == "my-id"
assert headers["openpanel-client-secret"] == "my-secret"
assert headers["Content-Type"] == "application/json"
def test_client_ip_header(self):
"""Should include x-client-ip when provided."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
headers = client._get_headers(client_ip="1.2.3.4")
assert headers["x-client-ip"] == "1.2.3.4"
def test_user_agent_header(self):
"""Should include user-agent when provided."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
headers = client._get_headers(user_agent="Mozilla/5.0")
assert headers["user-agent"] == "Mozilla/5.0"
def test_additional_headers(self):
"""Should merge additional headers."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
headers = client._get_headers(additional_headers={"X-Custom": "val"})
assert headers["X-Custom"] == "val"
class TestOpenPanelClientTrack:
"""Test Track API methods."""
@pytest.mark.asyncio
async def test_track_event_builds_correct_payload(self):
"""Should build correct track payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.track_event(name="purchase", properties={"amount": 99}, profile_id="user1")
client.request.assert_called_once()
call_args = client.request.call_args
json_data = call_args.kwargs.get("json_data") or call_args[1].get("json_data")
assert json_data["type"] == "track"
assert json_data["payload"]["name"] == "purchase"
assert json_data["payload"]["properties"]["amount"] == 99
assert json_data["payload"]["profileId"] == "user1"
@pytest.mark.asyncio
async def test_identify_user_builds_correct_payload(self):
"""Should build correct identify payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.identify_user(profile_id="user1", first_name="John", email="john@test.com")
json_data = client.request.call_args.kwargs.get("json_data")
assert json_data["type"] == "identify"
assert json_data["payload"]["profileId"] == "user1"
assert json_data["payload"]["firstName"] == "John"
assert json_data["payload"]["email"] == "john@test.com"
@pytest.mark.asyncio
async def test_increment_property(self):
"""Should build correct increment payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.increment_property(profile_id="user1", property_name="visits", value=5)
json_data = client.request.call_args.kwargs.get("json_data")
assert json_data["type"] == "increment"
assert json_data["payload"]["property"] == "visits"
assert json_data["payload"]["value"] == 5
@pytest.mark.asyncio
async def test_decrement_property(self):
"""Should build correct decrement payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.decrement_property(profile_id="user1", property_name="credits", value=3)
json_data = client.request.call_args.kwargs.get("json_data")
assert json_data["type"] == "decrement"
assert json_data["payload"]["value"] == 3
@pytest.mark.asyncio
async def test_track_group(self):
"""Should build correct group payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.track_group(
group_id="g1", group_type="company", name="Acme", properties={"plan": "pro"}
)
json_data = client.request.call_args.kwargs.get("json_data")
assert json_data["type"] == "group"
assert json_data["payload"]["id"] == "g1"
assert json_data["payload"]["type"] == "company"
assert json_data["payload"]["name"] == "Acme"
assert json_data["payload"]["properties"]["plan"] == "pro"
@pytest.mark.asyncio
async def test_assign_group(self):
"""Should build correct assign_group payload."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.assign_group(group_ids=["g1", "g2"], profile_id="user1")
json_data = client.request.call_args.kwargs.get("json_data")
assert json_data["type"] == "assign_group"
assert json_data["payload"]["groupIds"] == ["g1", "g2"]
assert json_data["payload"]["profileId"] == "user1"
class TestOpenPanelClientExport:
"""Test Export API methods."""
@pytest.mark.asyncio
async def test_export_events_params(self):
"""Should build correct export_events query params."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"meta": {}, "data": []})
await client.export_events(
project_id="proj1", event="purchase", start="2024-01-01", limit=100
)
call_args = client.request.call_args
assert call_args[0][0] == "GET"
assert call_args[0][1] == "/export/events"
params = call_args.kwargs.get("params")
assert params["projectId"] == "proj1"
assert params["event"] == "purchase"
assert params["start"] == "2024-01-01"
assert params["limit"] == 100
@pytest.mark.asyncio
async def test_export_events_array_event_filter(self):
"""Should join array event filters."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"meta": {}, "data": []})
await client.export_events(project_id="proj1", event=["click", "view"])
params = client.request.call_args.kwargs.get("params")
assert params["event"] == "click,view"
@pytest.mark.asyncio
async def test_export_charts_params(self):
"""Should build correct export_charts query params."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={})
await client.export_charts(
project_id="proj1",
events=[{"name": "page_view", "segment": "user"}],
interval="day",
date_range="7d",
)
call_args = client.request.call_args
assert call_args[0][0] == "GET"
assert call_args[0][1] == "/export/charts"
params = call_args.kwargs.get("params")
assert params["projectId"] == "proj1"
assert params["interval"] == "day"
assert params["range"] == "7d"
class TestOpenPanelClientInsights:
"""Test Insights API methods."""
@pytest.mark.asyncio
async def test_get_overview_stats(self):
"""Should call correct insights endpoint."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"visitors": 100})
result = await client.get_overview_stats(project_id="proj1", date_range="7d")
call_args = client.request.call_args
assert call_args[0][0] == "GET"
assert call_args[0][1] == "/insights/proj1/metrics"
assert result["visitors"] == 100
@pytest.mark.asyncio
async def test_get_live_visitors(self):
"""Should call live endpoint."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"count": 5})
result = await client.get_live_visitors(project_id="proj1")
assert client.request.call_args[0][1] == "/insights/proj1/live"
assert result["count"] == 5
@pytest.mark.asyncio
async def test_get_top_pages(self):
"""Should call pages endpoint with params."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value=[])
await client.get_top_pages(project_id="proj1", date_range="30d", limit=20)
assert client.request.call_args[0][1] == "/insights/proj1/pages"
params = client.request.call_args.kwargs.get("params")
assert params["limit"] == 20
@pytest.mark.asyncio
async def test_get_insights_breakdown(self):
"""Should call breakdown endpoint with correct column."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value=[])
await client.get_insights_breakdown(project_id="proj1", column="country")
assert client.request.call_args[0][1] == "/insights/proj1/country"
@pytest.mark.asyncio
async def test_get_top_sources_uses_referrer_name(self):
"""Should use referrer_name breakdown."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.get_insights_breakdown = AsyncMock(return_value=[])
await client.get_top_sources(project_id="proj1")
client.get_insights_breakdown.assert_called_once()
assert client.get_insights_breakdown.call_args[0][1] == "referrer_name"
class TestOpenPanelClientManage:
"""Test Manage API methods."""
@pytest.mark.asyncio
async def test_list_projects(self):
"""Should call GET /manage/projects."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value=[{"id": "p1", "name": "Test"}])
result = await client.list_projects()
assert client.request.call_args[0] == ("GET", "/manage/projects")
assert result[0]["id"] == "p1"
@pytest.mark.asyncio
async def test_create_project(self):
"""Should call POST /manage/projects."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"id": "new", "name": "New"})
await client.create_project({"name": "New", "domain": "new.com"})
assert client.request.call_args[0] == ("POST", "/manage/projects")
assert client.request.call_args.kwargs["json_data"]["name"] == "New"
@pytest.mark.asyncio
async def test_delete_project(self):
"""Should call DELETE /manage/projects/:id."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
await client.delete_project("proj1")
assert client.request.call_args[0] == ("DELETE", "/manage/projects/proj1")
@pytest.mark.asyncio
async def test_list_clients(self):
"""Should call GET /manage/clients."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value=[])
await client.list_clients()
assert client.request.call_args[0] == ("GET", "/manage/clients")
@pytest.mark.asyncio
async def test_create_client(self):
"""Should call POST /manage/clients."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"id": "c1"})
await client.create_client({"name": "test", "projectId": "p1", "mode": "read"})
assert client.request.call_args[0] == ("POST", "/manage/clients")
class TestOpenPanelClientHealth:
"""Test health check methods."""
@pytest.mark.asyncio
async def test_health_check_healthy(self):
"""Should report healthy when both endpoints respond."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.request = AsyncMock(return_value={"success": True})
result = await client.health_check()
assert result["healthy"] is True
assert "api" in result["services"]
assert "track_api" in result["services"]
@pytest.mark.asyncio
async def test_get_instance_info(self):
"""Should return instance details."""
client = OpenPanelClient(
base_url="https://analytics.example.com",
client_id="long-client-id-12345",
client_secret="csec",
project_id="proj1",
)
info = await client.get_instance_info()
assert info["url"] == "https://analytics.example.com"
assert info["client_id"] == "long-cli..."
assert info["default_project_id"] == "proj1"
assert info["type"] == "openpanel"
class TestOpenPanelClientErrors:
"""Test error handling."""
def test_extract_error_message_dict(self):
"""Should extract message from dict."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
msg = client._extract_error_message({"message": "Not found"}, 404)
assert "Not found" in msg
assert "Hint" in msg
def test_extract_error_auth_hint(self):
"""Should add auth hint for 401/403."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
msg = client._extract_error_message({"error": "Unauthorized"}, 401)
assert "Verify client_id" in msg
def test_extract_error_rate_limit_hint(self):
"""Should add rate limit hint for 429."""
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
msg = client._extract_error_message({"message": "Too many"}, 429)
assert "Rate limited" in msg
# --- OpenPanelPlugin Tests ---
class TestOpenPanelPluginInit:
"""Test OpenPanelPlugin initialization."""
def test_valid_initialization(self):
"""Should initialize with valid config."""
plugin = OpenPanelPlugin(
config={
"url": "https://analytics.example.com",
"client_id": "cid",
"client_secret": "csec",
}
)
assert isinstance(plugin.client, OpenPanelClient)
assert plugin.client.base_url == "https://analytics.example.com"
def test_project_id_passed_to_client(self):
"""Should pass project_id to client."""
plugin = OpenPanelPlugin(
config={
"url": "https://a.com",
"client_id": "cid",
"client_secret": "csec",
"project_id": "proj123",
}
)
assert plugin.client.default_project_id == "proj123"
assert plugin.openpanel_project_id == "proj123"
def test_plugin_name(self):
"""Should return correct plugin name."""
assert OpenPanelPlugin.get_plugin_name() == "openpanel"
def test_required_config_keys(self):
"""Should require url, client_id, client_secret."""
keys = OpenPanelPlugin.get_required_config_keys()
assert "url" in keys
assert "client_id" in keys
assert "client_secret" in keys
class TestOpenPanelPluginToolSpecs:
"""Test tool specifications."""
def test_tool_count(self):
"""Should return 42 tools."""
specs = OpenPanelPlugin.get_tool_specifications()
assert len(specs) == 42
def test_all_specs_have_required_fields(self):
"""All specs should have name, method_name, description, schema."""
specs = OpenPanelPlugin.get_tool_specifications()
for spec in specs:
assert "name" in spec, "Missing name in spec"
assert "method_name" in spec, f"Missing method_name in {spec.get('name')}"
assert "description" in spec, f"Missing description in {spec.get('name')}"
assert "schema" in spec, f"Missing schema in {spec.get('name')}"
def test_unique_tool_names(self):
"""All tool names should be unique."""
specs = OpenPanelPlugin.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(
set(names)
), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
def test_event_tools_present(self):
"""Should include core event tracking tools."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "track_event" in names
assert "identify_user" in names
assert "track_page_view" in names
assert "track_revenue" in names
assert "track_batch" in names
assert "create_group" in names
assert "assign_group" in names
def test_export_tools_present(self):
"""Should include export tools."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "export_events" in names
assert "export_chart_data" in names
assert "get_top_pages" in names
assert "get_top_referrers" in names
assert "get_geo_data" in names
def test_manage_tools_present(self):
"""Should include project and client management tools."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "list_projects" in names
assert "create_project" in names
assert "delete_project" in names
assert "list_clients" in names
assert "create_client" in names
def test_no_alias_tool(self):
"""Should NOT include alias_user (not supported by OpenPanel)."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "alias_user" not in names
def test_no_dashboard_tools(self):
"""Should NOT include dashboard tools (no public API)."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "list_dashboards" not in names
assert "create_dashboard" not in names
assert "add_chart" not in names
def test_no_funnel_tools(self):
"""Should NOT include funnel tools (no public API)."""
specs = OpenPanelPlugin.get_tool_specifications()
names = {s["name"] for s in specs}
assert "list_funnels" not in names
assert "create_funnel" not in names
class TestOpenPanelPluginDelegation:
"""Test handler method delegation."""
def test_track_event_delegated(self):
"""Should delegate track_event to events handler."""
plugin = OpenPanelPlugin(
config={"url": "https://a.com", "client_id": "cid", "client_secret": "csec"}
)
# __getattr__ should find track_event in events handler
assert callable(plugin.track_event)
def test_export_events_delegated(self):
"""Should delegate export_events to export handler."""
plugin = OpenPanelPlugin(
config={"url": "https://a.com", "client_id": "cid", "client_secret": "csec"}
)
assert callable(plugin.export_events)
def test_list_projects_delegated(self):
"""Should delegate list_projects to projects handler."""
plugin = OpenPanelPlugin(
config={"url": "https://a.com", "client_id": "cid", "client_secret": "csec"}
)
assert callable(plugin.list_projects)
def test_nonexistent_method_raises(self):
"""Should raise AttributeError for unknown methods."""
plugin = OpenPanelPlugin(
config={"url": "https://a.com", "client_id": "cid", "client_secret": "csec"}
)
with pytest.raises(AttributeError):
plugin.nonexistent_method()
# --- Handler Integration Tests ---
class TestEventHandlers:
"""Test event handler functions."""
@pytest.mark.asyncio
async def test_track_event_handler(self):
"""Should return success JSON on track."""
from plugins.openpanel.handlers.events import track_event
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.track_event = AsyncMock(return_value={"success": True})
result = await track_event(client, name="test_event")
data = json.loads(result)
assert data["success"] is True
assert data["event"] == "test_event"
@pytest.mark.asyncio
async def test_track_event_error(self):
"""Should return error JSON on failure."""
from plugins.openpanel.handlers.events import track_event
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.track_event = AsyncMock(side_effect=Exception("Connection refused"))
result = await track_event(client, name="test_event")
data = json.loads(result)
assert data["success"] is False
assert "Connection refused" in data["error"]
@pytest.mark.asyncio
async def test_create_group_handler(self):
"""Should return success JSON on group creation."""
from plugins.openpanel.handlers.events import create_group
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.track_group = AsyncMock(return_value={"success": True})
result = await create_group(client, group_id="g1", group_type="company", name="Acme")
data = json.loads(result)
assert data["success"] is True
assert data["group_id"] == "g1"
@pytest.mark.asyncio
async def test_track_batch_handler(self):
"""Should track multiple events."""
from plugins.openpanel.handlers.events import track_batch
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.track_event = AsyncMock(return_value={"success": True})
events = [{"name": "event1"}, {"name": "event2"}, {"name": "event3"}]
result = await track_batch(client, events=events)
data = json.loads(result)
assert data["success"] is True
assert data["tracked"] == 3
assert data["total"] == 3
class TestExportHandlers:
"""Test export handler functions."""
@pytest.mark.asyncio
async def test_export_events_handler(self):
"""Should return formatted export data."""
from plugins.openpanel.handlers.export import export_events
client = OpenPanelClient(
base_url="https://a.com", client_id="cid", client_secret="csec", project_id="proj1"
)
client.export_events = AsyncMock(
return_value={
"meta": {"totalCount": 2, "current": 1, "pages": 1},
"data": [{"name": "ev1"}, {"name": "ev2"}],
}
)
result = await export_events(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert data["total"] == 2
@pytest.mark.asyncio
async def test_get_top_pages_handler(self):
"""Should return top pages data."""
from plugins.openpanel.handlers.export import get_top_pages
client = OpenPanelClient(
base_url="https://a.com", client_id="cid", client_secret="csec", project_id="proj1"
)
client.get_top_pages = AsyncMock(return_value=[{"path": "/home", "count": 100}])
result = await get_top_pages(client)
data = json.loads(result)
assert data["success"] is True
class TestProjectHandlers:
"""Test project management handler functions."""
@pytest.mark.asyncio
async def test_list_projects_handler(self):
"""Should return projects list."""
from plugins.openpanel.handlers.projects import list_projects
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.list_projects = AsyncMock(return_value=[{"id": "p1", "name": "Test"}])
result = await list_projects(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 1
@pytest.mark.asyncio
async def test_create_project_handler(self):
"""Should create and return project."""
from plugins.openpanel.handlers.projects import create_project
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.create_project = AsyncMock(return_value={"id": "new", "name": "New"})
result = await create_project(client, name="New", domain="new.com")
data = json.loads(result)
assert data["success"] is True
assert "New" in data["message"]
class TestSystemHandlers:
"""Test system handler functions."""
@pytest.mark.asyncio
async def test_health_check_handler(self):
"""Should return health status."""
from plugins.openpanel.handlers.system import health_check
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.health_check = AsyncMock(
return_value={"healthy": True, "services": {"api": "ok", "track_api": "ok"}}
)
result = await health_check(client)
data = json.loads(result)
assert data["success"] is True
assert data["healthy"] is True
@pytest.mark.asyncio
async def test_test_connection_handler(self):
"""Should return connection status."""
from plugins.openpanel.handlers.system import test_connection
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
client.health_check = AsyncMock(return_value={"healthy": True})
result = await test_connection(client)
data = json.loads(result)
assert data["success"] is True
assert data["connection"] == "ok"
class TestUtilsModule:
"""Test utility functions."""
def test_get_project_id_explicit(self):
"""Should use explicit project_id."""
from plugins.openpanel.handlers.utils import get_project_id
client = OpenPanelClient(
base_url="https://a.com", client_id="cid", client_secret="csec", project_id="default"
)
assert get_project_id(client, "explicit") == "explicit"
def test_get_project_id_default(self):
"""Should fall back to client default."""
from plugins.openpanel.handlers.utils import get_project_id
client = OpenPanelClient(
base_url="https://a.com", client_id="cid", client_secret="csec", project_id="default"
)
assert get_project_id(client, None) == "default"
def test_get_project_id_missing_raises(self):
"""Should raise ValueError when no project_id available."""
from plugins.openpanel.handlers.utils import get_project_id
client = OpenPanelClient(base_url="https://a.com", client_id="cid", client_secret="csec")
with pytest.raises(ValueError, match="project_id"):
get_project_id(client, None)