Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a0805412 | |||
| 3fc02b5734 | |||
| d7e3946e11 | |||
| 151ba8e6e6 | |||
| bf893a743a | |||
| 2c9d4ed3d2 | |||
| 4f0c2f9bb0 | |||
| d79c2fd94a | |||
| 1615daf1a7 | |||
| c47ec7fd28 | |||
| 3815f5a6d5 | |||
| 81417b552f | |||
| 75cac1cb4d | |||
| 12b06ce66f | |||
| 9905a28eb0 | |||
| 68c84b3ece | |||
| 9b70b259bb | |||
|
|
f6dbbeaab0 | ||
|
|
c0d55f9061 | ||
|
|
a806671e2d | ||
|
|
4a5381d765 | ||
|
|
fdb050454e | ||
|
|
ea51c10e03 | ||
|
|
479d0c7252 | ||
|
|
d3bcb31053 | ||
|
|
1736779d69 | ||
|
|
983d93c2a7 | ||
|
|
80377a1a22 | ||
|
|
684c889c27 | ||
|
|
55d30ba876 | ||
|
|
eceba04578 | ||
|
|
1fcc539093 | ||
|
|
9e6f06d933 | ||
|
|
42ea75bcb3 | ||
|
|
647a650629 | ||
|
|
c57fd666c9 | ||
|
|
7a16a260f1 | ||
|
|
f29dc299b2 | ||
|
|
5d97b31c12 | ||
|
|
72cfdad5e3 | ||
|
|
5ce70f120e | ||
|
|
cafcd64eff | ||
|
|
d74594524a | ||
|
|
b6551abe0a | ||
|
|
cb2a835d0e | ||
|
|
bb2a416dd5 | ||
|
|
ff79968170 | ||
|
|
bb851e4be2 | ||
|
|
91979ee18d | ||
|
|
809dc264af | ||
|
|
cb6bcd8136 | ||
|
|
3b87c01d78 | ||
|
|
85379ec36c | ||
|
|
4a45178d2d | ||
|
|
f303a04322 | ||
|
|
bb74703311 | ||
|
|
aa8afce707 | ||
|
|
076a0b940e | ||
|
|
7daf604a32 | ||
|
|
5e0441c85e | ||
|
|
3a3f04aca8 |
13
.env.example
13
.env.example
@@ -2,16 +2,19 @@
|
|||||||
# MCP Hub - Environment Configuration
|
# MCP Hub - Environment Configuration
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
#
|
#
|
||||||
# Version: 3.0.0
|
# Version: 3.4.0
|
||||||
# Last Updated: 2026-02-17
|
# Last Updated: 2026-04-02
|
||||||
#
|
#
|
||||||
# This file contains all environment variables needed to run the MCP server.
|
# This file contains environment variables needed to run the MCP server.
|
||||||
# Copy this file to .env and fill in your actual values.
|
# Copy this file to .env and fill in your actual values.
|
||||||
#
|
#
|
||||||
|
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
|
||||||
|
# The plugin sections below are kept as credential reference only.
|
||||||
|
#
|
||||||
# SECURITY NOTE: Never commit .env file to version control!
|
# SECURITY NOTE: Never commit .env file to version control!
|
||||||
#
|
#
|
||||||
# Multi-Endpoint Architecture (v3.0.0):
|
# Multi-Endpoint Architecture (v3.0.0):
|
||||||
# - /mcp → Admin (all 587 tools, Master API Key required)
|
# - /mcp → Admin (all 565 tools, Master API Key required)
|
||||||
# - /system/mcp → System (17 tools, Master Key)
|
# - /system/mcp → System (17 tools, Master Key)
|
||||||
# - /wordpress/mcp → WordPress Core (65 tools)
|
# - /wordpress/mcp → WordPress Core (65 tools)
|
||||||
# - /woocommerce/mcp → WooCommerce (28 tools)
|
# - /woocommerce/mcp → WooCommerce (28 tools)
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
# - /gitea/mcp → Gitea (56 tools)
|
# - /gitea/mcp → Gitea (56 tools)
|
||||||
# - /n8n/mcp → n8n Automation (56 tools)
|
# - /n8n/mcp → n8n Automation (56 tools)
|
||||||
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
||||||
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
|
# - /openpanel/mcp → OpenPanel Analytics (42 tools)
|
||||||
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
||||||
# - /directus/mcp → Directus CMS (100 tools)
|
# - /directus/mcp → Directus CMS (100 tools)
|
||||||
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,6 +28,8 @@ ENV/
|
|||||||
.coverage.*
|
.coverage.*
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
|
pytest-out.txt
|
||||||
|
scripts/community-build/BUILD_REPORT.md
|
||||||
.nox/
|
.nox/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
@@ -159,3 +161,7 @@ pytest-cache-files-*/
|
|||||||
# Project specific
|
# Project specific
|
||||||
# ====================================
|
# ====================================
|
||||||
# Add project-specific ignores here
|
# Add project-specific ignores here
|
||||||
|
.worktrees/
|
||||||
|
worktrees/
|
||||||
|
/.agents
|
||||||
|
uv.lock
|
||||||
|
|||||||
163
CHANGELOG.md
163
CHANGELOG.md
@@ -5,6 +5,167 @@ 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.6.0] — 2026-04-02
|
||||||
|
|
||||||
|
### Gitea Plugin — Public Release (Track F.16)
|
||||||
|
|
||||||
|
Gitea plugin fully reviewed, tested, and published for public use. Two missing tools added, comprehensive test suite created.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **`update_webhook` tool**: Update existing webhook configuration, events, and active status (admin scope)
|
||||||
|
- **`delete_label` tool**: Delete labels from repositories (write scope)
|
||||||
|
- **`tests/test_gitea_plugin.py`**: Comprehensive test suite — 85+ tests covering client init, headers, tool specs, all 5 handler groups, health check, and plugin delegation
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Gitea plugin now public**: Added `gitea` to `DEFAULT_PUBLIC_PLUGINS` — available to all OAuth users
|
||||||
|
- **Tool count**: 565 → 567 (added update_webhook + delete_label)
|
||||||
|
- **env.example**: Updated default `ENABLED_PLUGINS` to include `gitea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.5.0] — 2026-04-02
|
||||||
|
|
||||||
|
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
|
||||||
|
|
||||||
|
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
|
||||||
|
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
|
||||||
|
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
|
||||||
|
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
|
||||||
|
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
|
||||||
|
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Platform Hardening & Admin Unification (Track F.1–F.8)
|
||||||
|
|
||||||
|
Major quality release: plugin visibility control, UI/UX polish, unified admin panel, database-backed settings, and security hardening. No breaking API changes.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Plugin Visibility Control** (F.1): `ENABLED_PLUGINS` env var controls which plugins public users see. Default: `wordpress,woocommerce,supabase`. Admin sees all. New module: `core/plugin_visibility.py`
|
||||||
|
- **MCP Service Pages** (F.3): Dedicated `/dashboard/services/{type}` pages showing plugin capabilities, tool list, and setup requirements. Services list page with grid of plugin cards
|
||||||
|
- **Admin by Email** (F.4a): `ADMIN_EMAILS` env var for designating OAuth users as admin (supports multiple emails). OAuth admin users see full admin sidebar
|
||||||
|
- **Master Key Scope Control** (F.4b): `DISABLE_MASTER_KEY_LOGIN` to block dashboard login via master key. `MASTER_KEY_SCOPE` (`all`/`admin`) to restrict master key to admin endpoints only
|
||||||
|
- **Panel Unification** (F.4c): OAuth admin sees both "My Tools" and "Administration" sidebar sections. Master key admin auto-creates user record on first login for unified site management
|
||||||
|
- **Settings from UI** (F.4c.3): Database-backed settings with DB > ENV > Default priority. Admin can edit `ENABLED_PLUGINS`, `MAX_SITES_PER_USER`, rate limits, registration toggle from dashboard. New module: `core/settings.py`
|
||||||
|
- **Dashboard Stats** (F.3): Admin home page shows Total Users and User Sites stat cards
|
||||||
|
- **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
- **Test Connection** (F.3): Fixed 504/HTML error handling; status badge auto-updates without page refresh; added `last_tested_at` timestamp (DB schema v4)
|
||||||
|
- **Admin Auth** (F.4): Fixed `_require_admin_session()` to recognize OAuth admin sessions; hide "Admin Login with API Key" button when `DISABLE_MASTER_KEY_LOGIN=true`
|
||||||
|
- **Starlette API** (F.2): Updated `TemplateResponse` to new API (request as first arg)
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- **exec() Removal** (F.8): Replaced `exec()` in `core/tool_generator.py` with closure-based tool generation
|
||||||
|
- **Shell Injection Fix** (F.8): Replaced `create_subprocess_shell` with `create_subprocess_exec` in WP-CLI handler
|
||||||
|
- **bcrypt Migration** (F.8): Admin API key hashing upgraded from unsalted SHA-256 to bcrypt
|
||||||
|
|
||||||
|
#### Tests
|
||||||
|
- New test suites: `test_plugin_visibility.py`, `test_admin_system.py`, `test_f3_admin_stats.py`, `test_f3_last_tested.py`, `test_f3_service_pages.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.2.0] — 2026-02-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bug A**: OAuth consent redirect loop — after GitHub/Google login, page now returns to consent screen instead of `/dashboard`
|
||||||
|
- **Bug B**: `/u/{user_id}/{alias}/mcp` endpoint now accepts OAuth JWT tokens (issued after social login) in addition to `mhu_` API keys
|
||||||
|
- **Bug C**: OAuth users can now create, list, and delete their own OAuth clients via the new `/dashboard/connect/oauth-clients` page
|
||||||
|
- **D-1**: Dashboard sidebar border uses Tailwind class instead of inline style; version shown in footer
|
||||||
|
- **D-2**: Removed broken `setInterval` in dashboard that targeted non-existent `#stats-container`
|
||||||
|
- **D-3**: 404 page uses purple primary colors and respects system dark mode preference
|
||||||
|
- **D-4**: Fixed invisible buttons (`bg-gray-200` + `text-white` in light mode) across api-keys, health, projects pages
|
||||||
|
- **D-5**: Pinned Alpine.js to 3.14.8; removed duplicate CSRF meta tag from `head_assets.html`
|
||||||
|
- **D-6**: Fixed invisible language toggle button in settings (dark mode)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `OAuthClient.owner_user_id` field for per-user OAuth client isolation
|
||||||
|
- `dashboard_connect_page` now includes Claude.ai connection guide with endpoint URL and link to OAuth Clients
|
||||||
|
- New route: `GET /dashboard/connect/oauth-clients` — user's OAuth clients list
|
||||||
|
- New API: `POST /api/dashboard/user-oauth-clients/create` — create OAuth client for logged-in user
|
||||||
|
- New API: `DELETE /api/dashboard/user-oauth-clients/{client_id}` — delete own OAuth client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-02-23
|
||||||
|
|
||||||
|
### Live Platform Foundation (Track E.1 - E.3)
|
||||||
|
|
||||||
|
Major release introducing the Live Platform architecture — SQLite database, OAuth social login, site management, and per-user MCP endpoints. All features are included in the Community Edition.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **SQLite Database Backend** (E.1): Async SQLite via aiosqlite, WAL mode, schema versioning with migrations framework, CRUD for users/sites/API keys/connection tokens
|
||||||
|
- **Credential Encryption** (E.1): AES-256-GCM with HKDF per-site key derivation, versioned wire format for future migration support
|
||||||
|
- **OAuth Social Login** (E.2): GitHub + Google OAuth 2.0, CSRF state tokens, JWT session management, dual session types (admin + oauth_user)
|
||||||
|
- **Site Management API** (E.3): Plugin credential definitions for all 9 plugins, connection validation, encrypted site CRUD, MAX_SITES_PER_USER=10
|
||||||
|
- **User API Keys** (E.3): bcrypt-hashed keys with `mhu_` prefix, 8-char prefix for indexed lookup, 5-minute validation cache
|
||||||
|
- **Per-User MCP Endpoints** (E.3): Direct JSON-RPC handler at `/u/{user_id}/{alias}/mcp`, per-user rate limiting (30/min, 500/hr)
|
||||||
|
- **Config Snippets** (E.3): Auto-generated config for Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT
|
||||||
|
- **Dashboard Pages**: My Sites (list/add/test/delete), Connect (API keys + config snippets), Profile, OAuth Login
|
||||||
|
- **Dark/Light Mode Toggle**: Theme switcher across all dashboard pages
|
||||||
|
- **RBAC**: Role-based access control for dashboard
|
||||||
|
- **Active Health Checks**: Background health monitoring for connected services
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- CSRF middleware body consumption bug
|
||||||
|
- OAuth log noise and DCR crash on startup
|
||||||
|
- WordPress site connection validation (uses aiohttp to match plugin client)
|
||||||
|
- Tenant isolation enforced on all site queries
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- All bare `except:` replaced with `except Exception:` across 12 files
|
||||||
|
- Network error differentiation (DNS, SSL, timeout, connection refused)
|
||||||
|
- Retry with exponential backoff for transient errors only
|
||||||
|
- Auth/config errors never retried
|
||||||
|
|
||||||
|
#### Tests
|
||||||
|
- 452 total tests (up from 303), all passing
|
||||||
|
- New test suites: database (37), encryption (27), user_auth (32), site_api (17), user_keys (14), user_endpoints (12), config_snippets (10)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [2.9.0] - 2026-02-14
|
## [2.9.0] - 2026-02-14
|
||||||
@@ -30,7 +191,7 @@ After 2-month hiatus (Dec 2025 - Feb 2026), updated all dependencies and verifie
|
|||||||
- All 54 tests now pass (previously 5 were failing)
|
- All 54 tests now pass (previously 5 were failing)
|
||||||
|
|
||||||
#### Verified
|
#### Verified
|
||||||
- All 589 tools generate correctly
|
- All 596 tools generate correctly
|
||||||
- Middleware API stable (Middleware, MiddlewareContext, get_http_headers)
|
- Middleware API stable (Middleware, MiddlewareContext, get_http_headers)
|
||||||
- 30+ custom routes operational (dashboard + OAuth)
|
- 30+ custom routes operational (dashboard + OAuth)
|
||||||
- Multi-endpoint architecture functional
|
- Multi-endpoint architecture functional
|
||||||
|
|||||||
83
CLAUDE.md
83
CLAUDE.md
@@ -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 projects through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~589 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 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
cp env.example .env # Copy and fill in credentials
|
cp env.example .env # Copy and fill in credentials
|
||||||
pip install -e ".[dev]" # Install with dev deps
|
pip install -e ".[dev]" # Install with dev deps
|
||||||
python server.py # Run (stdio) or:
|
python server.py # Run (stdio) or:
|
||||||
python server.py --transport sse --port 8000 # Run (HTTP)
|
python server.py --transport streamable-http --port 8000 # Run (HTTP)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & Development Commands
|
## Build & Development Commands
|
||||||
@@ -24,8 +24,8 @@ pip install -e ".[dev]"
|
|||||||
# Run server (stdio transport for Claude Desktop)
|
# Run server (stdio transport for Claude Desktop)
|
||||||
python server.py
|
python server.py
|
||||||
|
|
||||||
# Run server (SSE/HTTP transport for testing)
|
# Run server (HTTP transport for testing)
|
||||||
python server.py --transport sse --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pytest
|
pytest
|
||||||
@@ -69,10 +69,9 @@ All configured in `pyproject.toml`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── server.py # Primary entry point
|
├── server.py # Primary entry point
|
||||||
├── server_multi.py # Alternative multi-endpoint server
|
|
||||||
├── core/ # Layer 1: Core system modules
|
├── core/ # Layer 1: Core system modules
|
||||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||||
├── templates/ # Jinja2 templates (dashboard + OAuth)
|
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
├── tests/ # Organized test suite
|
├── tests/ # Organized test suite
|
||||||
├── scripts/ # Setup & deployment scripts
|
├── scripts/ # Setup & deployment scripts
|
||||||
├── wordpress-plugin/ # Companion WP plugins (PHP)
|
├── wordpress-plugin/ # Companion WP plugins (PHP)
|
||||||
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
|
|||||||
### Entry Points
|
### Entry Points
|
||||||
|
|
||||||
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
||||||
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
|
|
||||||
|
|
||||||
### Multi-Endpoint Architecture
|
### Multi-Endpoint Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -119,30 +116,23 @@ plugins/{name}/
|
|||||||
└── schemas/ # Pydantic models for validation
|
└── schemas/ # Pydantic models for validation
|
||||||
```
|
```
|
||||||
|
|
||||||
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus
|
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus, coolify
|
||||||
|
|
||||||
|
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.
|
||||||
|
|
||||||
### Tool Generation
|
### Tool Generation
|
||||||
|
|
||||||
Tools are dynamically generated at startup:
|
Tools are dynamically generated at startup from plugin specifications:
|
||||||
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
|
1. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||||
2. `ToolGenerator` creates unified tools with a `site` parameter injected
|
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||||
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
|
||||||
|
|
||||||
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
||||||
|
|
||||||
### Site Configuration via Environment Variables
|
### Site Configuration
|
||||||
|
|
||||||
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
|
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
|
||||||
```bash
|
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
|
|
||||||
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
|
|
||||||
```
|
|
||||||
|
|
||||||
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
|
|
||||||
|
|
||||||
### Key Core Modules
|
### Key Core Modules
|
||||||
|
|
||||||
@@ -150,7 +140,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
|||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `core/auth.py` | Master API key validation, request authentication |
|
| `core/auth.py` | Master API key validation, request authentication |
|
||||||
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
||||||
| `core/site_manager.py` | Type-safe site config discovery from env vars |
|
| `core/site_manager.py` | Type-safe site config registration and lookup |
|
||||||
| `core/tool_registry.py` | Central tool definitions and lookup |
|
| `core/tool_registry.py` | Central tool definitions and lookup |
|
||||||
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
||||||
| `core/health.py` | Health monitoring, metrics, alerts |
|
| `core/health.py` | Health monitoring, metrics, alerts |
|
||||||
@@ -159,14 +149,24 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
|||||||
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
|
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
|
||||||
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
|
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
|
||||||
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
|
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
|
||||||
|
| `core/plugin_visibility.py` | Plugin enable/disable for public vs admin users |
|
||||||
|
| `core/user_auth.py` | OAuth Social Login (GitHub + Google) |
|
||||||
|
| `core/user_endpoints.py` | Per-user MCP endpoints (`/u/{user_id}/{alias}/mcp`) |
|
||||||
|
| `core/site_api.py` | User site CRUD, connection testing, credential encryption |
|
||||||
|
| `core/user_keys.py` | User API key management (bcrypt, `mhu_` prefix) |
|
||||||
|
| `core/database.py` | SQLite backend (aiosqlite, WAL mode, migrations) |
|
||||||
|
| `core/encryption.py` | AES-256-GCM credential encryption |
|
||||||
|
|
||||||
|
### User System (Track E)
|
||||||
|
|
||||||
|
OAuth Social Login (GitHub + Google) via `core/user_auth.py`. Users register, add sites, get personal MCP endpoints at `/u/{user_id}/{alias}/mcp`. Per-user API keys with `mhu_` prefix (bcrypt-hashed). Credentials encrypted with AES-256-GCM in SQLite.
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`). 8 pages: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`).
|
||||||
|
|
||||||
### Legacy Modules (Deprecated)
|
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
||||||
|
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
|
||||||
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
|
|
||||||
|
|
||||||
## Commit Style
|
## Commit Style
|
||||||
|
|
||||||
@@ -175,13 +175,27 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
|
|||||||
```
|
```
|
||||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
## Live Instances
|
||||||
|
|
||||||
|
- **Platform**: `mcp.example.com` — Live MCP Hub with OAuth login
|
||||||
|
- **Blog**: `blog.example.com` — WordPress test site + project blog
|
||||||
|
- **Deployment**: Coolify / Docker Compose, port 8000
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel, Gitea (configurable via `ENABLED_PLUGINS` env var)
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
||||||
- `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, seo-api-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
|
||||||
- Dashboard templates live in `templates/` (not inside `core/dashboard/`)
|
- 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled.
|
||||||
|
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
|
||||||
|
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
|
||||||
|
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
|
||||||
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
|
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
|
||||||
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
||||||
|
|
||||||
@@ -192,3 +206,6 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
|||||||
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
|
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
|
||||||
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
|
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
|
||||||
- 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`
|
||||||
|
- 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,openpanel,gitea` (default)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pytest # Verify setup
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python server.py # stdio (Claude Desktop)
|
python server.py # stdio (Claude Desktop)
|
||||||
python server.py --transport sse --port 8000 # HTTP (testing)
|
python server.py --transport streamable-http --port 8000 # HTTP (testing)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
@@ -119,8 +119,8 @@ Then register in `plugins/__init__.py` and add tests.
|
|||||||
```
|
```
|
||||||
core/ # Core system (auth, site manager, tool registry, dashboard)
|
core/ # Core system (auth, site manager, tool registry, dashboard)
|
||||||
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
||||||
templates/ # Jinja2 templates (dashboard + OAuth)
|
core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
tests/ # Test suite (289 tests)
|
tests/ # Test suite (481 tests)
|
||||||
scripts/ # Setup & deployment scripts
|
scripts/ # Setup & deployment scripts
|
||||||
docs/ # Documentation
|
docs/ # Documentation
|
||||||
```
|
```
|
||||||
|
|||||||
182
DOCKER_README.md
Normal file
182
DOCKER_README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# MCP Hub
|
||||||
|
|
||||||
|
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||||
|
|
||||||
|
565 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||||
|
|
||||||
|
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create a `.env` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recommended (auto-generates temp key if omitted — check container logs)
|
||||||
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
After starting, open the **web dashboard** to add your sites — no env vars needed for site configuration.
|
||||||
|
|
||||||
|
### 2. Run the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name mcphub \
|
||||||
|
-p 8000:8000 \
|
||||||
|
--env-file .env \
|
||||||
|
airano/mcphub:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check (wait ~30 seconds for startup)
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
|
||||||
|
# Open the web dashboard
|
||||||
|
# http://localhost:8000/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Connect your AI client
|
||||||
|
|
||||||
|
**Claude Desktop** (`claude_desktop_config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcphub": {
|
||||||
|
"type": "streamableHttp",
|
||||||
|
"url": "http://localhost:8000/wordpress/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer your-secure-key-here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VS Code** (`.vscode/mcp.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"mcphub": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:8000/wordpress/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer your-secure-key-here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Endpoints](#endpoints) below.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
MCP Hub uses **Bearer token** authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
> `X-API-Key` header and query parameter auth are **not** supported.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Use the most specific endpoint for your use case:
|
||||||
|
|
||||||
|
| Endpoint | Tools | `site` param? | Best for |
|
||||||
|
|----------|------:|:-------------:|----------|
|
||||||
|
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
|
||||||
|
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
||||||
|
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
||||||
|
| `/mcp` | 565 | Yes | Admin & discovery only |
|
||||||
|
|
||||||
|
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
||||||
|
|
||||||
|
## Using Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mcphub:
|
||||||
|
image: airano/mcphub:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- mcphub-data:/app/data
|
||||||
|
- mcphub-logs:/app/logs
|
||||||
|
# Optional: mount Docker socket for WP-CLI tools
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mcphub-data:
|
||||||
|
mcphub-logs:
|
||||||
|
```
|
||||||
|
|
||||||
|
> **WP-CLI tools** (cache flush, database export, plugin updates via CLI) require Docker socket access.
|
||||||
|
> Add `WORDPRESS_SITE1_CONTAINER=your-wp-container-name` to your `.env` and uncomment the Docker socket volume above.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## After Starting
|
||||||
|
|
||||||
|
| URL | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `http://localhost:8000/health` | Health check & status |
|
||||||
|
| `http://localhost:8000/dashboard` | Web dashboard (manage sites, API keys, OAuth clients, health monitoring). Login via MASTER_API_KEY or GitHub/Google OAuth |
|
||||||
|
| `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `MASTER_API_KEY` | Recommended | API key for authentication. If omitted, a temporary key is auto-generated and printed to logs |
|
||||||
|
| `WORDPRESS_SITE1_URL` | For WP | WordPress site URL |
|
||||||
|
| `WORDPRESS_SITE1_USERNAME` | For WP | WordPress admin username |
|
||||||
|
| `WORDPRESS_SITE1_APP_PASSWORD` | For WP | WordPress Application Password |
|
||||||
|
| `WORDPRESS_SITE1_ALIAS` | Recommended | Friendly name (e.g., `myblog`) |
|
||||||
|
| `WORDPRESS_SITE1_CONTAINER` | For WP-CLI | Docker container name of your WordPress site (enables cache/db/system tools) |
|
||||||
|
| `OAUTH_JWT_SECRET_KEY` | For OAuth | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
|
||||||
|
| `OAUTH_BASE_URL` | For OAuth | Public URL of your server (not needed for Claude/Cursor) |
|
||||||
|
| `GITHUB_CLIENT_ID` | For Social Login | GitHub OAuth App client ID |
|
||||||
|
| `GITHUB_CLIENT_SECRET` | For Social Login | GitHub OAuth App client secret |
|
||||||
|
| `GOOGLE_CLIENT_ID` | For Social Login | Google OAuth client ID |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | For Social Login | Google OAuth client secret |
|
||||||
|
| `PUBLIC_URL` | For Social Login | Public URL for OAuth callbacks (e.g., `https://mcp.example.com`) |
|
||||||
|
| `ENCRYPTION_KEY` | For Multi-user | AES-256 key for encrypting user site credentials. Generate: `python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"` |
|
||||||
|
|
||||||
|
> **CONTAINER**: Required for WP-CLI tools (cache flush, database export, system info) and **all** WordPress Advanced tools. Find your container name: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
|
||||||
|
|
||||||
|
> **OAuth**: Only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
|
||||||
|
|
||||||
|
> **Social Login**: Enable GitHub/Google login for multi-user mode. Users can add sites via the dashboard and get personal MCP endpoints (`/u/{user_id}/{alias}/mcp`).
|
||||||
|
|
||||||
|
Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md).
|
||||||
|
|
||||||
|
## Supported Plugins
|
||||||
|
|
||||||
|
| Plugin | Tools | Env Prefix |
|
||||||
|
|--------|-------|------------|
|
||||||
|
| WordPress | 67 | `WORDPRESS_` |
|
||||||
|
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
||||||
|
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` |
|
||||||
|
| Gitea | 56 | `GITEA_` |
|
||||||
|
| n8n | 56 | `N8N_` |
|
||||||
|
| Supabase | 70 | `SUPABASE_` |
|
||||||
|
| OpenPanel | 73 | `OPENPANEL_` |
|
||||||
|
| Appwrite | 100 | `APPWRITE_` |
|
||||||
|
| Directus | 100 | `DIRECTUS_` |
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- **GitHub**: [github.com/airano-ir/mcphub](https://github.com/airano-ir/mcphub)
|
||||||
|
- **PyPI**: [pypi.org/project/mcphub-server](https://pypi.org/project/mcphub-server/)
|
||||||
|
- **Documentation**: [Getting Started Guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md)
|
||||||
|
- **License**: MIT
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# ===================================
|
# ===================================
|
||||||
# Coolify Projects MCP Server - Dockerfile
|
# MCP Hub — Dockerfile
|
||||||
# ===================================
|
# ===================================
|
||||||
# Multi-stage build for optimized image size
|
# Multi-stage build for optimized image size
|
||||||
# Production-ready with security best practices
|
# Production-ready with security best practices
|
||||||
|
|||||||
185
README.md
185
README.md
@@ -1,3 +1,5 @@
|
|||||||
|
<!-- mcp-name: io.github.airano-ir/mcphub -->
|
||||||
|
|
||||||
# MCP Hub
|
# MCP Hub
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -6,12 +8,13 @@
|
|||||||
|
|
||||||
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||||
|
|
||||||
|
[](https://github.com/airano-ir/mcphub/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://pypi.org/project/mcphub-server/)
|
[](https://pypi.org/project/mcphub-server/)
|
||||||
[](https://hub.docker.com/r/airano/mcphub)
|
[](https://hub.docker.com/r/airano/mcphub)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +49,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 589 Tools Across 9 Plugins
|
## 565 Tools Across 9 Plugins
|
||||||
|
|
||||||
| Plugin | Tools | What You Can Do |
|
| Plugin | Tools | What You Can Do |
|
||||||
|--------|-------|-----------------|
|
|--------|-------|-----------------|
|
||||||
@@ -56,11 +59,11 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
||||||
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
||||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
||||||
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
|
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
|
||||||
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
||||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
||||||
| **System** | 17 | Health monitoring, API keys, project discovery |
|
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
||||||
| **Total** | **589** | Constant count — scales to unlimited sites |
|
| **Total** | **565** | Constant count — scales to unlimited sites |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,14 +75,15 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
git clone https://github.com/airano-ir/mcphub.git
|
git clone https://github.com/airano-ir/mcphub.git
|
||||||
cd mcphub
|
cd mcphub
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env — set MASTER_API_KEY, then add sites via the web dashboard
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: PyPI
|
### Option 2: Docker Hub (No Clone)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install mcphub-server
|
# Create a .env file with MASTER_API_KEY (see "Configure Your Sites" below)
|
||||||
|
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: From Source
|
### Option 3: From Source
|
||||||
@@ -89,38 +93,86 @@ git clone https://github.com/airano-ir/mcphub.git
|
|||||||
cd mcphub
|
cd mcphub
|
||||||
pip install -e .
|
pip install -e .
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env — set MASTER_API_KEY
|
||||||
python server.py --transport sse --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verify It Works
|
||||||
|
|
||||||
|
After starting the server, wait ~30 seconds then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check server health
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the **web dashboard** in your browser: **http://localhost:8000/dashboard**
|
||||||
|
|
||||||
|
You should see the login page. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
|
||||||
|
|
||||||
|
### Try It Now (No Setup Required)
|
||||||
|
|
||||||
|
**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**
|
||||||
|
2. Add your sites via the dashboard (My Sites → Add Service)
|
||||||
|
3. Go to **Connect** page — generate config for your AI client
|
||||||
|
4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code
|
||||||
|
|
||||||
|
Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mcp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Configure Your Sites
|
### Configure Your Sites
|
||||||
|
|
||||||
Add site credentials to `.env`:
|
Sites are managed via the **web dashboard** — no environment variables needed.
|
||||||
|
|
||||||
|
1. Set `MASTER_API_KEY` in your `.env` file
|
||||||
|
2. Start the server and open the dashboard
|
||||||
|
3. Add sites with their credentials (URL, username, password/token)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Master API Key (required)
|
# .env — only system configuration needed
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
# WordPress Site
|
|
||||||
WORDPRESS_SITE1_URL=https://myblog.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
WORDPRESS_SITE1_ALIAS=myblog
|
|
||||||
|
|
||||||
# WooCommerce Store
|
|
||||||
WOOCOMMERCE_STORE1_URL=https://mystore.com
|
|
||||||
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
|
||||||
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
|
||||||
WOOCOMMERCE_STORE1_ALIAS=mystore
|
|
||||||
|
|
||||||
# Gitea Instance
|
|
||||||
GITEA_REPO1_URL=https://git.example.com
|
|
||||||
GITEA_REPO1_TOKEN=your_gitea_token
|
|
||||||
GITEA_REPO1_ALIAS=mygitea
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Full Environment Variable Reference</b></summary>
|
||||||
|
|
||||||
|
**System Configuration:**
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||||
|
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
|
||||||
|
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
|
||||||
|
| `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) |
|
||||||
|
|
||||||
|
> **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
|
||||||
|
|
||||||
|
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
|
||||||
|
|
||||||
|
| Plugin | Required Credentials | Notes |
|
||||||
|
|--------|---------------------|-------|
|
||||||
|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
|
||||||
|
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
|
||||||
|
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
|
||||||
|
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
|
||||||
|
| n8n | URL, API Key | Settings → API → Create API Key |
|
||||||
|
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
|
||||||
|
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
|
||||||
|
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
|
||||||
|
| Directus | URL, Static Token | Directus Admin → Settings |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Connect Your AI Client
|
### Connect Your AI Client
|
||||||
|
|
||||||
|
All MCP clients use **Bearer token** authentication: `Authorization: Bearer YOUR_API_KEY`
|
||||||
|
|
||||||
|
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Architecture](#architecture) below.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Claude Desktop</b></summary>
|
<summary><b>Claude Desktop</b></summary>
|
||||||
|
|
||||||
@@ -129,10 +181,11 @@ Add to `claude_desktop_config.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"url": "https://your-server:8000/mcp",
|
"type": "streamableHttp",
|
||||||
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,11 +202,11 @@ Add to `.mcp.json` in your project:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "https://your-server:8000/mcp",
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,9 +220,9 @@ Add to `.mcp.json` in your project:
|
|||||||
|
|
||||||
Go to **Settings > MCP Servers > Add Server**:
|
Go to **Settings > MCP Servers > Add Server**:
|
||||||
|
|
||||||
- **Name**: MCP Hub
|
- **Name**: MCP Hub WordPress
|
||||||
- **URL**: `https://your-server:8000/mcp`
|
- **URL**: `http://your-server:8000/wordpress/mcp`
|
||||||
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
- **Headers**: `Authorization: Bearer YOUR_API_KEY`
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -181,11 +234,11 @@ Add to `.vscode/mcp.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"servers": {
|
"servers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "https://your-server:8000/mcp",
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,25 +258,36 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
> **Transport types**: Use `"type": "streamableHttp"` for Claude Desktop and `"type": "http"` for VS Code/Claude Code. Using `"type": "sse"` will cause `400 Bad Request` errors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
/mcp → Admin endpoint (all 589 tools)
|
/mcp → Admin endpoint (all 565 tools)
|
||||||
/system/mcp → System tools only (17 tools)
|
/system/mcp → System tools only (24 tools)
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
/wordpress/mcp → WordPress tools (67 tools)
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||||
|
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
|
||||||
/gitea/mcp → Gitea tools (56 tools)
|
/gitea/mcp → Gitea tools (56 tools)
|
||||||
/n8n/mcp → n8n tools (56 tools)
|
/n8n/mcp → n8n tools (56 tools)
|
||||||
/supabase/mcp → Supabase tools (70 tools)
|
/supabase/mcp → Supabase tools (70 tools)
|
||||||
/openpanel/mcp → OpenPanel tools (73 tools)
|
/openpanel/mcp → OpenPanel tools (42 tools)
|
||||||
/appwrite/mcp → Appwrite tools (100 tools)
|
/appwrite/mcp → Appwrite tools (100 tools)
|
||||||
/directus/mcp → Directus tools (100 tools)
|
/directus/mcp → Directus tools (100 tools)
|
||||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||||
|
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Multi-endpoint architecture**: Give each team member or AI agent access to only the tools they need.
|
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
|
||||||
|
|
||||||
|
| Endpoint | Use Case | Tools |
|
||||||
|
|----------|----------|------:|
|
||||||
|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
||||||
|
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||||
|
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||||
|
| `/mcp` | Admin & discovery only | 565 |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -235,6 +299,27 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
|
|
||||||
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
|
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
|
||||||
|
|
||||||
|
### WordPress Plugin Requirements
|
||||||
|
|
||||||
|
Some MCP Hub tools require companion WordPress plugins:
|
||||||
|
|
||||||
|
| Tools | Requirement |
|
||||||
|
|-------|-------------|
|
||||||
|
| SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-bridge/)) + Rank Math or Yoast SEO |
|
||||||
|
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
|
||||||
|
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config |
|
||||||
|
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
|
||||||
|
| WooCommerce tools | WooCommerce plugin installed on your WordPress site |
|
||||||
|
|
||||||
|
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the `container` field when adding a WordPress site in the dashboard. Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -258,14 +343,14 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
# Install with dev dependencies
|
# Install with dev dependencies
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
# Run tests (289 tests)
|
# Run tests (481 tests)
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
# Format and lint
|
# Format and lint
|
||||||
black . && ruff check --fix .
|
black . && ruff check --fix .
|
||||||
|
|
||||||
# Run server locally
|
# Run server locally
|
||||||
python server.py --transport sse --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -37,20 +37,13 @@ from core.health import (
|
|||||||
initialize_health_monitor,
|
initialize_health_monitor,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Project and Site Management
|
|
||||||
from core.project_manager import ProjectManager, get_project_manager
|
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
||||||
|
|
||||||
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
|
||||||
from core.site_registry import SiteInfo, SiteRegistry, get_site_registry
|
|
||||||
from core.tool_generator import ToolGenerator
|
from core.tool_generator import ToolGenerator
|
||||||
|
|
||||||
# Tool Management (Option B architecture)
|
# Tool Management (Option B architecture)
|
||||||
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
||||||
from core.unified_tools import UnifiedToolGenerator
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Authentication
|
# Authentication
|
||||||
@@ -58,17 +51,10 @@ __all__ = [
|
|||||||
"get_auth_manager",
|
"get_auth_manager",
|
||||||
"APIKeyManager",
|
"APIKeyManager",
|
||||||
"get_api_key_manager",
|
"get_api_key_manager",
|
||||||
# Project/Site Management
|
# Site Management
|
||||||
"ProjectManager",
|
|
||||||
"get_project_manager",
|
|
||||||
"SiteManager",
|
"SiteManager",
|
||||||
"SiteConfig",
|
"SiteConfig",
|
||||||
"get_site_manager",
|
"get_site_manager",
|
||||||
# Legacy (deprecated)
|
|
||||||
"SiteRegistry",
|
|
||||||
"SiteInfo",
|
|
||||||
"get_site_registry",
|
|
||||||
"UnifiedToolGenerator",
|
|
||||||
# Tool Management
|
# Tool Management
|
||||||
"ToolRegistry",
|
"ToolRegistry",
|
||||||
"ToolDefinition",
|
"ToolDefinition",
|
||||||
|
|||||||
27
core/admin_utils.py
Normal file
27
core/admin_utils.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Admin utility functions for role determination.
|
||||||
|
|
||||||
|
Centralizes admin email checking so it can be used in OAuth callback,
|
||||||
|
dashboard auth, and future admin/user panel unification (F.5+).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_email(email: str | None) -> bool:
|
||||||
|
"""Check if an email is in the ADMIN_EMAILS env var list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email matches an admin email (case-insensitive).
|
||||||
|
"""
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
admin_emails_raw = os.environ.get("ADMIN_EMAILS", "")
|
||||||
|
if not admin_emails_raw.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
admin_emails = {e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()}
|
||||||
|
return email.strip().lower() in admin_emails
|
||||||
@@ -14,6 +14,8 @@ 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__)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ class APIKey:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
key_id: str
|
key_id: str
|
||||||
key_hash: str
|
key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy)
|
||||||
project_id: str
|
project_id: str
|
||||||
scope: Scope
|
scope: Scope
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -180,8 +182,16 @@ class APIKeyManager:
|
|||||||
logger.error(f"Failed to save keys: {e}")
|
logger.error(f"Failed to save keys: {e}")
|
||||||
|
|
||||||
def _hash_key(self, api_key: str) -> str:
|
def _hash_key(self, api_key: str) -> str:
|
||||||
"""Hash API key for storage."""
|
"""Hash API key for storage using bcrypt."""
|
||||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
def _verify_key(self, api_key: str, key_hash: str) -> bool:
|
||||||
|
"""Verify API key against stored hash (supports bcrypt and legacy SHA-256)."""
|
||||||
|
if key_hash.startswith("$2"):
|
||||||
|
# bcrypt hash
|
||||||
|
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
|
||||||
|
# Legacy SHA-256 fallback
|
||||||
|
return hashlib.sha256(api_key.encode()).hexdigest() == key_hash
|
||||||
|
|
||||||
def create_key(
|
def create_key(
|
||||||
self,
|
self,
|
||||||
@@ -269,11 +279,9 @@ class APIKeyManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[str]: key_id if valid, None otherwise
|
Optional[str]: key_id if valid, None otherwise
|
||||||
"""
|
"""
|
||||||
key_hash = self._hash_key(api_key)
|
# Find key by verifying against stored hash (bcrypt or legacy SHA-256)
|
||||||
|
|
||||||
# Find key by hash
|
|
||||||
for key_id, key in self.keys.items():
|
for key_id, key in self.keys.items():
|
||||||
if key.key_hash != key_hash:
|
if not self._verify_key(api_key, key.key_hash):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if valid (not revoked, not expired)
|
# Check if valid (not revoked, not expired)
|
||||||
@@ -341,10 +349,8 @@ class APIKeyManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[APIKey]: The APIKey object if found, None otherwise
|
Optional[APIKey]: The APIKey object if found, None otherwise
|
||||||
"""
|
"""
|
||||||
key_hash = self._hash_key(api_key)
|
|
||||||
|
|
||||||
for key_id, key in self.keys.items():
|
for key_id, key in self.keys.items():
|
||||||
if key.key_hash == key_hash:
|
if self._verify_key(api_key, key.key_hash):
|
||||||
logger.debug(f"Found API key {key_id} by token")
|
logger.debug(f"Found API key {key_id} by token")
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|||||||
12
core/auth.py
12
core/auth.py
@@ -27,11 +27,14 @@ class AuthManager:
|
|||||||
if not self.master_api_key:
|
if not self.master_api_key:
|
||||||
# Generate a random key if not provided (dev mode)
|
# Generate a random key if not provided (dev mode)
|
||||||
self.master_api_key = secrets.token_urlsafe(32)
|
self.master_api_key = secrets.token_urlsafe(32)
|
||||||
|
self._is_temporary_key = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No MASTER_API_KEY environment variable found. "
|
"No MASTER_API_KEY environment variable found. "
|
||||||
f"Generated temporary key: {self.master_api_key[:8]}***{self.master_api_key[-4:]} "
|
f"Generated temporary key: {self.master_api_key} "
|
||||||
"(set MASTER_API_KEY in .env for production use)"
|
"(set MASTER_API_KEY in .env for production use)"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self._is_temporary_key = False
|
||||||
|
|
||||||
# Project-specific keys (future feature)
|
# Project-specific keys (future feature)
|
||||||
self.project_keys = {}
|
self.project_keys = {}
|
||||||
@@ -48,10 +51,13 @@ class AuthManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if valid
|
bool: True if valid
|
||||||
"""
|
"""
|
||||||
is_valid = secrets.compare_digest(api_key, self.master_api_key)
|
if not self.master_api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_valid = secrets.compare_digest(api_key.strip(), self.master_api_key.strip())
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.debug("Master key validation failed for provided token")
|
||||||
|
|
||||||
return is_valid
|
return is_valid
|
||||||
|
|
||||||
|
|||||||
128
core/config_snippets.py
Normal file
128
core/config_snippets.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""MCP client configuration snippet generation (Track E.3).
|
||||||
|
|
||||||
|
Generates copy-paste configuration snippets for connecting AI clients
|
||||||
|
(Claude Desktop, Claude Code, Cursor, VS Code, ChatGPT) to per-user
|
||||||
|
MCP endpoints.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.config_snippets import generate_config, get_supported_clients
|
||||||
|
|
||||||
|
snippet = generate_config(
|
||||||
|
base_url="https://mcp.example.com",
|
||||||
|
user_id="abc123",
|
||||||
|
alias="myblog",
|
||||||
|
api_key="mhu_...",
|
||||||
|
client_type="claude_desktop",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Supported MCP client types
|
||||||
|
SUPPORTED_CLIENTS = [
|
||||||
|
{
|
||||||
|
"id": "claude_connectors",
|
||||||
|
"label": "Claude.ai Connectors",
|
||||||
|
"description": "claude.ai/customize/connectors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "claude_desktop",
|
||||||
|
"label": "Claude Desktop",
|
||||||
|
"description": "Anthropic's desktop app for Claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "claude_code",
|
||||||
|
"label": "Claude Code",
|
||||||
|
"description": "Anthropic's CLI for Claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cursor",
|
||||||
|
"label": "Cursor",
|
||||||
|
"description": "AI-first code editor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vscode",
|
||||||
|
"label": "VS Code",
|
||||||
|
"description": "Visual Studio Code with MCP extension",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chatgpt",
|
||||||
|
"label": "ChatGPT",
|
||||||
|
"description": "OpenAI ChatGPT (URL-based)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Clients that only need a URL (no JSON config snippet, no transport Note)
|
||||||
|
WEB_CLIENTS = {"claude_connectors", "chatgpt"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_clients() -> list[dict[str, str]]:
|
||||||
|
"""Return the list of supported MCP client types."""
|
||||||
|
return SUPPORTED_CLIENTS
|
||||||
|
|
||||||
|
|
||||||
|
def generate_config(
|
||||||
|
base_url: str,
|
||||||
|
user_id: str,
|
||||||
|
alias: str,
|
||||||
|
api_key: str,
|
||||||
|
client_type: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a configuration snippet for the given MCP client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Public URL of the MCP Hub instance (no trailing slash).
|
||||||
|
user_id: User UUID.
|
||||||
|
alias: Site alias.
|
||||||
|
api_key: User API key (``mhu_...``).
|
||||||
|
client_type: One of the supported client type IDs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string ready for copy-paste.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If client_type is not supported.
|
||||||
|
"""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp"
|
||||||
|
server_name = f"mcphub-{alias}"
|
||||||
|
|
||||||
|
# Claude Desktop uses streamableHttp; Claude Code, VS Code, Cursor use http
|
||||||
|
transport_type = "streamableHttp" if client_type == "claude_desktop" else "http"
|
||||||
|
|
||||||
|
if client_type in ("claude_desktop", "claude_code"):
|
||||||
|
config = {
|
||||||
|
"mcpServers": {
|
||||||
|
server_name: {
|
||||||
|
"type": transport_type,
|
||||||
|
"url": endpoint_url,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
elif client_type in ("cursor", "vscode"):
|
||||||
|
config = {
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
server_name: {
|
||||||
|
"type": transport_type,
|
||||||
|
"url": endpoint_url,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
elif client_type in ("claude_connectors", "chatgpt"):
|
||||||
|
return endpoint_url
|
||||||
|
|
||||||
|
else:
|
||||||
|
valid = [c["id"] for c in SUPPORTED_CLIENTS]
|
||||||
|
raise ValueError(f"Unsupported client type '{client_type}'. Valid: {valid}")
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Dashboard Authentication - Session-based authentication for Web UI.
|
Dashboard Authentication - Session-based authentication for Web UI.
|
||||||
|
|
||||||
Phase K.1: Core Infrastructure
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -91,19 +89,44 @@ class DashboardAuth:
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
return False, "", None
|
return False, "", None
|
||||||
|
|
||||||
# Check master API key
|
api_key_clean = api_key.strip()
|
||||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
# Check if master key dashboard login is disabled
|
||||||
|
master_login_disabled = (
|
||||||
|
os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check master API key (from env var)
|
||||||
|
if (
|
||||||
|
not master_login_disabled
|
||||||
|
and self.master_api_key
|
||||||
|
and secrets.compare_digest(api_key_clean, self.master_api_key.strip())
|
||||||
|
):
|
||||||
return True, "master", None
|
return True, "master", None
|
||||||
|
|
||||||
|
# Check AuthManager's master key (covers auto-generated temp keys)
|
||||||
|
if not master_login_disabled:
|
||||||
|
try:
|
||||||
|
from core.auth import get_auth_manager
|
||||||
|
|
||||||
|
auth_mgr = get_auth_manager()
|
||||||
|
if auth_mgr.validate_master_key(api_key):
|
||||||
|
return True, "master", None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"AuthManager check skipped: {e}")
|
||||||
|
|
||||||
# Check project API keys with admin scope
|
# Check project API keys with admin scope
|
||||||
try:
|
try:
|
||||||
from core.api_keys import get_api_key_manager
|
from core.api_keys import get_api_key_manager
|
||||||
|
|
||||||
api_key_manager = get_api_key_manager()
|
api_key_manager = get_api_key_manager()
|
||||||
|
|
||||||
key_info = api_key_manager.validate_key(api_key)
|
# Dashboard login is not project-specific, so skip project check
|
||||||
if key_info and "admin" in key_info.get("scope", "").split():
|
# and require admin scope
|
||||||
return True, "api_key", key_info.get("key_id")
|
key_id = api_key_manager.validate_key(
|
||||||
|
api_key, project_id="*", required_scope="admin", skip_project_check=True
|
||||||
|
)
|
||||||
|
if key_id:
|
||||||
|
return True, "api_key", key_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error checking API key: {e}")
|
logger.warning(f"Error checking API key: {e}")
|
||||||
|
|
||||||
@@ -190,6 +213,9 @@ class DashboardAuth:
|
|||||||
user_type=payload["type"],
|
user_type=payload["type"],
|
||||||
key_id=payload.get("kid"),
|
key_id=payload.get("kid"),
|
||||||
)
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
|
||||||
|
return None
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.debug("Dashboard session expired")
|
logger.debug("Dashboard session expired")
|
||||||
return None
|
return None
|
||||||
@@ -212,6 +238,27 @@ class DashboardAuth:
|
|||||||
return None
|
return None
|
||||||
return self.validate_session(token)
|
return self.validate_session(token)
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, request: Request) -> dict | None:
|
||||||
|
"""Extract and validate an OAuth user session from request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User session dict (user_id, email, name, role, type)
|
||||||
|
or None.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(self.COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from core.user_auth import get_user_auth
|
||||||
|
|
||||||
|
user_auth = get_user_auth()
|
||||||
|
return user_auth.validate_user_session(token)
|
||||||
|
except (RuntimeError, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
def set_session_cookie(self, response: Response, token: str) -> Response:
|
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Set session cookie on response.
|
Set session cookie on response.
|
||||||
@@ -261,13 +308,15 @@ class DashboardAuth:
|
|||||||
RedirectResponse to login page if not authenticated, None if OK.
|
RedirectResponse to login page if not authenticated, None if OK.
|
||||||
"""
|
"""
|
||||||
session = self.get_session_from_request(request)
|
session = self.get_session_from_request(request)
|
||||||
if not session:
|
user_session = self.get_user_session_from_request(request)
|
||||||
|
|
||||||
|
if not session and not user_session:
|
||||||
# Store original URL for redirect after login
|
# Store original URL for redirect after login
|
||||||
next_url = str(request.url.path)
|
next_url = str(request.url.path)
|
||||||
if request.url.query:
|
if request.url.query:
|
||||||
next_url += f"?{request.url.query}"
|
next_url += f"?{request.url.query}"
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"/dashboard/login?next={next_url}",
|
url=f"/auth/login?next={next_url}",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -279,3 +328,58 @@ def get_dashboard_auth() -> DashboardAuth:
|
|||||||
if _dashboard_auth is None:
|
if _dashboard_auth is None:
|
||||||
_dashboard_auth = DashboardAuth()
|
_dashboard_auth = DashboardAuth()
|
||||||
return _dashboard_auth
|
return _dashboard_auth
|
||||||
|
|
||||||
|
|
||||||
|
# ── Role-checking helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_session(session) -> bool:
|
||||||
|
"""Check if session is admin (master key or API key with admin scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if admin session.
|
||||||
|
"""
|
||||||
|
if isinstance(session, DashboardSession):
|
||||||
|
return session.user_type in ("master", "api_key")
|
||||||
|
if isinstance(session, dict):
|
||||||
|
return session.get("type") == "master" or session.get("role") == "admin"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_display_info(session) -> dict:
|
||||||
|
"""Get display info for header/UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with name, type, email, avatar keys.
|
||||||
|
"""
|
||||||
|
if isinstance(session, DashboardSession):
|
||||||
|
return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
|
||||||
|
if isinstance(session, dict):
|
||||||
|
session_type = "admin" if session.get("role") == "admin" else "user"
|
||||||
|
return {
|
||||||
|
"name": session.get("name") or session.get("email", "User"),
|
||||||
|
"type": session_type,
|
||||||
|
"email": session.get("email"),
|
||||||
|
"avatar": None,
|
||||||
|
}
|
||||||
|
return {"name": "Unknown", "type": "unknown", "email": None, "avatar": None}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user_id(session) -> str | None:
|
||||||
|
"""Get user_id for OAuth sessions, None for admin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User UUID string or None.
|
||||||
|
"""
|
||||||
|
if isinstance(session, dict):
|
||||||
|
return session.get("user_id")
|
||||||
|
return None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
931
core/database.py
Normal file
931
core/database.py
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
"""SQLite database backend for the Live Platform (Track E).
|
||||||
|
|
||||||
|
Manages the database connection, schema creation, migrations, and CRUD
|
||||||
|
operations for the user system. Uses aiosqlite for async SQLite access
|
||||||
|
with WAL mode and foreign key enforcement.
|
||||||
|
|
||||||
|
This module is only for user-registered sites on the Live Platform.
|
||||||
|
Admin endpoints continue to use env var sites via SiteManager.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
db = await initialize_database()
|
||||||
|
user = await db.create_user(
|
||||||
|
email="user@example.com",
|
||||||
|
name="Jane",
|
||||||
|
provider="github",
|
||||||
|
provider_id="12345",
|
||||||
|
)
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
# Or as a context manager:
|
||||||
|
async with Database("/tmp/test.db") as db:
|
||||||
|
user = await db.get_user_by_id("some-uuid")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default data directory: /app/data in Docker, ./data elsewhere
|
||||||
|
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||||
|
|
||||||
|
# Schema version — increment when adding migrations
|
||||||
|
SCHEMA_VERSION = 7
|
||||||
|
|
||||||
|
# Initial schema DDL
|
||||||
|
_SCHEMA_SQL = """\
|
||||||
|
-- Users (OAuth social login)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login TEXT,
|
||||||
|
UNIQUE(provider, provider_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User's registered sites (any plugin type)
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
plugin_type TEXT NOT NULL,
|
||||||
|
alias TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
credentials BLOB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
status_msg TEXT,
|
||||||
|
last_health TEXT,
|
||||||
|
last_tested_at TEXT,
|
||||||
|
tool_scope TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, alias)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-user API keys (for MCP client authentication)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT NOT NULL DEFAULT 'read write',
|
||||||
|
last_used TEXT,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||||
|
CREATE TABLE IF NOT EXISTS connection_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Schema version tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- F.7b: per-site tool toggles (scope-based visibility overrides)
|
||||||
|
CREATE TABLE IF NOT EXISTS site_tool_toggles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
reason TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(site_id, tool_name)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Migration registry: version -> SQL string
|
||||||
|
# Migrations are applied sequentially from the current version + 1 up to SCHEMA_VERSION.
|
||||||
|
_MIGRATIONS: dict[int, str] = {
|
||||||
|
2: (
|
||||||
|
"ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n"
|
||||||
|
),
|
||||||
|
4: "ALTER TABLE sites ADD COLUMN last_tested_at TEXT;\n",
|
||||||
|
5: (
|
||||||
|
"ALTER TABLE sites ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0;\n"
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_system_site_alias "
|
||||||
|
"ON sites(alias) WHERE is_system = 1;\n"
|
||||||
|
"CREATE TABLE IF NOT EXISTS settings (\n"
|
||||||
|
" key TEXT PRIMARY KEY,\n"
|
||||||
|
" value TEXT NOT NULL,\n"
|
||||||
|
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
|
||||||
|
");\n"
|
||||||
|
),
|
||||||
|
6: (
|
||||||
|
# F.7: per-user tool toggles for scope-based visibility & per-tool disable
|
||||||
|
"CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
|
||||||
|
" tool_name TEXT NOT NULL,\n"
|
||||||
|
" enabled INTEGER NOT NULL DEFAULT 1,\n"
|
||||||
|
" reason TEXT,\n"
|
||||||
|
" updated_at TEXT NOT NULL,\n"
|
||||||
|
" UNIQUE(user_id, tool_name)\n"
|
||||||
|
");\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
|
||||||
|
"ON user_tool_toggles(user_id);\n"
|
||||||
|
),
|
||||||
|
7: (
|
||||||
|
# F.7b: move tool toggles from per-user to per-site and add a
|
||||||
|
# per-site preset column. user_tool_toggles was merged on Phase-1
|
||||||
|
# but never populated with real data — safe to drop.
|
||||||
|
"DROP TABLE IF EXISTS user_tool_toggles;\n"
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
|
||||||
|
" tool_name TEXT NOT NULL,\n"
|
||||||
|
" enabled INTEGER NOT NULL DEFAULT 1,\n"
|
||||||
|
" reason TEXT,\n"
|
||||||
|
" updated_at TEXT NOT NULL,\n"
|
||||||
|
" UNIQUE(site_id, tool_name)\n"
|
||||||
|
");\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
|
||||||
|
"ON site_tool_toggles(site_id);\n"
|
||||||
|
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Async SQLite database for the Live Platform.
|
||||||
|
|
||||||
|
Manages connections, schema, migrations, and provides CRUD helpers
|
||||||
|
for users, sites, API keys, and connection tokens.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
db_path: Resolved path to the SQLite database file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | None = None) -> None:
|
||||||
|
"""Initialize database configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file. If not provided,
|
||||||
|
reads DATABASE_PATH env var, defaulting to ``data/mcphub.db``.
|
||||||
|
"""
|
||||||
|
if db_path is None:
|
||||||
|
db_file = os.getenv("DATABASE_PATH", None)
|
||||||
|
if db_file:
|
||||||
|
self.db_path = Path(db_file)
|
||||||
|
else:
|
||||||
|
self.db_path = Path(_DEFAULT_DATA_DIR) / "mcphub.db"
|
||||||
|
else:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
|
||||||
|
self._conn: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Create data directory, connect, and set up schema.
|
||||||
|
|
||||||
|
Enables WAL mode and foreign keys, creates tables if they do
|
||||||
|
not exist, and runs any pending migrations.
|
||||||
|
"""
|
||||||
|
# Ensure parent directory exists
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._conn = await aiosqlite.connect(str(self.db_path))
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
|
# Enable WAL mode for better concurrent read performance
|
||||||
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# Enable foreign key enforcement
|
||||||
|
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
await self._create_schema()
|
||||||
|
await self._run_migrations()
|
||||||
|
|
||||||
|
logger.info("Database initialized at %s", self.db_path)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the database connection."""
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Context manager
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "Database":
|
||||||
|
"""Enter async context — initialize and return self."""
|
||||||
|
await self.initialize()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
"""Exit async context — close the connection."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Low-level query helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require_conn(self) -> aiosqlite.Connection:
|
||||||
|
"""Return the active connection or raise if not initialized."""
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call initialize() first.")
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
async def execute(self, sql: str, params: tuple[Any, ...] = ()) -> aiosqlite.Cursor:
|
||||||
|
"""Execute a single write SQL statement and commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL statement.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The aiosqlite Cursor.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
async def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> aiosqlite.Cursor:
|
||||||
|
"""Execute a SQL statement with multiple parameter sets and commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL statement.
|
||||||
|
params_list: List of parameter tuples.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The aiosqlite Cursor.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.executemany(sql, params_list)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
async def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||||
|
"""Fetch a single row as a dictionary (read-only, no commit).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL query.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Row as a dict, or None if no result.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch all rows as a list of dictionaries (read-only, no commit).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL query.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows, each as a dict.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schema management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_schema(self) -> None:
|
||||||
|
"""Create all tables if they do not already exist."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
|
# Check if it's a completely fresh DB (no users table)
|
||||||
|
row = await self.fetchone(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)
|
||||||
|
is_fresh = row is None
|
||||||
|
|
||||||
|
await conn.executescript(_SCHEMA_SQL)
|
||||||
|
|
||||||
|
if is_fresh:
|
||||||
|
# Seed initial schema version
|
||||||
|
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||||
|
if row is None or row["v"] is None:
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(SCHEMA_VERSION, _utc_now()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# For existing v1 databases, if schema_version was just created
|
||||||
|
row = await self.fetchone("SELECT COUNT(*) as c FROM schema_version")
|
||||||
|
if row and row["c"] == 0:
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(1, _utc_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_migrations(self) -> None:
|
||||||
|
"""Apply any pending migrations sequentially."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||||
|
current = row["v"] if row and row["v"] is not None else 0
|
||||||
|
|
||||||
|
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||||
|
if version == 3:
|
||||||
|
logger.info("Applying python migration for version 3")
|
||||||
|
# Idempotent repair for v1->v2 upgrade bug (some DBs stamped v2 but missed the column)
|
||||||
|
try:
|
||||||
|
await self.execute("ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT")
|
||||||
|
except Exception as e:
|
||||||
|
if "duplicate column name" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
await self.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
migration_sql = _MIGRATIONS.get(version)
|
||||||
|
if migration_sql is not None:
|
||||||
|
logger.info("Applying migration to version %d", version)
|
||||||
|
await conn.executescript(migration_sql)
|
||||||
|
logger.info("Migration to version %d applied", version)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No migration SQL for version %d, recording version only", version
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always record version to avoid infinite retry
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(version, _utc_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
name: str | None,
|
||||||
|
provider: str,
|
||||||
|
provider_id: str,
|
||||||
|
avatar_url: str | None = None,
|
||||||
|
role: str = "user",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email (unique).
|
||||||
|
name: Display name.
|
||||||
|
provider: OAuth provider ('github' or 'google').
|
||||||
|
provider_id: Provider's unique user ID.
|
||||||
|
avatar_url: URL to user avatar.
|
||||||
|
role: User role ('user' or 'admin').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created user as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
aiosqlite.IntegrityError: If email or provider+provider_id already exists.
|
||||||
|
"""
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO users (id, email, name, avatar_url, provider, provider_id, role, "
|
||||||
|
"created_at, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, email, name, avatar_url, provider, provider_id, role, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.get_user_by_id(user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created user {user_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by their UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by their email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone("SELECT * FROM users WHERE email = ?", (email,))
|
||||||
|
|
||||||
|
async def get_user_by_provider(self, provider: str, provider_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by OAuth provider and provider ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: OAuth provider name.
|
||||||
|
provider_id: Provider's unique user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM users WHERE provider = ? AND provider_id = ?",
|
||||||
|
(provider, provider_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_user_last_login(self, user_id: str) -> None:
|
||||||
|
"""Update a user's last_login timestamp to now.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||||
|
(_utc_now(), user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Site CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_site(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
alias: str,
|
||||||
|
url: str,
|
||||||
|
credentials: bytes,
|
||||||
|
status: str = "pending",
|
||||||
|
status_msg: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new site for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
plugin_type: Plugin type (e.g. 'wordpress', 'gitea').
|
||||||
|
alias: User-chosen friendly name (unique per user).
|
||||||
|
url: Site URL.
|
||||||
|
credentials: AES-256-GCM encrypted credentials blob.
|
||||||
|
status: Initial status ('pending', 'active', 'error', 'disabled').
|
||||||
|
status_msg: Optional human-readable status message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created site as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
aiosqlite.IntegrityError: If alias is duplicated for the same user.
|
||||||
|
"""
|
||||||
|
site_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
||||||
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, user_id, plugin_type, alias, url, credentials, status, status_msg, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.get_site(site_id, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_sites_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all sites belonging to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dicts.
|
||||||
|
"""
|
||||||
|
return await self.fetchall(
|
||||||
|
"SELECT * FROM sites WHERE user_id = ? ORDER BY created_at",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_site(self, site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a single site, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict, or None if not found or not owned by user.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM sites WHERE id = ? AND user_id = ?",
|
||||||
|
(site_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_site(self, site_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete a site, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted, False otherwise.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM sites WHERE id = ? AND user_id = ?",
|
||||||
|
(site_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def update_site_status(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
status: str,
|
||||||
|
status_msg: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a site's status and optional status message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
status: New status ('pending', 'active', 'error', 'disabled').
|
||||||
|
status_msg: Optional human-readable status message.
|
||||||
|
user_id: Optional user UUID for tenant-scoped update. When provided,
|
||||||
|
only updates if the site belongs to this user. When None,
|
||||||
|
performs system-level update (e.g., health checks).
|
||||||
|
"""
|
||||||
|
now = _utc_now()
|
||||||
|
if user_id is not None:
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?"
|
||||||
|
" WHERE id = ? AND user_id = ?",
|
||||||
|
(status, status_msg, now, site_id, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?" " WHERE id = ?",
|
||||||
|
(status, status_msg, now, site_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_site_credentials(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
url: str,
|
||||||
|
credentials: bytes,
|
||||||
|
) -> bool:
|
||||||
|
"""Update URL and credentials for an existing site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
url: New base URL for the site.
|
||||||
|
credentials: New AES-256-GCM encrypted credentials blob.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if site not found or not owned by user.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"UPDATE sites SET url = ?, credentials = ?, status = 'pending' WHERE id = ? AND user_id = ?",
|
||||||
|
(url, credentials, site_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a site by user ID and alias.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
alias: Site alias (unique per user).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM sites WHERE user_id = ? AND alias = ?",
|
||||||
|
(user_id, alias),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def count_sites_by_user(self, user_id: str) -> int:
|
||||||
|
"""Count the number of sites belonging to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sites.
|
||||||
|
"""
|
||||||
|
row = await self.fetchone(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM sites WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Settings CRUD (Phase 4C.3)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_setting(self, key: str) -> str | None:
|
||||||
|
"""Get a setting value by key."""
|
||||||
|
row = await self.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
|
||||||
|
return row["value"] if row else None
|
||||||
|
|
||||||
|
async def set_setting(self, key: str, value: str) -> None:
|
||||||
|
"""Set a setting value (upsert)."""
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)"
|
||||||
|
" ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?",
|
||||||
|
(key, value, _utc_now(), value, _utc_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_setting(self, key: str) -> bool:
|
||||||
|
"""Delete a setting. Returns True if deleted."""
|
||||||
|
cursor = await self.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def get_all_settings(self) -> dict[str, str]:
|
||||||
|
"""Get all settings as a dict."""
|
||||||
|
rows = await self.fetchall("SELECT key, value FROM settings")
|
||||||
|
return {r["key"]: r["value"] for r in rows}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User API Key CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_api_key(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
key_hash: str,
|
||||||
|
key_prefix: str,
|
||||||
|
name: str,
|
||||||
|
scopes: str = "read write",
|
||||||
|
expires_at: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new user API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
key_hash: bcrypt hash of the API key.
|
||||||
|
key_prefix: First 8 chars after ``mhu_`` prefix for fast lookup.
|
||||||
|
name: Human label (e.g. "Claude Desktop").
|
||||||
|
scopes: Space-separated scopes.
|
||||||
|
expires_at: Optional ISO 8601 expiry timestamp.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created API key row as a dict.
|
||||||
|
"""
|
||||||
|
key_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO user_api_keys "
|
||||||
|
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.fetchone(
|
||||||
|
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||||
|
"created_at, expires_at FROM user_api_keys WHERE id = ?",
|
||||||
|
(key_id,),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created API key {key_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_api_keys_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all API keys for a user (without key_hash).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of API key dicts (key_hash excluded).
|
||||||
|
"""
|
||||||
|
return await self.fetchall(
|
||||||
|
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||||
|
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_api_key_by_prefix(self, key_prefix: str) -> dict[str, Any] | None:
|
||||||
|
"""Get an API key by its prefix (includes key_hash for verification).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_prefix: First 8 chars of the key after ``mhu_``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API key dict including key_hash, or None.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM user_api_keys WHERE key_prefix = ?",
|
||||||
|
(key_prefix,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_api_key(self, key_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete an API key, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted, False otherwise.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM user_api_keys WHERE id = ? AND user_id = ?",
|
||||||
|
(key_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def update_api_key_usage(self, key_id: str) -> None:
|
||||||
|
"""Increment use_count and update last_used timestamp for an API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE user_api_keys SET use_count = use_count + 1, last_used = ? WHERE id = ?",
|
||||||
|
(_utc_now(), key_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Site tool toggles & tool_scope (F.7b)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
|
||||||
|
"""Get explicit tool toggle overrides for a site.
|
||||||
|
|
||||||
|
Only rows where a tool has been explicitly toggled are stored.
|
||||||
|
Tools not in the result are implicitly enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping ``tool_name`` → ``enabled`` (bool).
|
||||||
|
"""
|
||||||
|
rows = await self.fetchall(
|
||||||
|
"SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
|
||||||
|
(site_id,),
|
||||||
|
)
|
||||||
|
return {row["tool_name"]: bool(row["enabled"]) for row in rows}
|
||||||
|
|
||||||
|
async def set_site_tool_toggle(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Upsert a single tool toggle for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
|
||||||
|
enabled: Whether the tool should be visible on this site.
|
||||||
|
reason: Optional note.
|
||||||
|
"""
|
||||||
|
toggle_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
|
||||||
|
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
|
||||||
|
(toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
|
||||||
|
"""Delete a site's toggle for a tool (reverts to the default).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
|
||||||
|
(site_id, tool_name),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def bulk_set_site_tool_toggles(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
toggles: list[tuple[str, bool]],
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Upsert multiple tool toggles for a site in one transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
toggles: List of ``(tool_name, enabled)`` pairs.
|
||||||
|
reason: Optional shared reason applied to every row.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows affected.
|
||||||
|
"""
|
||||||
|
if not toggles:
|
||||||
|
return 0
|
||||||
|
now = _utc_now()
|
||||||
|
rows = [
|
||||||
|
(str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
|
||||||
|
for tool_name, enabled in toggles
|
||||||
|
]
|
||||||
|
await self.executemany(
|
||||||
|
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
|
||||||
|
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def get_site_tool_scope(self, site_id: str) -> str:
|
||||||
|
"""Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
|
||||||
|
row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
|
||||||
|
if row is None:
|
||||||
|
return "admin"
|
||||||
|
return row["tool_scope"] or "admin"
|
||||||
|
|
||||||
|
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
|
||||||
|
"""Update the ``tool_scope`` preset for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
scope: One of ``read``, ``read:sensitive``, ``deploy``,
|
||||||
|
``write``, ``admin``, ``custom``.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET tool_scope = ? WHERE id = ?",
|
||||||
|
(scope, site_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Module-level helpers
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
"""Return the current UTC time as an ISO 8601 string."""
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_database: Database | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""Get the singleton Database instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Database singleton.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If initialize_database() has not been called.
|
||||||
|
"""
|
||||||
|
if _database is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call initialize_database() first.")
|
||||||
|
return _database
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_database(db_path: str | None = None) -> Database:
|
||||||
|
"""Create, initialize, and store the singleton Database instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Optional path to the SQLite database file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized Database instance.
|
||||||
|
"""
|
||||||
|
global _database
|
||||||
|
db = Database(db_path)
|
||||||
|
await db.initialize()
|
||||||
|
_database = db
|
||||||
|
return db
|
||||||
212
core/encryption.py
Normal file
212
core/encryption.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Credential encryption for the Live Platform.
|
||||||
|
|
||||||
|
Provides AES-256-GCM encryption with HKDF key derivation for per-site
|
||||||
|
credential storage. Credentials are encrypted as JSON blobs and stored
|
||||||
|
in SQLite. Decryption happens only during tool execution and plaintext
|
||||||
|
is never logged.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
encryption = get_credential_encryption()
|
||||||
|
cipherdata = encryption.encrypt_credentials(
|
||||||
|
{"username": "admin", "app_password": "xxxx xxxx"},
|
||||||
|
site_id="site_abc123",
|
||||||
|
)
|
||||||
|
credentials = encryption.decrypt_credentials(cipherdata, site_id="site_abc123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
_NONCE_LENGTH = 12 # 96-bit nonce for AES-GCM
|
||||||
|
_KEY_LENGTH = 32 # 256-bit key
|
||||||
|
_HKDF_SALT = b"mcphub-v1"
|
||||||
|
_FORMAT_VERSION = b"\x01" # Wire format version for future migration support
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEncryption:
|
||||||
|
"""AES-256-GCM encryption with per-site HKDF-derived keys.
|
||||||
|
|
||||||
|
The master key is read from the ENCRYPTION_KEY environment variable
|
||||||
|
(base64-encoded 32-byte key). Per-site keys are derived via HKDF
|
||||||
|
using the site_id as the info parameter, ensuring each site has a
|
||||||
|
unique encryption key.
|
||||||
|
|
||||||
|
Storage format: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, encryption_key: str | None = None) -> None:
|
||||||
|
"""Initialize credential encryption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||||
|
reads from the ENCRYPTION_KEY environment variable.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the encryption key is missing or invalid.
|
||||||
|
"""
|
||||||
|
raw_key = encryption_key or os.getenv("ENCRYPTION_KEY")
|
||||||
|
|
||||||
|
if not raw_key:
|
||||||
|
raise ValueError(
|
||||||
|
"ENCRYPTION_KEY is required. Set it as an environment variable "
|
||||||
|
"or pass it directly. Generate one with: "
|
||||||
|
'python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._master_key = base64.b64decode(raw_key)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError("ENCRYPTION_KEY must be a valid base64-encoded string.") from exc
|
||||||
|
|
||||||
|
if len(self._master_key) != _KEY_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"ENCRYPTION_KEY must decode to exactly {_KEY_LENGTH} bytes, "
|
||||||
|
f"got {len(self._master_key)} bytes."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Credential encryption initialized")
|
||||||
|
|
||||||
|
def _derive_key(self, site_id: str) -> bytes:
|
||||||
|
"""Derive a per-site encryption key using HKDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Unique identifier for the site.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
32-byte derived key for the given site.
|
||||||
|
"""
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=_KEY_LENGTH,
|
||||||
|
salt=_HKDF_SALT,
|
||||||
|
info=site_id.encode("utf-8"),
|
||||||
|
)
|
||||||
|
return hkdf.derive(self._master_key)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str, site_id: str) -> bytes:
|
||||||
|
"""Encrypt a plaintext string for a specific site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plaintext: The string to encrypt.
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes: version (1) || nonce (12) || ciphertext || tag (16).
|
||||||
|
"""
|
||||||
|
derived_key = self._derive_key(site_id)
|
||||||
|
aesgcm = AESGCM(derived_key)
|
||||||
|
nonce = os.urandom(_NONCE_LENGTH)
|
||||||
|
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||||
|
return _FORMAT_VERSION + nonce + ciphertext_with_tag
|
||||||
|
|
||||||
|
def decrypt(self, cipherdata: bytes, site_id: str) -> str:
|
||||||
|
"""Decrypt cipherdata for a specific site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipherdata: Encrypted bytes (nonce || ciphertext || tag).
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original plaintext string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
cryptography.exceptions.InvalidTag: If decryption fails
|
||||||
|
(wrong key, tampered data, or wrong site_id).
|
||||||
|
ValueError: If cipherdata is too short or has unsupported version.
|
||||||
|
"""
|
||||||
|
# Minimum: 1 (version) + 12 (nonce) + 16 (tag) = 29 bytes
|
||||||
|
min_length = 1 + _NONCE_LENGTH + 16
|
||||||
|
if len(cipherdata) < min_length:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cipherdata too short: expected at least {min_length} bytes, "
|
||||||
|
f"got {len(cipherdata)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
version = cipherdata[:1]
|
||||||
|
if version != _FORMAT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported encryption format version: {version!r}. "
|
||||||
|
f"Expected {_FORMAT_VERSION!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
nonce = cipherdata[1 : 1 + _NONCE_LENGTH]
|
||||||
|
ciphertext_with_tag = cipherdata[1 + _NONCE_LENGTH :]
|
||||||
|
|
||||||
|
derived_key = self._derive_key(site_id)
|
||||||
|
aesgcm = AESGCM(derived_key)
|
||||||
|
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||||
|
return plaintext_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
def encrypt_credentials(self, credentials: dict, site_id: str) -> bytes:
|
||||||
|
"""Encrypt a credentials dictionary for a specific site.
|
||||||
|
|
||||||
|
The dictionary is serialized to JSON, then encrypted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: Dictionary of credentials (e.g., username, password).
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes.
|
||||||
|
"""
|
||||||
|
json_str = json.dumps(credentials, separators=(",", ":"), sort_keys=True)
|
||||||
|
return self.encrypt(json_str, site_id)
|
||||||
|
|
||||||
|
def decrypt_credentials(self, cipherdata: bytes, site_id: str) -> dict:
|
||||||
|
"""Decrypt cipherdata back to a credentials dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipherdata: Encrypted bytes from encrypt_credentials.
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original credentials dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
cryptography.exceptions.InvalidTag: If decryption fails.
|
||||||
|
json.JSONDecodeError: If decrypted data is not valid JSON.
|
||||||
|
"""
|
||||||
|
json_str = self.decrypt(cipherdata, site_id)
|
||||||
|
return json.loads(json_str)
|
||||||
|
|
||||||
|
|
||||||
|
# Global credential encryption instance
|
||||||
|
_credential_encryption: CredentialEncryption | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_credential_encryption(
|
||||||
|
encryption_key: str | None = None,
|
||||||
|
) -> CredentialEncryption:
|
||||||
|
"""Initialize the global credential encryption instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||||
|
reads from the ENCRYPTION_KEY environment variable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized CredentialEncryption instance.
|
||||||
|
"""
|
||||||
|
global _credential_encryption
|
||||||
|
_credential_encryption = CredentialEncryption(encryption_key)
|
||||||
|
return _credential_encryption
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_encryption() -> CredentialEncryption:
|
||||||
|
"""Get the global credential encryption instance.
|
||||||
|
|
||||||
|
Lazily initializes from the ENCRYPTION_KEY environment variable
|
||||||
|
if not already initialized via initialize_credential_encryption().
|
||||||
|
"""
|
||||||
|
global _credential_encryption
|
||||||
|
if _credential_encryption is None:
|
||||||
|
_credential_encryption = CredentialEncryption()
|
||||||
|
return _credential_encryption
|
||||||
@@ -99,7 +99,7 @@ ENDPOINT_CONFIGS = {
|
|||||||
# Mounted at "/" → /mcp (FastMCP adds /mcp automatically)
|
# Mounted at "/" → /mcp (FastMCP adds /mcp automatically)
|
||||||
EndpointType.ADMIN: EndpointConfig(
|
EndpointType.ADMIN: EndpointConfig(
|
||||||
path="/",
|
path="/",
|
||||||
name="Coolify Admin",
|
name="MCP Hub Admin",
|
||||||
description="Full administrative access to all tools and plugins",
|
description="Full administrative access to all tools and plugins",
|
||||||
endpoint_type=EndpointType.ADMIN,
|
endpoint_type=EndpointType.ADMIN,
|
||||||
plugin_types=[], # Empty = all plugins
|
plugin_types=[], # Empty = all plugins
|
||||||
@@ -107,7 +107,7 @@ ENDPOINT_CONFIGS = {
|
|||||||
allowed_scopes={"admin"},
|
allowed_scopes={"admin"},
|
||||||
max_tools=400,
|
max_tools=400,
|
||||||
),
|
),
|
||||||
# System endpoint - system tools only (17 tools) - Phase X.3
|
# System endpoint - system tools only (24 tools)
|
||||||
# For API key management, OAuth, rate limiting without loading all plugins
|
# For API key management, OAuth, rate limiting without loading all plugins
|
||||||
EndpointType.SYSTEM: EndpointConfig(
|
EndpointType.SYSTEM: EndpointConfig(
|
||||||
path="/system",
|
path="/system",
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class MCPEndpointFactory:
|
|||||||
self.tool_registry = tool_registry
|
self.tool_registry = tool_registry
|
||||||
self.middleware_classes = middleware_classes or []
|
self.middleware_classes = middleware_classes or []
|
||||||
self.endpoints: dict[str, FastMCP] = {}
|
self.endpoints: dict[str, FastMCP] = {}
|
||||||
|
self._tool_counts: dict[str, int] = {}
|
||||||
self._tool_handlers: dict[str, Callable] = {}
|
self._tool_handlers: dict[str, Callable] = {}
|
||||||
|
|
||||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||||
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
|
|||||||
for middleware in custom_middleware:
|
for middleware in custom_middleware:
|
||||||
mcp.add_middleware(middleware)
|
mcp.add_middleware(middleware)
|
||||||
|
|
||||||
# Store endpoint
|
# Store endpoint and tool count
|
||||||
self.endpoints[config.path] = mcp
|
self.endpoints[config.path] = mcp
|
||||||
|
self._tool_counts[config.path] = len(tools)
|
||||||
|
|
||||||
logger.info(f" - Endpoint {config.path} created successfully")
|
logger.info(f" - Endpoint {config.path} created successfully")
|
||||||
|
|
||||||
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
|
|||||||
"""
|
"""
|
||||||
info = []
|
info = []
|
||||||
for path, mcp in self.endpoints.items():
|
for path, mcp in self.endpoints.items():
|
||||||
# Get tool count
|
tool_count = self._tool_counts.get(path, 0)
|
||||||
# Note: This requires accessing FastMCP internals
|
|
||||||
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
|
|
||||||
|
|
||||||
info.append(
|
info.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -149,11 +149,7 @@ class EndpointRegistry:
|
|||||||
tool_count = 0
|
tool_count = 0
|
||||||
|
|
||||||
if mcp:
|
if mcp:
|
||||||
# Try to get tool count from FastMCP
|
tool_count = self.factory._tool_counts.get(path, 0)
|
||||||
try:
|
|
||||||
tool_count = len(mcp._tool_manager._tools)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
endpoints.append(
|
endpoints.append(
|
||||||
EndpointInfo(
|
EndpointInfo(
|
||||||
|
|||||||
217
core/health.py
217
core/health.py
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Enhanced Health Monitoring System for MCP Server (Phase 7.2)
|
Enhanced Health Monitoring System for MCP Server
|
||||||
|
|
||||||
This module provides comprehensive health monitoring capabilities including:
|
This module provides comprehensive health monitoring capabilities including:
|
||||||
- Response time tracking
|
- Response time tracking
|
||||||
@@ -9,10 +9,10 @@ This module provides comprehensive health monitoring capabilities including:
|
|||||||
- Dependency health checks
|
- Dependency health checks
|
||||||
- System uptime tracking
|
- System uptime tracking
|
||||||
|
|
||||||
Author: Coolify MCP Team
|
Author: MCP Hub Team
|
||||||
Version: 7.2
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.audit_log import AuditLogger
|
from core.audit_log import AuditLogger
|
||||||
from core.project_manager import ProjectManager
|
from core.site_manager import SiteManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -129,21 +129,21 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
metrics_retention_hours: int = 24,
|
metrics_retention_hours: int = 24,
|
||||||
max_metrics_per_project: int = 1000,
|
max_metrics_per_project: int = 1000,
|
||||||
|
site_manager: SiteManager | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize health monitor.
|
Initialize health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger for logging health events
|
audit_logger: Optional audit logger for logging health events
|
||||||
metrics_retention_hours: Hours to retain historical metrics
|
metrics_retention_hours: Hours to retain historical metrics
|
||||||
max_metrics_per_project: Maximum metrics to store per project
|
max_metrics_per_project: Maximum metrics to store per project
|
||||||
|
site_manager: SiteManager for site discovery
|
||||||
"""
|
"""
|
||||||
self.project_manager = project_manager
|
self.site_manager = site_manager
|
||||||
self.audit_logger = audit_logger
|
self.audit_logger = audit_logger
|
||||||
self.metrics_retention_hours = metrics_retention_hours
|
self.metrics_retention_hours = metrics_retention_hours
|
||||||
self.max_metrics_per_project = max_metrics_per_project
|
self.max_metrics_per_project = max_metrics_per_project
|
||||||
@@ -172,7 +172,12 @@ class HealthMonitor:
|
|||||||
# Request rate tracking (for requests per minute)
|
# Request rate tracking (for requests per minute)
|
||||||
self.request_timestamps: deque = deque(maxlen=1000)
|
self.request_timestamps: deque = deque(maxlen=1000)
|
||||||
|
|
||||||
logger.info("HealthMonitor initialized (Phase 7.2)")
|
# Active background checks
|
||||||
|
self.latest_health_status: dict[str, ProjectHealthStatus] = {}
|
||||||
|
self._bg_task: asyncio.Task | None = None
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
|
logger.info("HealthMonitor initialized")
|
||||||
|
|
||||||
def _setup_default_thresholds(self):
|
def _setup_default_thresholds(self):
|
||||||
"""Setup default alert thresholds."""
|
"""Setup default alert thresholds."""
|
||||||
@@ -401,9 +406,9 @@ class HealthMonitor:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get plugin instance
|
if self.site_manager:
|
||||||
plugin = self.project_manager.projects.get(project_id)
|
health_result = await self._site_manager_health_check(project_id)
|
||||||
if not plugin:
|
else:
|
||||||
return ProjectHealthStatus(
|
return ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=False,
|
healthy=False,
|
||||||
@@ -413,9 +418,6 @@ class HealthMonitor:
|
|||||||
recent_errors=["Project not found"],
|
recent_errors=["Project not found"],
|
||||||
alerts=["CRITICAL: Project not found"],
|
alerts=["CRITICAL: Project not found"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Perform health check
|
|
||||||
health_result = await plugin.health_check()
|
|
||||||
response_time_ms = (time.time() - start_time) * 1000
|
response_time_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
# Handle both dict and string (JSON) responses
|
# Handle both dict and string (JSON) responses
|
||||||
@@ -462,7 +464,7 @@ class HealthMonitor:
|
|||||||
}
|
}
|
||||||
alerts = self._check_alerts(project_id, alert_check_data)
|
alerts = self._check_alerts(project_id, alert_check_data)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=is_healthy,
|
healthy=is_healthy,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -472,6 +474,8 @@ class HealthMonitor:
|
|||||||
alerts=alerts,
|
alerts=alerts,
|
||||||
details=health_result,
|
details=health_result,
|
||||||
)
|
)
|
||||||
|
self.latest_health_status[project_id] = status
|
||||||
|
return status
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
response_time_ms = (time.time() - start_time) * 1000
|
response_time_ms = (time.time() - start_time) * 1000
|
||||||
@@ -485,7 +489,7 @@ class HealthMonitor:
|
|||||||
error_message=error_msg,
|
error_message=error_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=False,
|
healthy=False,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -494,6 +498,133 @@ class HealthMonitor:
|
|||||||
recent_errors=[error_msg],
|
recent_errors=[error_msg],
|
||||||
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
||||||
)
|
)
|
||||||
|
self.latest_health_status[project_id] = status
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Find site info from SiteManager by full_id."""
|
||||||
|
if not self.site_manager:
|
||||||
|
return None
|
||||||
|
for info in self.site_manager.list_all_sites():
|
||||||
|
if info["full_id"] == project_id:
|
||||||
|
return info
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Health check for a site via SiteManager.
|
||||||
|
|
||||||
|
Creates a temporary plugin instance and calls its health_check() method,
|
||||||
|
falling back to a basic HTTP check if plugin instantiation fails.
|
||||||
|
"""
|
||||||
|
if not self.site_manager:
|
||||||
|
return {"healthy": False, "message": "SiteManager not available"}
|
||||||
|
|
||||||
|
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
|
||||||
|
site_info = self._find_site_info(project_id)
|
||||||
|
if not site_info:
|
||||||
|
return {"healthy": False, "message": f"Site not found: {project_id}"}
|
||||||
|
|
||||||
|
plugin_type = site_info["plugin_type"]
|
||||||
|
site_id = site_info["site_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = self.site_manager.get_site_config(plugin_type, site_id)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return {"healthy": False, "message": f"Site config not found: {project_id}"}
|
||||||
|
|
||||||
|
# Try to create a temporary plugin instance for a proper health check
|
||||||
|
try:
|
||||||
|
from plugins import registry as plugin_registry
|
||||||
|
|
||||||
|
config_dict = config.to_dict()
|
||||||
|
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
||||||
|
return await plugin_instance.health_check()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not create plugin instance for {project_id}, "
|
||||||
|
f"falling back to authenticated HTTP check: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: authenticated health check for WordPress-based plugins
|
||||||
|
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
if plugin_type == "woocommerce":
|
||||||
|
# WooCommerce uses consumer_key/consumer_secret
|
||||||
|
ck = getattr(config, "consumer_key", None) or ""
|
||||||
|
cs = getattr(config, "consumer_secret", None) or ""
|
||||||
|
# consumer_key/consumer_secret may be in model_extra
|
||||||
|
if not ck and hasattr(config, "model_extra"):
|
||||||
|
ck = (config.model_extra or {}).get("consumer_key", "")
|
||||||
|
cs = (config.model_extra or {}).get("consumer_secret", "")
|
||||||
|
auth = aiohttp.BasicAuth(ck, cs)
|
||||||
|
auth_check_url = f"{config.url}/wp-json/wc/v3/system_status"
|
||||||
|
else:
|
||||||
|
auth = aiohttp.BasicAuth(
|
||||||
|
config.username or "",
|
||||||
|
config.app_password or "",
|
||||||
|
)
|
||||||
|
auth_check_url = f"{config.url}/wp-json/wp/v2/users/me"
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(auth=auth) as session:
|
||||||
|
async with session.get(
|
||||||
|
auth_check_url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
ssl=False,
|
||||||
|
) as resp:
|
||||||
|
auth_valid = resp.status == 200
|
||||||
|
basic_result = await self._basic_http_health_check(config.url, project_id)
|
||||||
|
basic_result["auth_valid"] = auth_valid
|
||||||
|
if not auth_valid:
|
||||||
|
basic_result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||||
|
return basic_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Last resort: basic HTTP check
|
||||||
|
return await self._basic_http_health_check(config.url, project_id)
|
||||||
|
|
||||||
|
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
||||||
|
"""Basic HTTP health check as a last-resort fallback."""
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return {"healthy": False, "message": "No URL configured for site"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
url, timeout=aiohttp.ClientTimeout(total=10), ssl=False
|
||||||
|
) as resp:
|
||||||
|
return {
|
||||||
|
"healthy": resp.status < 500,
|
||||||
|
"status_code": resp.status,
|
||||||
|
"message": f"HTTP {resp.status} from {url}",
|
||||||
|
}
|
||||||
|
except TimeoutError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "timeout",
|
||||||
|
"message": f"Site at {url} did not respond within 10 seconds.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorDNSError:
|
||||||
|
host = url.split("://")[-1].split("/")[0]
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "dns_failure",
|
||||||
|
"message": f"DNS resolution failed for '{host}'. Check the URL.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "connection_refused",
|
||||||
|
"message": f"Cannot connect to {url}. Server unreachable.",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
|
||||||
|
|
||||||
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -507,8 +638,14 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
health_statuses = {}
|
health_statuses = {}
|
||||||
|
|
||||||
|
# Collect all known site IDs from SiteManager
|
||||||
|
all_project_ids = set()
|
||||||
|
if self.site_manager:
|
||||||
|
for site_info in self.site_manager.list_all_sites():
|
||||||
|
all_project_ids.add(site_info["full_id"])
|
||||||
|
|
||||||
# Check each project
|
# Check each project
|
||||||
for project_id in self.project_manager.projects.keys():
|
for project_id in sorted(all_project_ids):
|
||||||
status = await self.check_project_health(project_id, include_metrics)
|
status = await self.check_project_health(project_id, include_metrics)
|
||||||
health_statuses[project_id] = status.to_dict()
|
health_statuses[project_id] = status.to_dict()
|
||||||
|
|
||||||
@@ -660,8 +797,46 @@ class HealthMonitor:
|
|||||||
self.failed_requests = 0
|
self.failed_requests = 0
|
||||||
self.response_times.clear()
|
self.response_times.clear()
|
||||||
self.request_timestamps.clear()
|
self.request_timestamps.clear()
|
||||||
|
self.latest_health_status.clear()
|
||||||
logger.warning("All metrics have been reset")
|
logger.warning("All metrics have been reset")
|
||||||
|
|
||||||
|
async def start_background_checks(self, interval_seconds: int = 60):
|
||||||
|
"""Start background health checks for all projects."""
|
||||||
|
if self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_running = True
|
||||||
|
logger.info(f"Starting background health checks every {interval_seconds} seconds")
|
||||||
|
|
||||||
|
async def _loop():
|
||||||
|
# Initial wait to let server start up fully
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
while self._is_running:
|
||||||
|
try:
|
||||||
|
await self.check_all_projects_health(include_metrics=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in background health check loop: {e}")
|
||||||
|
|
||||||
|
# Sleep interval, check _is_running periodically
|
||||||
|
for _ in range(interval_seconds):
|
||||||
|
if not self._is_running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
self._bg_task = asyncio.create_task(_loop())
|
||||||
|
|
||||||
|
async def stop_background_checks(self):
|
||||||
|
"""Stop background health checks."""
|
||||||
|
self._is_running = False
|
||||||
|
if self._bg_task:
|
||||||
|
self._bg_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._bg_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._bg_task = None
|
||||||
|
logger.info("Background health checks stopped")
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
_health_monitor: HealthMonitor | None = None
|
_health_monitor: HealthMonitor | None = None
|
||||||
@@ -673,19 +848,21 @@ def get_health_monitor() -> HealthMonitor | None:
|
|||||||
|
|
||||||
|
|
||||||
def initialize_health_monitor(
|
def initialize_health_monitor(
|
||||||
project_manager: ProjectManager, audit_logger: AuditLogger | None = None, **kwargs
|
audit_logger: AuditLogger | None = None,
|
||||||
|
site_manager: SiteManager | None = None,
|
||||||
|
**kwargs,
|
||||||
) -> HealthMonitor:
|
) -> HealthMonitor:
|
||||||
"""
|
"""
|
||||||
Initialize the global health monitor.
|
Initialize the global health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger
|
audit_logger: Optional audit logger
|
||||||
|
site_manager: SiteManager for site discovery
|
||||||
**kwargs: Additional configuration options
|
**kwargs: Additional configuration options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthMonitor instance
|
HealthMonitor instance
|
||||||
"""
|
"""
|
||||||
global _health_monitor
|
global _health_monitor
|
||||||
_health_monitor = HealthMonitor(project_manager, audit_logger, **kwargs)
|
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
|
||||||
return _health_monitor
|
return _health_monitor
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class ClientRegistry:
|
|||||||
grant_types: list[str] | None = None,
|
grant_types: list[str] | None = None,
|
||||||
allowed_scopes: list[str] | None = None,
|
allowed_scopes: list[str] | None = None,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
owner_user_id: str | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Create new OAuth client.
|
Create new OAuth client.
|
||||||
@@ -97,8 +98,9 @@ class ClientRegistry:
|
|||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
||||||
allowed_scopes=allowed_scopes or ["read", "write"],
|
allowed_scopes=allowed_scopes or ["read", "write", "admin"],
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ class OAuthClient(BaseModel):
|
|||||||
default=["authorization_code", "refresh_token"], description="Allowed grant types"
|
default=["authorization_code", "refresh_token"], description="Allowed grant types"
|
||||||
)
|
)
|
||||||
response_types: list[str] = Field(default=["code"], description="Allowed response types")
|
response_types: list[str] = Field(default=["code"], description="Allowed response types")
|
||||||
scope: str = Field(default="read", description="Default scope for this client")
|
scope: str = Field(default="read write admin", description="Default scope for this client")
|
||||||
allowed_scopes: list[str] = Field(
|
allowed_scopes: list[str] = Field(
|
||||||
default=["read", "write"], description="All scopes this client can request"
|
default=["read", "write", "admin"], description="All scopes this client can request"
|
||||||
)
|
)
|
||||||
token_endpoint_auth_method: str = Field(
|
token_endpoint_auth_method: str = Field(
|
||||||
default="client_secret_post", description="Token endpoint authentication method"
|
default="client_secret_post", description="Token endpoint authentication method"
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
metadata: dict = Field(default_factory=dict)
|
metadata: dict = Field(default_factory=dict)
|
||||||
|
owner_user_id: str | None = Field(
|
||||||
|
default=None, description="ID of the OAuth user who created this client (None = admin)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("redirect_uris")
|
@field_validator("redirect_uris")
|
||||||
def validate_redirect_uris(cls, v):
|
def validate_redirect_uris(cls, v):
|
||||||
@@ -63,6 +66,7 @@ class AuthorizationCode(BaseModel):
|
|||||||
api_key_id: str | None = None # API Key ID for scope/project inheritance
|
api_key_id: str | None = None # API Key ID for scope/project inheritance
|
||||||
api_key_project_id: str | None = None # Project ID from API Key
|
api_key_project_id: str | None = None # Project ID from API Key
|
||||||
api_key_scope: str | None = None # Scope from API Key
|
api_key_scope: str | None = None # Scope from API Key
|
||||||
|
resource: str | None = None # RFC 8707: Resource indicator
|
||||||
|
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
"""Check if code is expired"""
|
"""Check if code is expired"""
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class OAuthServer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate scope
|
# Validate scope
|
||||||
requested_scopes = scope.split() if scope else ["read"]
|
requested_scopes = scope.split() if scope else ["read", "write", "admin"]
|
||||||
for s in requested_scopes:
|
for s in requested_scopes:
|
||||||
if s not in client.allowed_scopes:
|
if s not in client.allowed_scopes:
|
||||||
raise OAuthError(
|
raise OAuthError(
|
||||||
@@ -143,6 +143,7 @@ class OAuthServer:
|
|||||||
api_key_id: str | None = None,
|
api_key_id: str | None = None,
|
||||||
api_key_project_id: str | None = None,
|
api_key_project_id: str | None = None,
|
||||||
api_key_scope: str | None = None,
|
api_key_scope: str | None = None,
|
||||||
|
resource: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create authorization code (Step 2 of Authorization Code flow)
|
Create authorization code (Step 2 of Authorization Code flow)
|
||||||
@@ -157,6 +158,7 @@ class OAuthServer:
|
|||||||
api_key_id: Optional API Key ID for scope/project inheritance
|
api_key_id: Optional API Key ID for scope/project inheritance
|
||||||
api_key_project_id: Optional project ID from API Key
|
api_key_project_id: Optional project ID from API Key
|
||||||
api_key_scope: Optional scope from API Key
|
api_key_scope: Optional scope from API Key
|
||||||
|
resource: Optional resource indicator (RFC 8707)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Authorization code (valid for 5 minutes)
|
Authorization code (valid for 5 minutes)
|
||||||
@@ -178,6 +180,7 @@ class OAuthServer:
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
api_key_project_id=api_key_project_id,
|
api_key_project_id=api_key_project_id,
|
||||||
api_key_scope=api_key_scope,
|
api_key_scope=api_key_scope,
|
||||||
|
resource=resource,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to storage
|
# Save to storage
|
||||||
@@ -257,12 +260,14 @@ class OAuthServer:
|
|||||||
# If authorization code has API Key metadata, use it for scoping
|
# If authorization code has API Key metadata, use it for scoping
|
||||||
project_id = auth_code.api_key_project_id or "*"
|
project_id = auth_code.api_key_project_id or "*"
|
||||||
token_scope = auth_code.api_key_scope or auth_code.scope
|
token_scope = auth_code.api_key_scope or auth_code.scope
|
||||||
|
resource = auth_code.resource
|
||||||
|
|
||||||
access_token = self.token_manager.generate_access_token(
|
access_token = self.token_manager.generate_access_token(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
scope=token_scope,
|
scope=token_scope,
|
||||||
user_id=auth_code.user_id or auth_code.api_key_id,
|
user_id=auth_code.user_id or auth_code.api_key_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
resource=resource,
|
||||||
)
|
)
|
||||||
|
|
||||||
refresh_token = self.token_manager.generate_refresh_token(
|
refresh_token = self.token_manager.generate_refresh_token(
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ class TokenManager:
|
|||||||
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
|
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
|
||||||
|
|
||||||
def generate_access_token(
|
def generate_access_token(
|
||||||
self, client_id: str, scope: str, user_id: str | None = None, project_id: str = "*"
|
self,
|
||||||
|
client_id: str,
|
||||||
|
scope: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
project_id: str = "*",
|
||||||
|
resource: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate JWT access token.
|
Generate JWT access token.
|
||||||
@@ -64,6 +69,7 @@ class TokenManager:
|
|||||||
scope: Granted scopes (space-separated)
|
scope: Granted scopes (space-separated)
|
||||||
user_id: User ID (optional, for user-based auth)
|
user_id: User ID (optional, for user-based auth)
|
||||||
project_id: Project ID for scoping (default: "*" for global)
|
project_id: Project ID for scoping (default: "*" for global)
|
||||||
|
resource: Resource indicator for aud claim (RFC 8707)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JWT access token
|
JWT access token
|
||||||
@@ -87,6 +93,9 @@ class TokenManager:
|
|||||||
if user_id:
|
if user_id:
|
||||||
payload["sub"] = user_id # Subject (user ID)
|
payload["sub"] = user_id # Subject (user ID)
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
payload["aud"] = resource
|
||||||
|
|
||||||
# Encode JWT
|
# Encode JWT
|
||||||
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ class TokenManager:
|
|||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_nbf": True,
|
"verify_nbf": True,
|
||||||
|
"verify_aud": False, # Server is the resource server; no external aud check needed
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +149,7 @@ class TokenManager:
|
|||||||
logger.warning("Expired access token")
|
logger.warning("Expired access token")
|
||||||
raise
|
raise
|
||||||
except jwt.InvalidTokenError as e:
|
except jwt.InvalidTokenError as e:
|
||||||
logger.warning(f"Invalid access token: {e}")
|
logger.debug(f"Invalid access token: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def generate_refresh_token(self, client_id: str, access_token: str) -> str:
|
def generate_refresh_token(self, client_id: str, access_token: str) -> str:
|
||||||
@@ -241,8 +251,8 @@ class TokenManager:
|
|||||||
try:
|
try:
|
||||||
old_payload = self.validate_access_token(token_data.access_token)
|
old_payload = self.validate_access_token(token_data.access_token)
|
||||||
scope = old_payload.get("scope", "read")
|
scope = old_payload.get("scope", "read")
|
||||||
except:
|
except Exception:
|
||||||
pass # Old token might be expired, use default
|
pass # Old token might be expired, use default scope
|
||||||
|
|
||||||
# Generate new tokens
|
# Generate new tokens
|
||||||
new_access_token = self.generate_access_token(
|
new_access_token = self.generate_access_token(
|
||||||
|
|||||||
59
core/plugin_visibility.py
Normal file
59
core/plugin_visibility.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Plugin visibility control for public vs admin users (Track F.1).
|
||||||
|
|
||||||
|
Controls which plugins are visible to public (OAuth) users vs admin users.
|
||||||
|
Admin users (MASTER_API_KEY) always see all plugins. Public users only see
|
||||||
|
plugins listed in the ENABLED_PLUGINS setting (DB > ENV > default).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.plugin_visibility import get_public_plugin_types, is_plugin_public
|
||||||
|
|
||||||
|
public_types = get_public_plugin_types() # {"wordpress", "woocommerce", "supabase"}
|
||||||
|
if is_plugin_public("gitea"): # True
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Default plugins available to public (OAuth) users
|
||||||
|
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel", "gitea"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_plugins(val: str) -> set[str]:
|
||||||
|
"""Parse comma-separated plugin string into set."""
|
||||||
|
return {p.strip().lower() for p in val.split(",") if p.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_plugin_types() -> set[str]:
|
||||||
|
"""Return the set of plugin types visible to public users.
|
||||||
|
|
||||||
|
Checks DB settings first (sync-safe), then env var, then defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of lowercase plugin type strings.
|
||||||
|
"""
|
||||||
|
# Try DB setting (sync access via cached value)
|
||||||
|
try:
|
||||||
|
from core.settings import _cached_plugins
|
||||||
|
|
||||||
|
if _cached_plugins is not None:
|
||||||
|
return set(_cached_plugins)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to env var
|
||||||
|
env_val = os.getenv("ENABLED_PLUGINS", "").strip()
|
||||||
|
if not env_val:
|
||||||
|
return set(DEFAULT_PUBLIC_PLUGINS)
|
||||||
|
return _parse_plugins(env_val)
|
||||||
|
|
||||||
|
|
||||||
|
def is_plugin_public(plugin_type: str) -> bool:
|
||||||
|
"""Check if a plugin type is enabled for public users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type string (e.g., "wordpress").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the plugin is in the public set.
|
||||||
|
"""
|
||||||
|
return plugin_type.lower() in get_public_plugin_types()
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
"""
|
|
||||||
Project Manager
|
|
||||||
|
|
||||||
Discovers and manages project instances from environment variables.
|
|
||||||
Handles plugin lifecycle and tool registration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from plugins import BasePlugin, registry
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectManager:
|
|
||||||
"""
|
|
||||||
Manage multiple project instances.
|
|
||||||
|
|
||||||
Projects are discovered from environment variables:
|
|
||||||
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
|
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_URL=https://other.com
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize project manager."""
|
|
||||||
self.projects: dict[str, BasePlugin] = {}
|
|
||||||
self.logger = logging.getLogger("ProjectManager")
|
|
||||||
|
|
||||||
def discover_projects(self) -> None:
|
|
||||||
"""
|
|
||||||
Discover projects from environment variables.
|
|
||||||
|
|
||||||
Scans environment for project configurations and creates
|
|
||||||
plugin instances.
|
|
||||||
"""
|
|
||||||
self.logger.info("Starting project discovery...")
|
|
||||||
|
|
||||||
# Get all registered plugin types
|
|
||||||
plugin_types = registry.get_registered_types()
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
self._discover_plugin_type(plugin_type)
|
|
||||||
|
|
||||||
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
|
|
||||||
|
|
||||||
def _discover_plugin_type(self, plugin_type: str) -> None:
|
|
||||||
"""
|
|
||||||
Discover all projects of a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Find all project IDs for this plugin type
|
|
||||||
project_ids = set()
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
project_id = match.group(1).lower()
|
|
||||||
project_ids.add(project_id)
|
|
||||||
|
|
||||||
# Create plugin instance for each project
|
|
||||||
for project_id in project_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_project_config(plugin_type, project_id)
|
|
||||||
if config:
|
|
||||||
self._create_project_instance(plugin_type, project_id, config)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to create {plugin_type} project '{project_id}': {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a project from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
project_id: Project ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with configuration or None if incomplete
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# Collect all config keys for this project
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config[config_key] = env_value
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
def _create_project_instance(
|
|
||||||
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create a plugin instance for a project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
project_id: Project ID
|
|
||||||
config: Project configuration
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create plugin instance
|
|
||||||
plugin = registry.create_instance(plugin_type, project_id, config)
|
|
||||||
|
|
||||||
# Store with full identifier
|
|
||||||
full_id = f"{plugin_type}_{project_id}"
|
|
||||||
self.projects[full_id] = plugin
|
|
||||||
|
|
||||||
self.logger.info(f"Created project: {full_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
|
|
||||||
|
|
||||||
def get_project(self, full_id: str) -> BasePlugin | None:
|
|
||||||
"""
|
|
||||||
Get a project plugin instance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: Full project identifier (plugin_type_project_id)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Plugin instance or None
|
|
||||||
"""
|
|
||||||
return self.projects.get(full_id)
|
|
||||||
|
|
||||||
def get_all_projects(self) -> dict[str, BasePlugin]:
|
|
||||||
"""Get all project instances."""
|
|
||||||
return self.projects.copy()
|
|
||||||
|
|
||||||
def get_projects_by_type(self, plugin_type: str) -> dict[str, BasePlugin]:
|
|
||||||
"""
|
|
||||||
Get all projects of a specific type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type to filter by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of project_id -> plugin
|
|
||||||
"""
|
|
||||||
prefix = plugin_type + "_"
|
|
||||||
return {
|
|
||||||
full_id: plugin
|
|
||||||
for full_id, plugin in self.projects.items()
|
|
||||||
if full_id.startswith(prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_all_tools(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all MCP tools from all projects.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of tool definitions
|
|
||||||
"""
|
|
||||||
all_tools = []
|
|
||||||
|
|
||||||
for full_id, plugin in self.projects.items():
|
|
||||||
try:
|
|
||||||
tools = plugin.get_tools()
|
|
||||||
all_tools.extend(tools)
|
|
||||||
self.logger.debug(f"Loaded {len(tools)} tools from {full_id}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error loading tools from {full_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
self.logger.debug(f"Total tools loaded: {len(all_tools)}")
|
|
||||||
return all_tools
|
|
||||||
|
|
||||||
async def check_all_health(self) -> dict[str, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Check health of all projects.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping project ID to health status
|
|
||||||
"""
|
|
||||||
health_results = {}
|
|
||||||
|
|
||||||
for full_id, plugin in self.projects.items():
|
|
||||||
try:
|
|
||||||
health = await plugin.health_check()
|
|
||||||
health_results[full_id] = health
|
|
||||||
except Exception as e:
|
|
||||||
health_results[full_id] = {
|
|
||||||
"healthy": False,
|
|
||||||
"message": f"Health check failed: {str(e)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return health_results
|
|
||||||
|
|
||||||
def get_project_info(self, full_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Get information about a specific project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: Full project identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Project info dict or None
|
|
||||||
"""
|
|
||||||
plugin = self.get_project(full_id)
|
|
||||||
if plugin:
|
|
||||||
return plugin.get_project_info()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def list_projects(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all projects with basic information.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of project info dicts
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
{"id": full_id, "type": plugin.get_plugin_name(), "project_id": plugin.project_id}
|
|
||||||
for full_id, plugin in self.projects.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Global project manager instance
|
|
||||||
_project_manager: ProjectManager | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_project_manager() -> ProjectManager:
|
|
||||||
"""Get the global project manager instance."""
|
|
||||||
global _project_manager
|
|
||||||
if _project_manager is None:
|
|
||||||
_project_manager = ProjectManager()
|
|
||||||
_project_manager.discover_projects()
|
|
||||||
return _project_manager
|
|
||||||
162
core/settings.py
Normal file
162
core/settings.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Unified settings access with DB > ENV > Default priority (Phase 4C.3).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.settings import get_setting
|
||||||
|
|
||||||
|
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel,gitea")
|
||||||
|
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cached plugin set for sync access (updated when settings change)
|
||||||
|
_cached_plugins: set[str] | None = None
|
||||||
|
|
||||||
|
# Default values for all managed settings
|
||||||
|
SETTING_DEFAULTS: dict[str, str] = {
|
||||||
|
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel,gitea",
|
||||||
|
"MAX_SITES_PER_USER": "10",
|
||||||
|
"USER_RATE_LIMIT_PER_MIN": "30",
|
||||||
|
"USER_RATE_LIMIT_PER_HR": "500",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human-readable labels for the settings UI
|
||||||
|
SETTING_LABELS: dict[str, dict[str, str]] = {
|
||||||
|
"ENABLED_PLUGINS": {
|
||||||
|
"label": "Enabled Plugins",
|
||||||
|
"label_fa": "پلاگینهای فعال",
|
||||||
|
"hint": "Comma-separated plugin types visible to public users",
|
||||||
|
"hint_fa": "انواع پلاگین قابل مشاهده برای کاربران عمومی (با کاما جدا شوند)",
|
||||||
|
},
|
||||||
|
"MAX_SITES_PER_USER": {
|
||||||
|
"label": "Max Sites per User",
|
||||||
|
"label_fa": "حداکثر سایت هر کاربر",
|
||||||
|
"hint": "Maximum number of sites each user can create",
|
||||||
|
"hint_fa": "حداکثر تعداد سایتهایی که هر کاربر میتواند بسازد",
|
||||||
|
},
|
||||||
|
"USER_RATE_LIMIT_PER_MIN": {
|
||||||
|
"label": "User Rate Limit (per minute)",
|
||||||
|
"label_fa": "محدودیت نرخ کاربر (در دقیقه)",
|
||||||
|
"hint": "Maximum MCP requests per user per minute",
|
||||||
|
"hint_fa": "حداکثر درخواست MCP هر کاربر در دقیقه",
|
||||||
|
},
|
||||||
|
"USER_RATE_LIMIT_PER_HR": {
|
||||||
|
"label": "User Rate Limit (per hour)",
|
||||||
|
"label_fa": "محدودیت نرخ کاربر (در ساعت)",
|
||||||
|
"hint": "Maximum MCP requests per user per hour",
|
||||||
|
"hint_fa": "حداکثر درخواست MCP هر کاربر در ساعت",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""Get a setting value with priority: Database > Environment > Default.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key (e.g., "ENABLED_PLUGINS").
|
||||||
|
default: Fallback if not found anywhere. If None, uses SETTING_DEFAULTS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Setting value string, or None if not found anywhere.
|
||||||
|
"""
|
||||||
|
# 1. Try database
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db_val = await db.get_setting(key)
|
||||||
|
if db_val is not None:
|
||||||
|
return db_val
|
||||||
|
except Exception:
|
||||||
|
pass # DB not initialized yet (startup) — fall through
|
||||||
|
|
||||||
|
# 2. Try environment variable
|
||||||
|
env_val = os.environ.get(key)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
|
||||||
|
# 3. Use provided default or SETTING_DEFAULTS
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
return SETTING_DEFAULTS.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_plugin_cache() -> None:
|
||||||
|
"""Refresh the cached plugin set from DB/ENV/default."""
|
||||||
|
global _cached_plugins
|
||||||
|
val = await get_setting("ENABLED_PLUGINS")
|
||||||
|
if val:
|
||||||
|
_cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()}
|
||||||
|
else:
|
||||||
|
_cached_plugins = None
|
||||||
|
|
||||||
|
|
||||||
|
async def save_setting(key: str, value: str) -> None:
|
||||||
|
"""Save a setting to database and refresh caches."""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_setting(key, value)
|
||||||
|
|
||||||
|
if key == "ENABLED_PLUGINS":
|
||||||
|
await refresh_plugin_cache()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_setting_value(key: str) -> bool:
|
||||||
|
"""Delete a setting from database (revert to ENV/default)."""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_setting(key)
|
||||||
|
|
||||||
|
if key == "ENABLED_PLUGINS":
|
||||||
|
await refresh_plugin_cache()
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_managed_settings() -> list[dict[str, str]]:
|
||||||
|
"""Get all managed settings with their current values and sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with: key, value, source ("database"/"environment"/"default"),
|
||||||
|
label, hint.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db_settings = await db.get_all_settings()
|
||||||
|
except Exception:
|
||||||
|
db_settings = {}
|
||||||
|
|
||||||
|
for key, default_val in SETTING_DEFAULTS.items():
|
||||||
|
meta = SETTING_LABELS.get(key, {})
|
||||||
|
db_val = db_settings.get(key)
|
||||||
|
env_val = os.environ.get(key)
|
||||||
|
|
||||||
|
if db_val is not None:
|
||||||
|
value, source = db_val, "database"
|
||||||
|
elif env_val is not None:
|
||||||
|
value, source = env_val, "environment"
|
||||||
|
else:
|
||||||
|
value, source = default_val, "default"
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"source": source,
|
||||||
|
"default": default_val,
|
||||||
|
"label": meta.get("label", key),
|
||||||
|
"label_fa": meta.get("label_fa", key),
|
||||||
|
"hint": meta.get("hint", ""),
|
||||||
|
"hint_fa": meta.get("hint_fa", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
690
core/site_api.py
Normal file
690
core/site_api.py
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
"""Site management logic for the Live Platform (Track E.3).
|
||||||
|
|
||||||
|
Provides site CRUD operations, connection validation, and credential
|
||||||
|
field definitions for all 9 plugin types. Coordinates between the
|
||||||
|
database, encryption, and plugin health check layers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.site_api import create_user_site, get_user_sites, validate_site_connection
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection("wordpress", "https://example.com", creds)
|
||||||
|
site = await create_user_site(user_id, "wordpress", "myblog", url, creds)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum sites per user (configurable via env var)
|
||||||
|
MAX_SITES_PER_USER = int(os.getenv("MAX_SITES_PER_USER", "10"))
|
||||||
|
|
||||||
|
# Plugin credential field definitions — drives the dynamic "Add Site" form
|
||||||
|
# and server-side validation. Each field has:
|
||||||
|
# name: form input name (matches credential JSON key)
|
||||||
|
# label: display label
|
||||||
|
# type: "text" or "password"
|
||||||
|
# required: whether the field is mandatory
|
||||||
|
PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"wordpress": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Your WordPress admin username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"woocommerce": [
|
||||||
|
{
|
||||||
|
"name": "consumer_key",
|
||||||
|
"label": "Consumer Key",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WooCommerce → Settings → Advanced → REST API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "consumer_secret",
|
||||||
|
"label": "Consumer Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Shown once when creating the REST API key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"wordpress_advanced": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Your WordPress admin username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "container",
|
||||||
|
"label": "Docker Container Name",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"hint": "Docker container running WordPress (for WP-CLI access)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"gitea": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Access Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Gitea → Settings → Applications → Generate Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"n8n": [
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "n8n → Settings → API → Create API Key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supabase": [
|
||||||
|
{
|
||||||
|
"name": "service_role_key",
|
||||||
|
"label": "Service Role Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → service_role key. "
|
||||||
|
"Note: On supabase.com cloud, postgres-meta tools "
|
||||||
|
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
|
||||||
|
"they only work on self-hosted Supabase."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "anon_key",
|
||||||
|
"label": "Anon Key (Optional)",
|
||||||
|
"type": "password",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → anon key. "
|
||||||
|
"Optional — if omitted, service_role_key is used for all calls. "
|
||||||
|
"Only useful for testing RLS policies as a regular user."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta_url",
|
||||||
|
"label": "postgres-meta URL (Optional)",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Only needed if your Supabase setup does not expose /pg/ through Kong. "
|
||||||
|
"Most self-hosted installs (including Coolify) work without this. "
|
||||||
|
"Example: http://supabase-meta:8080 or https://your-meta.example.com"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta_auth",
|
||||||
|
"label": "postgres-meta Auth (Optional)",
|
||||||
|
"type": "password",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Basic Auth for postgres-meta (format: username:password). "
|
||||||
|
"Only needed when postgres-meta is exposed via a public URL."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"openpanel": [
|
||||||
|
{
|
||||||
|
"name": "client_id",
|
||||||
|
"label": "Client ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client_secret",
|
||||||
|
"label": "Client Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"name": "project_id",
|
||||||
|
"label": "Project ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → Project ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → API Keys → Create",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"directus": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Static Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Directus → Settings → User → Static Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"coolify": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "API Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Coolify → Keys & Tokens → API tokens → Create",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plugin display names for UI
|
||||||
|
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
"wordpress": "WordPress",
|
||||||
|
"woocommerce": "WooCommerce",
|
||||||
|
"wordpress_advanced": "WordPress Advanced",
|
||||||
|
"gitea": "Gitea",
|
||||||
|
"n8n": "n8n",
|
||||||
|
"supabase": "Supabase",
|
||||||
|
"openpanel": "OpenPanel",
|
||||||
|
"appwrite": "Appwrite",
|
||||||
|
"directus": "Directus",
|
||||||
|
"coolify": "Coolify",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoints per plugin type
|
||||||
|
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
|
||||||
|
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
|
||||||
|
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
|
||||||
|
"wordpress_advanced": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
|
||||||
|
"gitea": {"path": "/api/v1/user", "method": "GET"},
|
||||||
|
"n8n": {"path": "/healthz", "method": "GET"},
|
||||||
|
"supabase": {"path": "/rest/v1/", "method": "GET"},
|
||||||
|
"openpanel": {"path": "/healthcheck", "method": "GET"},
|
||||||
|
"appwrite": {"path": "/v1/health", "method": "GET"},
|
||||||
|
"directus": {"path": "/server/health", "method": "GET"},
|
||||||
|
"coolify": {"path": "/api/v1/version", "method": "GET"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get credential field definitions for a plugin type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of field definition dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If plugin_type is unknown.
|
||||||
|
"""
|
||||||
|
fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type)
|
||||||
|
if fields is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Get credential fields for public (non-admin) users.
|
||||||
|
|
||||||
|
Only includes plugins enabled via ENABLED_PLUGINS env var.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered dict of plugin_type -> field definitions.
|
||||||
|
"""
|
||||||
|
from core.plugin_visibility import get_public_plugin_types
|
||||||
|
|
||||||
|
public = get_public_plugin_types()
|
||||||
|
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_plugin_names() -> dict[str, str]:
|
||||||
|
"""Get plugin display names for public (non-admin) users.
|
||||||
|
|
||||||
|
Only includes plugins enabled via ENABLED_PLUGINS env var.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered dict of plugin_type -> display name.
|
||||||
|
"""
|
||||||
|
from core.plugin_visibility import get_public_plugin_types
|
||||||
|
|
||||||
|
public = get_public_plugin_types()
|
||||||
|
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate that all required credential fields are present and non-empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
credentials: Dict of credential key→value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_error_messages).
|
||||||
|
"""
|
||||||
|
fields = get_credential_fields(plugin_type)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
if field["required"]:
|
||||||
|
value = credentials.get(field["name"], "").strip()
|
||||||
|
if not value:
|
||||||
|
errors.append(f"'{field['label']}' is required")
|
||||||
|
|
||||||
|
return (len(errors) == 0, errors)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_site_connection(
|
||||||
|
plugin_type: str, url: str, credentials: dict[str, str]
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to a site using HTTP health check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message). Message is "OK" on success or
|
||||||
|
a human-readable error description.
|
||||||
|
"""
|
||||||
|
endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type)
|
||||||
|
if endpoint_info is None:
|
||||||
|
return False, f"Unknown plugin type '{plugin_type}'"
|
||||||
|
|
||||||
|
check_url = url.rstrip("/") + endpoint_info["path"]
|
||||||
|
method = endpoint_info["method"]
|
||||||
|
|
||||||
|
# Build auth headers per plugin type
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if plugin_type in ("wordpress", "wordpress_advanced"):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
app_password = credentials.get("app_password", "")
|
||||||
|
token = base64.b64encode(f"{username}:{app_password}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "woocommerce":
|
||||||
|
import base64
|
||||||
|
|
||||||
|
ck = credentials.get("consumer_key", "")
|
||||||
|
cs = credentials.get("consumer_secret", "")
|
||||||
|
token = base64.b64encode(f"{ck}:{cs}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "gitea":
|
||||||
|
headers["Authorization"] = f"token {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "n8n":
|
||||||
|
headers["X-N8N-API-KEY"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type == "supabase":
|
||||||
|
headers["apikey"] = credentials.get("service_role_key", "")
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}"
|
||||||
|
elif plugin_type == "appwrite":
|
||||||
|
headers["X-Appwrite-Project"] = credentials.get("project_id", "")
|
||||||
|
headers["X-Appwrite-Key"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type in ("directus", "coolify"):
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "openpanel":
|
||||||
|
headers["openpanel-client-id"] = credentials.get("client_id", "")
|
||||||
|
headers["openpanel-client-secret"] = credentials.get("client_secret", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
if method == "POST":
|
||||||
|
async with session.post(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
else:
|
||||||
|
async with session.get(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
|
||||||
|
if status_code < 400:
|
||||||
|
return True, "OK"
|
||||||
|
elif status_code == 401:
|
||||||
|
return False, "Authentication failed — check credentials"
|
||||||
|
elif status_code == 403:
|
||||||
|
return False, "Access forbidden — check permissions or API may be disabled"
|
||||||
|
elif status_code == 404:
|
||||||
|
return False, f"Endpoint not found at {check_url} — check URL"
|
||||||
|
else:
|
||||||
|
return False, f"HTTP {status_code}: {resp_text[:200]}"
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return False, "Connection failed — check URL and ensure the site is reachable"
|
||||||
|
except TimeoutError:
|
||||||
|
return False, "Connection timed out (15s) — site may be slow or unreachable"
|
||||||
|
except aiohttp.InvalidURL:
|
||||||
|
return False, "Invalid URL protocol — use https:// or http://"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Connection error: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user_site(
|
||||||
|
user_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
alias: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new site for a user.
|
||||||
|
|
||||||
|
Validates credentials, tests the connection, encrypts credentials,
|
||||||
|
and stores in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
alias: User-chosen friendly name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On validation errors (bad plugin type, missing fields,
|
||||||
|
alias taken, site limit reached, connection failed).
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
# Validate plugin type
|
||||||
|
if plugin_type not in PLUGIN_CREDENTIAL_FIELDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate alias format
|
||||||
|
alias = alias.strip().lower()
|
||||||
|
if not alias or len(alias) < 2 or len(alias) > 50:
|
||||||
|
raise ValueError("Alias must be 2-50 characters")
|
||||||
|
if not alias.replace("-", "").replace("_", "").isalnum():
|
||||||
|
raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores")
|
||||||
|
|
||||||
|
# Validate required credential fields
|
||||||
|
valid, errors = validate_credentials(plugin_type, credentials)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Check site limit
|
||||||
|
count = await db.count_sites_by_user(user_id)
|
||||||
|
if count >= MAX_SITES_PER_USER:
|
||||||
|
raise ValueError(f"Site limit reached ({MAX_SITES_PER_USER} sites per user)")
|
||||||
|
|
||||||
|
# Check alias uniqueness (DB constraint will also catch this)
|
||||||
|
existing = await db.get_site_by_alias(user_id, alias)
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Alias '{alias}' is already in use")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
status = "active"
|
||||||
|
status_msg = "Connection verified"
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, credentials)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt credentials
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
# We need the site_id for encryption, but we don't have it yet.
|
||||||
|
# Use a pre-generated UUID as the site_id.
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
site_id = str(uuid.uuid4())
|
||||||
|
encrypted = encryptor.encrypt_credentials(credentials, site_id)
|
||||||
|
|
||||||
|
# Store in database — we bypass db.create_site() to use our pre-generated ID
|
||||||
|
from core.database import _utc_now
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
||||||
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the created site (without credentials blob)
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||||
|
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_sites(user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all sites for a user (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dicts.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
sites = await db.get_sites_by_user(user_id)
|
||||||
|
# Strip credentials blob from response
|
||||||
|
return [{k: v for k, v in site.items() if k != "credentials"} for site in sites]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a single site (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict or None.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
return None
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete a user's site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_site(site_id, user_id)
|
||||||
|
if deleted:
|
||||||
|
logger.info("Deleted site %s for user %s", site_id, user_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_site(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update URL and credentials for an existing site.
|
||||||
|
|
||||||
|
Password fields left blank are preserved from the existing encrypted credentials.
|
||||||
|
Re-validates the connection after update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
url: New base URL (required).
|
||||||
|
credentials: Credential dict — blank password fields keep their current value.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found, validation fails, or connection test fails.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Verify site ownership
|
||||||
|
existing_site = await db.get_site(site_id, user_id)
|
||||||
|
if existing_site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
plugin_type = existing_site["plugin_type"]
|
||||||
|
|
||||||
|
# Validate URL
|
||||||
|
url = url.strip().rstrip("/")
|
||||||
|
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||||
|
raise ValueError("URL must start with http:// or https://")
|
||||||
|
|
||||||
|
# Merge new credentials with existing ones — blank fields keep existing values
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
|
||||||
|
|
||||||
|
merged: dict[str, str] = dict(existing_credentials)
|
||||||
|
for key, value in credentials.items():
|
||||||
|
# Only override if the new value is non-empty
|
||||||
|
if value and value.strip():
|
||||||
|
merged[key] = value.strip()
|
||||||
|
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
|
||||||
|
else:
|
||||||
|
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
|
||||||
|
if key in field_defs and not field_defs[key].get("required", True):
|
||||||
|
merged[key] = ""
|
||||||
|
|
||||||
|
# Strip empty optional values before storing (keep storage clean)
|
||||||
|
merged = {k: v for k, v in merged.items() if v}
|
||||||
|
|
||||||
|
# Validate required fields are still present
|
||||||
|
valid, errors = validate_credentials(plugin_type, merged)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, merged)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt merged credentials
|
||||||
|
encrypted = encryptor.encrypt_credentials(merged, site_id)
|
||||||
|
|
||||||
|
# Persist
|
||||||
|
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
|
||||||
|
if not updated:
|
||||||
|
raise RuntimeError(f"Failed to update site {site_id}")
|
||||||
|
|
||||||
|
# Mark active after successful connection test
|
||||||
|
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
|
||||||
|
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
|
||||||
|
|
||||||
|
result = await db.get_site(site_id, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back updated site {site_id}")
|
||||||
|
|
||||||
|
site_dict = dict(result)
|
||||||
|
site_dict.pop("credentials", None)
|
||||||
|
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
|
||||||
|
return site_dict
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to an existing site.
|
||||||
|
|
||||||
|
Decrypts credentials from the database, runs the health check,
|
||||||
|
and updates the site status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials)
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
new_status = "active" if ok else "error"
|
||||||
|
await db.update_site_status(site_id, new_status, msg, user_id=user_id)
|
||||||
|
|
||||||
|
return ok, msg
|
||||||
@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
|
|||||||
Manages site configurations with Pydantic validation.
|
Manages site configurations with Pydantic validation.
|
||||||
Part of Option B clean architecture refactoring.
|
Part of Option B clean architecture refactoring.
|
||||||
|
|
||||||
Discovers sites from environment variables:
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
The SiteManager provides registration and lookup infrastructure for
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
plugin tool generation and endpoint routing.
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_ALIAS=myblog
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||||
@@ -50,6 +42,7 @@ class SiteConfig(BaseModel):
|
|||||||
site_id: str = Field(..., description="Unique site identifier")
|
site_id: str = Field(..., description="Unique site identifier")
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
alias: str | None = Field(None, description="Friendly alias for the site")
|
alias: str | None = Field(None, description="Friendly alias for the site")
|
||||||
|
user_id: str | None = Field(None, description="Owner user ID for the site")
|
||||||
|
|
||||||
# Common config fields (plugins may require additional fields)
|
# Common config fields (plugins may require additional fields)
|
||||||
url: str | None = Field(None, description="Site URL")
|
url: str | None = Field(None, description="Site URL")
|
||||||
@@ -110,7 +103,8 @@ class SiteManager:
|
|||||||
"""
|
"""
|
||||||
Manage site configurations with type safety.
|
Manage site configurations with type safety.
|
||||||
|
|
||||||
Discovers, validates, and provides access to site configurations.
|
Provides registration and lookup of site configurations.
|
||||||
|
Sites are registered programmatically (e.g., from database) via register_site().
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
sites: Dictionary mapping plugin_type to site configurations
|
sites: Dictionary mapping plugin_type to site configurations
|
||||||
@@ -119,7 +113,7 @@ class SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = SiteManager()
|
>>> manager = SiteManager()
|
||||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
>>> manager.register_site(config)
|
||||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||||
>>> sites = manager.list_sites('wordpress')
|
>>> sites = manager.list_sites('wordpress')
|
||||||
"""
|
"""
|
||||||
@@ -135,152 +129,6 @@ class SiteManager:
|
|||||||
self.logger = logging.getLogger("SiteManager")
|
self.logger = logging.getLogger("SiteManager")
|
||||||
self.logger.info("SiteManager initialized")
|
self.logger.info("SiteManager initialized")
|
||||||
|
|
||||||
def discover_sites(self, plugin_types: list[str]) -> int:
|
|
||||||
"""
|
|
||||||
Discover sites from environment variables.
|
|
||||||
|
|
||||||
Scans environment for site configurations and registers them.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sites discovered
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> count = manager.discover_sites(['wordpress', 'gitea'])
|
|
||||||
>>> print(f"Discovered {count} sites")
|
|
||||||
"""
|
|
||||||
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
|
|
||||||
|
|
||||||
total_discovered = 0
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
count = self._discover_plugin_sites(plugin_type)
|
|
||||||
total_discovered += count
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Discovery complete. Found {total_discovered} sites "
|
|
||||||
f"across {len(plugin_types)} plugin types."
|
|
||||||
)
|
|
||||||
|
|
||||||
return total_discovered
|
|
||||||
|
|
||||||
# Reserved words that should NOT be interpreted as site IDs
|
|
||||||
RESERVED_SITE_WORDS = {
|
|
||||||
"limit",
|
|
||||||
"rate",
|
|
||||||
"config",
|
|
||||||
"debug",
|
|
||||||
"log",
|
|
||||||
"level",
|
|
||||||
"mode",
|
|
||||||
"timeout",
|
|
||||||
"retry",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"default",
|
|
||||||
"global",
|
|
||||||
"enabled",
|
|
||||||
"disabled",
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"path",
|
|
||||||
"key",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"advanced",
|
|
||||||
"basic",
|
|
||||||
"simple",
|
|
||||||
"pro",
|
|
||||||
"premium",
|
|
||||||
"standard",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _discover_plugin_sites(self, plugin_type: str) -> int:
|
|
||||||
"""
|
|
||||||
Discover all sites for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sites discovered for this plugin type
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> count = manager._discover_plugin_sites('wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
# Find all unique site IDs
|
|
||||||
site_ids = set()
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
site_id = match.group(1).lower()
|
|
||||||
# Skip reserved words that are not real site IDs
|
|
||||||
if site_id not in self.RESERVED_SITE_WORDS:
|
|
||||||
site_ids.add(site_id)
|
|
||||||
|
|
||||||
# Load configuration for each site
|
|
||||||
discovered_count = 0
|
|
||||||
for site_id in site_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_site_config(plugin_type, site_id)
|
|
||||||
if config:
|
|
||||||
self.register_site(config)
|
|
||||||
discovered_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return discovered_count
|
|
||||||
|
|
||||||
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a site from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_id: Site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SiteConfig if successful, None if incomplete
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> config = manager._load_site_config('wordpress', 'site1')
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
|
||||||
config_data = {"site_id": site_id, "plugin_type": plugin_type}
|
|
||||||
|
|
||||||
# Collect all config keys for this site
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config_data[config_key] = env_value
|
|
||||||
|
|
||||||
# Must have at least some configuration beyond site_id and plugin_type
|
|
||||||
if len(config_data) <= 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create and validate SiteConfig
|
|
||||||
config = SiteConfig(**config_data)
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def register_site(self, config: SiteConfig) -> None:
|
def register_site(self, config: SiteConfig) -> None:
|
||||||
"""
|
"""
|
||||||
Register a site configuration.
|
Register a site configuration.
|
||||||
@@ -340,7 +188,7 @@ class SiteManager:
|
|||||||
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No sites configured for plugin type: {plugin_type}. "
|
f"No sites configured for plugin type: {plugin_type}. "
|
||||||
f"Please check your environment variables."
|
f"Please add a site via the dashboard."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try direct lookup
|
# Try direct lookup
|
||||||
@@ -357,7 +205,7 @@ class SiteManager:
|
|||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Site '{site}' not configured for {plugin_type}. "
|
f"Site '{site}' not configured for {plugin_type}. "
|
||||||
f"Please verify the site alias/ID and check environment variables."
|
f"Please verify the site alias/ID in the dashboard."
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_sites(self, plugin_type: str) -> list[str]:
|
def list_sites(self, plugin_type: str) -> list[str]:
|
||||||
@@ -437,6 +285,7 @@ class SiteManager:
|
|||||||
"site_id": config.site_id,
|
"site_id": config.site_id,
|
||||||
"alias": config.alias,
|
"alias": config.alias,
|
||||||
"full_id": config.get_full_id(),
|
"full_id": config.get_full_id(),
|
||||||
|
"user_id": config.user_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -486,21 +335,14 @@ class SiteManager:
|
|||||||
>>> suffix = manager.get_effective_path_suffix('wordpress_site1')
|
>>> suffix = manager.get_effective_path_suffix('wordpress_site1')
|
||||||
>>> print(suffix) # 'myblog' or 'wordpress_site1'
|
>>> print(suffix) # 'myblog' or 'wordpress_site1'
|
||||||
"""
|
"""
|
||||||
# Parse full_id to get plugin_type and site_id
|
# Look up by full_id from registered sites (handles multi-word plugin types)
|
||||||
parts = full_id.split("_", 1)
|
for info in self.list_all_sites():
|
||||||
if len(parts) != 2:
|
if info["full_id"] == full_id:
|
||||||
return full_id
|
config = self.sites[info["plugin_type"]].get(info["site_id"])
|
||||||
|
if config and config.alias and config.alias != config.site_id:
|
||||||
plugin_type, site_id = parts
|
|
||||||
|
|
||||||
# Try to get config
|
|
||||||
try:
|
|
||||||
config = self.get_site_config(plugin_type, site_id)
|
|
||||||
# If alias exists and is different from site_id, use alias
|
|
||||||
if config.alias and config.alias != config.site_id:
|
|
||||||
return config.alias
|
return config.alias
|
||||||
return full_id
|
return full_id
|
||||||
except ValueError:
|
|
||||||
return full_id
|
return full_id
|
||||||
|
|
||||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
||||||
@@ -535,7 +377,7 @@ def get_site_manager() -> SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = get_site_manager()
|
>>> manager = get_site_manager()
|
||||||
>>> manager.discover_sites(['wordpress'])
|
>>> manager.register_site(config)
|
||||||
"""
|
"""
|
||||||
global _site_manager
|
global _site_manager
|
||||||
if _site_manager is None:
|
if _site_manager is None:
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
"""
|
|
||||||
Site Registry
|
|
||||||
|
|
||||||
Manages site configurations discovered from environment variables.
|
|
||||||
Supports multi-site setups with aliases and dynamic discovery.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SiteInfo:
|
|
||||||
"""Information about a single site."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, plugin_type: str, site_id: str, config: dict[str, Any], alias: str | None = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize site information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
site_id: Site identifier (e.g., 'site1')
|
|
||||||
config: Site configuration from environment
|
|
||||||
alias: Optional friendly alias for the site
|
|
||||||
"""
|
|
||||||
self.plugin_type = plugin_type
|
|
||||||
self.site_id = site_id
|
|
||||||
self.config = config
|
|
||||||
self.alias = alias or site_id
|
|
||||||
|
|
||||||
def get_full_id(self) -> str:
|
|
||||||
"""Get full site identifier: plugin_type_site_id"""
|
|
||||||
return f"{self.plugin_type}_{self.site_id}"
|
|
||||||
|
|
||||||
def get_display_name(self) -> str:
|
|
||||||
"""Get display name (alias if available, otherwise site_id)"""
|
|
||||||
return self.alias
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""Convert to dictionary for serialization."""
|
|
||||||
return {
|
|
||||||
"plugin_type": self.plugin_type,
|
|
||||||
"site_id": self.site_id,
|
|
||||||
"alias": self.alias,
|
|
||||||
"full_id": self.get_full_id(),
|
|
||||||
"config_keys": list(self.config.keys()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRegistry:
|
|
||||||
"""
|
|
||||||
Registry for managing site configurations across plugin types.
|
|
||||||
|
|
||||||
Discovers sites from environment variables:
|
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_URL=https://myblog.com
|
|
||||||
WORDPRESS_SITE2_ALIAS=myblog
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize site registry."""
|
|
||||||
self.sites: dict[str, SiteInfo] = {} # full_id -> SiteInfo
|
|
||||||
self.aliases: dict[str, str] = {} # alias -> full_id
|
|
||||||
self.alias_conflicts: dict[str, list[str]] = {} # alias -> [full_ids that wanted it]
|
|
||||||
self.logger = logging.getLogger("SiteRegistry")
|
|
||||||
|
|
||||||
def discover_sites(self, plugin_types: list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Discover sites from environment variables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
|
||||||
"""
|
|
||||||
self.logger.info("Starting site discovery...")
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
self._discover_plugin_sites(plugin_type)
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Discovery complete. Found {len(self.sites)} sites "
|
|
||||||
f"with {len(self.aliases)} aliases."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log alias conflicts if any
|
|
||||||
if self.alias_conflicts:
|
|
||||||
self.logger.warning("=" * 50)
|
|
||||||
self.logger.warning("DUPLICATE ALIAS CONFLICTS DETECTED:")
|
|
||||||
for alias, full_ids in self.alias_conflicts.items():
|
|
||||||
winner = self.aliases.get(alias)
|
|
||||||
losers = [fid for fid in full_ids if fid != winner]
|
|
||||||
self.logger.warning(
|
|
||||||
f" Alias '{alias}': {winner} (winner), {losers} (using full_id)"
|
|
||||||
)
|
|
||||||
self.logger.warning("=" * 50)
|
|
||||||
|
|
||||||
# Reserved words that should NOT be interpreted as site IDs
|
|
||||||
RESERVED_SITE_WORDS = {
|
|
||||||
"limit",
|
|
||||||
"rate",
|
|
||||||
"config",
|
|
||||||
"debug",
|
|
||||||
"log",
|
|
||||||
"level",
|
|
||||||
"mode",
|
|
||||||
"timeout",
|
|
||||||
"retry",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"default",
|
|
||||||
"global",
|
|
||||||
"enabled",
|
|
||||||
"disabled",
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"path",
|
|
||||||
"key",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"advanced",
|
|
||||||
"basic",
|
|
||||||
"simple",
|
|
||||||
"pro",
|
|
||||||
"premium",
|
|
||||||
"standard",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _discover_plugin_sites(self, plugin_type: str) -> None:
|
|
||||||
"""
|
|
||||||
Discover all sites for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Find all site IDs for this plugin type
|
|
||||||
site_ids = set()
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
site_id = match.group(1).lower()
|
|
||||||
# Skip reserved words that are not real site IDs
|
|
||||||
if site_id not in self.RESERVED_SITE_WORDS:
|
|
||||||
site_ids.add(site_id)
|
|
||||||
|
|
||||||
# Create SiteInfo for each discovered site
|
|
||||||
for site_id in site_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_site_config(plugin_type, site_id)
|
|
||||||
if config:
|
|
||||||
alias = config.pop("alias", None) # Extract alias if present
|
|
||||||
site_info = SiteInfo(plugin_type, site_id, config, alias)
|
|
||||||
|
|
||||||
full_id = site_info.get_full_id()
|
|
||||||
self.sites[full_id] = site_info
|
|
||||||
|
|
||||||
# Register alias with duplicate detection
|
|
||||||
if alias:
|
|
||||||
self._register_alias_safe(alias, full_id)
|
|
||||||
# Also register with prefix
|
|
||||||
prefixed_alias = f"{plugin_type}_{alias}"
|
|
||||||
self._register_alias_safe(prefixed_alias, full_id)
|
|
||||||
|
|
||||||
# Always register site_id as alias too (with safe check)
|
|
||||||
self._register_alias_safe(site_id, full_id)
|
|
||||||
self.aliases[full_id] = (
|
|
||||||
full_id # full_id can reference itself (no conflict possible)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log with alias status
|
|
||||||
effective_alias = self._get_effective_alias(alias, full_id)
|
|
||||||
self.logger.info(f"Discovered site: {full_id} " f"(path: {effective_alias})")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _register_alias_safe(self, alias: str, full_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Register an alias with duplicate detection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
alias: The alias to register
|
|
||||||
full_id: The full_id to map the alias to
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if alias was registered, False if it was a duplicate
|
|
||||||
"""
|
|
||||||
if alias in self.aliases:
|
|
||||||
existing_full_id = self.aliases[alias]
|
|
||||||
if existing_full_id != full_id:
|
|
||||||
# Duplicate alias detected
|
|
||||||
if alias not in self.alias_conflicts:
|
|
||||||
self.alias_conflicts[alias] = [existing_full_id]
|
|
||||||
self.alias_conflicts[alias].append(full_id)
|
|
||||||
self.logger.warning(
|
|
||||||
f"Duplicate alias '{alias}': {full_id} conflicts with {existing_full_id}. "
|
|
||||||
f"{full_id} will use full_id for endpoint path."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.aliases[alias] = full_id
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _get_effective_alias(self, alias: str | None, full_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get the effective path suffix for a site.
|
|
||||||
|
|
||||||
If the alias was taken by another site, returns full_id.
|
|
||||||
Otherwise returns the alias (or full_id if no alias).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
alias: The desired alias
|
|
||||||
full_id: The full site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The effective path suffix
|
|
||||||
"""
|
|
||||||
if alias:
|
|
||||||
# Check if this site owns the alias
|
|
||||||
if self.aliases.get(alias) == full_id:
|
|
||||||
return alias
|
|
||||||
else:
|
|
||||||
# Alias was taken, use full_id
|
|
||||||
return full_id
|
|
||||||
return full_id
|
|
||||||
|
|
||||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
|
||||||
"""
|
|
||||||
Get all alias conflicts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping conflicted aliases to list of full_ids that wanted them
|
|
||||||
"""
|
|
||||||
return self.alias_conflicts.copy()
|
|
||||||
|
|
||||||
def get_effective_path_suffix(self, full_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get the effective path suffix for a site's endpoint.
|
|
||||||
|
|
||||||
Uses alias if available and not conflicted, otherwise full_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: The full site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path suffix to use in endpoint URL
|
|
||||||
"""
|
|
||||||
site_info = self.sites.get(full_id)
|
|
||||||
if not site_info:
|
|
||||||
return full_id
|
|
||||||
|
|
||||||
alias = site_info.alias
|
|
||||||
return self._get_effective_alias(alias, full_id)
|
|
||||||
|
|
||||||
def _load_site_config(self, plugin_type: str, site_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a site from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_id: Site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with configuration or None if incomplete
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# Collect all config keys for this site
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config[config_key] = env_value
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(f"Loaded config for {plugin_type}/{site_id}: {list(config.keys())}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
def get_site(self, plugin_type: str, site_identifier: str) -> SiteInfo | None:
|
|
||||||
"""
|
|
||||||
Get site information by ID or alias.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type (e.g., 'wordpress')
|
|
||||||
site_identifier: Site ID, alias, or full_id
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SiteInfo if found, None otherwise
|
|
||||||
"""
|
|
||||||
# Try direct lookup first
|
|
||||||
full_id = f"{plugin_type}_{site_identifier}"
|
|
||||||
if full_id in self.sites:
|
|
||||||
return self.sites[full_id]
|
|
||||||
|
|
||||||
# Try alias lookup
|
|
||||||
if site_identifier in self.aliases:
|
|
||||||
resolved_full_id = self.aliases[site_identifier]
|
|
||||||
return self.sites.get(resolved_full_id)
|
|
||||||
|
|
||||||
# Try prefixed alias
|
|
||||||
prefixed = f"{plugin_type}_{site_identifier}"
|
|
||||||
if prefixed in self.aliases:
|
|
||||||
resolved_full_id = self.aliases[prefixed]
|
|
||||||
return self.sites.get(resolved_full_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sites_by_type(self, plugin_type: str) -> list[SiteInfo]:
|
|
||||||
"""
|
|
||||||
Get all sites of a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type to filter by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SiteInfo objects
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
site_info for site_info in self.sites.values() if site_info.plugin_type == plugin_type
|
|
||||||
]
|
|
||||||
|
|
||||||
def list_all_sites(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all discovered sites.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of site info dictionaries
|
|
||||||
"""
|
|
||||||
return [site_info.to_dict() for site_info in self.sites.values()]
|
|
||||||
|
|
||||||
def get_site_options(self, plugin_type: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get available site options for a plugin type (for schema enum).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of valid site identifiers (IDs and aliases)
|
|
||||||
"""
|
|
||||||
options = set()
|
|
||||||
for site_info in self.get_sites_by_type(plugin_type):
|
|
||||||
options.add(site_info.site_id)
|
|
||||||
if site_info.alias and site_info.alias != site_info.site_id:
|
|
||||||
options.add(site_info.alias)
|
|
||||||
return sorted(options)
|
|
||||||
|
|
||||||
|
|
||||||
# Global site registry instance
|
|
||||||
_site_registry: SiteRegistry | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_site_registry() -> SiteRegistry:
|
|
||||||
"""Get the global site registry instance."""
|
|
||||||
global _site_registry
|
|
||||||
if _site_registry is None:
|
|
||||||
_site_registry = SiteRegistry()
|
|
||||||
return _site_registry
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}MCP Hub{% endblock %}</title>
|
<title>{% block title %}MCP Hub{% endblock %}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Tailwind CSS from CDN -->
|
<!-- Tailwind CSS from CDN -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
59
core/templates/dashboard/404.html
Normal file
59
core/templates/dashboard/404.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>404 — Page Not Found | MCP Hub</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center px-6 py-16 max-w-lg">
|
||||||
|
<div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8">
|
||||||
|
<svg class="w-10 h-10 text-primary-500 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-6xl font-bold text-primary-500 dark:text-primary-400 mb-4">404</h1>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-300 mb-3">Page Not Found</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p>
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<a href="/dashboard"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/health"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors">
|
||||||
|
Health Check
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-12 text-xs text-gray-400 dark:text-gray-600">MCP Hub</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400 text-sm">
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
<select
|
<select
|
||||||
name="project"
|
name="project"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_project or selected_status != 'active' %}
|
{% if search_query or selected_project or selected_status != 'active' %}
|
||||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -84,42 +84,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Table -->
|
<!-- API Keys Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if api_keys %}
|
{% if api_keys %}
|
||||||
{% for key in api_keys %}
|
{% for key in api_keys %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||||
<!-- Key ID -->
|
<!-- Key ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||||
<button
|
<button
|
||||||
onclick="copyToClipboard('{{ key.key_id }}')"
|
onclick="copyToClipboard('{{ key.key_id }}')"
|
||||||
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-sm text-gray-300">{{ key.project_id }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ key.description or '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
|
|
||||||
<!-- Usage -->
|
<!-- Usage -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300">{{ key.usage_count }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span>
|
||||||
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||||
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -236,8 +236,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% if page_number > 1 %}
|
||||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %}
|
||||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -280,9 +280,9 @@
|
|||||||
|
|
||||||
<!-- Create Key Modal -->
|
<!-- Create Key Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,13 +291,13 @@
|
|||||||
|
|
||||||
<!-- Project -->
|
<!-- Project -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="project_id"
|
name="project_id"
|
||||||
required
|
required
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
{% for project in available_projects %}
|
{% for project in available_projects %}
|
||||||
@@ -308,46 +308,46 @@
|
|||||||
|
|
||||||
<!-- Scope -->
|
<!-- Scope -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiration -->
|
<!-- Expiration -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="expires_in_days"
|
name="expires_in_days"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
||||||
@@ -357,10 +357,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeCreateModal()"
|
onclick="closeCreateModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -376,9 +376,9 @@
|
|||||||
|
|
||||||
<!-- New Key Modal (shows the actual key) -->
|
<!-- New Key Modal (shows the actual key) -->
|
||||||
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white flex items-center">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
<svg class="w-6 h-6 text-green-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="newKeyValue"
|
id="newKeyValue"
|
||||||
readonly
|
readonly
|
||||||
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick="copyNewKey()"
|
onclick="copyNewKey()"
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onclick="closeNewKeyModal()"
|
onclick="closeNewKeyModal()"
|
||||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
||||||
@@ -427,14 +427,14 @@
|
|||||||
|
|
||||||
<!-- Revoke Modal -->
|
<!-- Revoke Modal -->
|
||||||
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -445,10 +445,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeRevokeModal()"
|
onclick="closeRevokeModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -464,14 +464,14 @@
|
|||||||
|
|
||||||
<!-- Delete Modal -->
|
<!-- Delete Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -482,10 +482,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeDeleteModal()"
|
onclick="closeDeleteModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -6,16 +6,16 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="project"
|
name="project"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
|
|
||||||
<!-- Event Type Filter -->
|
<!-- Event Type Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
<select
|
<select
|
||||||
name="event_type"
|
name="event_type"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
|
|
||||||
<!-- Level Filter -->
|
<!-- Level Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="level"
|
name="level"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
@@ -60,12 +60,12 @@
|
|||||||
|
|
||||||
<!-- Date Filter -->
|
<!-- Date Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="date"
|
name="date"
|
||||||
value="{{ selected_date }}"
|
value="{{ selected_date }}"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
||||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
|
|
||||||
<!-- Stats Summary -->
|
<!-- Stats Summary -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.total }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -108,11 +108,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.tool_calls }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.tool_calls }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -121,11 +121,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.auth_events }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.auth_events }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -134,11 +134,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.errors }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.errors }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -150,35 +150,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Table -->
|
<!-- Logs Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ log.timestamp[:19] }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] if log.timestamp else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
<!-- Event/Message -->
|
<!-- Event/Message -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
||||||
{% if log.project_id %}
|
{% if log.project_id %}
|
||||||
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -249,8 +249,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -261,17 +261,17 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% if page_number > 1 %}
|
||||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in range(1, total_pages + 1) %}
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
{% if page_num == page_number %}
|
{% if page_num == page_number %}
|
||||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-gray-900 dark:text-white">{{ page_num }}</span>
|
||||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %}
|
||||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
209
core/templates/dashboard/auth-login.html
Normal file
209
core/templates/dashboard/auth-login.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - MCP Hub</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
|
<style>
|
||||||
|
.login-gradient {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shadow {
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github {
|
||||||
|
background: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github:hover {
|
||||||
|
background: #2f363d;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(36, 41, 46, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google {
|
||||||
|
background: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(66, 133, 244, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-admin {
|
||||||
|
border: 1px solid #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-admin:hover {
|
||||||
|
background: #374151;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-all {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||||
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||||
|
style="animation-delay: -3s;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-md">
|
||||||
|
<div class="card-shadow bg-gray-800 rounded-2xl p-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||||
|
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||||
|
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||||
|
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
{% if lang == 'fa' %}برای مدیریت سایتهایتان وارد شوید{% else %}Sign in to manage your sites{% endif
|
||||||
|
%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} flex-shrink-0"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-red-400 text-sm">
|
||||||
|
{% if error == 'rate_limit' %}
|
||||||
|
{% if lang == 'fa' %}تلاشهای ثبتنام زیاد. لطفاً بعداً تلاش کنید.{% else %}Too many
|
||||||
|
registration attempts. Please try again later.{% endif %}
|
||||||
|
{% elif error == 'oauth_denied' %}
|
||||||
|
{% if lang == 'fa' %}احراز هویت لغو یا رد شد.{% else %}Authentication was cancelled or denied.{%
|
||||||
|
endif %}
|
||||||
|
{% elif error == 'no_email' %}
|
||||||
|
{% if lang == 'fa' %}آدرس ایمیل شما قابل دریافت نبود.{% else %}Could not retrieve your email
|
||||||
|
address. Please check your provider settings.{% endif %}
|
||||||
|
{% elif error == 'invalid_state' %}
|
||||||
|
{% if lang == 'fa' %}نشست منقضی شده. لطفاً دوباره تلاش کنید.{% else %}Session expired. Please
|
||||||
|
try again.{% endif %}
|
||||||
|
{% elif error == 'exchange_failed' %}
|
||||||
|
{% if lang == 'fa' %}احراز هویت ناموفق. لطفاً دوباره تلاش کنید.{% else %}Authentication failed.
|
||||||
|
Please try again.{% endif %}
|
||||||
|
{% elif error == 'provider_unavailable' %}
|
||||||
|
{% if lang == 'fa' %}این ارائهدهنده ورود در دسترس نیست.{% else %}This login provider is not
|
||||||
|
available.{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if lang == 'fa' %}خطایی رخ داد. لطفاً دوباره تلاش کنید.{% else %}An error occurred. Please
|
||||||
|
try again.{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- OAuth Buttons -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% if "github" in providers %}
|
||||||
|
<a href="/auth/github"
|
||||||
|
class="btn-github transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود با GitHub{% else %}Continue with GitHub{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if "google" in providers %}
|
||||||
|
<a href="/auth/google"
|
||||||
|
class="btn-google transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#fff" />
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#fff" fill-opacity="0.8" />
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#fff" fill-opacity="0.6" />
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#fff" fill-opacity="0.9" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود با Google{% else %}Continue with Google{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not providers %}
|
||||||
|
<div class="text-center p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<p class="text-yellow-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}هیچ ارائهدهنده OAuth پیکربندی نشده.{% else %}No OAuth providers
|
||||||
|
configured. Set GITHUB_CLIENT_ID/SECRET or GOOGLE_CLIENT_ID/SECRET in your environment.{% endif
|
||||||
|
%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider + Admin Login Link (hidden when DISABLE_MASTER_KEY_LOGIN=true) -->
|
||||||
|
{% if not master_login_disabled %}
|
||||||
|
<div class="my-6 flex items-center">
|
||||||
|
<div class="flex-1 border-t border-gray-600"></div>
|
||||||
|
<span class="px-4 text-sm text-gray-400">
|
||||||
|
{% if lang == 'fa' %}یا{% else %}or{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Language Toggle -->
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||||
|
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||||
|
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-500 text-sm mt-6">MCP Hub v{{ version }}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
324
core/templates/dashboard/base.html
Normal file
324
core/templates/dashboard/base.html
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar — light mode */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar — dark mode */
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar transition */
|
||||||
|
.sidebar-transition {
|
||||||
|
transition: width 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effect */
|
||||||
|
.card-hover {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator pulse */
|
||||||
|
.status-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL adjustments */
|
||||||
|
[dir="rtl"] .sidebar-icon {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .sidebar-icon {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
{# ── Derive RBAC info from session ── #}
|
||||||
|
{% set _is_admin = is_admin_session(session) %}
|
||||||
|
{% set _is_oauth_admin = _is_admin and session is mapping %}
|
||||||
|
{% set _display = get_session_display_info(session) %}
|
||||||
|
|
||||||
|
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
|
||||||
|
x-data="{ sidebarOpen: true, darkMode: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
|
||||||
|
x-init="$watch('darkMode', val => {
|
||||||
|
if(val) { document.documentElement.classList.add('dark'); localStorage.theme = 'dark'; }
|
||||||
|
else { document.documentElement.classList.remove('dark'); localStorage.theme = 'light'; }
|
||||||
|
})">
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col {% if lang=='fa' %}border-l{% else %}border-r{% endif %} border-gray-200 dark:border-gray-700"
|
||||||
|
:class="sidebarOpen ? 'w-64' : 'w-20'">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center" x-show="sidebarOpen">
|
||||||
|
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||||
|
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
|
||||||
|
</div>
|
||||||
|
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
||||||
|
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||||
|
</div>
|
||||||
|
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{# ── Common nav items (all users) ── #}
|
||||||
|
{% set common_nav = [
|
||||||
|
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1
|
||||||
|
1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# ── User-only nav items ── #}
|
||||||
|
{% set user_nav = [
|
||||||
|
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
|
||||||
|
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||||
|
'/dashboard/sites'),
|
||||||
|
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
||||||
|
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
||||||
|
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
|
||||||
|
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
|
||||||
|
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
|
||||||
|
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# ── Admin-only nav items ── #}
|
||||||
|
{% set admin_nav = [
|
||||||
|
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||||
|
'/dashboard/projects'),
|
||||||
|
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
||||||
|
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
|
||||||
|
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
|
||||||
|
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
||||||
|
'/dashboard/audit-logs'),
|
||||||
|
('health', t.health, 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12
|
||||||
|
7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', '/dashboard/health'),
|
||||||
|
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573
|
||||||
|
1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724
|
||||||
|
0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35
|
||||||
|
0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0
|
||||||
|
00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31
|
||||||
|
2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# Render common nav #}
|
||||||
|
{% for item_id, label, icon_path, url in common_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Render user nav (non-admin users + OAuth admins) #}
|
||||||
|
{% if not _is_admin or _is_oauth_admin %}
|
||||||
|
{% for item_id, label, icon_path, url in user_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Render admin nav (admin only) #}
|
||||||
|
{% if _is_admin %}
|
||||||
|
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p x-show="sidebarOpen" class="px-3 mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
|
{% if lang == 'fa' %}مدیریت{% else %}Administration{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% for item_id, label, icon_path, url in admin_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Support link -->
|
||||||
|
<div x-show="sidebarOpen" class="px-4 py-2">
|
||||||
|
<a href="https://nowpayments.io/donation/airano" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="flex items-center text-xs text-gray-400 dark:text-gray-500 hover:text-pink-400 dark:hover:text-pink-400 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}حمایت از MCP Hub{% else %}Support MCP Hub{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version footer -->
|
||||||
|
{% if project_version %}
|
||||||
|
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
v{{ project_version }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- User Section -->
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{# Profile link for OAuth users (including OAuth admins) #}
|
||||||
|
{% if not _is_admin or _is_oauth_admin %}
|
||||||
|
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen">{{ t.get('profile', 'Profile') }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/dashboard/logout"
|
||||||
|
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
||||||
|
<!-- Top Header -->
|
||||||
|
<header class="sticky top-0 z-10 bg-white/95 dark:bg-gray-800/95 backdrop-blur border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between h-16 px-6">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button @click="darkMode = !darkMode"
|
||||||
|
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Toggle Dark Mode">
|
||||||
|
<svg x-show="darkMode" class="w-5 h-5" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
<svg x-show="!darkMode" class="w-5 h-5" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Language Toggle -->
|
||||||
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||||
|
class="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-800 dark:text-gray-200">
|
||||||
|
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
hx-target="body" hx-swap="outerHTML"
|
||||||
|
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="{{ t.refresh }}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
{% if session %}
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
{% if _is_admin %}
|
||||||
|
<span class="px-2 py-1 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded text-xs font-medium">
|
||||||
|
{{ t.get('admin_badge', 'Admin') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ _display.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="p-6">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
299
core/templates/dashboard/connect.html
Normal file
299
core/templates/dashboard/connect.html
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.connect }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.connect }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
|
||||||
|
<!-- API Keys Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.api_keys }}</h3>
|
||||||
|
<button onclick="createKey()" id="create-key-btn"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.generate_key }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New key alert -->
|
||||||
|
{% if new_key %}
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
||||||
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
||||||
|
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- New Key Modal (stays open until user clicks Done) -->
|
||||||
|
<div id="new-key-display" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.your_api_key }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ t.key_shown_once }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-key-value" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyNewKey()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}تأیید{% else %}Done{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if api_keys %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.api_key_name }}</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}دسترسی{% else %}Access{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr id="key-{{ key.id }}">
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
||||||
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button onclick="deleteKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">{{ t.delete }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_api_keys }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Snippets + Connection Guide (unified) -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3>
|
||||||
|
|
||||||
|
{% if sites %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_site }}</label>
|
||||||
|
<select id="config-site" onchange="updateConfig()"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for site in sites %}
|
||||||
|
<option value="{{ site.alias }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_client }}</label>
|
||||||
|
<select id="config-client" onchange="updateConfig()"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
||||||
|
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transport type info (hidden for web-based clients via JS) -->
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WordPress SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
|
||||||
|
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||||
|
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||||
|
را روی سایت وردپرسی خود نصب کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>WordPress:</strong> For SEO tools, install the
|
||||||
|
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||||
|
plugin on your WordPress site.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection tip (shown only for web-based clients via JS) -->
|
||||||
|
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||||
|
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||||
|
{% else %}
|
||||||
|
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||||
|
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key hint for desktop/CLI clients (hidden for web clients via JS) -->
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one above and use it in your config:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function createKey() {
|
||||||
|
const name = prompt('Key name (e.g., "Claude Desktop"):');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('create-key-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/keys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok && data.key) {
|
||||||
|
document.getElementById('new-key-value').textContent = data.key.key;
|
||||||
|
document.getElementById('new-key-display').classList.remove('hidden');
|
||||||
|
document.getElementById('new-key-display').classList.add('flex');
|
||||||
|
// Modal stays open — user clicks "Done" to dismiss
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to create key');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(keyId) {
|
||||||
|
if (!confirm('Delete this API key?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('key-' + keyId).remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to delete key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const siteSelect = document.getElementById('config-site');
|
||||||
|
const alias = siteSelect?.value;
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!alias || !client) return;
|
||||||
|
|
||||||
|
const isWebClient = WEB_CLIENTS.includes(client);
|
||||||
|
|
||||||
|
// Show/hide transport note
|
||||||
|
const transportNote = document.getElementById('transport-note');
|
||||||
|
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
// Show/hide connection tip (only for web clients)
|
||||||
|
const connTip = document.getElementById('connection-tip');
|
||||||
|
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||||
|
|
||||||
|
// Show/hide bearer hint (only for desktop/CLI clients)
|
||||||
|
const bearerHint = document.getElementById('bearer-hint');
|
||||||
|
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
// Show/hide WordPress SEO note based on selected site's plugin type
|
||||||
|
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||||
|
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||||
|
const wpNote = document.getElementById('wp-seo-note');
|
||||||
|
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('config-output').textContent = data.config;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('config-output').textContent = 'Error loading config';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
const key = document.getElementById('new-key-value').textContent;
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewKeyModal() {
|
||||||
|
document.getElementById('new-key-display').classList.add('hidden');
|
||||||
|
document.getElementById('new-key-display').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
btn.textContent = '{{ t.copied }}';
|
||||||
|
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config on page load
|
||||||
|
{% if sites %}
|
||||||
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
<!-- System Status Overview -->
|
<!-- System Status Overview -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Overall Status -->
|
<!-- Overall Status -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
||||||
{% if async_load %}
|
{% if async_load %}
|
||||||
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime -->
|
<!-- Uptime -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{{ t.system_uptime }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ uptime.formatted }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ uptime.formatted }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -59,11 +59,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Requests -->
|
<!-- Total Requests -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ metrics.total_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ metrics.total_requests|default(0) }}</p>
|
||||||
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Rate -->
|
<!-- Error Rate -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
||||||
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
||||||
</p>
|
</p>
|
||||||
@@ -113,8 +113,8 @@
|
|||||||
<!-- Response Time & Throughput -->
|
<!-- Response Time & Throughput -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Response Time -->
|
<!-- Response Time -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
@@ -134,22 +134,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Request Summary -->
|
<!-- Request Summary -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="w-full bg-gray-700 rounded-full h-2">
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
{% set total = metrics.total_requests|default(0) %}
|
{% set total = metrics.total_requests|default(0) %}
|
||||||
{% set successful = metrics.successful_requests|default(0) %}
|
{% set successful = metrics.successful_requests|default(0) %}
|
||||||
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
||||||
@@ -164,12 +164,12 @@
|
|||||||
<div id="alerts-container"></div>
|
<div id="alerts-container"></div>
|
||||||
|
|
||||||
<!-- Projects Health -->
|
<!-- Projects Health -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span id="projects-summary" class="text-sm text-gray-400">
|
<span id="projects-summary" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if async_load %}
|
{% if async_load %}
|
||||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -180,21 +180,21 @@
|
|||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
<!-- HTMX async loading - wraps tbody for proper swap -->
|
<!-- HTMX async loading - wraps tbody for proper swap -->
|
||||||
<tbody
|
<tbody
|
||||||
id="projects-health-body"
|
id="projects-health-body"
|
||||||
class="divide-y divide-gray-700"
|
class="divide-y divide-gray-200 dark:divide-gray-700"
|
||||||
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,12 +223,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% for project_id, project in projects_health.items() %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ project_id }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
@@ -252,14 +252,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<td colspan="5" class="px-6 py-12 text-center">
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -272,26 +272,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Information -->
|
<!-- System Information -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
<!-- Refresh Buttons -->
|
<!-- Refresh Buttons -->
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
{% if is_cached %}
|
{% if is_cached %}
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||||
{{ t.refresh }}
|
{{ t.refresh }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- Projects Table Rows -->
|
<!-- Projects Table Rows -->
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% for project_id, project in projects_health.items() %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ project_id }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
@@ -25,14 +25,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<td colspan="5" class="px-6 py-12 text-center">
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
439
core/templates/dashboard/index.html
Normal file
439
core/templates/dashboard/index.html
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.dashboard }}{% endblock %}
|
||||||
|
{% block page_title %}{{ t.overview }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{% if is_admin is defined and is_admin %}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
{# ADMIN DASHBOARD #}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- Admin Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Projects Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_projects }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.projects_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.active_api_keys }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-green-400 hover:text-green-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_tools }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uptime Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-orange-400 hover:text-orange-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.users_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}کاربران ثبتنام شده{% else %}Registered users{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Sites Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سایتهای کاربران{% else %}User Sites{% endif %}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.user_sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-teal-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}سایتهای متصل شده{% else %}Connected sites{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if recent_activity %}
|
||||||
|
{% for activity in recent_activity %}
|
||||||
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if activity.type == 'tool_call' %}
|
||||||
|
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif activity.type == 'auth' %}
|
||||||
|
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif activity.level == 'ERROR' %}
|
||||||
|
<div class="w-8 h-8 bg-red-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-8 h-8 bg-gray-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" 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>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ activity.message }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project if activity.project and activity.project != 'None' else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
<p>{{ t.no_activity }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if recent_activity %}
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||||
|
{{ t.view_all }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Projects by Type -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.projects_by_type }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'bg-blue-500',
|
||||||
|
'woocommerce': 'bg-purple-500',
|
||||||
|
'wordpress_advanced': 'bg-indigo-500',
|
||||||
|
'gitea': 'bg-green-500',
|
||||||
|
'n8n': 'bg-orange-500',
|
||||||
|
'supabase': 'bg-emerald-500',
|
||||||
|
'openpanel': 'bg-cyan-500',
|
||||||
|
'appwrite': 'bg-pink-500',
|
||||||
|
'directus': 'bg-violet-500',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% set plugin_icons = {
|
||||||
|
'wordpress': 'W',
|
||||||
|
'woocommerce': 'WC',
|
||||||
|
'wordpress_advanced': 'WA',
|
||||||
|
'gitea': 'G',
|
||||||
|
'n8n': 'n8n',
|
||||||
|
'supabase': 'SB',
|
||||||
|
'openpanel': 'OP',
|
||||||
|
'appwrite': 'AW',
|
||||||
|
'directus': 'DI',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% if projects_by_type %}
|
||||||
|
{% for plugin_type, count in projects_by_type.items() %}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ plugin_type|plugin_name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health Status -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.health_status }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
{% for component, status in health_summary.components.items() %}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ component }}</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if status == 'healthy' %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-green-600 dark:text-green-400">{{ t.healthy }}</span>
|
||||||
|
{% elif status == 'warning' %}
|
||||||
|
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-yellow-600 dark:text-yellow-400">{{ t.warning }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400">{{ t.error }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
||||||
|
{{ health_summary.last_check[:19] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||||
|
{{ t.view_all }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
{# USER DASHBOARD #}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- User Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- My Sites Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('my_services', 'My Services') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Connections Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('active_connections', 'Active Connections') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.active_sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}اتصالات فعال{% else %}Active connections{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('user_api_keys', 'API Keys') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-purple-400 hover:text-purple-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Site Status -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.get('site_status', 'Site Status') }}</h2>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.get('add_site', 'Add Service') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if user_sites %}
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'bg-blue-500',
|
||||||
|
'woocommerce': 'bg-purple-500',
|
||||||
|
'wordpress_advanced': 'bg-indigo-500',
|
||||||
|
'gitea': 'bg-green-500',
|
||||||
|
'n8n': 'bg-orange-500',
|
||||||
|
'supabase': 'bg-emerald-500',
|
||||||
|
'openpanel': 'bg-cyan-500',
|
||||||
|
'appwrite': 'bg-pink-500',
|
||||||
|
'directus': 'bg-violet-500',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for site in user_sites %}
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 {{ plugin_colors.get(site.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<span class="text-xs font-bold text-white">{{ site.plugin_type[:2]|upper }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ site.alias }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ site.plugin_type|plugin_name }} · {{ site.url[:40] }}{% if site.url|length > 40 %}...{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if site.status == 'active' %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-green-600 dark:text-green-400">{{ t.get('active', 'Active') }}</span>
|
||||||
|
{% elif site.status == 'error' %}
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400">{{ t.get('error', 'Error') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">{{ t.get('no_services_yet', "You haven't connected any services yet.") }}</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 mb-6">{{ t.get('add_first_service', 'Add your first service to start using MCP tools.') }}</p>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.get('add_site', 'Add Service') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
493
core/templates/dashboard/keys/list.html
Normal file
493
core/templates/dashboard/keys/list.html
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
{# ── Admin view: full filters, all project keys ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
|
||||||
|
</p>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Filters -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
|
<select name="project" onchange="this.form.submit()"
|
||||||
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
|
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
|
||||||
|
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||||
|
<select name="status" onchange="this.form.submit()"
|
||||||
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
|
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
|
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<input type="text" name="search" value="{{ search_query }}"
|
||||||
|
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{{ t.search }}</button>
|
||||||
|
{% if search_query or selected_project or selected_status != 'active' %}
|
||||||
|
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">{{ t['clear'] }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Keys Table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if api_keys %}
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||||
|
<button onclick="copyToClipboard('{{ key.key_id }}')" class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors" title="Copy ID">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.project_id == '*' %}
|
||||||
|
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</span>
|
||||||
|
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for scope in key.scope.split() %}
|
||||||
|
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.revoked %}<div class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-red-400">{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</span></div>
|
||||||
|
{% else %}<div class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-green-400">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if not key.revoked %}
|
||||||
|
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors" title="{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-6 py-12 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
|
||||||
|
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}نمایش {{ ((page_number-1)*per_page)+1 }} تا {{ [page_number*per_page, total_count]|min }} از {{ total_count }}{% else %}Showing {{ ((page_number-1)*per_page)+1 }}–{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if page_number > 1 %}<a href="?page={{ page_number-1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
|
||||||
|
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
|
||||||
|
{% if page_number < total_pages %}<a href="?page={{ page_number+1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ── User view: personal keys with scope selector ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
|
||||||
|
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام میشود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Keys Table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% if user_keys %}
|
||||||
|
{% for key in user_keys %}
|
||||||
|
<tr id="ukey-{{ key.id }}">
|
||||||
|
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% set scopes = (key.scopes or 'read write admin').split() %}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for s in scopes %}
|
||||||
|
<span class="px-2 py-0.5 {% if s == 'admin' %}bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400{% elif s == 'write' %}bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400{% elif s == 'deploy' %}bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400{% else %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400{% endif %} text-xs rounded font-medium">{{ s }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-6 py-12 text-center">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
|
||||||
|
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Modals ── #}
|
||||||
|
|
||||||
|
<!-- Create Key Modal -->
|
||||||
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{% if is_admin %}
|
||||||
|
{# Admin create form — posts to existing admin endpoint #}
|
||||||
|
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</label>
|
||||||
|
<select name="project_id" required class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_read" value="read" checked class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}</label>
|
||||||
|
<input type="text" name="description" placeholder="{% if lang == 'fa' %}مثال: CI/CD{% else %}e.g., CI/CD key{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
|
||||||
|
<select name="expires_in_days" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
|
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# User create form — uses /api/keys via JS #}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
|
||||||
|
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude Desktop{% endif %}"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
|
||||||
|
<select id="new-key-scope" 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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="read">Read — {% if lang == 'fa' %}فقط خواندن{% else %}view-only{% endif %}</option>
|
||||||
|
<option value="read:sensitive">Read + Sensitive — {% if lang == 'fa' %}خواندن + داده حساس{% else %}logs, envs{% endif %}</option>
|
||||||
|
<option value="deploy">Deploy — {% if lang == 'fa' %}start/stop/restart{% else %}start/stop/restart{% endif %}</option>
|
||||||
|
<option value="write">Write — {% if lang == 'fa' %}نوشتن + lifecycle{% else %}create/update + lifecycle{% endif %}</option>
|
||||||
|
<option value="read write admin" selected>Full Access — {% if lang == 'fa' %}دسترسی کامل{% else %}all tools{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
|
||||||
|
{% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام میشود.{% else %}Per-site tool filters are set in Site Settings.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeCreateModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Key Display Modal -->
|
||||||
|
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده میشود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" id="newKeyValue" readonly class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm">
|
||||||
|
<button onclick="copyNewKey()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<!-- Revoke Modal -->
|
||||||
|
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}</h3></div>
|
||||||
|
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeRevokeModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Modal -->
|
||||||
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}</h3></div>
|
||||||
|
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeDeleteModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const isAdmin = {{ 'true' if is_admin else 'false' }};
|
||||||
|
const lang = '{{ lang }}';
|
||||||
|
let currentRevokeKeyId = null;
|
||||||
|
let currentDeleteKeyId = null;
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
|
||||||
|
}
|
||||||
|
window.copyToClipboard = copyToClipboard;
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.remove('hidden');
|
||||||
|
document.getElementById('createModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openCreateModal = openCreateModal;
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.add('hidden');
|
||||||
|
document.getElementById('createModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeCreateModal = closeCreateModal;
|
||||||
|
|
||||||
|
function closeNewKeyModal() {
|
||||||
|
document.getElementById('newKeyModal').classList.add('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
window.closeNewKeyModal = closeNewKeyModal;
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
copyToClipboard(document.getElementById('newKeyValue').value);
|
||||||
|
}
|
||||||
|
window.copyNewKey = copyNewKey;
|
||||||
|
|
||||||
|
function showNewKey(key) {
|
||||||
|
document.getElementById('newKeyValue').value = key;
|
||||||
|
document.getElementById('newKeyModal').classList.remove('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin: collect scopes + submit form via JS
|
||||||
|
const form = document.getElementById('createKeyForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const scopes = [];
|
||||||
|
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
|
||||||
|
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
|
||||||
|
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
|
||||||
|
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
|
||||||
|
const data = {
|
||||||
|
project_id: formData.get('project_id'),
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
description: formData.get('description') || null,
|
||||||
|
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
|
||||||
|
};
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/create', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.error) { alert(res.error); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key);
|
||||||
|
} else {
|
||||||
|
// User: POST to /api/keys
|
||||||
|
const name = document.getElementById('new-key-name').value.trim();
|
||||||
|
const scope = document.getElementById('new-key-scope').value;
|
||||||
|
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
|
||||||
|
const r = await fetch('/api/keys', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, scopes: scope }),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (!r.ok) { alert(res.error || 'Failed'); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.submitCreate = submitCreate;
|
||||||
|
|
||||||
|
async function deleteUserKey(keyId) {
|
||||||
|
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
|
||||||
|
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
const row = document.getElementById('ukey-' + keyId);
|
||||||
|
if (row) row.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.deleteUserKey = deleteUserKey;
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
function openRevokeModal(keyId, keyName) {
|
||||||
|
currentRevokeKeyId = keyId;
|
||||||
|
document.getElementById('revokeKeyName').textContent = keyName;
|
||||||
|
document.getElementById('revokeModal').classList.remove('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openRevokeModal = openRevokeModal;
|
||||||
|
|
||||||
|
function closeRevokeModal() {
|
||||||
|
document.getElementById('revokeModal').classList.add('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeRevokeModal = closeRevokeModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmRevokeBtn').onclick = async function() {
|
||||||
|
if (!currentRevokeKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDeleteModal(keyId, keyName) {
|
||||||
|
currentDeleteKeyId = keyId;
|
||||||
|
document.getElementById('deleteKeyName').textContent = keyName;
|
||||||
|
document.getElementById('deleteModal').classList.remove('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openDeleteModal = openDeleteModal;
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').classList.add('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmDeleteBtn').onclick = async function() {
|
||||||
|
if (!currentDeleteKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
{% endif %}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,45 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.login_title }} - MCP Hub</title>
|
<title>{{ t.login_title }} - MCP Hub</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-gradient {
|
.login-gradient {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-shadow {
|
.card-shadow {
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-focus:focus {
|
.input-focus:focus {
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px); }
|
|
||||||
50% { transform: translateY(-20px); }
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||||
<!-- Background decoration -->
|
<!-- Background decoration -->
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float" style="animation-delay: -3s;"></div>
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||||
|
style="animation-delay: -3s;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Card -->
|
<!-- Login Card -->
|
||||||
@@ -48,9 +64,8 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||||
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>{{ t.login_subtitle }}</p>
|
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>{{ t.login_subtitle }}</p>
|
||||||
@@ -58,10 +73,13 @@
|
|||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||||
|
endif %}>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-red-400 text-sm">
|
<span class="text-red-400 text-sm">
|
||||||
{% if error == 'rate_limit' %}
|
{% if error == 'rate_limit' %}
|
||||||
@@ -76,63 +94,70 @@
|
|||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form method="POST" action="/dashboard/login" class="space-y-6">
|
<form method="POST" action="/dashboard/login" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
|
||||||
<input type="hidden" name="next" value="{{ next_url }}">
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
|
|
||||||
<div {% if lang=='fa' %}dir="rtl" {% endif %}>
|
<div {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
{{ t.api_key_label }}
|
{{ t.api_key_label }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input type="password" id="api_key" name="api_key" required autocomplete="off"
|
||||||
type="password"
|
|
||||||
id="api_key"
|
|
||||||
name="api_key"
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="{{ t.api_key_placeholder }}"
|
placeholder="{{ t.api_key_placeholder }}"
|
||||||
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
|
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
|
||||||
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
|
{% if lang=='fa' %}dir="ltr" style="text-align: right;" {% endif %}>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit"
|
||||||
type="submit"
|
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||||
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800"
|
|
||||||
>
|
|
||||||
{{ t.login_button }}
|
{{ t.login_button }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Security Note -->
|
<!-- Security Note -->
|
||||||
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||||
|
endif %}>
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<p class="text-sm text-blue-300">
|
<div class="text-sm text-blue-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
برای دسترسی به داشبورد به Master API Key یا یک API Key با scope=admin نیاز دارید.
|
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
|
||||||
|
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code
|
||||||
|
class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
You need a Master API Key or an API Key with scope=admin to access the dashboard.
|
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the
|
||||||
|
dashboard.</p>
|
||||||
|
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code
|
||||||
|
class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the
|
||||||
|
temporary key.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<p class="mt-2">
|
||||||
|
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank"
|
||||||
|
class="text-purple-400 hover:text-purple-300 underline">
|
||||||
|
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<a
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
||||||
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
{% if lang == 'fa' %}English{% else %}<span lang="fa" dir="rtl">فارسی</span>{% endif %}
|
||||||
>
|
|
||||||
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<p class="text-center text-gray-500 text-sm mt-6">
|
<p class="text-center text-gray-500 text-sm mt-6">
|
||||||
MCP Hub v3.0.0
|
MCP Hub v{{ version }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ total_count }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">OAuth 2.1</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">OAuth 2.1</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -71,37 +71,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients Table -->
|
<!-- Clients Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if clients %}
|
{% if clients %}
|
||||||
{% for client in clients %}
|
{% for client in clients %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ client.client_name }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ client.client_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<code class="text-sm text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
<code class="text-sm text-gray-500 dark:text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -143,15 +143,15 @@
|
|||||||
<td colspan="5" class="px-6 py-4">
|
<td colspan="5" class="px-6 py-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{% for uri in client.redirect_uris %}
|
{% for uri in client.redirect_uris %}
|
||||||
<code class="block text-xs text-gray-300 bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
<code class="block text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{% for grant in client.grant_types %}
|
{% for grant in client.grant_types %}
|
||||||
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
|
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400 mb-4">
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -188,51 +188,51 @@
|
|||||||
|
|
||||||
<!-- Create Client Modal -->
|
<!-- Create Client Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||||
<h3 class="text-xl font-semibold text-white mb-4">
|
<h3 class="text-xl font-semibold text-white mb-4">
|
||||||
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form id="createForm" class="space-y-4">
|
<form id="createForm" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="client_name"
|
name="client_name"
|
||||||
required
|
required
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="redirect_uris"
|
name="redirect_uris"
|
||||||
required
|
required
|
||||||
rows="3"
|
rows="3"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
>https://claude.ai/api/mcp/auth_callback
|
||||||
></textarea>
|
https://claude.com/api/mcp/auth_callback</textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
|
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">read</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">write</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">admin</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="closeCreateModal()"
|
onclick="closeCreateModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{{ t.cancel }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
|
|
||||||
<!-- Success Modal (shows client secret) -->
|
<!-- Success Modal (shows client secret) -->
|
||||||
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -271,16 +271,16 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-400 mb-4">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Client ID</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-300 overflow-x-auto"></code>
|
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-700 dark:text-gray-300 overflow-x-auto"></code>
|
||||||
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
|
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Client Secret</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
|
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
|
||||||
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-md mx-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-400 mb-1">
|
<p class="text-gray-500 dark:text-gray-400 mb-1">
|
||||||
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeDeleteModal()"
|
onclick="closeDeleteModal()"
|
||||||
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{{ t.cancel }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
105
core/templates/dashboard/partials/head_assets.html
Normal file
105
core/templates/dashboard/partials/head_assets.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<!-- Vazirmatn Font for Persian -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- HTMX for dynamic updates -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Alpine.js for simple interactions -->
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom Tailwind Config -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
200: '#ddd6fe',
|
||||||
|
300: '#c4b5fd',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
800: '#5b21b6',
|
||||||
|
900: '#4c1d95',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Theme Initialization -->
|
||||||
|
<script>
|
||||||
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Global CSRF Interceptor -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content;
|
||||||
|
|
||||||
|
// Intercept HTMX requests
|
||||||
|
document.body.addEventListener('htmx:configRequest', (event) => {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
if (token && event.detail.verb !== 'get') {
|
||||||
|
event.detail.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept standard fetch requests
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async function (resource, init) {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
if (token) {
|
||||||
|
const url = typeof resource === 'string' ? new URL(resource, window.location.origin) : resource;
|
||||||
|
if (url.origin === window.location.origin) {
|
||||||
|
init = init || {};
|
||||||
|
init.headers = init.headers || {};
|
||||||
|
|
||||||
|
const method = (init.method || 'GET').toUpperCase();
|
||||||
|
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.append('X-CSRF-Token', token);
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
init.headers.push(['X-CSRF-Token', token]);
|
||||||
|
} else {
|
||||||
|
init.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalFetch.call(this, resource, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vazirmatn font for Persian */
|
||||||
|
html[lang="fa"],
|
||||||
|
html[lang="fa"] *,
|
||||||
|
[lang="fa"],
|
||||||
|
[lang="fa"] * {
|
||||||
|
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
core/templates/dashboard/profile.html
Normal file
90
core/templates/dashboard/profile.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<!-- User Info Card -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
{% if user and user.avatar_url %}
|
||||||
|
<img src="{{ user.avatar_url }}" alt="{{ user.name or user.email }}" class="w-16 h-16 rounded-full border-2 border-purple-500">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-16 h-16 rounded-full bg-purple-600 flex items-center justify-center text-white text-xl font-bold">
|
||||||
|
{{ (session.name or session.email or "?")[0]|upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ user.name or session.name or "User" }}</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{{ user.email or session.email }}</p>
|
||||||
|
{% if user %}
|
||||||
|
<div class="flex items-center mt-1 space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
{% if user.provider == 'github' %}bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300{% else %}bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300{% endif %}">
|
||||||
|
{% if user.provider == 'github' %}
|
||||||
|
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ user.provider|title }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300">
|
||||||
|
{{ user.role|title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Details -->
|
||||||
|
{% if user %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}جزئیات حساب{% else %}Account Details{% endif %}
|
||||||
|
</h3>
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کاربر{% else %}User ID{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200 font-mono text-sm">{{ user.id[:8] }}...</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ارائهدهنده ورود{% else %}Login Provider{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.provider|title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ عضویت{% else %}Joined{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.created_at[:10] if user.created_at else "Unknown" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین ورود{% else %}Last Login{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.last_login[:10] if user.last_login else "Never" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Account linking note -->
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
حساب شما به آدرس ایمیل {{ user.email }} متصل است. میتوانید با هر ارائهدهندهای که از این ایمیل استفاده میکند وارد شوید.
|
||||||
|
{% else %}
|
||||||
|
Your account is linked to {{ user.email }}. You can sign in with any provider that uses this email address.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/auth/logout" class="inline-flex items-center px-4 py-2 text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 border border-red-200 dark:border-red-500/30 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}خروج{% else %}Sign Out{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-400 hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
||||||
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- Status Card -->
|
<!-- Status Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
||||||
<div class="flex items-center mt-2">
|
<div class="flex items-center mt-2">
|
||||||
{% if project.health.status == 'healthy' %}
|
{% if project.health.status == 'healthy' %}
|
||||||
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
@@ -50,18 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
||||||
{% elif project.health.last_check %}
|
{% elif project.health.last_check %}
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools Card -->
|
<!-- Tools Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.tools_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- API Keys Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.api_keys_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.api_keys_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -91,11 +91,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Requests Card -->
|
<!-- Requests Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.requests_24h }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.requests_24h }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,33 +112,33 @@
|
|||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Config Section -->
|
<!-- Config Section -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.full_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.full_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
||||||
<dd class="text-white">{{ project.plugin_type|plugin_name }}</dd>
|
<dd class="text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.site_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.site_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if project.alias %}
|
{% if project.alias %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
||||||
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.url %}
|
{% if project.url %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">URL</dt>
|
<dt class="text-gray-500 dark:text-gray-400">URL</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
||||||
{{ project.url }}
|
{{ project.url }}
|
||||||
@@ -150,22 +150,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex justify-between py-2">
|
<div class="flex justify-between py-2">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
||||||
<dd class="text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
<dd class="text-gray-900 dark:text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Available Tools -->
|
<!-- Available Tools -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700 flex items-center justify-between">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
||||||
<span class="px-2 py-1 bg-gray-700 rounded-lg text-sm text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
<span class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50 sticky top-0">
|
<thead class="bg-gray-50 dark:bg-gray-700/50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
||||||
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||||
@@ -178,14 +178,14 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% for tool in project.tools[:50] %}
|
{% for tool in project.tools[:50] %}
|
||||||
<tr class="hover:bg-gray-700/30">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="text-sm text-white font-mono">{{ tool.name }}</span>
|
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ tool.name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="text-sm text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
<span class="text-sm text-gray-500 dark:text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
||||||
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -213,9 +213,9 @@
|
|||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Health Check Result Display -->
|
<!-- Health Check Result Display -->
|
||||||
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::before-request="showHealthCheckLoading()"
|
hx-on::before-request="showHealthCheckLoading()"
|
||||||
hx-on::after-request="handleHealthCheckResponse(event)"
|
hx-on::after-request="handleHealthCheckResponse(event)"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
|
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
|
||||||
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
|
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span>{% if lang == 'fa' %}مشاهده لاگها{% else %}View Logs{% endif %}</span>
|
<span>{% if lang == 'fa' %}مشاهده لاگها{% else %}View Logs{% endif %}</span>
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -262,14 +262,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if project.recent_activity %}
|
{% if project.recent_activity %}
|
||||||
{% for activity in project.recent_activity[:5] %}
|
{% for activity in project.recent_activity[:5] %}
|
||||||
<div class="p-4 hover:bg-gray-700/30">
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% if activity.level == 'ERROR' %}
|
{% if activity.level == 'ERROR' %}
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-sm text-white">{{ activity.message }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ activity.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,19 +343,19 @@
|
|||||||
resultDiv.classList.add('bg-green-500/20');
|
resultDiv.classList.add('bg-green-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
||||||
'</p><p class="text-sm text-gray-400">' +
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' +
|
||||||
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
||||||
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
||||||
} else if (response.status === 'unhealthy') {
|
} else if (response.status === 'unhealthy') {
|
||||||
resultDiv.classList.add('bg-red-500/20');
|
resultDiv.classList.add('bg-red-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
||||||
'</p><p class="text-sm text-gray-400">' + (response.message || '') + '</p>';
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || '') + '</p>';
|
||||||
} else {
|
} else {
|
||||||
resultDiv.classList.add('bg-yellow-500/20');
|
resultDiv.classList.add('bg-yellow-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
||||||
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
||||||
'</p><p class="text-sm text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-hide after 5 seconds
|
// Auto-hide after 5 seconds
|
||||||
293
core/templates/dashboard/projects/list.html
Normal file
293
core/templates/dashboard/projects/list.html
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.projects }}{% endblock %}
|
||||||
|
{% block page_title %}{{ t.projects }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<!-- Plugin Type Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
|
<select name="plugin_type" onchange="this.form.submit()"
|
||||||
|
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||||
|
<option value="">{{ t.all }} Types</option>
|
||||||
|
{% for plugin_type in available_plugin_types %}
|
||||||
|
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
|
||||||
|
{{ plugin_type|plugin_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="status" onchange="this.form.submit()"
|
||||||
|
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||||
|
<option value="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
||||||
|
<option value="healthy" {% if selected_status=='healthy' %}selected{% endif %}>{{ t.healthy }}
|
||||||
|
</option>
|
||||||
|
<option value="warning" {% if selected_status=='warning' %}selected{% endif %}>{% if lang == 'fa'
|
||||||
|
%}هشدار{% else %}Warning{% endif %}</option>
|
||||||
|
<option value="unhealthy" {% if selected_status=='unhealthy' %}selected{% endif %}>{% if lang ==
|
||||||
|
'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
||||||
|
<option value="unknown" {% if selected_status=='unknown' %}selected{% endif %}>{% if lang == 'fa'
|
||||||
|
%}نامشخص{% else %}Unknown{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<input type="text" name="search" value="{{ search_query }}" placeholder="{{ t.search }}..."
|
||||||
|
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{{ t.search }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if search_query or selected_plugin_type or selected_status %}
|
||||||
|
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||||
|
{{ t['clear'] }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
URL
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if projects %}
|
||||||
|
{% for project in projects %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
|
<!-- Plugin Type -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'bg-blue-500',
|
||||||
|
'woocommerce': 'bg-purple-500',
|
||||||
|
'wordpress_advanced': 'bg-indigo-500',
|
||||||
|
'gitea': 'bg-green-500',
|
||||||
|
'n8n': 'bg-orange-500',
|
||||||
|
'supabase': 'bg-emerald-500',
|
||||||
|
'openpanel': 'bg-cyan-500',
|
||||||
|
'appwrite': 'bg-pink-500',
|
||||||
|
'directus': 'bg-violet-500',
|
||||||
|
} %}
|
||||||
|
{% set plugin_icons = {
|
||||||
|
'wordpress': 'W',
|
||||||
|
'woocommerce': 'WC',
|
||||||
|
'wordpress_advanced': 'WA',
|
||||||
|
'gitea': 'G',
|
||||||
|
'n8n': 'n8n',
|
||||||
|
'supabase': 'SB',
|
||||||
|
'openpanel': 'OP',
|
||||||
|
'appwrite': 'AW',
|
||||||
|
'directus': 'DI',
|
||||||
|
} %}
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type,
|
||||||
|
project.plugin_type[:2]|upper) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Site ID -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ project.site_id }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Alias -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if project.alias %}
|
||||||
|
<span class="px-2 py-1 bg-primary-500/20 text-primary-400 text-xs rounded-lg font-medium">
|
||||||
|
{{ project.alias }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500 text-sm">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if project.url %}
|
||||||
|
<a href="{{ project.url }}" target="_blank"
|
||||||
|
class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block"
|
||||||
|
title="{{ project.url }}">
|
||||||
|
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500 text-sm">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if project.health_status == 'healthy' %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif project.health_status == 'warning' %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{%
|
||||||
|
endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% elif project.health_status == 'unhealthy' %}
|
||||||
|
<div class="flex items-center" {% if project.reason %}title="{{ project.reason }}" {% endif
|
||||||
|
%}>
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-red-400">
|
||||||
|
{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}
|
||||||
|
{% if project.reason %}
|
||||||
|
<svg class="w-4 h-4 inline-block ml-1 opacity-70" 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"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif
|
||||||
|
%}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
|
{{ t.view }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-12 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none"
|
||||||
|
stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if search_query or selected_plugin_type %}
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
سایتها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایتها</a> مدیریت میشوند.
|
||||||
|
{% else %}
|
||||||
|
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{
|
||||||
|
total_count }} پروژه
|
||||||
|
{% else %}
|
||||||
|
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of
|
||||||
|
{{ total_count }} projects
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if page_number > 1 %}
|
||||||
|
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
|
{% if page_num == page_number %}
|
||||||
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||||
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <=
|
||||||
|
page_number + 2) %} <a
|
||||||
|
href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
|
<span class="text-gray-500">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_number < total_pages %} <a
|
||||||
|
href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||||
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
210
core/templates/dashboard/service.html
Normal file
210
core/templates/dashboard/service.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ service.display_name }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ service.display_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Back button + Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/dashboard/services{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-400 hover:text-gray-300 transition-colors">
|
||||||
|
<svg class="w-5 h-5{% if lang == 'fa' %} rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ service.display_name }}</h2>
|
||||||
|
{% if service.is_public %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/sites/add?plugin_type={{ service.plugin_type }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.get('add_site', 'Add Site') }}
|
||||||
|
</a>
|
||||||
|
</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 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Tools Count -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ service.tools_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Read Tools -->
|
||||||
|
{% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ read_tools|length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Write Tools -->
|
||||||
|
{% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ write_tools|length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Setup Requirements -->
|
||||||
|
{% if service.credential_fields %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in service.credential_fields %}
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div class="w-8 h-8 bg-purple-500/20 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
{% if field.type == 'password' %}
|
||||||
|
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ field.label }}</p>
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.required %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{% if lang == 'fa' %}الزامی{% else %}Required{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Tools List -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %}
|
||||||
|
<span class="text-sm font-normal text-gray-400 dark:text-gray-500">({{ service.tools_count }})</span>
|
||||||
|
</h3>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search tools...{% endif %}"
|
||||||
|
class="w-48 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700" id="tools-list">
|
||||||
|
{% for tool in service.tools %}
|
||||||
|
<div class="tool-item p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" data-name="{{ tool.name }}" data-desc="{{ tool.description|lower }}">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-sm font-mono text-primary-600 dark:text-primary-400">{{ tool.name }}</code>
|
||||||
|
{% if tool.scope == 'read' %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">read</span>
|
||||||
|
{% elif tool.scope == 'write' %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300">write</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">admin</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{{ tool.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('tool-search').addEventListener('input', function(e) {
|
||||||
|
const q = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.tool-item').forEach(function(item) {
|
||||||
|
const name = item.dataset.name || '';
|
||||||
|
const desc = item.dataset.desc || '';
|
||||||
|
item.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
57
core/templates/dashboard/services_list.html
Normal file
57
core/templates/dashboard/services_list.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.get('services', 'Services') }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.get('services', 'Services') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}سرویسهای MCP{% else %}MCP Services{% endif %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'blue',
|
||||||
|
'woocommerce': 'purple',
|
||||||
|
'supabase': 'emerald',
|
||||||
|
'gitea': 'green',
|
||||||
|
'n8n': 'orange',
|
||||||
|
'openpanel': 'cyan',
|
||||||
|
'appwrite': 'pink',
|
||||||
|
'directus': 'violet',
|
||||||
|
'wordpress_advanced': 'indigo',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for svc in services %}
|
||||||
|
{% set color = plugin_colors.get(svc.plugin_type, 'gray') %}
|
||||||
|
<a href="/dashboard/services/{{ svc.plugin_type }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white transition-colors">
|
||||||
|
{{ svc.display_name }}
|
||||||
|
</h3>
|
||||||
|
{% if svc.is_public %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||||
|
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
|
||||||
|
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
323
core/templates/dashboard/settings/index.html
Normal file
323
core/templates/dashboard/settings/index.html
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.settings }}{% endblock %}
|
||||||
|
{% block page_title %}{{ t.settings }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Language Settings -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/dashboard/settings?lang=en"
|
||||||
|
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
|
English
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard/settings?lang=fa"
|
||||||
|
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
|
فارسی
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
زبان فعلی: فارسی
|
||||||
|
{% else %}
|
||||||
|
Current language: English
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Configuration -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.server_mode }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.port }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.log_level }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Settings -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Managed Settings (4C.3) -->
|
||||||
|
{% if managed_settings %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}تنظیمات قابل مدیریت{% else %}Managed Settings{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
{% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیشفرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for s in managed_settings %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4" id="setting-{{ s.key }}">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}{{ s.label_fa }}{% else %}{{ s.label }}{% endif %}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
{% if s.source == 'database' %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300
|
||||||
|
{% elif s.source == 'environment' %}bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400{% endif %}"
|
||||||
|
id="source-{{ s.key }}">
|
||||||
|
{{ s.source }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" value="{{ s.value }}" id="input-{{ s.key }}"
|
||||||
|
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<button onclick="saveSetting('{{ s.key }}')"
|
||||||
|
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||||
|
id="save-{{ s.key }}">
|
||||||
|
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if s.source == 'database' %}
|
||||||
|
<button onclick="resetSetting('{{ s.key }}')"
|
||||||
|
class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 text-sm rounded-lg transition-colors"
|
||||||
|
id="reset-{{ s.key }}"
|
||||||
|
title="{% if lang == 'fa' %}بازگشت به پیشفرض{% else %}Reset to default{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1" id="default-{{ s.key }}">
|
||||||
|
{% if lang == 'fa' %}پیشفرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Registered Plugins -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
||||||
|
</h3>
|
||||||
|
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||||
|
{% if lang == 'fa' %}قابلیت آینده{% else %}Coming Soon{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% if plugins %}
|
||||||
|
{% for plugin in plugins %}
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-900 dark:text-white font-medium">{{ plugin.name }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ plugin.description }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
||||||
|
{{ t.active }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700/30 rounded-lg p-4">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
سیستم پلاگین در فازهای آینده اضافه خواهد شد. این امکان به شما اجازه میدهد قابلیتهای سفارشی به MCP Hub اضافه کنید.
|
||||||
|
{% else %}
|
||||||
|
The plugin system will be added in future phases. This will allow you to extend MCP Hub with custom functionality.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-16 h-16 bg-primary-500/20 rounded-xl flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xl font-bold text-gray-900 dark:text-white">MCP Hub</h4>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.version }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.mcp_version }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.python_version }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.tools_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
||||||
|
{% else %}
|
||||||
|
MCP Hub is a multi-purpose MCP server that enables connection to various services including WordPress, Gitea, n8n, and other services.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Information -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.created_at[:19] if session_display.created_at else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a href="/dashboard/logout"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
||||||
|
{{ t.logout }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function saveSetting(key) {
|
||||||
|
const input = document.getElementById('input-' + key);
|
||||||
|
const btn = document.getElementById('save-' + key);
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
btn.textContent = '...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value, action: 'save' }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
btn.textContent = '✓';
|
||||||
|
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
|
||||||
|
const source = document.getElementById('source-' + key);
|
||||||
|
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to save');
|
||||||
|
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSetting(key) {
|
||||||
|
if (!confirm('Reset this setting to default?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, action: 'reset' }),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.error || 'Failed to reset');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
189
core/templates/dashboard/sites/add.html
Normal file
189
core/templates/dashboard/sites/add.html
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.add_site }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.add_site }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||||
|
|
||||||
|
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||||
|
<!-- Plugin Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||||
|
<select id="plugin_type" name="plugin_type" required
|
||||||
|
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
onchange="updateFields()">
|
||||||
|
<option value="">{{ t.select_plugin }}</option>
|
||||||
|
{% for ptype, pname in plugin_names.items() %}
|
||||||
|
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Alias -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||||
|
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||||
|
minlength="2" maxlength="50"
|
||||||
|
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 class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Credential Fields -->
|
||||||
|
<div id="credential-fields" class="space-y-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex items-center gap-4 pt-4">
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||||
|
{{ t.add_site }}
|
||||||
|
</button>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
{{ t.cancel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
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() {
|
||||||
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
|
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]) {
|
||||||
|
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>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
|
||||||
|
const advFields = pluginFields[ptype].filter(f => f.advanced);
|
||||||
|
|
||||||
|
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||||
|
|
||||||
|
function renderField(field) {
|
||||||
|
const req = field.required ? 'required' : '';
|
||||||
|
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
|
||||||
|
return `
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
|
||||||
|
<input type="${field.type}" name="cred_${field.name}" ${req}
|
||||||
|
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"
|
||||||
|
placeholder="${field.label}">
|
||||||
|
${hintHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFields.forEach(field => { html += renderField(field); });
|
||||||
|
|
||||||
|
if (advFields.length > 0) {
|
||||||
|
html += `
|
||||||
|
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||||
|
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||||
|
Advanced Settings
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4 pt-2">`;
|
||||||
|
advFields.forEach(field => { html += renderField(field); });
|
||||||
|
html += `</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
|
||||||
|
if (document.getElementById('plugin_type').value) {
|
||||||
|
updateFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const errorEl = document.getElementById('error-msg');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
btn.textContent = '{{ t.adding_site }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
const alias = document.getElementById('alias').value;
|
||||||
|
|
||||||
|
// Collect credentials
|
||||||
|
const creds = {};
|
||||||
|
if (pluginFields[ptype]) {
|
||||||
|
pluginFields[ptype].forEach(field => {
|
||||||
|
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||||
|
if (input) creds[field.name] = input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_type: ptype,
|
||||||
|
url: url,
|
||||||
|
alias: alias,
|
||||||
|
credentials: creds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Failed to add site';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.add_site }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Network error: ' + err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.add_site }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
370
core/templates/dashboard/sites/edit.html
Normal file
370
core/templates/dashboard/sites/edit.html
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.edit_site }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.edit_site }}: {{ site.alias }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info notice about password fields -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{{ t.keep_existing }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||||
|
|
||||||
|
<form id="edit-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||||
|
|
||||||
|
<!-- Plugin Type (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div>
|
||||||
|
<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 value="{{ site.url }}"
|
||||||
|
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">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alias (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ site.alias }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credential Fields -->
|
||||||
|
<div id="credential-fields" class="space-y-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||||
|
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if not field.get('advanced', False) %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{{ field.label }}{% if field.required %} *{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||||
|
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||||
|
data-required="{{ field.required | lower }}"
|
||||||
|
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">
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||||
|
{% if has_advanced %}
|
||||||
|
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||||
|
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||||
|
Advanced Settings
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if field.get('advanced', False) %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||||
|
placeholder="{{ t.leave_blank_to_clear }}"
|
||||||
|
data-required="false"
|
||||||
|
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">
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex items-center gap-4 pt-4">
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||||
|
{{ t.save }}
|
||||||
|
</button>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
{{ t.cancel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tool Access Card -->
|
||||||
|
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="tool-access-content" class="hidden space-y-4">
|
||||||
|
<!-- Scope selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
|
||||||
|
</label>
|
||||||
|
<select id="tool-scope-select"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
|
||||||
|
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
|
||||||
|
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
|
||||||
|
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
|
||||||
|
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
|
||||||
|
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message for scope save -->
|
||||||
|
<div id="scope-status" class="hidden text-xs"></div>
|
||||||
|
|
||||||
|
<!-- Advanced: per-tool overrides -->
|
||||||
|
<details id="tool-overrides-section">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
|
||||||
|
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
|
||||||
|
</summary>
|
||||||
|
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteId = "{{ site.id }}";
|
||||||
|
const pluginType = "{{ site.plugin_type }}";
|
||||||
|
const pluginFields = {{ plugin_fields_json | safe }};
|
||||||
|
|
||||||
|
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const errorEl = document.getElementById('error-msg');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
btn.textContent = '{{ t.updating_site }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
|
||||||
|
// Collect credentials — non-empty values always sent,
|
||||||
|
// optional fields sent as empty string to allow clearing
|
||||||
|
const creds = {};
|
||||||
|
if (pluginFields[pluginType]) {
|
||||||
|
pluginFields[pluginType].forEach(field => {
|
||||||
|
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val) {
|
||||||
|
creds[field.name] = val;
|
||||||
|
} else if (input.dataset.required === 'false') {
|
||||||
|
// Send empty string for optional fields so server can clear them
|
||||||
|
creds[field.name] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url, credentials: creds }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Failed to update site';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Network error: ' + err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tool Access ──────────────────────────────────────────────
|
||||||
|
const SCOPE_DESCS = {
|
||||||
|
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون دادههای حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
|
||||||
|
'read:sensitive': '{% if lang == "fa" %}read + لاگها، بکاپها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
|
||||||
|
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
|
||||||
|
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
|
||||||
|
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
|
||||||
|
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال میشوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
|
||||||
|
};
|
||||||
|
const CAT_LABELS = {
|
||||||
|
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
|
||||||
|
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
|
||||||
|
'lifecycle': 'Lifecycle',
|
||||||
|
'crud': 'CRUD',
|
||||||
|
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
|
||||||
|
'backup': 'Backup',
|
||||||
|
'system': 'System',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCsrf() {
|
||||||
|
const m = document.cookie.match(/(?:^|;\s*)dashboard_csrf=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToolAccess() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||||
|
if (!r.ok) { hideToolAccess(); return; }
|
||||||
|
const data = await r.json();
|
||||||
|
renderToolAccess(data);
|
||||||
|
} catch (_) { hideToolAccess(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideToolAccess() {
|
||||||
|
document.getElementById('tool-access-loading').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolAccess(data) {
|
||||||
|
const loading = document.getElementById('tool-access-loading');
|
||||||
|
const content = document.getElementById('tool-access-content');
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Set scope dropdown
|
||||||
|
const select = document.getElementById('tool-scope-select');
|
||||||
|
select.value = data.tool_scope || 'admin';
|
||||||
|
updateScopeDesc(data.tool_scope || 'admin');
|
||||||
|
|
||||||
|
// Scope change handler
|
||||||
|
select.onchange = async () => {
|
||||||
|
const scope = select.value;
|
||||||
|
updateScopeDesc(scope);
|
||||||
|
const statusEl = document.getElementById('scope-status');
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-gray-400';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||||
|
body: JSON.stringify({ scope }),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-green-500';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
setTimeout(() => statusEl.classList.add('hidden'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render per-tool list grouped by category
|
||||||
|
const grouped = {};
|
||||||
|
for (const tool of data.tools) {
|
||||||
|
const cat = tool.category || 'read';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(tool);
|
||||||
|
}
|
||||||
|
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
|
||||||
|
const container = document.getElementById('tool-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const cat of catOrder) {
|
||||||
|
if (!grouped[cat]) continue;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'space-y-1';
|
||||||
|
const header = document.createElement('p');
|
||||||
|
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
|
||||||
|
header.textContent = CAT_LABELS[cat] || cat;
|
||||||
|
section.appendChild(header);
|
||||||
|
for (const tool of grouped[cat]) {
|
||||||
|
section.appendChild(renderToolRow(tool));
|
||||||
|
}
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScopeDesc(scope) {
|
||||||
|
const el = document.getElementById('scope-desc');
|
||||||
|
el.textContent = SCOPE_DESCS[scope] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolRow(tool) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.id = 'tool-row-' + tool.name;
|
||||||
|
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
|
||||||
|
nameEl.textContent = tool.name;
|
||||||
|
nameEl.title = tool.description || '';
|
||||||
|
left.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (tool.sensitivity === 'sensitive') {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
|
||||||
|
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
|
||||||
|
left.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switch
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.className = 'sr-only peer';
|
||||||
|
input.checked = tool.enabled !== false;
|
||||||
|
input.onchange = async () => {
|
||||||
|
const enabled = input.checked;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { input.checked = !enabled; }
|
||||||
|
} catch (_) { input.checked = !enabled; }
|
||||||
|
};
|
||||||
|
const slider = document.createElement('div');
|
||||||
|
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(slider);
|
||||||
|
|
||||||
|
row.appendChild(left);
|
||||||
|
row.appendChild(label);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadToolAccess);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
197
core/templates/dashboard/sites/list.html
Normal file
197
core/templates/dashboard/sites/list.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.my_sites }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.my_sites }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header with Add Site button -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.my_sites }}</h2>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium"
|
||||||
|
title="{{ t.add_site }}">
|
||||||
|
+ {{ t.add_site }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash messages -->
|
||||||
|
{% if msg %}
|
||||||
|
<div class="bg-green-50 dark:bg-green-500/20 border border-green-200 dark:border-green-500/50 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg">
|
||||||
|
{{ msg }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sites %}
|
||||||
|
<!-- Sites table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_alias }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.plugin_type }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_url }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.status }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for site in sites %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors" id="site-{{ site.id }}">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ site.alias }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
|
||||||
|
<td class="px-6 py-4" id="status-cell-{{ site.id }}">
|
||||||
|
{% if site.status == 'active' %}
|
||||||
|
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||||
|
{% elif site.status == 'error' %}
|
||||||
|
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||||
|
{% elif site.status == 'pending' %}
|
||||||
|
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if site.status_msg %}
|
||||||
|
<p class="text-xs text-gray-500 mt-1 status-msg">{{ site.status_msg[:60] }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
|
||||||
|
{% if site.last_tested_at %}
|
||||||
|
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
|
||||||
|
{% else %}
|
||||||
|
{{ t.never_tested }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="testSite('{{ site.id }}')"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||||
|
id="test-btn-{{ site.id }}">
|
||||||
|
{{ t.test_connection }}
|
||||||
|
</button>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
|
||||||
|
{{ t.get('connect', 'Connect') }}
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{{ t.edit }}
|
||||||
|
</a>
|
||||||
|
<button onclick="deleteSite('{{ site.id }}')"
|
||||||
|
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||||
|
{{ t.delete }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.no_sites }}</h3>
|
||||||
|
<p class="text-gray-500 mb-6">{{ t.add_first_site }}</p>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-6 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.add_site }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function updateStatusBadge(siteId, status) {
|
||||||
|
const cell = document.getElementById('status-cell-' + siteId);
|
||||||
|
if (!cell) return;
|
||||||
|
const badge = cell.querySelector('.status-badge');
|
||||||
|
if (badge) {
|
||||||
|
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||||
|
if (status === 'active') {
|
||||||
|
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
|
||||||
|
badge.textContent = '{{ t.active }}';
|
||||||
|
} else {
|
||||||
|
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
|
||||||
|
badge.textContent = '{{ t.error }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msgEl = cell.querySelector('.status-msg');
|
||||||
|
if (msgEl) msgEl.remove();
|
||||||
|
const timeEl = cell.querySelector('.last-tested-time');
|
||||||
|
if (timeEl) {
|
||||||
|
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSite(siteId) {
|
||||||
|
const btn = document.getElementById('test-btn-' + siteId);
|
||||||
|
btn.textContent = '{{ t.testing }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ct = resp.headers.get('content-type') || '';
|
||||||
|
if (!ct.includes('application/json')) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
btn.textContent = '{{ t.connection_ok }}';
|
||||||
|
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
updateStatusBadge(siteId, data.status || 'active');
|
||||||
|
} else {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, data.status || 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = '{{ t.test_connection }}';
|
||||||
|
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSite(siteId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('site-' + siteId).remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to delete site');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
134
core/templates/dashboard/sites/view.html
Normal file
134
core/templates/dashboard/sites/view.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ site.alias }}
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MCP Endpoint URL -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">
|
||||||
|
{{ mcp_url }}
|
||||||
|
</code>
|
||||||
|
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
Authenticate with an API key (Bearer token) or OAuth.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Snippets -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
|
||||||
|
</label>
|
||||||
|
<select id="config-client" onchange="updateConfig()"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||||
|
<button onclick="copyConfig()" id="copy-config-btn"
|
||||||
|
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteAlias = "{{ site.alias }}";
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!client) return;
|
||||||
|
const isWeb = WEB_CLIENTS.includes(client);
|
||||||
|
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
|
||||||
|
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
|
||||||
|
const data = await r.json();
|
||||||
|
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
|
||||||
|
} catch (_) {
|
||||||
|
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMcpUrl() {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
|
||||||
|
setTimeout(() => btn.textContent = orig, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
241
core/templates/dashboard/user-oauth-clients.html
Normal file
241
core/templates/dashboard/user-oauth-clients.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}کلاینتهای OAuth{% else %}My OAuth Clients{% endif %} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}کلاینتهای OAuth من{% else %}My OAuth Clients{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div>
|
||||||
|
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1 rotate-180{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}بازگشت به اتصال{% else %}Back to Connect{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Banner -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای اتصال Claude.ai Connectors به MCP Hub، یک OAuth Client بسازید. Client ID و Client Secret را در Claude.ai وارد کنید.
|
||||||
|
{% else %}
|
||||||
|
Create an OAuth Client to connect Claude.ai Connectors to your MCP Hub sites. Enter the Client ID and Client Secret in Claude.ai → Settings → Connectors.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clients List -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}کلاینتهای OAuth{% else %}OAuth Clients{% endif %}
|
||||||
|
</h3>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {% if lang == 'fa' %}کلاینت جدید{% else %}New Client{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if clients %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
Client ID
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}Redirect URIs{% else %}Redirect URIs{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for client in clients %}
|
||||||
|
<tr id="client-{{ client.client_id }}">
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-white font-medium">{{ client.client_name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-xs">{{ client.client_id }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||||
|
{% for uri in client.redirect_uris %}
|
||||||
|
<div class="truncate max-w-xs" title="{{ uri }}">{{ uri }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button onclick="deleteClient('{{ client.client_id }}')"
|
||||||
|
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||||
|
{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}هنوز کلاینت OAuth ندارید. یکی بسازید.{% else %}No OAuth clients yet. Create one to connect Claude.ai.{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Client Modal -->
|
||||||
|
<div id="create-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}کلاینت OAuth جدید{% else %}New OAuth Client{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="text" id="client-name"
|
||||||
|
placeholder="{% if lang == 'fa' %}مثال: Claude.ai{% else %}e.g. Claude.ai Desktop{% endif %}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
|
||||||
|
</label>
|
||||||
|
<textarea id="redirect-uris" rows="3"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm">https://claude.ai/api/mcp/auth_callback
|
||||||
|
https://claude.com/api/mcp/auth_callback</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button onclick="closeCreateModal()"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}لغو{% else %}Cancel{% endif %}
|
||||||
|
</button>
|
||||||
|
<button onclick="createClient()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{% if lang == 'fa' %}ایجاد{% else %}Create{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Modal (shown once after create) -->
|
||||||
|
<div id="success-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}کلاینت ساخته شد{% else %}Client Created{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
{% if lang == 'fa' %}Client Secret فقط یکبار نمایش داده میشود. الان کپی کنید.{% else %}Client Secret is shown only once. Copy it now.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-client-id" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyText('new-client-id')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-client-secret" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyText('new-client-secret')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button onclick="closeSuccessModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{% if lang == 'fa' %}متوجه شدم{% else %}Done{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
|
document.getElementById('client-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
|
document.getElementById('client-name').value = '';
|
||||||
|
document.getElementById('redirect-uris').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSuccessModal() {
|
||||||
|
document.getElementById('success-modal').classList.add('hidden');
|
||||||
|
document.getElementById('success-modal').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(elementId) {
|
||||||
|
const text = document.getElementById(elementId).textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const name = document.getElementById('client-name').value.trim();
|
||||||
|
const uris = document.getElementById('redirect-uris').value.trim();
|
||||||
|
if (!name || !uris) {
|
||||||
|
alert('{% if lang == "fa" %}نام و Redirect URI الزامی است{% else %}Name and Redirect URI are required{% endif %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/user-oauth-clients/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_name: name, redirect_uris: uris }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
closeCreateModal();
|
||||||
|
document.getElementById('new-client-id').textContent = data.client_id;
|
||||||
|
document.getElementById('new-client-secret').textContent = data.client_secret;
|
||||||
|
document.getElementById('success-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('success-modal').classList.add('flex');
|
||||||
|
} else {
|
||||||
|
alert(data.error || '{% if lang == "fa" %}خطا در ایجاد کلاینت{% else %}Failed to create client{% endif %}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(clientId) {
|
||||||
|
if (!confirm('{% if lang == "fa" %}این کلاینت حذف شود؟{% else %}Delete this OAuth client?{% endif %}')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/user-oauth-clients/' + clientId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('client-' + clientId).remove();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.error || '{% if lang == "fa" %}خطا در حذف{% else %}Delete failed{% endif %}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
268
core/templates/oauth/authorize.html
Normal file
268
core/templates/oauth/authorize.html
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container px-4">
|
||||||
|
<div class="auth-card w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8">
|
||||||
|
<!-- Header with Icon -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-purple-100 dark:bg-purple-900 mb-4">
|
||||||
|
<svg class="h-10 w-10 text-purple-600 dark:text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<span class="font-semibold text-purple-600 dark:text-purple-400">{{ client_name }}</span>
|
||||||
|
{{ t.wants_access.replace('{client_name}', '') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Information -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
|
||||||
|
<div class="space-y-2 text-sm" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ t.client_id_label }}</span>
|
||||||
|
<span class="font-mono text-gray-900 dark:text-white text-xs">{{ client_id }}</span>
|
||||||
|
</div>
|
||||||
|
{% if redirect_uri %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ t.redirect_uri_label }}</span>
|
||||||
|
<span class="font-mono text-xs text-gray-900 dark:text-white truncate ml-2" title="{{ redirect_uri }}">
|
||||||
|
{{ redirect_uri[:40] }}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requested Permissions -->
|
||||||
|
{% if scopes %}
|
||||||
|
<div class="mb-6" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for scope in scopes %}
|
||||||
|
<div class="permission-badge flex items-center p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ scope }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Authorization Form -->
|
||||||
|
<form method="POST" action="/oauth/authorize/confirm" id="authForm" class="space-y-4">
|
||||||
|
<!-- Hidden fields -->
|
||||||
|
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||||
|
<input type="hidden" name="response_type" value="{{ response_type }}">
|
||||||
|
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
|
||||||
|
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
|
||||||
|
{% if scope %}
|
||||||
|
<input type="hidden" name="scope" value="{{ scope }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if state %}
|
||||||
|
<input type="hidden" name="state" value="{{ state }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if resource %}
|
||||||
|
<input type="hidden" name="resource" value="{{ resource }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
{% if session_user %}
|
||||||
|
<!-- Session-based consent (user already logged in) -->
|
||||||
|
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-green-800 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}وارد شده به عنوان{% else %}Logged in as{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{{ session_user.name or session_user.email or 'User' }}
|
||||||
|
{% if session_user.email %}
|
||||||
|
<span class="text-green-600 dark:text-green-500">({{ session_user.email }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="api_key" value="__session__" id="session_marker">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">با کلید API وارد شوید</a>
|
||||||
|
{% else %}
|
||||||
|
Or <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">enter an API key instead</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
|
||||||
|
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{{ t.enter_api_key }}
|
||||||
|
</label>
|
||||||
|
<input type="password" id="api_key_input" name="api_key_manual"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
||||||
|
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
|
||||||
|
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- API key mode (no active session) -->
|
||||||
|
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{{ t.enter_api_key }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="api_key"
|
||||||
|
name="api_key"
|
||||||
|
required
|
||||||
|
placeholder="{{ t.api_key_placeholder }}"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
||||||
|
autocomplete="off"
|
||||||
|
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
{{ t.api_key_note }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if return_url %}
|
||||||
|
<!-- Social login link -->
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا با حساب کاربری وارد شوید:
|
||||||
|
{% else %}
|
||||||
|
Or log in with your account:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<a href="/auth/github?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href="/auth/google?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 text-gray-700 text-sm rounded-lg border border-gray-300 transition-colors">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Google
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="action"
|
||||||
|
value="approve"
|
||||||
|
class="btn-primary flex-1 px-6 py-3 text-white font-semibold rounded-lg"
|
||||||
|
>
|
||||||
|
{{ t.approve }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="action"
|
||||||
|
value="deny"
|
||||||
|
class="btn-secondary flex-1 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-semibold rounded-lg"
|
||||||
|
>
|
||||||
|
{{ t.deny }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
<div class="flex {% if lang == 'fa' %}flex-row-reverse{% endif %}">
|
||||||
|
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 {% if lang == 'fa' %}mr-2{% else %}mr-2{% endif %} 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>
|
||||||
|
<p class="text-xs text-blue-800 dark:text-blue-300">
|
||||||
|
<strong>{{ t.security_note }}</strong> {{ t.security_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showApiKeyInput() {
|
||||||
|
// Switch from session mode to API key input mode
|
||||||
|
document.getElementById('api-key-section').classList.remove('hidden');
|
||||||
|
// Remove the __session__ hidden input
|
||||||
|
var hiddenInput = document.getElementById('session_marker');
|
||||||
|
if (hiddenInput) hiddenInput.remove();
|
||||||
|
// Make the manual input the actual api_key field
|
||||||
|
var manualInput = document.getElementById('api_key_input');
|
||||||
|
if (manualInput) {
|
||||||
|
manualInput.name = 'api_key';
|
||||||
|
manualInput.required = true;
|
||||||
|
manualInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
document.getElementById('authForm').addEventListener('submit', function(e) {
|
||||||
|
var action = e.submitter.value;
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
// Session-based consent — no API key validation needed
|
||||||
|
var sessionInput = document.getElementById('session_marker');
|
||||||
|
if (sessionInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKeyEl = document.getElementById('api_key') || document.getElementById('api_key_input');
|
||||||
|
var apiKey = apiKeyEl ? apiKeyEl.value : '';
|
||||||
|
|
||||||
|
if (!apiKey || apiKey.length < 10) {
|
||||||
|
e.preventDefault();
|
||||||
|
var lang = '{{ lang }}';
|
||||||
|
var message = lang === 'fa'
|
||||||
|
? 'لطفاً یک API Key معتبر وارد کنید'
|
||||||
|
: 'Please enter a valid API key';
|
||||||
|
alert(message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
|
||||||
|
var lang2 = '{{ lang }}';
|
||||||
|
var msg = lang2 === 'fa'
|
||||||
|
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
||||||
|
: 'The API key format looks unusual. Continue anyway?';
|
||||||
|
if (!window.confirm(msg)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus effect for API key input (if present)
|
||||||
|
var apiKeyInput = document.getElementById('api_key');
|
||||||
|
if (apiKeyInput) {
|
||||||
|
apiKeyInput.addEventListener('focus', function() {
|
||||||
|
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
||||||
|
});
|
||||||
|
apiKeyInput.addEventListener('blur', function() {
|
||||||
|
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
1
core/templates/static/logo.svg
Normal file
1
core/templates/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><style>.a{fill:#51b9f4}.b{fill:#fec13d}</style><path class="a" d="m496.4 11.5q5.4-0.4 10.9-0.5 5.4-0.1 10.9 0.1 5.4 0.2 10.8 0.7 5.5 0.4 10.9 1.2c56.2 7.7 99.4 36 133.3 81.3 31 41.4 41 94.3 34.8 144.5-6.9 54.7-31.6 89.2-71.8 124.8-9.7 8.6-19.4 21-29.9 28.7 8 7.2 17.5 17 25.1 24.8 8.6-11.9 22.8-24.8 33.4-35.4 10.6-10.1 20.7-22.2 32.2-31.2 44.5-34.8 109.1-43.7 163.2-31.3 97.6 22.4 162.8 115.9 151.4 215.3-12.8 111.6-114.9 189.3-225.7 173.7-56.9-8-89.1-29.9-126.7-72.5-8.4-9.5-20.3-19-28.1-29.2-3 4.4-20.1 21-24.7 25.2q0.8 0.7 1.6 1.4c10.3 8.9 20.5 20.3 30 29.2 31.4 29.2 55.6 55.7 65.1 98.8 3 13.7 4.9 22.1 6.1 36.6 4.1 52.5-11.2 105-46 144.9-28.1 32.3-64.1 56.5-106.4 64.9-9.6 2-17.3 3.9-27.3 4.6-11.3 1.4-27.3 0.9-38.7-0.3-55.1-6.2-100.6-31.3-135.2-74.7-6.8-8.5-12.8-17.6-18.1-27.2-5.2-9.5-9.7-19.4-13.3-29.7-3.6-10.3-6.3-20.8-8.1-31.6-1.9-10.7-2.8-21.5-2.8-32.4-0.3-41.9 11.1-88.4 38.8-121 5-5.8 10.5-11.8 16.2-17.1 15.8-14.8 31.2-33.3 48-46.7-7.7-8-17.1-16.5-24.2-24.6-8.4 9.8-18.5 19-27.4 28.3-10.5 10.8-20.7 21.7-31.8 31.9-36.3 32.8-85.8 44.5-133.6 42.7-12.8-0.5-25.5-2.3-38-5.3-12.5-3-24.6-7.3-36.2-12.7-11.7-5.4-22.7-11.9-33.1-19.4-10.3-7.6-19.9-16.2-28.6-25.6-46.8-50.7-62.6-116.6-46.9-183.1 8.7-41.1 30.3-72.1 60.7-100.4 64.9-60.6 180.9-67.1 250.4-10.7 10.9 8.8 22.5 21.5 32.4 31.7 10.5 10.9 21.9 21.7 32.1 33 8.1-11 14.4-15.9 23.9-24.9-37.7-41.9-77.8-63.2-94-122.1-17.9-65.2-8.7-131.9 34.5-185.2 37.9-46.8 81.1-66.8 139.9-73.5zm-44.5 380.8q-5.1 5.1-10.1 10.1-5.1 5-10.2 10.1-5 5-10.1 10.1-5 5-10 10.1c-3.7 3.7-16.3 16.8-20.1 19.1-12.9-14.3-28.9-27.9-41.3-41.9-35.7-40-68.4-67.4-124.3-70.7-53.9-3.2-94.4 9.6-135 46-8.2 7.5-15.6 15.7-22.2 24.7-6.6 8.9-12.3 18.5-16.9 28.5-4.7 10.1-8.4 20.6-11.1 31.4-2.6 10.7-4.2 21.7-4.6 32.8-6.6 133.7 129.3 221.3 249 166.3 3.7-2.1 7.3-4.3 10.8-6.5 10.9-6.8 20.8-15.5 30.3-24.1 8.2-7.5 14.8-16.4 22.4-24.5 13.8-14.7 29-28.3 43.3-42.4 4.5 5.4 11.9 12.5 17.1 17.6l28.4 28.4c3.5 3.5 11.1 10.7 14.1 14.3-7.4 9.1-18.4 18.4-26.7 27.1-18.8 19.6-40.3 36.2-56.9 57.9-24.9 32.5-31.7 75.7-28.5 115.7 4 47.7 27.3 87 63.8 117.4 6.4 5.3 13.3 10.2 20.5 14.5 7.2 4.3 14.8 8 22.5 11.2 7.8 3.2 15.8 5.8 23.9 7.8 8.2 2 16.5 3.4 24.8 4.2 7.8 0.6 17.8 0.5 25.6 0.3 35.2-1.1 68.9-14.1 95.9-36.2 8.1-6.7 12.7-9.6 20.1-17.7 33.7-36.9 49.6-74.3 48.1-124.8-1.2-38-9.3-69-34.2-98.8-10.3-12.4-23-23.8-34.6-34.8-14.7-14-29.1-29.5-43.8-43.4l-0.3-0.3c5.7-7.1 11.7-13.2 18.2-19.6 13.7-13.5 26.9-27.5 41.1-40.5l0.6 0.6c9.5 7.9 18.7 19.1 27.8 27.7 18.6 17.9 34.5 38.1 54.9 54.1 33.2 26.3 76.5 33.7 117.8 30.3 52.5-4.3 98.3-36.2 127.8-78.8 18.8-27.3 28.5-62 27.5-95.2-0.2-5 0-9.8-0.4-14.8-1-11.4-3.1-22.6-6.3-33.5-3.2-10.9-7.6-21.5-12.9-31.6-5.4-10-11.7-19.5-19-28.3-7.2-8.8-15.4-16.8-24.2-24-40.2-32.9-81.7-43.3-132.6-38.3-50.9 4.9-82 28.5-114.5 65.8-6.4 7.3-15 14.7-21.6 22-5.5 5.4-19.7 20.8-25 24.3-19.2-20.2-40.1-39.5-59.3-59.7 12.5-15 27.9-29.5 41.9-43.3 5.9-5.8 13.8-11.6 20.3-18.1 32.1-32.4 48.9-63.7 50.5-110 0.4-10.8 0.6-19.1-0.4-30.1-1.1-11.2-3.3-22.3-6.6-33.1-3.3-10.7-7.6-21.2-13-31.1-5.3-9.9-11.7-19.3-18.9-27.9-7.2-8.7-15.3-16.6-24-23.7-29.7-24.3-61.1-36.9-99.7-39-4.5-0.2-14.1-0.4-18.5 0.1-49.6 3.5-86.3 20.5-120.3 57.4-33.6 36.6-46.8 80.9-43.8 130 2.7 45.3 18.5 77.4 51.2 108.8 20 19.3 42.8 39.8 61.7 60z"/><path class="b" d="m210.1 432.1c43.8 0.6 78.9 36.4 78.7 80.1-0.3 43.8-35.9 79.2-79.6 79.2-44.3 0-80-35.9-79.8-80.1 0.3-44.2 36.5-79.8 80.7-79.2zm1.1 134.1c30-1.2 53.3-26.3 52.3-56.3-1-29.9-26-53.4-55.9-52.6-30.3 0.8-54.1 26-53.1 56.3 1 30.2 26.5 53.9 56.7 52.6z"/><path class="b" d="m503.9 129.7c43.8-4.5 82.9 27.4 87.3 71.2 4.4 43.8-27.5 82.9-71.3 87.3-43.8 4.3-82.8-27.6-87.2-71.3-4.4-43.8 27.5-82.8 71.2-87.2zm12.9 133.5c29.9-2.7 52-29.1 49.4-59.1-2.7-30-29.1-52.1-59.1-49.5-30 2.6-52.3 29.1-49.6 59.1 2.7 30.1 29.2 52.3 59.3 49.5z"/><path class="b" d="m503.3 735.5c43.8-4.8 83.1 26.9 87.9 70.7 4.7 43.7-27 83-70.8 87.7-43.7 4.6-82.9-27-87.6-70.7-4.7-43.7 26.9-82.9 70.5-87.7zm12.5 133.5c29.9-2.1 52.5-28 50.5-58-2-30-27.8-52.7-57.8-50.8-30.2 1.8-53.1 27.9-51.1 58 2 30.2 28.2 53 58.4 50.8z"/><path class="b" d="m805.8 432.7c43.7-5 83.3 26.4 88.2 70.2 4.9 43.7-26.6 83.2-70.3 88-43.7 4.9-83-26.5-87.9-70.2-5-43.6 26.4-83 70-88zm11.6 133.6c29.9-1.5 53-26.9 51.8-56.8-1.3-30-26.5-53.3-56.4-52.2-30.3 1-53.9 26.5-52.7 56.8 1.3 30.3 27 53.7 57.3 52.2z"/><path class="a" d="m511 451.9c3.1 0.9 33.1 32.8 37.8 36.9 2.7 2.4 19.9 19.4 22.3 22.7-2.6 4.7-53.2 55.7-59.3 60.2-0.2 0-0.4 0-0.5-0.1-2.1-0.8-56.2-54.4-59.3-59.3 1.4-4.2 52.5-54.7 59-60.4zm-24.9 59.5c3.3 4.6 19.8 20.5 24.8 25.4 0.5 0 1 0.1 1.4 0 8-8.8 16-16.6 24.6-24.8-3-3-24.1-24.6-25.8-25.3-3.7 4.3-21 21.9-25 24.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
Binary file not shown.
326
core/tool_access.py
Normal file
326
core/tool_access.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
|
||||||
|
|
||||||
|
Provides a central pipeline that filters the set of MCP tools presented for
|
||||||
|
a user endpoint based on:
|
||||||
|
|
||||||
|
1. **Scope → category mapping.** Every ``ToolDefinition`` carries a
|
||||||
|
``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``).
|
||||||
|
An API key's declared scopes **and** the site's stored ``tool_scope``
|
||||||
|
preset each map to a set of allowed categories via
|
||||||
|
:data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in
|
||||||
|
the intersection — the narrower of the two layers wins.
|
||||||
|
2. **Per-site tool toggles.** Site owners may explicitly disable specific
|
||||||
|
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||||
|
tools without an entry are enabled by default.
|
||||||
|
|
||||||
|
Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always
|
||||||
|
visible** (backward compatibility — legacy plugins that have not been
|
||||||
|
annotated yet default to ``category="read"``, which belongs to the ``read``
|
||||||
|
scope set anyway, but an unknown value would be preserved).
|
||||||
|
|
||||||
|
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
||||||
|
site-level preset filter" — in that case only the per-tool toggles and the
|
||||||
|
key scope are considered.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
mgr = get_tool_access_manager()
|
||||||
|
visible = await mgr.get_visible_tools(
|
||||||
|
site_id=site["id"],
|
||||||
|
key_scopes=["read"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping from scope → set of tool categories that scope may see.
|
||||||
|
# Used for BOTH API-key scopes and per-site ``tool_scope`` presets.
|
||||||
|
# Scopes are additive: presenting multiple scopes yields the union.
|
||||||
|
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||||
|
"read": {"read"},
|
||||||
|
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||||
|
"deploy": {"read", "lifecycle"},
|
||||||
|
"write": {"read", "lifecycle", "crud", "env"},
|
||||||
|
"admin": {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# All known categories — any tool whose category is outside this set is
|
||||||
|
# treated as "always visible" for backward compatibility.
|
||||||
|
KNOWN_CATEGORIES: set[str] = {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
||||||
|
SCOPE_CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||||
|
"""Return the union of categories allowed by the given scope list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scopes: List of scope strings as presented on the API key / token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of category names the scopes collectively allow.
|
||||||
|
"""
|
||||||
|
allowed: set[str] = set()
|
||||||
|
for scope in scopes:
|
||||||
|
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
class ToolAccessManager:
|
||||||
|
"""Central manager for scope-based visibility and per-site tool toggles."""
|
||||||
|
|
||||||
|
def apply_scope_filter(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
scopes: list[str],
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools whose category is not allowed by the presented scopes.
|
||||||
|
|
||||||
|
Tools with an unknown category (e.g. legacy plugins not yet annotated)
|
||||||
|
are always kept — backward compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Candidate tool list.
|
||||||
|
scopes: Scopes presented on the API key (or a single-element list
|
||||||
|
containing a site's ``tool_scope`` preset).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered tool list.
|
||||||
|
"""
|
||||||
|
allowed = scopes_to_categories(scopes)
|
||||||
|
if not allowed:
|
||||||
|
# No recognised scopes — preserve legacy behaviour and return
|
||||||
|
# only tools with unknown categories.
|
||||||
|
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||||
|
|
||||||
|
result: list[ToolDefinition] = []
|
||||||
|
for tool in tools:
|
||||||
|
if tool.category not in KNOWN_CATEGORIES:
|
||||||
|
result.append(tool)
|
||||||
|
continue
|
||||||
|
if tool.category in allowed:
|
||||||
|
result.append(tool)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def apply_site_toggles(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
site_id: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools the site owner has explicitly disabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Candidate tool list.
|
||||||
|
site_id: Site UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered tool list.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
except RuntimeError:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
if not toggles:
|
||||||
|
return tools
|
||||||
|
return [t for t in tools if toggles.get(t.name, True)]
|
||||||
|
|
||||||
|
async def get_visible_tools(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Return the visible tool list for a site on a given plugin.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. ``ToolRegistry.get_by_plugin_type``
|
||||||
|
2. Key-scope filter (API key's declared scopes)
|
||||||
|
3. Site-scope filter (site's stored ``tool_scope`` preset,
|
||||||
|
skipped when it is ``custom``)
|
||||||
|
4. Per-site toggle filter (``site_tool_toggles``)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID (the MCP endpoint alias resolves to this).
|
||||||
|
key_scopes: Scopes presented on the API key / token.
|
||||||
|
plugin_type: Plugin type (e.g. ``coolify``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of visible ``ToolDefinition`` objects.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
|
||||||
|
tools = self.apply_scope_filter(tools, key_scopes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
site_scope = await db.get_site_tool_scope(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
site_scope = "admin"
|
||||||
|
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
tools = self.apply_scope_filter(tools, [site_scope])
|
||||||
|
|
||||||
|
tools = await self.apply_site_toggles(tools, site_id)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
async def toggle_tool(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enable or disable a single tool for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name.
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
reason: Optional note.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
|
||||||
|
logger.info(
|
||||||
|
"site %s toggled %s → %s",
|
||||||
|
site_id,
|
||||||
|
tool_name,
|
||||||
|
"enabled" if enabled else "disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def bulk_toggle_by_scope(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
scope_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
plugin_type: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Toggle every tool whose category belongs to the given scope.
|
||||||
|
|
||||||
|
Only the *exclusive* category set of the scope is affected — i.e.
|
||||||
|
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
|
||||||
|
Tools outside those categories are left unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
plugin_type: Optional filter — only affect tools from this plugin.
|
||||||
|
When ``None`` every plugin's tools in that category are touched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tools affected.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
categories = SCOPE_TO_CATEGORIES.get(scope_name)
|
||||||
|
if categories is None:
|
||||||
|
raise ValueError(f"Unknown scope '{scope_name}'")
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
candidates = registry.get_all()
|
||||||
|
if plugin_type is not None:
|
||||||
|
candidates = [t for t in candidates if t.plugin_type == plugin_type]
|
||||||
|
affected = [t.name for t in candidates if t.category in categories]
|
||||||
|
|
||||||
|
if not affected:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.bulk_set_site_tool_toggles(
|
||||||
|
site_id,
|
||||||
|
[(name, enabled) for name in affected],
|
||||||
|
reason=f"bulk:{scope_name}",
|
||||||
|
)
|
||||||
|
return len(affected)
|
||||||
|
|
||||||
|
async def list_tools_for_site(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return every tool for a plugin, annotated with per-site toggle state.
|
||||||
|
|
||||||
|
Used by the dashboard API to present the per-site management view.
|
||||||
|
Does not apply scope filters — the UI decides what to show.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
plugin_type: Plugin type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with tool metadata + ``enabled`` flag.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
toggles = {}
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"plugin_type": t.plugin_type,
|
||||||
|
"category": t.category,
|
||||||
|
"sensitivity": t.sensitivity,
|
||||||
|
"required_scope": t.required_scope,
|
||||||
|
"enabled": toggles.get(t.name, True),
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: ToolAccessManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_access_manager() -> ToolAccessManager:
|
||||||
|
"""Return the singleton :class:`ToolAccessManager`."""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = ToolAccessManager()
|
||||||
|
return _manager
|
||||||
@@ -181,6 +181,9 @@ class ToolGenerator:
|
|||||||
description = spec["description"]
|
description = spec["description"]
|
||||||
schema = spec["schema"]
|
schema = spec["schema"]
|
||||||
scope = spec.get("scope", "read")
|
scope = spec.get("scope", "read")
|
||||||
|
# F.7: optional category + sensitivity for scope-based visibility
|
||||||
|
category = spec.get("category", "read")
|
||||||
|
sensitivity = spec.get("sensitivity", "normal")
|
||||||
|
|
||||||
# Create full tool name
|
# Create full tool name
|
||||||
tool_name = f"{plugin_type}_{action_name}"
|
tool_name = f"{plugin_type}_{action_name}"
|
||||||
@@ -202,6 +205,8 @@ class ToolGenerator:
|
|||||||
handler=handler,
|
handler=handler,
|
||||||
required_scope=scope,
|
required_scope=scope,
|
||||||
plugin_type=plugin_type,
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
sensitivity=sensitivity,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_site_parameter(
|
def _add_site_parameter(
|
||||||
@@ -448,12 +453,15 @@ class ToolGenerator:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Import custom exceptions for better error handling
|
# Import custom exceptions for better error handling
|
||||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
from plugins.wordpress.client import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
|
|
||||||
if isinstance(e, ConfigurationError):
|
if isinstance(e, ConfigurationError):
|
||||||
# Configuration error - likely missing env vars
|
|
||||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||||
return (
|
return (
|
||||||
f"Configuration Error: {str(e)}\n\n"
|
f"Configuration Error: {str(e)}\n\n"
|
||||||
@@ -464,12 +472,14 @@ class ToolGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(e, AuthenticationError):
|
elif isinstance(e, AuthenticationError):
|
||||||
# Authentication error - 401/403
|
|
||||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||||
return f"Authentication Error: {str(e)}"
|
return f"Authentication Error: {str(e)}"
|
||||||
|
|
||||||
|
elif isinstance(e, ConnectionError):
|
||||||
|
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
|
||||||
|
return f"Connection Error: {str(e)}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unexpected error
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
|
|||||||
handler: Async function that executes the tool
|
handler: Async function that executes the tool
|
||||||
required_scope: Required API key scope ("read", "write", "admin")
|
required_scope: Required API key scope ("read", "write", "admin")
|
||||||
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
||||||
|
category: Tool category for scope-based visibility filtering (F.7)
|
||||||
|
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
|
||||||
|
"backup", "system". Defaults to "read" for backward compatibility.
|
||||||
|
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = Field(..., description="Unique tool identifier")
|
name: str = Field(..., description="Unique tool identifier")
|
||||||
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
|
|||||||
default="read", description="Required API key scope (read/write/admin)"
|
default="read", description="Required API key scope (read/write/admin)"
|
||||||
)
|
)
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
|
category: str = Field(
|
||||||
|
default="read",
|
||||||
|
description="Tool category for scope-based visibility (F.7)",
|
||||||
|
)
|
||||||
|
sensitivity: str = Field(
|
||||||
|
default="normal",
|
||||||
|
description="Data sensitivity: normal or sensitive (F.7)",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||||
|
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
"""
|
|
||||||
Unified Tool Generator
|
|
||||||
|
|
||||||
Generates context-based tools that work across multiple sites.
|
|
||||||
Maintains backward compatibility by keeping per-site tools.
|
|
||||||
|
|
||||||
Architecture:
|
|
||||||
- Old: wordpress_site1_get_post(post_id)
|
|
||||||
- New: wordpress_get_post(site, post_id)
|
|
||||||
- Both work simultaneously!
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from core.site_registry import get_site_registry
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedToolGenerator:
|
|
||||||
"""
|
|
||||||
Generates unified tools from per-site tool definitions.
|
|
||||||
|
|
||||||
Takes existing plugin tools and creates context-based versions
|
|
||||||
that accept a 'site' parameter for dynamic routing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, project_manager):
|
|
||||||
"""
|
|
||||||
Initialize unified tool generator.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_manager: ProjectManager instance with discovered projects
|
|
||||||
"""
|
|
||||||
self.project_manager = project_manager
|
|
||||||
self.site_registry = get_site_registry()
|
|
||||||
self.logger = logging.getLogger("UnifiedToolGenerator")
|
|
||||||
|
|
||||||
def generate_unified_tools(self, plugin_type: str) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Generate unified tools for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of unified tool definitions
|
|
||||||
"""
|
|
||||||
# Get all projects of this type
|
|
||||||
projects = self.project_manager.get_projects_by_type(plugin_type)
|
|
||||||
|
|
||||||
if not projects:
|
|
||||||
self.logger.warning(f"No projects found for plugin type: {plugin_type}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Use the first project as a template to get tool definitions
|
|
||||||
first_project_id = list(projects.keys())[0]
|
|
||||||
template_plugin = projects[first_project_id]
|
|
||||||
template_tools = template_plugin.get_tools()
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Generating unified tools for {plugin_type} "
|
|
||||||
f"from {len(template_tools)} template tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
unified_tools = []
|
|
||||||
seen_actions = set()
|
|
||||||
|
|
||||||
for tool in template_tools:
|
|
||||||
# Extract action name from per-site tool name
|
|
||||||
# e.g., "wordpress_site1_get_post" -> "get_post"
|
|
||||||
tool_name = tool["name"]
|
|
||||||
parts = tool_name.split("_")
|
|
||||||
|
|
||||||
# Skip if not in expected format
|
|
||||||
if len(parts) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract action (everything after plugin_type_site_id_)
|
|
||||||
# e.g., wordpress_site1_get_post -> get_post
|
|
||||||
action = "_".join(parts[2:])
|
|
||||||
|
|
||||||
# Skip duplicates (we only need one unified tool per action)
|
|
||||||
if action in seen_actions:
|
|
||||||
continue
|
|
||||||
seen_actions.add(action)
|
|
||||||
|
|
||||||
# Create unified tool
|
|
||||||
unified_tool = self._create_unified_tool(
|
|
||||||
plugin_type=plugin_type, action=action, template_tool=tool
|
|
||||||
)
|
|
||||||
|
|
||||||
if unified_tool:
|
|
||||||
unified_tools.append(unified_tool)
|
|
||||||
|
|
||||||
self.logger.info(f"Generated {len(unified_tools)} unified tools for {plugin_type}")
|
|
||||||
return unified_tools
|
|
||||||
|
|
||||||
def _create_unified_tool(
|
|
||||||
self, plugin_type: str, action: str, template_tool: dict[str, Any]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Create a unified tool from a template.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type (e.g., 'wordpress')
|
|
||||||
action: Action name (e.g., 'get_post')
|
|
||||||
template_tool: Original per-site tool definition
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Unified tool definition
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create unified tool name
|
|
||||||
unified_name = f"{plugin_type}_{action}"
|
|
||||||
|
|
||||||
# Get available sites for this plugin type
|
|
||||||
site_options = self.site_registry.get_site_options(plugin_type)
|
|
||||||
|
|
||||||
if not site_options:
|
|
||||||
self.logger.warning(f"No sites available for {plugin_type}, skipping {action}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Modify input schema to add 'site' parameter
|
|
||||||
original_schema = template_tool.get("inputSchema", {})
|
|
||||||
unified_schema = self._add_site_parameter(original_schema, plugin_type, site_options)
|
|
||||||
|
|
||||||
# Update description to mention site parameter
|
|
||||||
original_description = template_tool.get("description", "")
|
|
||||||
unified_description = self._update_description(original_description, plugin_type)
|
|
||||||
|
|
||||||
# Create wrapper handler
|
|
||||||
original_handler = template_tool.get("handler")
|
|
||||||
unified_handler = self._create_unified_handler(plugin_type, action, original_handler)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": unified_name,
|
|
||||||
"description": unified_description,
|
|
||||||
"inputSchema": unified_schema,
|
|
||||||
"handler": unified_handler,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error creating unified tool for {plugin_type}_{action}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _add_site_parameter(
|
|
||||||
self, original_schema: dict[str, Any], plugin_type: str, site_options: list[str]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Add 'site' parameter to input schema.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original_schema: Original input schema
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_options: Available site IDs/aliases
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified schema with site parameter
|
|
||||||
"""
|
|
||||||
# Deep copy to avoid modifying original
|
|
||||||
import copy
|
|
||||||
|
|
||||||
schema = copy.deepcopy(original_schema)
|
|
||||||
|
|
||||||
# Ensure schema has required structure
|
|
||||||
if "properties" not in schema:
|
|
||||||
schema["properties"] = {}
|
|
||||||
if "required" not in schema:
|
|
||||||
schema["required"] = []
|
|
||||||
|
|
||||||
# Add 'site' as first parameter
|
|
||||||
schema["properties"] = {
|
|
||||||
"site": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
f"Site ID or alias. Available options: {', '.join(site_options)}. "
|
|
||||||
f"Use list_projects() to see all configured sites."
|
|
||||||
),
|
|
||||||
"enum": site_options,
|
|
||||||
},
|
|
||||||
**schema["properties"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make 'site' required
|
|
||||||
if "site" not in schema["required"]:
|
|
||||||
schema["required"].insert(0, "site")
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
def _update_description(self, original_description: str, plugin_type: str) -> str:
|
|
||||||
"""
|
|
||||||
Update tool description to mention unified context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original_description: Original description
|
|
||||||
plugin_type: Plugin type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated description
|
|
||||||
"""
|
|
||||||
# Remove site-specific mentions (e.g., "from site1", "in site2")
|
|
||||||
import re
|
|
||||||
|
|
||||||
cleaned = re.sub(
|
|
||||||
r"\b(?:from|in|for)\s+site\d+\b", "", original_description, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
cleaned = re.sub(
|
|
||||||
r"\bsite\d+\b", f"the specified {plugin_type} site", cleaned, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add unified context note
|
|
||||||
prefix = "[UNIFIED] "
|
|
||||||
if not cleaned.startswith(prefix):
|
|
||||||
cleaned = prefix + cleaned
|
|
||||||
|
|
||||||
return cleaned.strip()
|
|
||||||
|
|
||||||
def _create_unified_handler(
|
|
||||||
self, plugin_type: str, action: str, original_handler: Callable | None
|
|
||||||
) -> Callable:
|
|
||||||
"""
|
|
||||||
Create a unified handler that routes to the correct site.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
action: Action name
|
|
||||||
original_handler: Original handler (not used, we call plugin method directly)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Async handler function
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def unified_handler(site: str, **kwargs):
|
|
||||||
"""
|
|
||||||
Unified handler that routes to the correct site plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
site: Site ID or alias
|
|
||||||
**kwargs: Other parameters for the tool
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get site info from registry
|
|
||||||
site_info = self.site_registry.get_site(plugin_type, site)
|
|
||||||
|
|
||||||
if not site_info:
|
|
||||||
available = self.site_registry.get_site_options(plugin_type)
|
|
||||||
return (
|
|
||||||
f"Error: Site '{site}' not found for {plugin_type}. "
|
|
||||||
f"Available sites: {', '.join(available)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the plugin instance
|
|
||||||
full_id = site_info.get_full_id()
|
|
||||||
|
|
||||||
# SECURITY: Check if API key has access to this project
|
|
||||||
from core.context import get_api_key_context
|
|
||||||
|
|
||||||
api_key_info = get_api_key_context()
|
|
||||||
|
|
||||||
if api_key_info and not api_key_info.get("is_global"):
|
|
||||||
# Per-project key - must match the project
|
|
||||||
allowed_project = api_key_info.get("project_id")
|
|
||||||
|
|
||||||
# Resolve allowed_project to normalize alias vs site_id
|
|
||||||
# API key might have been created with alias (wordpress_myblog)
|
|
||||||
# or site_id (wordpress_site1)
|
|
||||||
allowed_project_normalized = allowed_project
|
|
||||||
if allowed_project and "_" in allowed_project:
|
|
||||||
# Extract plugin type and site identifier from allowed_project
|
|
||||||
allowed_parts = allowed_project.split("_", 1)
|
|
||||||
if len(allowed_parts) == 2:
|
|
||||||
allowed_plugin_type, allowed_site_identifier = allowed_parts
|
|
||||||
# Try to resolve the site identifier to site_id
|
|
||||||
try:
|
|
||||||
allowed_site_info = self.site_registry.get_site(
|
|
||||||
allowed_plugin_type, allowed_site_identifier
|
|
||||||
)
|
|
||||||
if allowed_site_info:
|
|
||||||
# Normalize to plugin_type_site_id format
|
|
||||||
allowed_project_normalized = allowed_site_info.get_full_id()
|
|
||||||
except (ValueError, Exception):
|
|
||||||
# Site not found, keep original for error message
|
|
||||||
pass
|
|
||||||
|
|
||||||
if allowed_project_normalized != full_id:
|
|
||||||
logger.warning(
|
|
||||||
f"Access denied: API key for project '{allowed_project}' "
|
|
||||||
f"attempted to access '{full_id}'"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Access denied. This API key is restricted to project '{allowed_project}'. "
|
|
||||||
f"Use a global API key or create a key for '{full_id}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin = self.project_manager.get_project(full_id)
|
|
||||||
|
|
||||||
if not plugin:
|
|
||||||
return f"Error: Plugin instance not found for {full_id}"
|
|
||||||
|
|
||||||
# Find the original handler method in the plugin
|
|
||||||
# The original per-site tool name was: {plugin_type}_{site_id}_{action}
|
|
||||||
original_tool_name = f"{plugin_type}_{site_info.site_id}_{action}"
|
|
||||||
|
|
||||||
# Get all tools from plugin and find the matching handler
|
|
||||||
tools = plugin.get_tools()
|
|
||||||
handler = None
|
|
||||||
|
|
||||||
for tool in tools:
|
|
||||||
if tool["name"] == original_tool_name:
|
|
||||||
handler = tool.get("handler")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not handler:
|
|
||||||
return (
|
|
||||||
f"Error: Handler not found for {original_tool_name}. "
|
|
||||||
f"This may be a plugin implementation issue."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out None values from kwargs to avoid validation errors
|
|
||||||
# WordPress API doesn't accept None values in query parameters
|
|
||||||
filtered_kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
|
||||||
|
|
||||||
# Call the handler with filtered kwargs
|
|
||||||
result = await handler(**filtered_kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error in unified handler for {plugin_type}_{action}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
return unified_handler
|
|
||||||
|
|
||||||
def generate_all_unified_tools(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Generate unified tools for all registered plugin types.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of all unified tool definitions
|
|
||||||
"""
|
|
||||||
all_tools = []
|
|
||||||
|
|
||||||
# Get all plugin types from registry
|
|
||||||
from plugins import registry
|
|
||||||
|
|
||||||
plugin_types = registry.get_registered_types()
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
tools = self.generate_unified_tools(plugin_type)
|
|
||||||
all_tools.extend(tools)
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Generated {len(all_tools)} total unified tools "
|
|
||||||
f"across {len(plugin_types)} plugin types"
|
|
||||||
)
|
|
||||||
|
|
||||||
return all_tools
|
|
||||||
483
core/user_auth.py
Normal file
483
core/user_auth.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""User authentication system with OAuth Social Login (GitHub + Google).
|
||||||
|
|
||||||
|
Handles OAuth 2.0 authorization flows, token exchange, user info fetching,
|
||||||
|
session creation (JWT cookies), and registration rate limiting.
|
||||||
|
|
||||||
|
This module is for user-facing authentication (Track E.2). The existing
|
||||||
|
admin authentication (core/auth.py, core/dashboard/auth.py) is unchanged.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
auth = initialize_user_auth(
|
||||||
|
github_client_id="...",
|
||||||
|
github_client_secret="...",
|
||||||
|
public_url="https://mcp.example.com",
|
||||||
|
)
|
||||||
|
url, state = auth.get_authorization_url("github")
|
||||||
|
# ... user redirected, callback received ...
|
||||||
|
user_info = await auth.exchange_code("github", code)
|
||||||
|
token = auth.create_user_session(user_id="...", email="...", name="...", role="user")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OAuth provider endpoints
|
||||||
|
_GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
|
||||||
|
_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||||
|
_GITHUB_USER_URL = "https://api.github.com/user"
|
||||||
|
_GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
|
||||||
|
|
||||||
|
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||||
|
|
||||||
|
# State expiry: 10 minutes
|
||||||
|
_STATE_EXPIRY_SECONDS = 600
|
||||||
|
|
||||||
|
# Registration rate limit: 3 per IP per hour
|
||||||
|
_REG_RATE_LIMIT = 3
|
||||||
|
_REG_RATE_WINDOW = 3600 # 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider:
|
||||||
|
"""OAuth provider constants (for type hints / constants)."""
|
||||||
|
|
||||||
|
GITHUB = "github"
|
||||||
|
GOOGLE = "google"
|
||||||
|
|
||||||
|
|
||||||
|
class UserAuth:
|
||||||
|
"""OAuth Social Login and user session management.
|
||||||
|
|
||||||
|
Manages OAuth authorization URL generation, code-for-token exchange,
|
||||||
|
user info fetching, JWT session creation/validation, and registration
|
||||||
|
rate limiting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
github_client_id: str | None = None,
|
||||||
|
github_client_secret: str | None = None,
|
||||||
|
google_client_id: str | None = None,
|
||||||
|
google_client_secret: str | None = None,
|
||||||
|
public_url: str | None = None,
|
||||||
|
session_secret: str | None = None,
|
||||||
|
session_expiry_hours: int = 168, # 7 days
|
||||||
|
) -> None:
|
||||||
|
"""Initialize user authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
github_client_id: GitHub OAuth App client ID.
|
||||||
|
github_client_secret: GitHub OAuth App client secret.
|
||||||
|
google_client_id: Google OAuth client ID.
|
||||||
|
google_client_secret: Google OAuth client secret.
|
||||||
|
public_url: Public-facing URL of the server (for callbacks).
|
||||||
|
session_secret: Secret key for JWT session signing.
|
||||||
|
session_expiry_hours: Session token lifetime in hours.
|
||||||
|
"""
|
||||||
|
self._github_client_id = github_client_id
|
||||||
|
self._github_client_secret = github_client_secret
|
||||||
|
self._google_client_id = google_client_id
|
||||||
|
self._google_client_secret = google_client_secret
|
||||||
|
self._public_url = (public_url or "").rstrip("/")
|
||||||
|
|
||||||
|
self._session_secret = session_secret or os.getenv(
|
||||||
|
"DASHBOARD_SESSION_SECRET",
|
||||||
|
os.getenv("OAUTH_JWT_SECRET_KEY", secrets.token_hex(32)),
|
||||||
|
)
|
||||||
|
self._session_expiry_hours = int(
|
||||||
|
os.getenv("SESSION_EXPIRY_HOURS", str(session_expiry_hours))
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSRF state tokens: state -> timestamp
|
||||||
|
self._pending_states: dict[str, float] = {}
|
||||||
|
|
||||||
|
# Registration rate limiting: IP -> [timestamps]
|
||||||
|
self._registration_records: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
providers = self.available_providers()
|
||||||
|
if providers and not self._public_url:
|
||||||
|
logger.warning(
|
||||||
|
"OAuth providers configured (%s) but PUBLIC_URL is not set. "
|
||||||
|
"OAuth login will fail until PUBLIC_URL is configured.",
|
||||||
|
providers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"UserAuth initialized: providers=%s, session_expiry=%dh",
|
||||||
|
providers,
|
||||||
|
self._session_expiry_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Provider availability ─────────────────────────────────
|
||||||
|
|
||||||
|
def available_providers(self) -> list[str]:
|
||||||
|
"""Return list of configured OAuth providers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of provider name strings (e.g. ["github", "google"]).
|
||||||
|
"""
|
||||||
|
providers: list[str] = []
|
||||||
|
if self._github_client_id and self._github_client_secret:
|
||||||
|
providers.append("github")
|
||||||
|
if self._google_client_id and self._google_client_secret:
|
||||||
|
providers.append("google")
|
||||||
|
return providers
|
||||||
|
|
||||||
|
# ── OAuth URL generation ──────────────────────────────────
|
||||||
|
|
||||||
|
def get_authorization_url(self, provider: str) -> tuple[str, str]:
|
||||||
|
"""Generate OAuth authorization URL with CSRF state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: "github" or "google".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (authorization_url, state_token).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider is unsupported or not configured.
|
||||||
|
"""
|
||||||
|
if not self._public_url:
|
||||||
|
raise ValueError(
|
||||||
|
"PUBLIC_URL environment variable must be set for OAuth login to work. "
|
||||||
|
"Example: PUBLIC_URL=https://mcp.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = secrets.token_hex(32)
|
||||||
|
self._pending_states[state] = time.time()
|
||||||
|
self._cleanup_expired_states()
|
||||||
|
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/{provider}"
|
||||||
|
|
||||||
|
if provider == "github":
|
||||||
|
if not self._github_client_id:
|
||||||
|
raise ValueError("GitHub OAuth not configured")
|
||||||
|
params = {
|
||||||
|
"client_id": self._github_client_id,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"scope": "read:user user:email",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
return f"{_GITHUB_AUTH_URL}?{urlencode(params)}", state
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
if not self._google_client_id:
|
||||||
|
raise ValueError("Google OAuth not configured")
|
||||||
|
params = {
|
||||||
|
"client_id": self._google_client_id,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"scope": "openid email profile",
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
"access_type": "online",
|
||||||
|
}
|
||||||
|
return f"{_GOOGLE_AUTH_URL}?{urlencode(params)}", state
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported provider: {provider}")
|
||||||
|
|
||||||
|
# ── State validation ──────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_state(self, state: str) -> bool:
|
||||||
|
"""Validate and consume an OAuth state token (one-time use).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: The state parameter from the callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid and not expired, False otherwise.
|
||||||
|
"""
|
||||||
|
timestamp = self._pending_states.pop(state, None)
|
||||||
|
if timestamp is None:
|
||||||
|
return False
|
||||||
|
return not (time.time() - timestamp > _STATE_EXPIRY_SECONDS)
|
||||||
|
|
||||||
|
def _cleanup_expired_states(self) -> None:
|
||||||
|
"""Remove expired state tokens."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [s for s, t in self._pending_states.items() if now - t > _STATE_EXPIRY_SECONDS]
|
||||||
|
for s in expired:
|
||||||
|
del self._pending_states[s]
|
||||||
|
|
||||||
|
# ── Token exchange ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def exchange_code(self, provider: str, code: str) -> dict:
|
||||||
|
"""Exchange authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: "github" or "google".
|
||||||
|
code: Authorization code from callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: provider, provider_id, email, name, avatar_url.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If token exchange or user info fetch fails.
|
||||||
|
"""
|
||||||
|
if provider == "github":
|
||||||
|
return await self._exchange_github(code)
|
||||||
|
if provider == "google":
|
||||||
|
return await self._exchange_google(code)
|
||||||
|
raise ValueError(f"Unsupported provider: {provider}")
|
||||||
|
|
||||||
|
async def _exchange_github(self, code: str) -> dict:
|
||||||
|
"""Exchange GitHub authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: GitHub authorization code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with provider, provider_id, email, name, avatar_url.
|
||||||
|
"""
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/github"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Exchange code for access token
|
||||||
|
token_resp = await client.post(
|
||||||
|
_GITHUB_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": self._github_client_id,
|
||||||
|
"client_secret": self._github_client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to exchange GitHub code: {token_resp.text}")
|
||||||
|
|
||||||
|
token_data = token_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError(f"Failed to exchange GitHub code: {token_data}")
|
||||||
|
|
||||||
|
# Fetch user info
|
||||||
|
user_resp = await client.get(
|
||||||
|
_GITHUB_USER_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
user_data = user_resp.json()
|
||||||
|
|
||||||
|
email = user_data.get("email")
|
||||||
|
if not email:
|
||||||
|
# Fetch from /user/emails endpoint (private email fallback)
|
||||||
|
emails_resp = await client.get(
|
||||||
|
_GITHUB_EMAILS_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
if emails_resp.status_code == 200:
|
||||||
|
emails = emails_resp.json()
|
||||||
|
primary = next(
|
||||||
|
(e for e in emails if e.get("primary") and e.get("verified")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if primary:
|
||||||
|
email = primary["email"]
|
||||||
|
elif emails:
|
||||||
|
email = emails[0]["email"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "github",
|
||||||
|
"provider_id": str(user_data["id"]),
|
||||||
|
"email": email,
|
||||||
|
"name": user_data.get("name") or user_data.get("login"),
|
||||||
|
"avatar_url": user_data.get("avatar_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _exchange_google(self, code: str) -> dict:
|
||||||
|
"""Exchange Google authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Google authorization code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with provider, provider_id, email, name, avatar_url.
|
||||||
|
"""
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/google"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Exchange code for access token
|
||||||
|
token_resp = await client.post(
|
||||||
|
_GOOGLE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": self._google_client_id,
|
||||||
|
"client_secret": self._google_client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to exchange Google code: {token_resp.text}")
|
||||||
|
|
||||||
|
token_data = token_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError(f"Failed to exchange Google code: {token_data}")
|
||||||
|
|
||||||
|
# Fetch user info
|
||||||
|
user_resp = await client.get(
|
||||||
|
_GOOGLE_USERINFO_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
user_data = user_resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "google",
|
||||||
|
"provider_id": str(user_data["sub"]),
|
||||||
|
"email": user_data.get("email"),
|
||||||
|
"name": user_data.get("name"),
|
||||||
|
"avatar_url": user_data.get("picture"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Registration rate limiting ────────────────────────────
|
||||||
|
|
||||||
|
def check_registration_rate(self, client_ip: str) -> bool:
|
||||||
|
"""Check if IP is within registration rate limit.
|
||||||
|
|
||||||
|
Allows up to 3 registrations per IP per hour. Expired records
|
||||||
|
are cleaned up automatically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_ip: Client IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if registration is allowed, False if rate limited.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
records = self._registration_records.get(client_ip, [])
|
||||||
|
# Keep only records within the window
|
||||||
|
records = [t for t in records if now - t < _REG_RATE_WINDOW]
|
||||||
|
self._registration_records[client_ip] = records
|
||||||
|
return len(records) < _REG_RATE_LIMIT
|
||||||
|
|
||||||
|
def record_registration(self, client_ip: str) -> None:
|
||||||
|
"""Record a registration event for rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_ip: Client IP address.
|
||||||
|
"""
|
||||||
|
if client_ip not in self._registration_records:
|
||||||
|
self._registration_records[client_ip] = []
|
||||||
|
self._registration_records[client_ip].append(time.time())
|
||||||
|
|
||||||
|
# ── Session management ────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user_session(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
email: str,
|
||||||
|
name: str | None,
|
||||||
|
role: str,
|
||||||
|
) -> str:
|
||||||
|
"""Create a JWT session token for an OAuth user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID from database.
|
||||||
|
email: User email.
|
||||||
|
name: User display name.
|
||||||
|
role: User role ('user' or 'admin').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JWT token string.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
payload = {
|
||||||
|
"uid": user_id,
|
||||||
|
"email": email,
|
||||||
|
"name": name or "",
|
||||||
|
"role": role,
|
||||||
|
"type": "oauth_user",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + self._session_expiry_hours * 3600,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, self._session_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
def validate_user_session(self, token: str) -> dict | None:
|
||||||
|
"""Validate a user session JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with user_id, email, name, role, type -- or None if invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, self._session_secret, algorithms=["HS256"])
|
||||||
|
return {
|
||||||
|
"user_id": payload["uid"],
|
||||||
|
"email": payload["email"],
|
||||||
|
"name": payload.get("name", ""),
|
||||||
|
"role": payload.get("role", "user"),
|
||||||
|
"type": payload.get("type", "oauth_user"),
|
||||||
|
}
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.debug("User session expired")
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.debug("Invalid user session token: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_user_auth: UserAuth | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_user_auth(
|
||||||
|
github_client_id: str | None = None,
|
||||||
|
github_client_secret: str | None = None,
|
||||||
|
google_client_id: str | None = None,
|
||||||
|
google_client_secret: str | None = None,
|
||||||
|
public_url: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> UserAuth:
|
||||||
|
"""Initialize the global UserAuth singleton.
|
||||||
|
|
||||||
|
Reads from env vars if arguments are not provided:
|
||||||
|
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET,
|
||||||
|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
||||||
|
PUBLIC_URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
github_client_id: GitHub OAuth App client ID.
|
||||||
|
github_client_secret: GitHub OAuth App client secret.
|
||||||
|
google_client_id: Google OAuth client ID.
|
||||||
|
google_client_secret: Google OAuth client secret.
|
||||||
|
public_url: Public-facing URL of the server.
|
||||||
|
**kwargs: Additional keyword args passed to UserAuth.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized UserAuth instance.
|
||||||
|
"""
|
||||||
|
global _user_auth
|
||||||
|
_user_auth = UserAuth(
|
||||||
|
github_client_id=github_client_id or os.getenv("GITHUB_CLIENT_ID"),
|
||||||
|
github_client_secret=github_client_secret or os.getenv("GITHUB_CLIENT_SECRET"),
|
||||||
|
google_client_id=google_client_id or os.getenv("GOOGLE_CLIENT_ID"),
|
||||||
|
google_client_secret=google_client_secret or os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||||
|
public_url=public_url or os.getenv("PUBLIC_URL", ""),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return _user_auth
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_auth() -> UserAuth:
|
||||||
|
"""Get the global UserAuth singleton.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The UserAuth singleton instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If initialize_user_auth() has not been called.
|
||||||
|
"""
|
||||||
|
if _user_auth is None:
|
||||||
|
raise RuntimeError("UserAuth not initialized. Call initialize_user_auth() first.")
|
||||||
|
return _user_auth
|
||||||
469
core/user_endpoints.py
Normal file
469
core/user_endpoints.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
"""Per-user MCP endpoint handler (Track E.3).
|
||||||
|
|
||||||
|
Handles MCP JSON-RPC requests for user-owned sites at
|
||||||
|
``/u/{user_id}/{alias}/mcp``. Implements the Streamable HTTP transport
|
||||||
|
protocol directly (no per-user FastMCP instances).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Validate user API key (Bearer token)
|
||||||
|
2. Look up user's site from SQLite
|
||||||
|
3. Decrypt credentials
|
||||||
|
4. For tools/list: return plugin tools (without ``site`` param)
|
||||||
|
5. For tools/call: create plugin instance, call method, return result
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# In server.py route registration:
|
||||||
|
from core.user_endpoints import user_mcp_handler
|
||||||
|
Route("/u/{user_id}/{alias}/mcp", endpoint=user_mcp_handler, methods=["POST"])
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Per-user rate limiting defaults
|
||||||
|
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
|
||||||
|
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
|
||||||
|
|
||||||
|
# In-memory rate limit tracking: user_id -> list of timestamps
|
||||||
|
_rate_limits: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Check per-user rate limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, error_message). error_message is empty if allowed.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
timestamps = _rate_limits.setdefault(user_id, [])
|
||||||
|
|
||||||
|
# Prune old entries (older than 1 hour)
|
||||||
|
cutoff_hr = now - 3600
|
||||||
|
_rate_limits[user_id] = [t for t in timestamps if t > cutoff_hr]
|
||||||
|
timestamps = _rate_limits[user_id]
|
||||||
|
|
||||||
|
# Check per-minute limit
|
||||||
|
cutoff_min = now - 60
|
||||||
|
recent_min = sum(1 for t in timestamps if t > cutoff_min)
|
||||||
|
if recent_min >= USER_RATE_LIMIT_PER_MIN:
|
||||||
|
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute"
|
||||||
|
|
||||||
|
# Check per-hour limit
|
||||||
|
if len(timestamps) >= USER_RATE_LIMIT_PER_HR:
|
||||||
|
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour"
|
||||||
|
|
||||||
|
timestamps.append(now)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
||||||
|
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
|
||||||
|
|
||||||
|
Strips the auto-injected ``site`` parameter, since user endpoints bind a
|
||||||
|
single site per alias.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for tool_def in tools:
|
||||||
|
schema = deepcopy(tool_def.input_schema)
|
||||||
|
if "properties" in schema:
|
||||||
|
schema["properties"].pop("site", None)
|
||||||
|
if "required" in schema and "site" in schema["required"]:
|
||||||
|
schema["required"] = [r for r in schema["required"] if r != "site"]
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": tool_def.name,
|
||||||
|
"description": tool_def.description,
|
||||||
|
"inputSchema": schema,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_visible_tools_for_site(
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
tools = await access.get_visible_tools(
|
||||||
|
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
|
||||||
|
)
|
||||||
|
return _tools_to_mcp_schema(tools)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_tool(
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
plugin_type: str,
|
||||||
|
config_dict: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a tool by creating a plugin instance and calling the method.
|
||||||
|
|
||||||
|
Uses the same pattern as unified_handler in tool_generator.py.
|
||||||
|
"""
|
||||||
|
from plugins import registry as plugin_registry
|
||||||
|
|
||||||
|
if not plugin_registry.is_registered(plugin_type):
|
||||||
|
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
||||||
|
|
||||||
|
method_name = tool_name
|
||||||
|
# Strip plugin_type prefix: "wordpress_list_posts" → "list_posts"
|
||||||
|
prefix = f"{plugin_type}_"
|
||||||
|
if method_name.startswith(prefix):
|
||||||
|
method_name = method_name[len(prefix) :]
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin_instance = plugin_registry.create_instance(
|
||||||
|
plugin_type,
|
||||||
|
project_id=f"user_{config_dict.get('alias', 'unknown')}",
|
||||||
|
config=config_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(plugin_instance, method_name):
|
||||||
|
return {"type": "text", "text": f"Error: Method '{method_name}' not found"}
|
||||||
|
|
||||||
|
method = getattr(plugin_instance, method_name)
|
||||||
|
|
||||||
|
# Process arguments (parse JSON strings, filter None/empty)
|
||||||
|
filtered_args = {}
|
||||||
|
for key, value in arguments.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if stripped == "":
|
||||||
|
continue
|
||||||
|
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
||||||
|
stripped.startswith("[") and stripped.endswith("]")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
filtered_args[key] = value
|
||||||
|
|
||||||
|
result = await method(**filtered_args)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Tool execution error %s: %s", tool_name, e, exc_info=True)
|
||||||
|
return {"type": "text", "text": f"Error: {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
||||||
|
"""Build a JSON-RPC error response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"error": {"code": code, "message": message},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]:
|
||||||
|
"""Build a JSON-RPC success response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def user_mcp_handler(request: Request) -> Response:
|
||||||
|
"""Handle MCP JSON-RPC requests for user endpoints.
|
||||||
|
|
||||||
|
Route: POST /u/{user_id}/{alias}/mcp
|
||||||
|
|
||||||
|
Validates user API key, looks up the site, and handles MCP protocol
|
||||||
|
methods (initialize, tools/list, tools/call).
|
||||||
|
"""
|
||||||
|
user_id = request.path_params.get("user_id", "")
|
||||||
|
alias = request.path_params.get("alias", "")
|
||||||
|
|
||||||
|
if not user_id or not alias:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid endpoint path"),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Authentication ---
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Missing Authorization header"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = auth_header[7:] # Strip "Bearer "
|
||||||
|
|
||||||
|
# Shared scope tracking — set by whichever auth path succeeds
|
||||||
|
key_scopes: list[str] = []
|
||||||
|
|
||||||
|
# Try mhu_ API key first, then fall back to OAuth JWT token
|
||||||
|
if api_key.startswith("mhu_"):
|
||||||
|
try:
|
||||||
|
from core.user_keys import get_user_key_manager
|
||||||
|
|
||||||
|
key_mgr = get_user_key_manager()
|
||||||
|
key_info = await key_mgr.validate_key(api_key)
|
||||||
|
except RuntimeError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Authentication service unavailable"),
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key_info is None:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid API key"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key_info["user_id"] != user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
key_scopes = key_info.get("scopes", "read").split()
|
||||||
|
else:
|
||||||
|
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
|
from core.oauth import get_token_manager
|
||||||
|
|
||||||
|
token_manager = get_token_manager()
|
||||||
|
jwt_payload = token_manager.validate_access_token(api_key)
|
||||||
|
|
||||||
|
# sub = "user:{uuid}" — extract actual user_id
|
||||||
|
sub = jwt_payload.get("sub", "")
|
||||||
|
if not sub.startswith("user:"):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token not authorized for user endpoint"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
jwt_user_id = sub[len("user:") :]
|
||||||
|
|
||||||
|
if jwt_user_id != user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token user mismatch"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
key_scopes = jwt_payload.get("scope", "read").split()
|
||||||
|
except pyjwt.ExpiredSignatureError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token expired"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid token"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Rate Limiting ---
|
||||||
|
allowed, rate_msg = _check_user_rate_limit(user_id)
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, rate_msg),
|
||||||
|
status_code=429,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Look up site ---
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site_by_alias(user_id, alias)
|
||||||
|
except RuntimeError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Database unavailable"),
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
if site is None:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, f"Site '{alias}' not found"),
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
if site["status"] == "disabled":
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Site is disabled"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Plugin visibility check ---
|
||||||
|
from core.plugin_visibility import is_plugin_public
|
||||||
|
|
||||||
|
if not is_plugin_public(site["plugin_type"]):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
None, -32600, f"Plugin '{site['plugin_type']}' is not currently available"
|
||||||
|
),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Parse JSON-RPC body ---
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32700, "Parse error"),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
req_id = body.get("id")
|
||||||
|
method = body.get("method", "")
|
||||||
|
params = body.get("params", {})
|
||||||
|
|
||||||
|
# --- Handle MCP methods ---
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
version = "3.1.0"
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_result(
|
||||||
|
req_id,
|
||||||
|
{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": f"mcphub-{alias}",
|
||||||
|
"version": version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif method == "notifications/initialized":
|
||||||
|
# Notification — no response needed
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
|
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
|
||||||
|
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name", "")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
# Verify tool belongs to this plugin type
|
||||||
|
plugin_prefix = f"{site['plugin_type']}_"
|
||||||
|
if not tool_name.startswith(plugin_prefix):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not available for this site")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check required scope
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tool_def = registry.get_by_name(tool_name)
|
||||||
|
if not tool_def:
|
||||||
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
|
||||||
|
|
||||||
|
required_scope = tool_def.required_scope
|
||||||
|
# key_scopes is set during authentication (both mhu_ and JWT paths)
|
||||||
|
|
||||||
|
# F.7b: enforce category-based scope allowlist in addition to the
|
||||||
|
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
|
||||||
|
# (a) the legacy hierarchy grants it, AND
|
||||||
|
# (b) the tool's category is in BOTH the key-scope set AND the
|
||||||
|
# site's stored tool_scope set (the narrower layer wins).
|
||||||
|
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
|
||||||
|
|
||||||
|
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
||||||
|
required_level = scope_hierarchy.get(required_scope, 0)
|
||||||
|
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
|
||||||
|
legacy_ok = key_level >= required_level
|
||||||
|
|
||||||
|
key_cats = scopes_to_categories(key_scopes)
|
||||||
|
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
|
||||||
|
|
||||||
|
# Site-level scope check (skipped for "custom" preset).
|
||||||
|
site_scope = site.get("tool_scope") or "admin"
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
site_cats = scopes_to_categories([site_scope])
|
||||||
|
site_category_ok = (
|
||||||
|
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
site_category_ok = True
|
||||||
|
|
||||||
|
if not (legacy_ok and key_category_ok and site_category_ok):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Insufficient scope. Tool '{tool_name}' requires "
|
||||||
|
f"scope '{required_scope}' (category '{tool_def.category}').",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
|
||||||
|
# even if scopes would otherwise allow it.
|
||||||
|
try:
|
||||||
|
toggles = await db.get_site_tool_toggles(site["id"])
|
||||||
|
if not toggles.get(tool_name, True):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Tool '{tool_name}' is disabled for this site.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # non-fatal — fall through on DB errors
|
||||||
|
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
|
||||||
|
|
||||||
|
# Decrypt credentials
|
||||||
|
try:
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site["id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Credential decryption failed for site %s: %s", site["id"], e)
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(req_id, -32603, "Failed to decrypt site credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build config dict for plugin instantiation
|
||||||
|
config_dict = {
|
||||||
|
"site_url": site["url"],
|
||||||
|
"url": site["url"],
|
||||||
|
"alias": alias,
|
||||||
|
**credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _execute_tool(tool_name, arguments, site["plugin_type"], config_dict)
|
||||||
|
|
||||||
|
# Format result as MCP content
|
||||||
|
if isinstance(result, str):
|
||||||
|
content = [{"type": "text", "text": result}]
|
||||||
|
elif isinstance(result, dict) and "type" in result:
|
||||||
|
content = [result]
|
||||||
|
elif isinstance(result, list):
|
||||||
|
content = result
|
||||||
|
else:
|
||||||
|
content = [{"type": "text", "text": json.dumps(result, default=str)}]
|
||||||
|
|
||||||
|
return JSONResponse(_jsonrpc_result(req_id, {"content": content}))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Method '{method}' not supported"))
|
||||||
246
core/user_keys.py
Normal file
246
core/user_keys.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""User API Key management for the Live Platform (Track E.3).
|
||||||
|
|
||||||
|
Provides bcrypt-hashed API keys for authenticating MCP client connections
|
||||||
|
to per-user endpoints (``/u/{user_id}/{alias}/mcp``).
|
||||||
|
|
||||||
|
Key format: ``mhu_<32 random urlsafe chars>``
|
||||||
|
Lookup: ``key_prefix`` column (first 8 chars after ``mhu_``) for indexed DB lookup,
|
||||||
|
then bcrypt verification on the matching row.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.user_keys import initialize_user_key_manager, get_user_key_manager
|
||||||
|
|
||||||
|
initialize_user_key_manager()
|
||||||
|
mgr = get_user_key_manager()
|
||||||
|
result = await mgr.create_key(user_id, "Claude Desktop")
|
||||||
|
# result["key"] is shown once to the user
|
||||||
|
info = await mgr.validate_key("mhu_...")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Key format constants
|
||||||
|
KEY_PREFIX_TAG = "mhu_"
|
||||||
|
KEY_RANDOM_BYTES = 32 # urlsafe_b64 of 32 bytes = 43 chars
|
||||||
|
KEY_PREFIX_LEN = 8 # chars after "mhu_" stored for fast DB lookup
|
||||||
|
|
||||||
|
# Validation cache TTL
|
||||||
|
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
class UserKeyManager:
|
||||||
|
"""Manages per-user API keys with bcrypt hashing.
|
||||||
|
|
||||||
|
Keys are stored in the ``user_api_keys`` SQLite table via
|
||||||
|
:class:`core.database.Database`. Each key is bcrypt-hashed;
|
||||||
|
a plaintext ``key_prefix`` column enables indexed lookup without
|
||||||
|
scanning all rows.
|
||||||
|
|
||||||
|
An in-memory cache avoids repeated bcrypt verification for the
|
||||||
|
same key within a 5-minute window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
||||||
|
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
||||||
|
|
||||||
|
async def create_key(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
scopes: str = "read write admin",
|
||||||
|
expires_in_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new API key for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
name: Human label (e.g. "Claude Desktop").
|
||||||
|
scopes: Access scopes (default: "read write admin" for full access).
|
||||||
|
expires_in_days: Optional expiry in days from now. None = never.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||||
|
``scopes``, ``created_at``, ``expires_at``.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES)
|
||||||
|
key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if expires_in_days is not None:
|
||||||
|
expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat()
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
row = await db.create_api_key(
|
||||||
|
user_id=user_id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
name=name,
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Created user API key %s for user %s", row["id"], user_id)
|
||||||
|
return {
|
||||||
|
"key": raw_key, # shown once
|
||||||
|
"key_id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"scopes": row["scopes"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"expires_at": row["expires_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate an API key and return its metadata.
|
||||||
|
|
||||||
|
Uses an in-memory cache to avoid repeated bcrypt verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: The raw API key string (e.g. ``mhu_...``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``key_id``, ``user_id``, ``scopes`` if valid, else None.
|
||||||
|
"""
|
||||||
|
if not api_key or not api_key.startswith(KEY_PREFIX_TAG):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached = self._cache.get(api_key)
|
||||||
|
if cached is not None:
|
||||||
|
key_id, user_id, scopes, cached_at = cached
|
||||||
|
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||||
|
# Update usage in background (fire-and-forget via DB)
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical
|
||||||
|
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
|
||||||
|
else:
|
||||||
|
del self._cache[api_key]
|
||||||
|
|
||||||
|
# Extract prefix for DB lookup
|
||||||
|
key_prefix = api_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
row = await db.get_api_key_by_prefix(key_prefix)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify bcrypt hash
|
||||||
|
if not bcrypt.checkpw(api_key.encode(), row["key_hash"].encode()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
if row["expires_at"] is not None:
|
||||||
|
expires = datetime.fromisoformat(row["expires_at"])
|
||||||
|
if expires < datetime.now(UTC):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update usage
|
||||||
|
await db.update_api_key_usage(row["id"])
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._cache[api_key] = (
|
||||||
|
row["id"],
|
||||||
|
row["user_id"],
|
||||||
|
row["scopes"],
|
||||||
|
time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key_id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"scopes": row["scopes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""List all API keys for a user (without hashes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of key metadata dicts.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
return await db.get_api_keys_by_user(user_id)
|
||||||
|
|
||||||
|
async def delete_key(self, key_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete an API key.
|
||||||
|
|
||||||
|
Also invalidates the validation cache for any cached key matching
|
||||||
|
this key_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_api_key(key_id, user_id)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
# Purge from cache
|
||||||
|
to_remove = [k for k, v in self._cache.items() if v[0] == key_id]
|
||||||
|
for k in to_remove:
|
||||||
|
del self._cache[k]
|
||||||
|
logger.info("Deleted user API key %s for user %s", key_id, user_id)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the entire validation cache."""
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: UserKeyManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_user_key_manager() -> UserKeyManager:
|
||||||
|
"""Create and store the singleton UserKeyManager."""
|
||||||
|
global _manager
|
||||||
|
_manager = UserKeyManager()
|
||||||
|
logger.info("UserKeyManager initialized")
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_key_manager() -> UserKeyManager:
|
||||||
|
"""Get the singleton UserKeyManager.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The UserKeyManager singleton.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If not initialized.
|
||||||
|
"""
|
||||||
|
if _manager is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"UserKeyManager not initialized. Call initialize_user_key_manager() first."
|
||||||
|
)
|
||||||
|
return _manager
|
||||||
77
docker-compose.coolify.yaml
Normal file
77
docker-compose.coolify.yaml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ===================================
|
||||||
|
# MCP Hub — Docker Compose (Coolify)
|
||||||
|
# ===================================
|
||||||
|
#
|
||||||
|
# Coolify auto-reads the 'environment' block below and shows
|
||||||
|
# each variable in the UI for editing. Just deploy and fill values.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. New Resource → Docker Compose → point to this file
|
||||||
|
# 2. Fill environment variables in Coolify UI
|
||||||
|
# 3. Set domain (e.g., mcp.yourdomain.com)
|
||||||
|
# 4. Deploy
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mcphub
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# === REQUIRED ===
|
||||||
|
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
|
||||||
|
|
||||||
|
# === OAUTH SOCIAL LOGIN (for user registration) ===
|
||||||
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||||
|
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||||
|
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
|
||||||
|
|
||||||
|
# === ADMIN SYSTEM (F.4) ===
|
||||||
|
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
|
||||||
|
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
|
||||||
|
|
||||||
|
# === LOGGING ===
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# === SITE MANAGEMENT (E.3) ===
|
||||||
|
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
|
||||||
|
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
||||||
|
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
||||||
|
|
||||||
|
# === SITES ===
|
||||||
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
|
# After deployment, open the dashboard to add sites.
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||||
|
- mcp-logs:/app/logs # Audit logs, health reports
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
# Coolify uses GID 988 for Docker socket
|
||||||
|
group_add:
|
||||||
|
- "988"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mcp-data:
|
||||||
|
driver: local
|
||||||
|
mcp-logs:
|
||||||
|
driver: local
|
||||||
@@ -1,78 +1,34 @@
|
|||||||
# ===================================
|
# ===================================
|
||||||
# MCP Hub — Docker Compose Configuration
|
# MCP Hub — Docker Compose (Standalone)
|
||||||
# ===================================
|
# ===================================
|
||||||
#
|
#
|
||||||
# Build Pack: Docker Compose
|
# Usage:
|
||||||
# ⚠️ CRITICAL RULES FOR COOLIFY:
|
# cp env.example .env # edit with your values
|
||||||
# 1. NO host port mappings: Use "8000" NOT "8000:8000"
|
# docker compose up -d
|
||||||
# 2. Listen on 0.0.0.0 (NOT localhost)
|
# curl http://localhost:8000/health
|
||||||
# 3. Health checks for ALL services
|
# open http://localhost:8000/dashboard
|
||||||
# 4. Use environment variables for ALL configs
|
#
|
||||||
|
# For Coolify deployment, use docker-compose.coolify.yaml instead.
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mcp-server:
|
mcp-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: airano/mcphub:latest
|
||||||
container_name: mcphub
|
container_name: mcphub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ⚠️ CRITICAL: Only container port (NO host port mapping)
|
|
||||||
# Coolify will handle routing via domain
|
|
||||||
ports:
|
ports:
|
||||||
- "8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
environment:
|
environment:
|
||||||
# Master API key
|
|
||||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# === OAuth 2.1 Configuration ===
|
|
||||||
# Required for OAuth authentication
|
|
||||||
- OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
|
|
||||||
- OAUTH_JWT_ALGORITHM=${OAUTH_JWT_ALGORITHM:-HS256}
|
|
||||||
- OAUTH_ACCESS_TOKEN_TTL=${OAUTH_ACCESS_TOKEN_TTL:-3600}
|
|
||||||
- OAUTH_REFRESH_TOKEN_TTL=${OAUTH_REFRESH_TOKEN_TTL:-604800}
|
|
||||||
- OAUTH_STORAGE_TYPE=${OAUTH_STORAGE_TYPE:-json}
|
|
||||||
- OAUTH_STORAGE_PATH=${OAUTH_STORAGE_PATH:-/app/data}
|
|
||||||
|
|
||||||
# OAuth Authorization Security
|
|
||||||
# IMPORTANT: API Key is always required for authorization (OAuth manual mode)
|
|
||||||
# Use 'required' (recommended) to enforce API Key authentication
|
|
||||||
- OAUTH_AUTH_MODE=${OAUTH_AUTH_MODE:-required}
|
|
||||||
# OAUTH_TRUSTED_DOMAINS is deprecated - API Key always required for security
|
|
||||||
- OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
|
||||||
|
|
||||||
# === WordPress Projects ===
|
|
||||||
# Configure WordPress sites in Coolify environment variables
|
|
||||||
# Format: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
#
|
|
||||||
# Required variables for each site:
|
|
||||||
# WORDPRESS_SITE1_URL=https://your-site.com
|
|
||||||
# WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
# WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
#
|
|
||||||
# Optional (for WP-CLI tools):
|
|
||||||
# WORDPRESS_SITE1_CONTAINER=wordpress_container_name
|
|
||||||
# WORDPRESS_SITE1_ALIAS=myblog
|
|
||||||
#
|
|
||||||
# ⚠️ DO NOT add example values here - configure in Coolify!
|
|
||||||
# The server will auto-discover all WORDPRESS_* variables at startup.
|
|
||||||
|
|
||||||
# === Other Plugins ===
|
|
||||||
# Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus plugins
|
|
||||||
# auto-discover their environment variables at startup.
|
|
||||||
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
# No need to pre-define them here.
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -80,31 +36,20 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Docker socket access for WP-CLI tools
|
|
||||||
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
|
|
||||||
volumes:
|
volumes:
|
||||||
|
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||||
|
- mcp-logs:/app/logs # Audit logs, health reports
|
||||||
|
# Docker socket — only needed for WP-CLI tools (WordPress Advanced plugin)
|
||||||
|
# Remove if you don't use wp_cache_flush, wp_transient_*, etc.
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- mcp-data:/app/data # Persistent storage for API keys
|
|
||||||
- mcp-logs:/app/logs # Persistent logs (audit, health)
|
|
||||||
|
|
||||||
# Grant access to Docker socket by adding user to docker group
|
# Docker socket GID — adjust to match your host's docker group
|
||||||
# The GID varies by host (988, 999, 133 are common)
|
# Check with: getent group docker | cut -d: -f3
|
||||||
# Coolify typically uses 988
|
|
||||||
group_add:
|
group_add:
|
||||||
- "988" # Docker group GID (adjust if needed)
|
- "999"
|
||||||
|
|
||||||
# Network - Coolify will handle this
|
|
||||||
# networks:
|
|
||||||
# - coolify
|
|
||||||
|
|
||||||
# Named volumes for persistent data
|
|
||||||
volumes:
|
volumes:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
driver: local
|
driver: local
|
||||||
mcp-logs:
|
mcp-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# Networks managed by Coolify
|
|
||||||
# networks:
|
|
||||||
# coolify:
|
|
||||||
# external: true
|
|
||||||
|
|||||||
@@ -42,20 +42,45 @@ For each WordPress site you want to manage:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Option 0: Use Hosted Version (No Setup Required)
|
||||||
|
|
||||||
|
The fastest way to try MCP Hub — no installation needed:
|
||||||
|
|
||||||
|
1. Visit **[mcp.example.com](https://mcp.example.com)**
|
||||||
|
2. Log in with **GitHub** or **Google**
|
||||||
|
3. Add your sites via the dashboard (**My Sites → Add Service**)
|
||||||
|
4. Go to **Connect** page to generate your AI client config
|
||||||
|
5. Copy-paste into Claude Desktop, VS Code, or Claude Code
|
||||||
|
|
||||||
|
Your personal MCP endpoint:
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
> If you prefer self-hosting, continue with the options below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Option 1: Docker (Recommended)
|
### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/airano-ir/mcphub.git
|
git clone https://github.com/airano-ir/mcphub.git
|
||||||
cd mcphub
|
cd mcphub
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env — set MASTER_API_KEY and add your site credentials (see Configuration below)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: PyPI
|
After starting, see [Verify Installation](#verify-installation) below.
|
||||||
|
|
||||||
|
### Option 2: Docker Hub (No Clone)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install mcphub-server
|
# 1. Create a .env file (see Configuration section below)
|
||||||
|
# 2. Run:
|
||||||
|
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: From Source
|
### Option 3: From Source
|
||||||
@@ -66,7 +91,7 @@ cd mcphub
|
|||||||
pip install -e .
|
pip install -e .
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env with your site credentials
|
||||||
python server.py --transport sse --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 4: Automated Setup Scripts
|
### Option 4: Automated Setup Scripts
|
||||||
@@ -125,7 +150,7 @@ Edit the `.env` file with your credentials:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ============================================
|
# ============================================
|
||||||
# Required
|
# Authentication (recommended — auto-generates temp key if omitted)
|
||||||
# ============================================
|
# ============================================
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
@@ -158,6 +183,21 @@ GITEA_REPO1_ALIAS=mygitea
|
|||||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
OAUTH_BASE_URL=https://your-server:8000
|
OAUTH_BASE_URL=https://your-server:8000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Social Login (GitHub/Google OAuth — for hosted/multi-user)
|
||||||
|
# ============================================
|
||||||
|
GITHUB_CLIENT_ID=your-github-oauth-app-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-oauth-app-secret
|
||||||
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
PUBLIC_URL=https://your-server:8000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Encryption (required for storing user site credentials)
|
||||||
|
# ============================================
|
||||||
|
# Generate: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
ENCRYPTION_KEY=your-base64-encoded-32-byte-key
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Optional
|
# Optional
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -167,6 +207,125 @@ RATE_LIMIT_PER_HOUR=1000
|
|||||||
RATE_LIMIT_PER_DAY=10000
|
RATE_LIMIT_PER_DAY=10000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### WordPress Plugin Requirements
|
||||||
|
|
||||||
|
Some tools require specific WordPress plugins or infrastructure:
|
||||||
|
|
||||||
|
| Tools | Requirement |
|
||||||
|
|-------|-------------|
|
||||||
|
| `wordpress_get_post_seo`, `wordpress_update_post_seo`, `wordpress_get_product_seo`, `wordpress_update_product_seo` | **Yoast SEO** or **RankMath** |
|
||||||
|
| `wordpress_wp_cache_*`, `wordpress_wp_db_*`, `wordpress_wp_plugin_*`, `wordpress_wp_theme_*`, `wordpress_wp_core_*`, `wordpress_wp_search_replace_dry_run` (15 tools) | Docker socket + `CONTAINER` env var |
|
||||||
|
| `wordpress_advanced_*` database/system tools | Docker socket + `CONTAINER` env var |
|
||||||
|
| `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) |
|
||||||
|
|
||||||
|
### Docker Socket for WP-CLI / WordPress Advanced
|
||||||
|
|
||||||
|
WP-CLI and WordPress Advanced system tools require Docker socket access:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mcphub:
|
||||||
|
image: airano/mcphub:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
WORDPRESS_SITE1_URL: https://your-site.com
|
||||||
|
WORDPRESS_SITE1_USERNAME: admin
|
||||||
|
WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
|
||||||
|
WORDPRESS_SITE1_CONTAINER: wordpress-container-name
|
||||||
|
WORDPRESS_SITE1_ALIAS: mysite
|
||||||
|
WORDPRESS_ADVANCED_SITE1_URL: https://your-site.com
|
||||||
|
WORDPRESS_ADVANCED_SITE1_USERNAME: admin
|
||||||
|
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
|
||||||
|
WORDPRESS_ADVANCED_SITE1_CONTAINER: wordpress-container-name
|
||||||
|
WORDPRESS_ADVANCED_SITE1_ALIAS: mysite-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Without Docker socket:
|
||||||
|
- WP-CLI tools return "not available" errors
|
||||||
|
- WordPress Advanced database/system tools are unavailable
|
||||||
|
- All REST API tools (bulk operations, content management) work normally
|
||||||
|
|
||||||
|
### Site Configuration
|
||||||
|
|
||||||
|
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
|
||||||
|
|
||||||
|
Each plugin requires specific credentials (see below for reference).
|
||||||
|
|
||||||
|
#### WordPress
|
||||||
|
```env
|
||||||
|
WORDPRESS_SITE1_URL=https://your-site.com # Required
|
||||||
|
WORDPRESS_SITE1_USERNAME=admin # Required
|
||||||
|
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx # Required (WordPress Application Password)
|
||||||
|
WORDPRESS_SITE1_ALIAS=my-site # Recommended
|
||||||
|
WORDPRESS_SITE1_CONTAINER=wp-container # Optional (for WP-CLI)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WooCommerce
|
||||||
|
```env
|
||||||
|
WOOCOMMERCE_SITE1_URL=https://your-site.com # Required
|
||||||
|
WOOCOMMERCE_SITE1_CONSUMER_KEY=ck_xxx # Required
|
||||||
|
WOOCOMMERCE_SITE1_CONSUMER_SECRET=cs_xxx # Required
|
||||||
|
WOOCOMMERCE_SITE1_ALIAS=my-shop # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WordPress Advanced
|
||||||
|
```env
|
||||||
|
WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com # Required
|
||||||
|
WORDPRESS_ADVANCED_SITE1_USERNAME=admin # Required
|
||||||
|
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx # Required
|
||||||
|
WORDPRESS_ADVANCED_SITE1_CONTAINER=wp-container # Required (for WP-CLI tools)
|
||||||
|
WORDPRESS_ADVANCED_SITE1_ALIAS=my-site-admin # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gitea
|
||||||
|
```env
|
||||||
|
GITEA_SITE1_URL=https://gitea.example.com # Required
|
||||||
|
GITEA_SITE1_TOKEN=your-access-token # Required
|
||||||
|
GITEA_SITE1_ALIAS=my-gitea # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### n8n
|
||||||
|
```env
|
||||||
|
N8N_SITE1_URL=https://n8n.example.com # Required
|
||||||
|
N8N_SITE1_API_KEY=your-api-key # Required
|
||||||
|
N8N_SITE1_ALIAS=my-n8n # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Supabase
|
||||||
|
```env
|
||||||
|
SUPABASE_SITE1_URL=https://your-project.supabase.co # Required
|
||||||
|
SUPABASE_SITE1_API_KEY=your-service-role-key # Required
|
||||||
|
SUPABASE_SITE1_ALIAS=my-supabase # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OpenPanel
|
||||||
|
```env
|
||||||
|
OPENPANEL_SITE1_URL=https://openpanel.example.com # Required
|
||||||
|
OPENPANEL_SITE1_CLIENT_ID=your-client-id # Required
|
||||||
|
OPENPANEL_SITE1_CLIENT_SECRET=your-client-secret # Required
|
||||||
|
OPENPANEL_SITE1_ALIAS=my-analytics # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Appwrite
|
||||||
|
```env
|
||||||
|
APPWRITE_SITE1_URL=https://appwrite.example.com # Required
|
||||||
|
APPWRITE_SITE1_API_KEY=your-api-key # Required
|
||||||
|
APPWRITE_SITE1_PROJECT_ID=your-project-id # Required
|
||||||
|
APPWRITE_SITE1_ALIAS=my-appwrite # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Directus
|
||||||
|
```env
|
||||||
|
DIRECTUS_SITE1_URL=https://directus.example.com # Required
|
||||||
|
DIRECTUS_SITE1_TOKEN=your-admin-token # Required
|
||||||
|
DIRECTUS_SITE1_ALIAS=my-directus # Recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Use `APP_PASSWORD` (WordPress Application Password), not `PASSWORD`.
|
||||||
|
|
||||||
### Configuration Tips
|
### Configuration Tips
|
||||||
|
|
||||||
- **Site Aliases**: Use friendly names like `myblog`, `mystore`, or `mygitea`
|
- **Site Aliases**: Use friendly names like `myblog`, `mystore`, or `mygitea`
|
||||||
@@ -178,10 +337,10 @@ RATE_LIMIT_PER_DAY=10000
|
|||||||
|
|
||||||
## Running the Server
|
## Running the Server
|
||||||
|
|
||||||
### SSE Transport (for remote AI clients)
|
### Streamable HTTP Transport (for remote AI clients)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python server.py --transport sse --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stdio Transport (for Claude Desktop local)
|
### Stdio Transport (for Claude Desktop local)
|
||||||
@@ -190,26 +349,62 @@ python server.py --transport sse --port 8000
|
|||||||
python server.py
|
python server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify Server is Running
|
### Verify Installation
|
||||||
|
|
||||||
Check logs for:
|
After starting (via Docker or locally), wait ~30 seconds for the server to initialize, then:
|
||||||
|
|
||||||
```
|
**1. Check health:**
|
||||||
INFO: MCP Hub initialized
|
|
||||||
INFO: Registered 589 tools
|
|
||||||
INFO: Server ready
|
|
||||||
```
|
|
||||||
|
|
||||||
Or test the health endpoint:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
|
# Expected: {"status": "ok", "tools_loaded": 565, ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**2. Open the web dashboard:**
|
||||||
|
|
||||||
|
Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
|
||||||
|
|
||||||
|
The dashboard lets you:
|
||||||
|
- View all connected sites and their health status
|
||||||
|
- **Add, edit, delete, and test sites** via the My Sites page (OAuth users)
|
||||||
|
- Create and manage per-project API keys
|
||||||
|
- **Generate AI client configs** on the Connect page (with correct transport type per client)
|
||||||
|
- View audit logs and monitor rate limits
|
||||||
|
- **Per-user MCP endpoints**: OAuth users get personal endpoints at `/u/{user_id}/{alias}/mcp` with `mhu_` API keys
|
||||||
|
|
||||||
|
**3. Check container status (Docker only):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
# Look for Status: "Up (healthy)"
|
||||||
|
# Note: Health check starts after 40 seconds — "starting" is normal initially
|
||||||
|
|
||||||
|
# View logs if something is wrong:
|
||||||
|
docker compose logs -f mcphub
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Troubleshooting:**
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Container exits immediately | Check logs: `docker compose logs mcphub` |
|
||||||
|
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
|
||||||
|
| Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors |
|
||||||
|
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
|
||||||
|
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Connect Your AI Client
|
## Connect Your AI Client
|
||||||
|
|
||||||
|
All requests require **Bearer token** authentication via the `Authorization` header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
> `X-API-Key` header and query parameter auth are **not** supported.
|
||||||
|
|
||||||
### Claude Desktop
|
### Claude Desktop
|
||||||
|
|
||||||
Add to `claude_desktop_config.json`:
|
Add to `claude_desktop_config.json`:
|
||||||
@@ -217,8 +412,9 @@ Add to `claude_desktop_config.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"url": "https://your-server:8000/mcp",
|
"type": "streamableHttp",
|
||||||
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||||
}
|
}
|
||||||
@@ -234,9 +430,9 @@ Add to `.mcp.json` in your project:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "https://your-server:8000/mcp",
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||||
}
|
}
|
||||||
@@ -249,8 +445,8 @@ Add to `.mcp.json` in your project:
|
|||||||
|
|
||||||
Go to **Settings > MCP Servers > Add Server**:
|
Go to **Settings > MCP Servers > Add Server**:
|
||||||
|
|
||||||
- **Name**: MCP Hub
|
- **Name**: MCP Hub WordPress
|
||||||
- **URL**: `https://your-server:8000/mcp`
|
- **URL**: `http://your-server:8000/wordpress/mcp`
|
||||||
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
||||||
|
|
||||||
### VS Code + Copilot
|
### VS Code + Copilot
|
||||||
@@ -260,9 +456,9 @@ Add to `.vscode/mcp.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"servers": {
|
"servers": {
|
||||||
"mcphub": {
|
"mcphub-wordpress": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"url": "https://your-server:8000/mcp",
|
"url": "http://your-server:8000/wordpress/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||||
}
|
}
|
||||||
@@ -279,24 +475,26 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
2. In ChatGPT, add MCP server: `https://your-server:8000/mcp`
|
2. In ChatGPT, add MCP server: `https://your-server:8000/mcp`
|
||||||
3. ChatGPT auto-discovers OAuth metadata and registers
|
3. ChatGPT auto-discovers OAuth metadata and registers
|
||||||
|
|
||||||
|
> **Important**: Use `"type": "streamableHttp"` for Claude Desktop and `"type": "http"` for VS Code/Claude Code. Using `"type": "sse"` will cause `400 Bad Request` errors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Using MCP Tools
|
## Using MCP Tools
|
||||||
|
|
||||||
### 589 Tools Across 9 Plugins
|
### 565 Tools Across 9 Plugins
|
||||||
|
|
||||||
| Plugin | Tools | Env Prefix |
|
| Plugin | Tools | Env Prefix |
|
||||||
|--------|-------|------------|
|
|--------|-------|------------|
|
||||||
| WordPress | 67 | `WORDPRESS_` |
|
| WordPress | 67 | `WORDPRESS_` |
|
||||||
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
||||||
| WordPress Advanced | 22 | `WORDPRESS_` (same sites, advanced ops) |
|
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` |
|
||||||
| Gitea | 56 | `GITEA_` |
|
| Gitea | 56 | `GITEA_` |
|
||||||
| n8n | 56 | `N8N_` |
|
| n8n | 56 | `N8N_` |
|
||||||
| Supabase | 70 | `SUPABASE_` |
|
| Supabase | 70 | `SUPABASE_` |
|
||||||
| OpenPanel | 73 | `OPENPANEL_` |
|
| OpenPanel | 42 | `OPENPANEL_` |
|
||||||
| Appwrite | 100 | `APPWRITE_` |
|
| Appwrite | 100 | `APPWRITE_` |
|
||||||
| Directus | 100 | `DIRECTUS_` |
|
| Directus | 100 | `DIRECTUS_` |
|
||||||
| System | 17 | (no config needed) |
|
| System | 24 | (no config needed) |
|
||||||
|
|
||||||
### Unified Tool Pattern
|
### Unified Tool Pattern
|
||||||
|
|
||||||
@@ -313,16 +511,40 @@ The `site` parameter accepts either a **site_id** (e.g., `site1`) or an **alias*
|
|||||||
|
|
||||||
### Multi-Endpoint Architecture
|
### Multi-Endpoint Architecture
|
||||||
|
|
||||||
Use specific endpoints to limit tool access:
|
Use the most specific endpoint for your use case to minimize token usage:
|
||||||
|
|
||||||
```
|
| Endpoint | Use Case | Tools | `site` param? |
|
||||||
/mcp → All 589 tools (Master API Key)
|
|----------|----------|------:|:-------------:|
|
||||||
/system/mcp → System tools only (17 tools)
|
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
| `/system/mcp` | System administration | 24 | N/A |
|
||||||
/gitea/mcp → Gitea tools (56 tools)
|
| `/mcp` | Admin & discovery only | 565 | Yes |
|
||||||
/project/{alias}/mcp → Per-project (auto-injects site)
|
|
||||||
```
|
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (565 tools) wastes context tokens and degrades AI response quality.
|
||||||
|
|
||||||
|
**Available plugin endpoints:**
|
||||||
|
|
||||||
|
| Endpoint | Plugin | Tools |
|
||||||
|
|----------|--------|------:|
|
||||||
|
| `/wordpress/mcp` | WordPress | 67 |
|
||||||
|
| `/woocommerce/mcp` | WooCommerce | 28 |
|
||||||
|
| `/wordpress-advanced/mcp` | WordPress Advanced | 22 |
|
||||||
|
| `/gitea/mcp` | Gitea | 56 |
|
||||||
|
| `/n8n/mcp` | n8n | 56 |
|
||||||
|
| `/supabase/mcp` | Supabase | 70 |
|
||||||
|
| `/openpanel/mcp` | OpenPanel | 42 |
|
||||||
|
| `/appwrite/mcp` | Appwrite | 100 |
|
||||||
|
| `/directus/mcp` | Directus | 100 |
|
||||||
|
| `/system/mcp` | System Management | 24 |
|
||||||
|
|
||||||
|
**Plugin endpoint vs Project endpoint:**
|
||||||
|
|
||||||
|
| Feature | Plugin (`/wordpress/mcp`) | Project (`/project/{alias}/mcp`) |
|
||||||
|
|---------|:------------------------:|:-------------------------------:|
|
||||||
|
| `list_sites` tool | Yes | No |
|
||||||
|
| `site` parameter needed | Yes | No (pre-scoped) |
|
||||||
|
| Tool count | N + 1 (includes `list_sites`) | N |
|
||||||
|
| Multi-site support | Yes | No (single site) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -334,34 +556,40 @@ Use specific endpoints to limit tool access:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After starting, verify the installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health # server health
|
||||||
|
open http://localhost:8000/dashboard # web dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Verify Installation](#verify-installation) for detailed steps.
|
||||||
|
|
||||||
### Docker Commands
|
### Docker Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View logs
|
# View logs
|
||||||
docker compose logs -f
|
docker compose logs -f mcphub
|
||||||
|
|
||||||
# Check status
|
# Check status (look for "healthy")
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# Restart
|
# Restart (needed after .env changes)
|
||||||
docker compose restart
|
docker compose restart
|
||||||
|
|
||||||
# Stop
|
# Stop
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# Rebuild
|
# Rebuild (after code changes)
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Check
|
### Adding Sites After Startup
|
||||||
|
|
||||||
```bash
|
1. Edit your `.env` file to add new site credentials
|
||||||
# Check container health
|
2. Restart the container: `docker compose restart`
|
||||||
docker compose ps
|
3. Verify: `curl http://localhost:8000/health` — check that tools are loaded
|
||||||
|
4. The dashboard at http://localhost:8000/dashboard will show the new sites
|
||||||
# Test API endpoint
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -381,18 +609,16 @@ curl http://localhost:8000/health
|
|||||||
|
|
||||||
### Step 3: Configure Environment Variables
|
### Step 3: Configure Environment Variables
|
||||||
|
|
||||||
Add all required environment variables in Coolify's environment variable UI:
|
Add the required environment variables in Coolify's environment variable UI:
|
||||||
|
|
||||||
```
|
```
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
OAUTH_BASE_URL=https://your-domain.com
|
OAUTH_BASE_URL=https://your-domain.com
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
ENCRYPTION_KEY=your-base64-encryption-key
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
|
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
|
||||||
|
|
||||||
### Step 4: Configure Health Check
|
### Step 4: Configure Health Check
|
||||||
|
|
||||||
@@ -412,7 +638,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Explore the full tool list**: See the [README](../README.md) for all 589 tools
|
1. **Explore the full tool list**: See the [README](../README.md) for all 565 tools
|
||||||
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
|
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
|
||||||
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
|
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
|
||||||
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
||||||
|
|||||||
90
docs/prompts/F16-gitea-public-release.md
Normal file
90
docs/prompts/F16-gitea-public-release.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# F.16 — Gitea Plugin Review, Test & Public Release
|
||||||
|
|
||||||
|
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. پروژه mcphub-internal روی branch Phase-1 است.
|
||||||
|
فایلهای مرجع:
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md
|
||||||
|
- CLAUDE.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- نسخه: v3.5.0
|
||||||
|
- 565 ابزار در 9 پلاگین (4 پلاگین عمومی: WordPress, WooCommerce, Supabase, OpenPanel)
|
||||||
|
- FastMCP: `>=3.0.0,<4.0.0`
|
||||||
|
- CI سبز
|
||||||
|
- F.15 تکمیل شده — FastMCP 3.x upgrade + legacy cleanup
|
||||||
|
|
||||||
|
## هدف: فاز F.16 — Gitea Plugin Review, Test & Public Enablement
|
||||||
|
|
||||||
|
مشابه F.10 (OpenPanel) — بررسی، تست و فعالسازی عمومی پلاگین Gitea.
|
||||||
|
|
||||||
|
### بخش 1: بررسی و تحلیل
|
||||||
|
1. **ساختار پلاگین**: `plugins/gitea/` شامل 56 ابزار در 5 هندلر:
|
||||||
|
- `handlers/repositories.py` — 16 tools (CRUD repos, branches, tags, files)
|
||||||
|
- `handlers/issues.py` — 12 tools (issues, comments, labels, milestones)
|
||||||
|
- `handlers/pull_requests.py` — 15 tools (PRs, reviews, merge, diff)
|
||||||
|
- `handlers/users.py` — 8 tools (users, orgs, teams)
|
||||||
|
- `handlers/webhooks.py` — 5 tools (CRUD webhooks, test)
|
||||||
|
2. **مشکلات شناساییشده**:
|
||||||
|
- `update_webhook` در client.py هست ولی به عنوان tool expose نشده
|
||||||
|
- هیچ تست اختصاصیای ندارد (0 تست!)
|
||||||
|
- فعلاً admin-only هست (نه در ENABLED_PLUGINS)
|
||||||
|
3. **تست واقعی**: سایت Gitea از MCPHub MCP endpoint:
|
||||||
|
- از ابزارهای Supabase MCP (`mcp.example.com`) استفاده کنید تا سایت Gitea موجود را پیدا کنید
|
||||||
|
- یا یک Gitea instance جدید از طریق داشبورد اضافه کنید
|
||||||
|
- هر دسته tool را با API واقعی تست کنید
|
||||||
|
|
||||||
|
### بخش 2: تست نوشتن
|
||||||
|
مشابه `tests/test_openpanel_plugin.py` (الگو — 767 خط, 62 تست):
|
||||||
|
1. `tests/test_gitea_plugin.py` بنویسید:
|
||||||
|
- Client initialization + health check
|
||||||
|
- Tool spec validation (نامها، پارامترها، type ها)
|
||||||
|
- Handler delegation (mock client)
|
||||||
|
- Error handling
|
||||||
|
- Pagination
|
||||||
|
2. هدف: حداقل 80 تست (56 tool + edge cases)
|
||||||
|
|
||||||
|
### بخش 3: رفع مشکلات
|
||||||
|
1. `update_webhook` را به عنوان tool expose کنید
|
||||||
|
2. هر tool که در تست واقعی مشکل دارد را fix کنید
|
||||||
|
3. توضیحات service page برای Gitea بنویسید
|
||||||
|
4. health check endpoint را بررسی کنید
|
||||||
|
|
||||||
|
### بخش 4: فعالسازی عمومی
|
||||||
|
1. `core/plugin_visibility.py`: اضافه کردن `gitea` به `DEFAULT_PUBLIC_PLUGINS`
|
||||||
|
2. `env.example`: آپدیت ENABLED_PLUGINS default
|
||||||
|
3. `glama.json`: آپدیت description اگر لازمه
|
||||||
|
|
||||||
|
### بخش 5: Release
|
||||||
|
1. Version bump: `3.5.0` → `3.6.0`
|
||||||
|
2. CHANGELOG update
|
||||||
|
3. Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
|
||||||
|
4. Commit + push Phase-1
|
||||||
|
5. Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||||
|
6. بعد از sync حتماً `uvx --python 3.12 black .` در public repo هم بزنید!
|
||||||
|
7. Commit + push public repo
|
||||||
|
|
||||||
|
### مراحل پیشنهادی
|
||||||
|
1. **تحلیل**: خواندن کامل هر handler + client + schemas
|
||||||
|
2. **تست واقعی**: اتصال به Gitea instance و تست ابزارها
|
||||||
|
3. **پلن اجرایی**: ارائه پلن و تایید قبل از شروع
|
||||||
|
4. **تست نوشتن**: test_gitea_plugin.py
|
||||||
|
5. **رفع مشکلات**: fix هر tool مشکلدار
|
||||||
|
6. **فعالسازی**: ENABLED_PLUGINS update
|
||||||
|
7. **Release**: v3.6.0
|
||||||
|
|
||||||
|
### نکات فنی
|
||||||
|
- Private repo: /config/workspace/mcphub-internal (branch Phase-1)
|
||||||
|
- Public repo: /config/workspace/mcphub (branch main)
|
||||||
|
- Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
|
||||||
|
- Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||||
|
- ایمیل git عمومی: hi.airano@gmail.com
|
||||||
|
- **مهم**: اول پلن بده، بدون تایید شروع نکن
|
||||||
|
- **الگوی مرجع**: F.10 (OpenPanel) — بخش "Phase F.10" در plan و `tests/test_openpanel_plugin.py`
|
||||||
|
```
|
||||||
95
docs/prompts/next-session-coolify-f17.md
Normal file
95
docs/prompts/next-session-coolify-f17.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Next Session — F.17 Coolify MCP Plugin (MVP)
|
||||||
|
|
||||||
|
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-04-02-coolify-mcp-plugin-design.md (mcphub-internal) ← طرح کامل پلاگین
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal, branch Phase-1) ← فاز F.17
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
- plugins/gitea/ (mcphub-internal) ← الگوی مرجع (آخرین پلاگین ساختهشده)
|
||||||
|
- plugins/base.py (mcphub-internal) ← BasePlugin interface
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.6.0 — 566 ابزار، 5 پلاگین عمومی
|
||||||
|
- FastMCP: >=3.0.0,<4.0.0
|
||||||
|
- CI سبز
|
||||||
|
- طرح Coolify: نوشته شده — ~68 ابزار در 6 handler
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.17 Phase 1 — Coolify MVP
|
||||||
|
|
||||||
|
### پیشنیاز اول: Coolify API Token
|
||||||
|
- [ ] در داشبورد Coolify، بخش Keys & Tokens، یک API token بساز
|
||||||
|
- [ ] تست اتصال: `curl -s https://COOLIFY_URL/api/v1/version -H "Authorization: Bearer TOKEN"`
|
||||||
|
- [ ] اگر URL و TOKEN مشخص نبود، از کاربر بپرس
|
||||||
|
|
||||||
|
### مرحله ۱: ساختار اولیه پلاگین
|
||||||
|
- [ ] `plugins/coolify/__init__.py` (خالی)
|
||||||
|
- [ ] `plugins/coolify/client.py` — CoolifyClient با Bearer Token auth
|
||||||
|
- الگو از `plugins/gitea/client.py` بگیر
|
||||||
|
- متدها: `request()`, `get()`, `post()`, `patch()`, `delete()`
|
||||||
|
- Error handling + retry مشابه Gitea client
|
||||||
|
- [ ] `plugins/coolify/plugin.py` — CoolifyPlugin(BasePlugin)
|
||||||
|
- الگو از `plugins/gitea/plugin.py` بگیر
|
||||||
|
- `get_tool_specifications()` باید ابزارهای handler ها رو جمع کنه
|
||||||
|
- [ ] `plugins/coolify/handlers/__init__.py`
|
||||||
|
- [ ] `plugins/coolify/schemas/__init__.py`
|
||||||
|
- [ ] `plugins/coolify/schemas/common.py` — مدلهای مشترک (UUID, pagination)
|
||||||
|
|
||||||
|
### مرحله ۲: Handler — Applications (18 ابزار)
|
||||||
|
- [ ] `plugins/coolify/handlers/applications.py`
|
||||||
|
- [ ] ابزارها (از طرح):
|
||||||
|
- list_applications, get_application
|
||||||
|
- create_application_public, create_application_dockerfile, create_application_docker_image, create_application_compose
|
||||||
|
- update_application, delete_application
|
||||||
|
- start_application, stop_application, restart_application
|
||||||
|
- get_application_logs
|
||||||
|
- list_application_envs, create_application_env, update_application_env, update_application_envs_bulk, delete_application_env
|
||||||
|
- [ ] schemas/application.py — Pydantic models
|
||||||
|
|
||||||
|
### مرحله ۳: Handler — Deployments (5 ابزار)
|
||||||
|
- [ ] `plugins/coolify/handlers/deployments.py`
|
||||||
|
- [ ] ابزارها: list_deployments, get_deployment, cancel_deployment, deploy, list_app_deployments
|
||||||
|
|
||||||
|
### مرحله ۴: Handler — Servers (8 ابزار)
|
||||||
|
- [ ] `plugins/coolify/handlers/servers.py`
|
||||||
|
- [ ] ابزارها: list_servers, get_server, create_server, update_server, delete_server, get_server_resources, get_server_domains, validate_server
|
||||||
|
- [ ] schemas/server.py
|
||||||
|
|
||||||
|
### مرحله ۵: ثبت پلاگین و تنظیمات
|
||||||
|
- [ ] در `plugins/__init__.py` اضافه کن: `from plugins.coolify.plugin import CoolifyPlugin` + `registry.register("coolify", CoolifyPlugin)`
|
||||||
|
- [ ] در `env.example` اضافه کن: `COOLIFY_URL`, `COOLIFY_TOKEN`
|
||||||
|
- [ ] پلاگین فعلا admin-only باشد (به ENABLED_PLUGINS اضافه نشود)
|
||||||
|
|
||||||
|
### مرحله ۶: تست
|
||||||
|
- [ ] `tests/test_coolify.py` — Unit tests با mocked HTTP
|
||||||
|
- الگو از `tests/test_gitea*.py` بگیر
|
||||||
|
- حداقل: test_list_applications, test_get_application, test_deploy, test_list_servers
|
||||||
|
- [ ] `pytest tests/test_coolify.py -v`
|
||||||
|
- [ ] اگر API Token موجود بود: integration test با Coolify واقعی
|
||||||
|
|
||||||
|
### مرحله ۷: تست نهایی و commit
|
||||||
|
- [ ] `uvx --python 3.12 black .`
|
||||||
|
- [ ] `uvx ruff check --fix .`
|
||||||
|
- [ ] `pytest` (همه تستها سبز)
|
||||||
|
- [ ] Commit: `feat(F.17): add Coolify MCP plugin — Phase 1 MVP (~31 tools)`
|
||||||
|
- [ ] Push to Phase-1
|
||||||
|
|
||||||
|
## قوانین
|
||||||
|
- اول پلن بده، بدون تایید شروع نکن
|
||||||
|
- از Gitea plugin به عنوان الگوی اصلی استفاده کن (آخرین و تمیزترین پلاگین)
|
||||||
|
- هر مرحله commit شود
|
||||||
|
- tool specifications باید دقیقا مطابق فرمت BasePlugin باشند (name, method_name, description, schema, scope)
|
||||||
|
- scope ها: read, write, admin — مطابق جدول در طرح
|
||||||
|
- حافظه آپدیت شود بعد از اتمام
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
```
|
||||||
77
docs/prompts/next-session-coolify-test.md
Normal file
77
docs/prompts/next-session-coolify-test.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Next Session — Coolify MCP Plugin Testing & Next Steps
|
||||||
|
|
||||||
|
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## هدف: تست پلاگین Coolify MCP و مراحل بعدی
|
||||||
|
|
||||||
|
### پیشنیاز
|
||||||
|
- MCPHub redeploy شده و سایت Coolify اضافه شده
|
||||||
|
- MCP endpoint `mcphub-coolify` در `.claude.json` تنظیم شده
|
||||||
|
- پلاگین 30 ابزار دارد (17 application + 5 deployment + 8 server)
|
||||||
|
|
||||||
|
### مرحله ۱: تأیید اتصال MCP
|
||||||
|
- [ ] لیست ابزارهای coolify را از MCP بگیر (باید 30 ابزار باشد)
|
||||||
|
- [ ] اگر ابزار coolify در لیست نبود، `.claude.json` و `settings.json` را بررسی کن
|
||||||
|
|
||||||
|
### مرحله ۲: تست ابزارهای read
|
||||||
|
- [ ] `coolify_list_servers` — لیست سرورها
|
||||||
|
- [ ] `coolify_list_applications` — لیست اپلیکیشنها
|
||||||
|
- [ ] `coolify_list_deployments` — لیست دیپلویمنتهای در حال اجرا
|
||||||
|
- [ ] `coolify_get_server_resources(uuid=SERVER_UUID)` — منابع سرور
|
||||||
|
- [ ] `coolify_get_server_domains(uuid=SERVER_UUID)` — دامنههای سرور
|
||||||
|
- [ ] یک اپلیکیشن انتخاب کن و:
|
||||||
|
- [ ] `coolify_get_application(uuid=APP_UUID)` — جزئیات
|
||||||
|
- [ ] `coolify_get_application_logs(uuid=APP_UUID, lines=50)` — لاگها
|
||||||
|
- [ ] `coolify_list_application_envs(uuid=APP_UUID)` — متغیرهای محیطی
|
||||||
|
- [ ] `coolify_list_app_deployments(uuid=APP_UUID)` — تاریخچه دیپلوی
|
||||||
|
|
||||||
|
### مرحله ۳: تست ابزارهای write (با احتیاط)
|
||||||
|
- [ ] از کاربر بپرس آیا مجاز است یک env var تست ایجاد/حذف کند
|
||||||
|
- [ ] اگر بله:
|
||||||
|
- [ ] `coolify_create_application_env(uuid=APP_UUID, key="TEST_VAR", value="test123")`
|
||||||
|
- [ ] `coolify_delete_application_env(uuid=APP_UUID, env_uuid=...)`
|
||||||
|
|
||||||
|
### مرحله ۴: گزارش نتایج
|
||||||
|
- [ ] خلاصه نتایج تست (چند ابزار کار کرد، مشکلات)
|
||||||
|
- [ ] مقایسه با ابزارهای Gitea و Supabase MCP (کیفیت پاسخها)
|
||||||
|
|
||||||
|
### مرحله ۵: اگر تست موفق بود — Sync به نسخه عمومی
|
||||||
|
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||||
|
- [ ] `cd /config/workspace/mcphub && uvx --python 3.12 black . && uvx ruff check --fix .`
|
||||||
|
- [ ] تستها: `python3.11 -m pytest tests/ -q`
|
||||||
|
- [ ] Commit و push نسخه عمومی
|
||||||
|
|
||||||
|
### مرحله ۶: آپدیتها
|
||||||
|
- [ ] mcp-skills/skills/coolify/SKILL.md — از planned به active تغییر کرده (بررسی شود)
|
||||||
|
- [ ] حافظه آپدیت شود
|
||||||
|
|
||||||
|
## پیشنهاد مراحل بعدی (بعد از تست)
|
||||||
|
|
||||||
|
### فوری
|
||||||
|
1. **F.17 Phase 2**: اضافه کردن databases (16 ابزار) + services (13 ابزار) → ~59 ابزار کل
|
||||||
|
2. **F.17 Phase 3**: projects (8 ابزار) → ~67 ابزار کل — تکمیل طرح اصلی
|
||||||
|
|
||||||
|
### میانمدت
|
||||||
|
3. **Coolify Workflow Skill**: مهارت اختصاصی برای عملیات متداول (deploy all, backup all, health check all)
|
||||||
|
4. **Integration Test**: تست خودکار با Coolify واقعی (pytest mark integration)
|
||||||
|
5. **F.14a**: ثبت MCPHub در Smithery + Official MCP Registry
|
||||||
|
|
||||||
|
### بلندمدت
|
||||||
|
6. **Blog Post**: نوشتن مقاله درباره "Self-hosted MCP Hub with Coolify Integration"
|
||||||
|
7. **F.5a**: Base64 media upload برای WordPress
|
||||||
|
8. **F.6**: Claude Code skills بومی
|
||||||
|
|
||||||
|
## قوانین
|
||||||
|
- اول ابزارهای read تست شوند، بعد write
|
||||||
|
- قبل از هر عملیات write از کاربر تأیید بگیر
|
||||||
|
- نتایج تست دقیق گزارش شود (UUID ها، خطاها، زمان پاسخ)
|
||||||
|
- حافظه آپدیت شود بعد از اتمام
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
```
|
||||||
115
docs/prompts/next-session-f17-phase3.md
Normal file
115
docs/prompts/next-session-f17-phase3.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Next Session — F.17 Phase 3: Projects + Phase 2: Databases & Services
|
||||||
|
|
||||||
|
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-04-02-coolify-mcp-plugin-design.md (mcphub-internal) ← طرح کامل پلاگین
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.17 آپدیت شده
|
||||||
|
- plugins/coolify/ (mcphub-internal) ← پلاگین Phase 1 (الگوی اصلی)
|
||||||
|
- plugins/gitea/ (mcphub-internal) ← الگوی مرجع معماری
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.7.0 — 596 ابزار، 10 پلاگین
|
||||||
|
- Coolify Phase 1: ✅ 30 ابزار (17 app + 5 deploy + 8 server) — deployed, tested, synced
|
||||||
|
- MCP endpoint فعال: mcphub-coolify در Claude Code (30 ابزار لود شده)
|
||||||
|
- CI سبز، 718 تست (internal), 686 تست (public)
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.17 Phase 3 + Phase 2
|
||||||
|
|
||||||
|
### بخش اول: Phase 3 — Projects & Environments (8 ابزار)
|
||||||
|
> اولویت بالا — بدون project_uuid ساخت app/db/service بلاک است
|
||||||
|
|
||||||
|
#### مرحله ۱: Projects Handler
|
||||||
|
- [ ] `plugins/coolify/handlers/projects.py`
|
||||||
|
- [ ] ابزارها:
|
||||||
|
- list_projects (GET /projects) — read
|
||||||
|
- get_project (GET /projects/{uuid}) — read
|
||||||
|
- create_project (POST /projects) — write
|
||||||
|
- update_project (PATCH /projects/{uuid}) — write
|
||||||
|
- delete_project (DELETE /projects/{uuid}) — admin
|
||||||
|
- list_environments (GET /projects/{uuid}/environments) — read
|
||||||
|
- get_environment (GET /projects/{uuid}/environments/{name}) — read
|
||||||
|
- create_environment (POST /projects/{uuid}/environments) — write
|
||||||
|
- [ ] متدهای client در `client.py` اضافه شود
|
||||||
|
- [ ] در `handlers/__init__.py` ایمپورت projects اضافه شود
|
||||||
|
- [ ] در `plugin.py` — specs و __getattr__ آپدیت شود
|
||||||
|
|
||||||
|
#### مرحله ۲: تست و دیپلوی Phase 3
|
||||||
|
- [ ] `tests/test_coolify_projects.py` — Unit tests با mocked HTTP
|
||||||
|
- [ ] `pytest tests/test_coolify*.py -v`
|
||||||
|
- [ ] Commit: `feat(F.17): add projects handler — Phase 3 (8 tools)`
|
||||||
|
- [ ] Push و درخواست redeploy
|
||||||
|
- [ ] تست live: list_projects → پیدا کردن project_uuid
|
||||||
|
- [ ] تست ساخت container: create_application_docker_image با project_uuid واقعی → دیپلوی nginx → تأیید → حذف
|
||||||
|
|
||||||
|
### بخش دوم: Phase 2 — Databases (16 ابزار)
|
||||||
|
- [ ] `plugins/coolify/handlers/databases.py`
|
||||||
|
- [ ] ابزارها:
|
||||||
|
- list_databases, get_database — read
|
||||||
|
- update_database — write
|
||||||
|
- delete_database — admin
|
||||||
|
- start_database, stop_database, restart_database — write
|
||||||
|
- create_postgresql, create_mysql, create_mariadb — write
|
||||||
|
- create_mongodb, create_redis, create_clickhouse — write
|
||||||
|
- get_database_backups — read
|
||||||
|
- create_database_backup — write
|
||||||
|
- list_backup_executions — read
|
||||||
|
- [ ] متدهای client اضافه شود
|
||||||
|
- [ ] تست: `tests/test_coolify_databases.py`
|
||||||
|
|
||||||
|
### بخش سوم: Phase 2 — Services (13 ابزار)
|
||||||
|
- [ ] `plugins/coolify/handlers/services.py`
|
||||||
|
- [ ] ابزارها:
|
||||||
|
- list_services, get_service — read
|
||||||
|
- create_service — write
|
||||||
|
- update_service — write
|
||||||
|
- delete_service — admin
|
||||||
|
- start_service, stop_service, restart_service — write
|
||||||
|
- list_service_envs — read
|
||||||
|
- create_service_env — write
|
||||||
|
- update_service_env — write
|
||||||
|
- update_service_envs_bulk — write
|
||||||
|
- delete_service_env — write
|
||||||
|
- [ ] متدهای client اضافه شود
|
||||||
|
- [ ] تست: `tests/test_coolify_services.py`
|
||||||
|
|
||||||
|
### بخش چهارم: ثبت و تست نهایی
|
||||||
|
- [ ] handlers/__init__.py آپدیت (projects, databases, services)
|
||||||
|
- [ ] plugin.py آپدیت (specs + __getattr__)
|
||||||
|
- [ ] server.py — نیازی به تغییر ندارد (coolify قبلا ثبت شده)
|
||||||
|
- [ ] `uvx --python 3.12 black .`
|
||||||
|
- [ ] `uvx ruff check --fix .`
|
||||||
|
- [ ] `pytest` (همه تستها سبز)
|
||||||
|
- [ ] Commit: `feat(F.17): add databases + services handlers — Phase 2 (29 tools)`
|
||||||
|
- [ ] Push و درخواست redeploy
|
||||||
|
- [ ] تست live: list_databases, list_services
|
||||||
|
- [ ] آپدیت ورژن به v3.8.0 (اگر تأیید شد)
|
||||||
|
|
||||||
|
### بخش پنجم: Sync و داکیومنت
|
||||||
|
- [ ] Sync به نسخه عمومی: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||||
|
- [ ] black + ruff + pytest در نسخه عمومی
|
||||||
|
- [ ] README آپدیت (تعداد ابزار، جدول Coolify)
|
||||||
|
- [ ] Commit و push نسخه عمومی
|
||||||
|
- [ ] mcp-skills/skills/coolify/SKILL.md آپدیت (67 ابزار)
|
||||||
|
- [ ] حافظه آپدیت شود
|
||||||
|
- [ ] پلن آپدیت شود (Phase 2+3 complete)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- server.py نیازی به تغییر ندارد — coolify قبلا register شده و generate_tools خودکار specs جدید رو میخونه
|
||||||
|
- site_api.py نیازی به تغییر ندارد — credential fields و display name قبلا اضافه شده
|
||||||
|
- الگوی handler: از applications.py کپی کن (آخرین و تمیزترین)
|
||||||
|
- هر بخش (Phase 3, databases, services) جداگانه commit شود
|
||||||
|
- قبل از هر عملیات write در تست live از کاربر تأیید بگیر
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
```
|
||||||
136
docs/prompts/next-session-f7-tool-access.md
Normal file
136
docs/prompts/next-session-f7-tool-access.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
|
||||||
|
- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
|
||||||
|
- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
|
||||||
|
- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
|
||||||
|
- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
|
||||||
|
- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
|
||||||
|
- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
|
||||||
|
- core/dashboard/routes.py (mcphub-internal) ← روتهای داشبورد
|
||||||
|
- server.py (mcphub-internal) ← middleware و scope enforcement
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
|
||||||
|
- تستها: 766 (internal), 734 (public)
|
||||||
|
- CI سبز
|
||||||
|
- Scope فعلی: read/write/admin (سه سطح ساده)
|
||||||
|
- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
|
||||||
|
|
||||||
|
### مشکلاتی که حل میشوند
|
||||||
|
1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده میشوند — کنترل per-tool نداریم
|
||||||
|
2. وقتی کاربر API key با scope خاص (مثلا read) میسازد، باز هم همه ابزارها در tools/list نمایش داده میشوند
|
||||||
|
3. ابزارهای وردپرس که نیاز به افزونههای کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده میشوند
|
||||||
|
4. کاربران نمیتوانند ابزارهایی که نیاز ندارند را غیرفعال کنند
|
||||||
|
|
||||||
|
### مدل scope پیشنهادی (گسترشیافته)
|
||||||
|
فعلی: `read`, `write`, `admin`
|
||||||
|
جدید:
|
||||||
|
- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
|
||||||
|
- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
|
||||||
|
|
||||||
|
**نگاشت scope → دستهبندی ابزار:**
|
||||||
|
| Scope | ابزارها |
|
||||||
|
|-------|---------|
|
||||||
|
| `read` | list_*, get_* (بدون sensitive) |
|
||||||
|
| `read:sensitive` | read + *_logs, *_envs, *_backups |
|
||||||
|
| `deploy` | read + start_*, stop_*, restart_*, deploy |
|
||||||
|
| `write` | deploy + create_*, update_*, delete_*_env |
|
||||||
|
| `admin` | write + delete_* (منابع)، create_server |
|
||||||
|
|
||||||
|
### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
|
||||||
|
|
||||||
|
#### مرحله ۱: دیتابیس
|
||||||
|
- [ ] جدول `user_tool_toggles` در `core/database.py`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_tool_toggles (
|
||||||
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- [ ] جدول `scope_presets` — پریستهای scope سیستمی + سفارشی
|
||||||
|
- [ ] Migration اجرا شود
|
||||||
|
|
||||||
|
#### مرحله ۲: ماژول tool_access.py
|
||||||
|
- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
|
||||||
|
- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
|
||||||
|
- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
|
||||||
|
- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
|
||||||
|
- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
|
||||||
|
- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دستهجمعی
|
||||||
|
|
||||||
|
#### مرحله ۳: Tool metadata enhancement
|
||||||
|
- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
|
||||||
|
- `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
|
||||||
|
- `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
|
||||||
|
- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگینها
|
||||||
|
- [ ] ToolDefinition در tool_registry.py آپدیت شود
|
||||||
|
|
||||||
|
#### مرحله ۴: فیلتر در user_endpoints.py
|
||||||
|
- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
|
||||||
|
- [ ] Pipeline فیلتر:
|
||||||
|
1. plugin_visibility (موجود)
|
||||||
|
2. scope-to-tool mapping (جدید)
|
||||||
|
3. user toggles (جدید)
|
||||||
|
- [ ] Scope enforcement در middleware آپدیت شود (server.py)
|
||||||
|
|
||||||
|
#### مرحله ۵: تست
|
||||||
|
- [ ] `tests/test_tool_access.py` — unit tests
|
||||||
|
- [ ] تستهای scope mapping: key با scope "read" → فقط ابزارهای read
|
||||||
|
- [ ] تستهای toggle: کاربر disable کرده → ابزار در tools/list نیست
|
||||||
|
- [ ] تستهای integration: API key scope → فیلتر واقعی
|
||||||
|
|
||||||
|
### بخش دوم: API — روتهای مدیریت toggle
|
||||||
|
|
||||||
|
- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
|
||||||
|
- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
|
||||||
|
- [ ] `POST /api/user/tools/bulk-toggle` — toggle دستهجمعی بر اساس scope
|
||||||
|
- [ ] `GET /api/user/scope-presets` — لیست presetها
|
||||||
|
- [ ] تست: روتها کار کنند
|
||||||
|
|
||||||
|
### بخش سوم: Prerequisites (وردپرس/ووکامرس)
|
||||||
|
|
||||||
|
- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
|
||||||
|
- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
|
||||||
|
- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
|
||||||
|
- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
|
||||||
|
- [ ] ابزارهای وابسته علامتگذاری شوند (نه حذف — فقط annotation)
|
||||||
|
|
||||||
|
### بخش چهارم: UI — صفحه مدیریت ابزار
|
||||||
|
|
||||||
|
- [ ] `core/templates/dashboard/tool-preferences.html`
|
||||||
|
- [ ] لیست ابزارها گروهبندی شده بر اساس category
|
||||||
|
- [ ] Toggle switch برای هر ابزار
|
||||||
|
- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
|
||||||
|
- [ ] Dropdown برای اعمال scope preset
|
||||||
|
- [ ] در صفحه Connect: پیشنمایش ابزارها هنگام ساخت API key
|
||||||
|
|
||||||
|
### بخش پنجم: ثبت و تست نهایی
|
||||||
|
|
||||||
|
- [ ] `uvx --python 3.12 black .`
|
||||||
|
- [ ] `uvx ruff check --fix .`
|
||||||
|
- [ ] `pytest` — همه تستها سبز
|
||||||
|
- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
|
||||||
|
- [ ] Push و درخواست redeploy
|
||||||
|
- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
|
||||||
|
- [ ] Sync به نسخه عمومی
|
||||||
|
- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
|
||||||
|
- [ ] حافظه آپدیت شود
|
||||||
|
- [ ] پلن آپدیت شود (F.7 complete)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
|
||||||
|
- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
|
||||||
|
- `user_tool_toggles` فقط overrideها رو ذخیره میکنه، نه همه ابزارها
|
||||||
|
- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
|
||||||
|
- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد
|
||||||
98
docs/prompts/next-session-f7b-ui.md
Normal file
98
docs/prompts/next-session-f7b-ui.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
|
||||||
|
- core/tool_access.py ← ToolAccessManager (سایتمحور — session 1)
|
||||||
|
- core/dashboard/routes.py ← روتهای API site tools (بخش F.7b)
|
||||||
|
- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
|
||||||
|
- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
|
||||||
|
- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
|
||||||
|
- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
|
||||||
|
- Tests: 813 passed، CI سبز
|
||||||
|
- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
|
||||||
|
- فقط UI باقی مانده — هدف این session
|
||||||
|
|
||||||
|
## ریپازیتوری
|
||||||
|
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
|
||||||
|
|
||||||
|
## هدف session: F.7b — UI + page merge
|
||||||
|
|
||||||
|
### ۱. بخش "Tool Access" در صفحه edit سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/edit.html`
|
||||||
|
|
||||||
|
- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
|
||||||
|
- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
|
||||||
|
- PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
|
||||||
|
- توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
|
||||||
|
- [ ] Collapsible "Advanced — per-tool overrides":
|
||||||
|
- گرید/لیست گروهبندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
|
||||||
|
- Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
|
||||||
|
- Badge قرمز برای `sensitivity=sensitive`
|
||||||
|
- نام کوتاه از `name`، tooltip با `description`
|
||||||
|
- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
|
||||||
|
- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
|
||||||
|
|
||||||
|
### ۲. انتقال config snippets از connect به صفحه سایت
|
||||||
|
فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
|
||||||
|
|
||||||
|
- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
|
||||||
|
- URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
|
||||||
|
- Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینتهای دیگر
|
||||||
|
- استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
|
||||||
|
- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
|
||||||
|
|
||||||
|
### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
|
||||||
|
UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگتر و کاملتر است طبق تأیید کاربر)
|
||||||
|
|
||||||
|
- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch میزند:
|
||||||
|
- OAuth user → نمایش `user_api_keys` برای آن کاربر
|
||||||
|
- Admin/master → نمایش کامل `api_keys` (همان view فعلی)
|
||||||
|
- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
|
||||||
|
- User view: سادهتر، scope selector در create dialog، لیست کلیدهای خود کاربر
|
||||||
|
- Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
|
||||||
|
- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
|
||||||
|
- Radio/select: read / read:sensitive / deploy / write / admin
|
||||||
|
- Helper text: "Per-site tool filters are set in Site Settings"
|
||||||
|
- POST به `/api/keys` (همان endpoint فعلی) با `scopes: "<selected>"`
|
||||||
|
- [ ] Redirect های قدیمی:
|
||||||
|
- `/dashboard/connect` → `/dashboard/keys` (301)
|
||||||
|
- `/dashboard/api-keys` → `/dashboard/keys` (301)
|
||||||
|
- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
|
||||||
|
- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
|
||||||
|
|
||||||
|
### ۴. گزینه B (ادغام عمیق DB) — deferred
|
||||||
|
در پلن session 1 ذکر شده اما اجرا نمیکنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
|
||||||
|
|
||||||
|
### ۵. تست
|
||||||
|
- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
|
||||||
|
- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (میتوان با TestClient چک کرد که template بدون 500 میآید)
|
||||||
|
- [ ] بهروزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
|
||||||
|
- [ ] pytest کامل سبز
|
||||||
|
|
||||||
|
### ۶. ورژن، sync، commit
|
||||||
|
- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
|
||||||
|
- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
|
||||||
|
- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
|
||||||
|
- [ ] Push به Phase-1
|
||||||
|
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
|
||||||
|
- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
|
||||||
|
- [ ] درخواست deploy از کاربر
|
||||||
|
|
||||||
|
### ۷. تست live پس از deploy
|
||||||
|
- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
|
||||||
|
- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
|
||||||
|
- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
|
||||||
|
- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- **Backward compatibility:** سایتهای موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایتهای قدیمی
|
||||||
|
- **فیلترها:** key scope و site scope **intersect** میشوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
|
||||||
|
- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل میکند.
|
||||||
|
- **CSS:** پروژه Tailwind دارد. از همان کلاسهای موجود در `api-keys/list.html` استفاده کن برای consistency.
|
||||||
|
- **i18n:** پروژه EN/FA است. متنهای جدید را به `core/i18n.py` اضافه کن.
|
||||||
|
- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
|
||||||
|
- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایتمحور است.
|
||||||
93
docs/prompts/next-session.md
Normal file
93
docs/prompts/next-session.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Next Session — Registry + MCP Skills + Infrastructure
|
||||||
|
|
||||||
|
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
|
||||||
|
|
||||||
|
## فایلهای مرجع
|
||||||
|
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal, branch Phase-1)
|
||||||
|
- CLAUDE.md (mcphub-internal)
|
||||||
|
- CHANGELOG.md (mcphub-internal)
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
- MCPHub: v3.6.0 — 567 ابزار، 5 پلاگین عمومی (WordPress, WooCommerce, Supabase, OpenPanel, Gitea)
|
||||||
|
- FastMCP: >=3.0.0,<4.0.0
|
||||||
|
- CI سبز
|
||||||
|
|
||||||
|
## MCP Endpoints فعال
|
||||||
|
- mcphub-supabase: 70 ابزار Supabase (DB, auth, storage)
|
||||||
|
- mcphub-gitea: 58 ابزار Gitea (repos, issues, PRs, webhooks)
|
||||||
|
|
||||||
|
## ریپازیتوریها
|
||||||
|
### GitHub (airano-ir)
|
||||||
|
- mcphub (public) → /config/workspace/mcphub
|
||||||
|
- mcphub (private, Phase-1) → /config/workspace/mcphub-internal
|
||||||
|
- mcp-skills (private, خالی) → /config/workspace/mcp-skills
|
||||||
|
- skillhub-internal → /config/workspace/skillhub-internal
|
||||||
|
- skillhub (public) → /config/workspace/skillhub
|
||||||
|
|
||||||
|
### Gitea (atlatl @ gitea.example.com)
|
||||||
|
- polymarket (private) → /config/workspace/polymarket
|
||||||
|
- polymarket-skill (private) → /config/workspace/polymarket-skill
|
||||||
|
- project-ops (private) → مهارت در /config/.claude/skills/project-ops/
|
||||||
|
|
||||||
|
## اهداف این session (به ترتیب اولویت)
|
||||||
|
|
||||||
|
### 1. Registry Submissions (F.14a)
|
||||||
|
وضعیت فعلی:
|
||||||
|
- Glama: ثبت شده (Score: A)، glama.json اضافه شده
|
||||||
|
- awesome-mcp-servers: PR #2147 باز — badge SVG + tool count اصلاح شده، منتظر merge
|
||||||
|
- Smithery.ai: ثبت نشده → از smithery.ai/new ثبت کن (نیاز به public HTTPS URL: mcp.example.com)
|
||||||
|
- Official MCP Registry: ثبت نشده → بررسی `mcp-publisher` CLI
|
||||||
|
کارها:
|
||||||
|
- [ ] وضعیت PR #2147 چک شود — اگر feedback جدید دارد رفع شود
|
||||||
|
- [ ] ثبت در Smithery.ai
|
||||||
|
- [ ] بررسی Official MCP Registry submission process
|
||||||
|
|
||||||
|
### 2. MCP Skills — اولین مهارتها (github:airano-ir/mcp-skills)
|
||||||
|
ریپازیتوری خالی ساخته شده. ساختار:
|
||||||
|
```
|
||||||
|
mcp-skills/skills/
|
||||||
|
├── wordpress/ ← اولین مهارت
|
||||||
|
├── supabase/
|
||||||
|
├── gitea/
|
||||||
|
├── woocommerce/
|
||||||
|
├── openpanel/
|
||||||
|
└── coolify/ ← آینده
|
||||||
|
```
|
||||||
|
کارها:
|
||||||
|
- [ ] از SkillHub بهترین مهارتهای مرتبط را جستجو کن: `npx skillhub search "wordpress mcp" --sort aiScore`
|
||||||
|
- [ ] اگر مهارت با کیفیت بالا (aiScore > 70) پیدا نشد، با skill-creator مهارت جدید بساز
|
||||||
|
- [ ] اولین مهارت: WordPress content workflow (استفاده از MCP endpoint مستقیم)
|
||||||
|
- [ ] هر مهارت باید SKILL.md + اسکریپتهای عملی داشته باشد
|
||||||
|
- [ ] بعد از تست، commit و push به github:airano-ir/mcp-skills
|
||||||
|
|
||||||
|
### 3. Project-Ops Sync با Gitea
|
||||||
|
- [ ] محتوای `/config/.claude/skills/project-ops/SKILL.md` را به `gitea:atlatl/project-ops` push کن
|
||||||
|
- [ ] مطمئن شو backup در Gitea up-to-date است
|
||||||
|
|
||||||
|
### 4. Blog Workspace Setup
|
||||||
|
- [ ] WordPress MCP endpoint اضافه کن از داشبورد mcp.example.com (blog.example.com)
|
||||||
|
- [ ] یک پست تست با MCP WordPress tools بنویس
|
||||||
|
- [ ] اگر API مشکلی داشت، در mcphub-internal fix کن
|
||||||
|
|
||||||
|
### 5. بررسی Coolify MCP (F.17)
|
||||||
|
- [ ] API documentation کولیفای را بررسی کن
|
||||||
|
- [ ] بررسی آیا Coolify API در دسترس است از این محیط
|
||||||
|
- [ ] یک طرح اولیه از tools مورد نیاز بنویس
|
||||||
|
- [ ] نتیجه را در docs/plans/ ذخیره کن
|
||||||
|
|
||||||
|
## قوانین
|
||||||
|
- اول پلن بده، بدون تایید شروع نکن
|
||||||
|
- هر مرحله commit شود
|
||||||
|
- حافظه و project-ops در صورت نیاز آپدیت شود
|
||||||
|
- از SkillHub برای پیدا کردن بهترین مهارتها استفاده کن (aiScore بالاترین)
|
||||||
|
- اگر مهارتی بررسی نشده، با auto-review بررسی کن
|
||||||
|
- ایمیل git عمومی: hi.airano@gmail.com
|
||||||
|
- ایمیل git داخلی: mcphub.dev@gmail.com
|
||||||
|
```
|
||||||
@@ -75,12 +75,11 @@ SUPABASE_SITE1_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
SUPABASE_SITE1_ALIAS=mysupabase
|
SUPABASE_SITE1_ALIAS=mysupabase
|
||||||
|
|
||||||
# Optional: Direct database access for postgres-meta
|
# Optional: Direct postgres-meta URL (when /pg/ is not exposed via Kong)
|
||||||
SUPABASE_SITE1_DB_HOST=db.supabase.example.com
|
# SUPABASE_SITE1_META_URL=http://supabase-meta:8080
|
||||||
SUPABASE_SITE1_DB_PORT=5432
|
#
|
||||||
SUPABASE_SITE1_DB_NAME=postgres
|
# Optional: Basic Auth for postgres-meta (when exposed via public URL)
|
||||||
SUPABASE_SITE1_DB_USER=postgres
|
# SUPABASE_SITE1_META_AUTH=admin:secretpassword
|
||||||
SUPABASE_SITE1_DB_PASSWORD=your-db-password
|
|
||||||
|
|
||||||
# Multiple Instances
|
# Multiple Instances
|
||||||
SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
||||||
@@ -704,6 +703,40 @@ EndpointType.SUPABASE: EndpointConfig(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security: postgres-meta
|
||||||
|
|
||||||
|
**postgres-meta has NO built-in authentication.** It is designed as an internal service
|
||||||
|
behind Kong API Gateway. When accessed through Kong's `/pg/` route, Kong validates JWT tokens.
|
||||||
|
|
||||||
|
### Risk: Public Exposure
|
||||||
|
|
||||||
|
If postgres-meta is exposed via a public URL (e.g., on Coolify with a public domain),
|
||||||
|
**anyone with the URL can execute SQL as `supabase_admin`**.
|
||||||
|
|
||||||
|
### Deployment Scenarios
|
||||||
|
|
||||||
|
| Deployment | meta_url needed? | meta_auth needed? | Notes |
|
||||||
|
|-----------|:---:|:---:|-------|
|
||||||
|
| supabase.com cloud | N/A | N/A | postgres-meta tools disabled |
|
||||||
|
| Docker Compose (standard) | No | No | Kong handles JWT via `/pg/` |
|
||||||
|
| Docker Compose (no /pg/) | Yes (internal URL) | No | Internal network is secure |
|
||||||
|
| Coolify (internal URL) | Yes | No | Internal Docker network |
|
||||||
|
| Coolify (public domain) | Yes | **Yes** | Exposed without auth |
|
||||||
|
| MCP Hub hosted users | Yes (public URL) | **Yes** | Must use public URL |
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
1. **Prefer internal Docker network URLs** (`http://supabase-meta:8080`) when possible
|
||||||
|
2. **If public URL required**: Configure Basic Auth via reverse proxy and set `META_AUTH`
|
||||||
|
3. **Never expose postgres-meta publicly without authentication**
|
||||||
|
|
||||||
|
### MCP Hub Support
|
||||||
|
|
||||||
|
MCP Hub supports `META_AUTH` (format: `username:password`) for Basic Auth with postgres-meta.
|
||||||
|
When configured, requests to postgres-meta use `Authorization: Basic ...` instead of JWT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
||||||
|
|||||||
113
env.example
Normal file
113
env.example
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ===================================
|
||||||
|
# MCP Hub — Environment Configuration
|
||||||
|
# ===================================
|
||||||
|
#
|
||||||
|
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
|
||||||
|
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||||
|
#
|
||||||
|
# After editing, run:
|
||||||
|
# docker compose up -d
|
||||||
|
# curl http://localhost:8000/health # verify server
|
||||||
|
# open http://localhost:8000/dashboard # web dashboard
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# REQUIRED
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Master API key for authentication (also used to log into the dashboard)
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
|
# Encryption key for user credentials (REQUIRED for Live Platform / Track E)
|
||||||
|
# Generate with: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Dashboard session secret (REQUIRED for production)
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
DASHBOARD_SESSION_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# PLUGIN VISIBILITY (Track F.1)
|
||||||
|
# ============================================
|
||||||
|
# Comma-separated list of plugins visible to public (OAuth) users.
|
||||||
|
# Admin users always see all plugins regardless of this setting.
|
||||||
|
# Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea
|
||||||
|
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ADMIN SYSTEM (Track F.4)
|
||||||
|
# ============================================
|
||||||
|
# Comma-separated list of emails that get admin role on OAuth login.
|
||||||
|
# These users see the full admin dashboard (projects, API keys, health, etc.)
|
||||||
|
# If not set, OAuth users only get the standard user dashboard.
|
||||||
|
# ADMIN_EMAILS=admin@example.com,boss@company.com
|
||||||
|
|
||||||
|
# Disable Master Key login to the web dashboard (default: false).
|
||||||
|
# When true, only OAuth users (with admin email) can access admin dashboard.
|
||||||
|
# Master Key still works for MCP endpoints — only dashboard login is affected.
|
||||||
|
# DISABLE_MASTER_KEY_LOGIN=false
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SITES
|
||||||
|
# ============================================
|
||||||
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
|
# After starting the server, open the dashboard to add sites:
|
||||||
|
# http://localhost:8000/dashboard
|
||||||
|
#
|
||||||
|
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
|
||||||
|
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||||
|
# ============================================
|
||||||
|
# Only needed if you want ChatGPT or Claude to auto-register via OAuth.
|
||||||
|
# For local testing with API key auth, you can skip these.
|
||||||
|
|
||||||
|
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
|
# OAUTH_BASE_URL=https://your-public-domain.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OAUTH SOCIAL LOGIN (optional — for user registration)
|
||||||
|
# ============================================
|
||||||
|
# Enable GitHub and/or Google login for the Live Platform.
|
||||||
|
# Create OAuth apps:
|
||||||
|
# GitHub: https://github.com/settings/developers → New OAuth App
|
||||||
|
# - Callback URL: https://your-domain.com/auth/callback/github
|
||||||
|
# Google: https://console.cloud.google.com/apis/credentials → Create OAuth Client
|
||||||
|
# - Callback URL: https://your-domain.com/auth/callback/google
|
||||||
|
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# PUBLIC_URL=https://mcp.example.com
|
||||||
|
# SESSION_EXPIRY_HOURS=168
|
||||||
|
|
||||||
|
# === SITE MANAGEMENT (E.3) ===
|
||||||
|
# MAX_SITES_PER_USER=10
|
||||||
|
# USER_RATE_LIMIT_PER_MIN=30
|
||||||
|
# USER_RATE_LIMIT_PER_HR=500
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# COOLIFY (admin-only — F.17)
|
||||||
|
# ============================================
|
||||||
|
# Coolify instance for AI-driven deployment management.
|
||||||
|
# Create an API token in Coolify: Keys & Tokens → API tokens
|
||||||
|
# COOLIFY_URL=https://coolify.example.com
|
||||||
|
# COOLIFY_TOKEN=your-api-token-here
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ADVANCED (optional — defaults are fine)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Database path (default: data/mcphub.db)
|
||||||
|
# DATABASE_PATH=/app/data/mcphub.db
|
||||||
|
|
||||||
|
# Rate limits (per client)
|
||||||
|
# RATE_LIMIT_PER_MINUTE=60
|
||||||
|
# RATE_LIMIT_PER_HOUR=1000
|
||||||
|
# RATE_LIMIT_PER_DAY=10000
|
||||||
36
glama.json
Normal file
36
glama.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||||
|
"maintainers": ["airano-ir"],
|
||||||
|
"name": "mcphub",
|
||||||
|
"display_name": "MCP Hub",
|
||||||
|
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 567 tools, multi-site support, and OAuth 2.1 authentication.",
|
||||||
|
"repository": "https://github.com/airano-ir/mcphub",
|
||||||
|
"homepage": "https://mcp.palebluedot.live",
|
||||||
|
"version": "3.6.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"runtime": "python",
|
||||||
|
"transport": ["stdio", "streamable-http"],
|
||||||
|
"categories": ["developer-tools", "cms", "databases", "automation"],
|
||||||
|
"keywords": ["mcp", "wordpress", "woocommerce", "supabase", "gitea", "n8n", "openpanel", "appwrite", "directus", "self-hosted", "multi-site"],
|
||||||
|
"author": {
|
||||||
|
"name": "airano-ir",
|
||||||
|
"url": "https://github.com/airano-ir"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"prompts": false,
|
||||||
|
"resources": false
|
||||||
|
},
|
||||||
|
"relatedServers": [
|
||||||
|
{
|
||||||
|
"name": "WordPress MCP",
|
||||||
|
"url": "https://glama.ai/mcp/servers/developer-in-world/wordpress-mcp",
|
||||||
|
"description": "WordPress REST API MCP server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Supabase MCP",
|
||||||
|
"url": "https://glama.ai/mcp/servers/supabase-community/supabase-mcp",
|
||||||
|
"description": "Official Supabase MCP server"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
|
|||||||
|
|
||||||
from plugins.appwrite.plugin import AppwritePlugin
|
from plugins.appwrite.plugin import AppwritePlugin
|
||||||
from plugins.base import BasePlugin, PluginRegistry
|
from plugins.base import BasePlugin, PluginRegistry
|
||||||
|
from plugins.coolify.plugin import CoolifyPlugin
|
||||||
from plugins.directus.plugin import DirectusPlugin
|
from plugins.directus.plugin import DirectusPlugin
|
||||||
from plugins.gitea.plugin import GiteaPlugin
|
from plugins.gitea.plugin import GiteaPlugin
|
||||||
from plugins.n8n.plugin import N8nPlugin
|
from plugins.n8n.plugin import N8nPlugin
|
||||||
@@ -33,6 +34,7 @@ registry.register("supabase", SupabasePlugin)
|
|||||||
registry.register("openpanel", OpenPanelPlugin)
|
registry.register("openpanel", OpenPanelPlugin)
|
||||||
registry.register("appwrite", AppwritePlugin)
|
registry.register("appwrite", AppwritePlugin)
|
||||||
registry.register("directus", DirectusPlugin)
|
registry.register("directus", DirectusPlugin)
|
||||||
|
registry.register("coolify", CoolifyPlugin)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BasePlugin",
|
"BasePlugin",
|
||||||
@@ -47,4 +49,5 @@ __all__ = [
|
|||||||
"OpenPanelPlugin",
|
"OpenPanelPlugin",
|
||||||
"AppwritePlugin",
|
"AppwritePlugin",
|
||||||
"DirectusPlugin",
|
"DirectusPlugin",
|
||||||
|
"CoolifyPlugin",
|
||||||
]
|
]
|
||||||
|
|||||||
5
plugins/coolify/__init__.py
Normal file
5
plugins/coolify/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Coolify Plugin — AI-driven deployment management for Coolify instances.
|
||||||
|
|
||||||
|
F.17: Phase 1 MVP — Applications, Deployments, Servers (~30 tools)
|
||||||
|
"""
|
||||||
429
plugins/coolify/client.py
Normal file
429
plugins/coolify/client.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Coolify REST API Client
|
||||||
|
|
||||||
|
Handles all HTTP communication with Coolify REST API.
|
||||||
|
Separates API communication from business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
class CoolifyClient:
|
||||||
|
"""
|
||||||
|
Coolify REST API client for HTTP communication.
|
||||||
|
|
||||||
|
Handles Bearer token authentication, request formatting,
|
||||||
|
and error handling for all Coolify API v1 endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, site_url: str, token: str):
|
||||||
|
"""
|
||||||
|
Initialize Coolify API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_url: Coolify instance URL (e.g., https://coolify.example.com)
|
||||||
|
token: API token for Bearer authentication
|
||||||
|
"""
|
||||||
|
self.site_url = site_url.rstrip("/")
|
||||||
|
self.api_base = f"{self.site_url}/api/v1"
|
||||||
|
self.token = token
|
||||||
|
self.logger = logging.getLogger(f"CoolifyClient.{site_url}")
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""Get request headers with Bearer authentication."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
json_data: dict | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Make authenticated request to Coolify REST API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, PATCH, DELETE)
|
||||||
|
endpoint: API endpoint (without base URL)
|
||||||
|
params: Query parameters
|
||||||
|
json_data: JSON body data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response (dict, list, or None)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: On API errors with status code and message
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
||||||
|
|
||||||
|
if params:
|
||||||
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
|
if json_data:
|
||||||
|
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||||
|
|
||||||
|
self.logger.debug(f"{method} {url}")
|
||||||
|
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as session,
|
||||||
|
session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_data,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
self.logger.debug(f"Response status: {response.status}")
|
||||||
|
|
||||||
|
if response.status == 204:
|
||||||
|
return {"success": True, "message": "Operation completed successfully"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await response.json()
|
||||||
|
except Exception:
|
||||||
|
response_text = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
raise Exception(
|
||||||
|
f"Coolify API error (status {response.status}): {response_text}"
|
||||||
|
)
|
||||||
|
return {"success": True, "message": response_text}
|
||||||
|
|
||||||
|
if response.status >= 400:
|
||||||
|
error_msg = response_data.get("message", "Unknown error")
|
||||||
|
raise Exception(f"Coolify API error (status {response.status}): {error_msg}")
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
# --- Applications ---
|
||||||
|
|
||||||
|
async def list_applications(self, tag: str | None = None) -> list[dict]:
|
||||||
|
"""List all applications."""
|
||||||
|
params = {"tag": tag} if tag else {}
|
||||||
|
return await self.request("GET", "applications", params=params)
|
||||||
|
|
||||||
|
async def get_application(self, uuid: str) -> dict:
|
||||||
|
"""Get application by UUID."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}")
|
||||||
|
|
||||||
|
async def create_application_public(self, data: dict) -> dict:
|
||||||
|
"""Create application from public repository."""
|
||||||
|
return await self.request("POST", "applications/public", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_dockerfile(self, data: dict) -> dict:
|
||||||
|
"""Create application from Dockerfile."""
|
||||||
|
return await self.request("POST", "applications/dockerfile", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_docker_image(self, data: dict) -> dict:
|
||||||
|
"""Create application from Docker image."""
|
||||||
|
return await self.request("POST", "applications/dockerimage", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_docker_compose(self, data: dict) -> dict:
|
||||||
|
"""Create application from Docker Compose."""
|
||||||
|
return await self.request("POST", "applications/dockercompose", json_data=data)
|
||||||
|
|
||||||
|
async def update_application(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update application by UUID."""
|
||||||
|
return await self.request("PATCH", f"applications/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_application(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
delete_connected_networks: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete application by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
"delete_connected_networks": str(delete_connected_networks).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"applications/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_application(
|
||||||
|
self, uuid: str, force: bool = False, instant_deploy: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""Deploy/start application."""
|
||||||
|
params = {}
|
||||||
|
if force:
|
||||||
|
params["force"] = "true"
|
||||||
|
if instant_deploy:
|
||||||
|
params["instant_deploy"] = "true"
|
||||||
|
return await self.request("GET", f"applications/{uuid}/start", params=params)
|
||||||
|
|
||||||
|
async def stop_application(self, uuid: str, docker_cleanup: bool = True) -> dict:
|
||||||
|
"""Stop application."""
|
||||||
|
params = {"docker_cleanup": str(docker_cleanup).lower()}
|
||||||
|
return await self.request("GET", f"applications/{uuid}/stop", params=params)
|
||||||
|
|
||||||
|
async def restart_application(self, uuid: str) -> dict:
|
||||||
|
"""Restart application."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/restart")
|
||||||
|
|
||||||
|
async def get_application_logs(self, uuid: str, lines: int = 100) -> dict:
|
||||||
|
"""Get application logs."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/logs", params={"lines": lines})
|
||||||
|
|
||||||
|
# --- Application Environment Variables ---
|
||||||
|
|
||||||
|
async def list_application_envs(self, uuid: str) -> list[dict]:
|
||||||
|
"""List application environment variables."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/envs")
|
||||||
|
|
||||||
|
async def create_application_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Create application environment variable."""
|
||||||
|
return await self.request("POST", f"applications/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_application_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update application environment variable."""
|
||||||
|
return await self.request("PATCH", f"applications/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_application_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
|
||||||
|
"""Bulk update application environment variables."""
|
||||||
|
return await self.request(
|
||||||
|
"PATCH", f"applications/{uuid}/envs/bulk", json_data={"data": data}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_application_env(self, uuid: str, env_uuid: str) -> dict:
|
||||||
|
"""Delete application environment variable."""
|
||||||
|
return await self.request("DELETE", f"applications/{uuid}/envs/{env_uuid}")
|
||||||
|
|
||||||
|
# --- Deployments ---
|
||||||
|
|
||||||
|
async def list_deployments(self) -> list[dict]:
|
||||||
|
"""List running deployments."""
|
||||||
|
return await self.request("GET", "deployments")
|
||||||
|
|
||||||
|
async def get_deployment(self, uuid: str) -> dict:
|
||||||
|
"""Get deployment by UUID."""
|
||||||
|
return await self.request("GET", f"deployments/{uuid}")
|
||||||
|
|
||||||
|
async def cancel_deployment(self, uuid: str) -> dict:
|
||||||
|
"""Cancel a deployment."""
|
||||||
|
return await self.request("POST", f"deployments/{uuid}/cancel")
|
||||||
|
|
||||||
|
async def deploy(
|
||||||
|
self,
|
||||||
|
tag: str | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Deploy by tag or UUID."""
|
||||||
|
params = {}
|
||||||
|
if tag:
|
||||||
|
params["tag"] = tag
|
||||||
|
if uuid:
|
||||||
|
params["uuid"] = uuid
|
||||||
|
if force:
|
||||||
|
params["force"] = "true"
|
||||||
|
return await self.request("GET", "deploy", params=params)
|
||||||
|
|
||||||
|
async def list_app_deployments(self, uuid: str, skip: int = 0, take: int = 10) -> list[dict]:
|
||||||
|
"""List deployments for a specific application."""
|
||||||
|
params = {"skip": skip, "take": take}
|
||||||
|
return await self.request("GET", f"deployments/applications/{uuid}", params=params)
|
||||||
|
|
||||||
|
# --- Servers ---
|
||||||
|
|
||||||
|
async def list_servers(self) -> list[dict]:
|
||||||
|
"""List all servers."""
|
||||||
|
return await self.request("GET", "servers")
|
||||||
|
|
||||||
|
async def get_server(self, uuid: str) -> dict:
|
||||||
|
"""Get server by UUID."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}")
|
||||||
|
|
||||||
|
async def create_server(self, data: dict) -> dict:
|
||||||
|
"""Create a new server."""
|
||||||
|
return await self.request("POST", "servers", json_data=data)
|
||||||
|
|
||||||
|
async def update_server(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update server by UUID."""
|
||||||
|
return await self.request("PATCH", f"servers/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_server(self, uuid: str) -> dict:
|
||||||
|
"""Delete server by UUID."""
|
||||||
|
return await self.request("DELETE", f"servers/{uuid}")
|
||||||
|
|
||||||
|
async def get_server_resources(self, uuid: str) -> list[dict]:
|
||||||
|
"""Get resources on a server."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/resources")
|
||||||
|
|
||||||
|
async def get_server_domains(self, uuid: str) -> list[dict]:
|
||||||
|
"""Get domains configured on a server."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/domains")
|
||||||
|
|
||||||
|
async def validate_server(self, uuid: str) -> dict:
|
||||||
|
"""Validate server connectivity and configuration."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/validate")
|
||||||
|
|
||||||
|
# --- Projects ---
|
||||||
|
|
||||||
|
async def list_projects(self) -> list[dict]:
|
||||||
|
"""List all projects."""
|
||||||
|
return await self.request("GET", "projects")
|
||||||
|
|
||||||
|
async def get_project(self, uuid: str) -> dict:
|
||||||
|
"""Get project by UUID."""
|
||||||
|
return await self.request("GET", f"projects/{uuid}")
|
||||||
|
|
||||||
|
async def create_project(self, data: dict) -> dict:
|
||||||
|
"""Create a new project."""
|
||||||
|
return await self.request("POST", "projects", json_data=data)
|
||||||
|
|
||||||
|
async def update_project(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update project by UUID."""
|
||||||
|
return await self.request("PATCH", f"projects/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_project(self, uuid: str) -> dict:
|
||||||
|
"""Delete project by UUID."""
|
||||||
|
return await self.request("DELETE", f"projects/{uuid}")
|
||||||
|
|
||||||
|
# --- Environments ---
|
||||||
|
|
||||||
|
async def list_environments(self, project_uuid: str) -> list[dict]:
|
||||||
|
"""List environments in a project."""
|
||||||
|
return await self.request("GET", f"projects/{project_uuid}/environments")
|
||||||
|
|
||||||
|
async def get_environment(self, project_uuid: str, environment_name: str) -> dict:
|
||||||
|
"""Get environment by name."""
|
||||||
|
return await self.request("GET", f"projects/{project_uuid}/environments/{environment_name}")
|
||||||
|
|
||||||
|
async def create_environment(self, project_uuid: str, data: dict) -> dict:
|
||||||
|
"""Create environment in a project."""
|
||||||
|
return await self.request("POST", f"projects/{project_uuid}/environments", json_data=data)
|
||||||
|
|
||||||
|
# --- Databases ---
|
||||||
|
|
||||||
|
async def list_databases(self) -> list[dict]:
|
||||||
|
"""List all databases."""
|
||||||
|
return await self.request("GET", "databases")
|
||||||
|
|
||||||
|
async def get_database(self, uuid: str) -> dict:
|
||||||
|
"""Get database by UUID."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}")
|
||||||
|
|
||||||
|
async def update_database(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update database by UUID."""
|
||||||
|
return await self.request("PATCH", f"databases/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_database(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete database by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"databases/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_database(self, uuid: str) -> dict:
|
||||||
|
"""Start database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/start")
|
||||||
|
|
||||||
|
async def stop_database(self, uuid: str) -> dict:
|
||||||
|
"""Stop database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/stop")
|
||||||
|
|
||||||
|
async def restart_database(self, uuid: str) -> dict:
|
||||||
|
"""Restart database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/restart")
|
||||||
|
|
||||||
|
async def create_database(self, db_type: str, data: dict) -> dict:
|
||||||
|
"""Create a database of given type (postgresql, mysql, mariadb, mongodb, redis, clickhouse)."""
|
||||||
|
return await self.request("POST", f"databases/{db_type}", json_data=data)
|
||||||
|
|
||||||
|
async def get_database_backups(self, uuid: str) -> dict:
|
||||||
|
"""Get database backups."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/backups")
|
||||||
|
|
||||||
|
async def create_database_backup(self, uuid: str) -> dict:
|
||||||
|
"""Create a manual database backup."""
|
||||||
|
return await self.request("POST", f"databases/{uuid}/backups")
|
||||||
|
|
||||||
|
async def list_backup_executions(self) -> list[dict]:
|
||||||
|
"""List all backup executions."""
|
||||||
|
return await self.request("GET", "databases/backup-executions")
|
||||||
|
|
||||||
|
# --- Services ---
|
||||||
|
|
||||||
|
async def list_services(self) -> list[dict]:
|
||||||
|
"""List all services."""
|
||||||
|
return await self.request("GET", "services")
|
||||||
|
|
||||||
|
async def get_service(self, uuid: str) -> dict:
|
||||||
|
"""Get service by UUID."""
|
||||||
|
return await self.request("GET", f"services/{uuid}")
|
||||||
|
|
||||||
|
async def create_service(self, data: dict) -> dict:
|
||||||
|
"""Create a service from template."""
|
||||||
|
return await self.request("POST", "services", json_data=data)
|
||||||
|
|
||||||
|
async def update_service(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update service by UUID."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_service(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete service by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"services/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_service(self, uuid: str) -> dict:
|
||||||
|
"""Start service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/start")
|
||||||
|
|
||||||
|
async def stop_service(self, uuid: str) -> dict:
|
||||||
|
"""Stop service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/stop")
|
||||||
|
|
||||||
|
async def restart_service(self, uuid: str) -> dict:
|
||||||
|
"""Restart service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/restart")
|
||||||
|
|
||||||
|
# --- Service Environment Variables ---
|
||||||
|
|
||||||
|
async def list_service_envs(self, uuid: str) -> list[dict]:
|
||||||
|
"""List service environment variables."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/envs")
|
||||||
|
|
||||||
|
async def create_service_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Create service environment variable."""
|
||||||
|
return await self.request("POST", f"services/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_service_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update service environment variable."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_service_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
|
||||||
|
"""Bulk update service environment variables."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}/envs/bulk", json_data={"data": data})
|
||||||
|
|
||||||
|
async def delete_service_env(self, uuid: str, env_uuid: str) -> dict:
|
||||||
|
"""Delete service environment variable."""
|
||||||
|
return await self.request("DELETE", f"services/{uuid}/envs/{env_uuid}")
|
||||||
16
plugins/coolify/handlers/__init__.py
Normal file
16
plugins/coolify/handlers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Coolify Plugin Handlers
|
||||||
|
|
||||||
|
All tool handlers for Coolify operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import applications, databases, deployments, projects, servers, services
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"applications",
|
||||||
|
"databases",
|
||||||
|
"deployments",
|
||||||
|
"projects",
|
||||||
|
"servers",
|
||||||
|
"services",
|
||||||
|
]
|
||||||
883
plugins/coolify/handlers/applications.py
Normal file
883
plugins/coolify/handlers/applications.py
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
"""Application Handler — manages Coolify applications, lifecycle, and env vars."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "list_applications",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_applications",
|
||||||
|
"description": "List all Coolify applications. Optionally filter by tag name.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Filter by tag name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_application",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_application",
|
||||||
|
"description": "Get details of a specific Coolify application by UUID.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_application_public",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_application_public",
|
||||||
|
"description": (
|
||||||
|
"Create a Coolify application from a public Git repository. "
|
||||||
|
"Requires project_uuid, server_uuid, environment, git_repository, "
|
||||||
|
"git_branch, build_pack, and ports_exposes."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name (e.g., 'production')",
|
||||||
|
},
|
||||||
|
"git_repository": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Git repository URL",
|
||||||
|
},
|
||||||
|
"git_branch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Git branch name",
|
||||||
|
},
|
||||||
|
"build_pack": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Build pack type",
|
||||||
|
"enum": ["nixpacks", "static", "dockerfile", "dockercompose"],
|
||||||
|
},
|
||||||
|
"ports_exposes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Exposed ports (e.g., '3000')",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application description",
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Comma-separated domain URLs",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately after creation",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project_uuid",
|
||||||
|
"server_uuid",
|
||||||
|
"environment_name",
|
||||||
|
"git_repository",
|
||||||
|
"git_branch",
|
||||||
|
"build_pack",
|
||||||
|
"ports_exposes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_application_dockerfile",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_application_dockerfile",
|
||||||
|
"description": (
|
||||||
|
"Create a Coolify application from a Dockerfile (without git). "
|
||||||
|
"Requires project_uuid, server_uuid, environment, and dockerfile content."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name",
|
||||||
|
},
|
||||||
|
"dockerfile": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Dockerfile content",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application description",
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Comma-separated domain URLs",
|
||||||
|
},
|
||||||
|
"ports_exposes": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Exposed ports",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project_uuid",
|
||||||
|
"server_uuid",
|
||||||
|
"environment_name",
|
||||||
|
"dockerfile",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_application_docker_image",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_application_docker_image",
|
||||||
|
"description": (
|
||||||
|
"Create a Coolify application from a Docker image. "
|
||||||
|
"Requires project_uuid, server_uuid, environment, "
|
||||||
|
"docker_registry_image_name, and ports_exposes."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name",
|
||||||
|
},
|
||||||
|
"docker_registry_image_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Docker image name (e.g., 'nginx')",
|
||||||
|
},
|
||||||
|
"ports_exposes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Exposed ports",
|
||||||
|
},
|
||||||
|
"docker_registry_image_tag": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Docker image tag (default: latest)",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application description",
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Comma-separated domain URLs",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project_uuid",
|
||||||
|
"server_uuid",
|
||||||
|
"environment_name",
|
||||||
|
"docker_registry_image_name",
|
||||||
|
"ports_exposes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_application_compose",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_application_compose",
|
||||||
|
"description": (
|
||||||
|
"Create a Coolify application from Docker Compose. "
|
||||||
|
"Note: Deprecated by Coolify — prefer using services instead."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name",
|
||||||
|
},
|
||||||
|
"docker_compose_raw": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Raw Docker Compose YAML content",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application name",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project_uuid",
|
||||||
|
"server_uuid",
|
||||||
|
"environment_name",
|
||||||
|
"docker_compose_raw",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_application",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "update_application",
|
||||||
|
"description": (
|
||||||
|
"Update a Coolify application settings. Supports name, description, "
|
||||||
|
"domains, build settings, health checks, resource limits, and more."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Application description",
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Comma-separated domain URLs",
|
||||||
|
},
|
||||||
|
"git_repository": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Git repository URL",
|
||||||
|
},
|
||||||
|
"git_branch": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Git branch",
|
||||||
|
},
|
||||||
|
"build_pack": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"nixpacks",
|
||||||
|
"static",
|
||||||
|
"dockerfile",
|
||||||
|
"dockercompose",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"description": "Build pack type",
|
||||||
|
},
|
||||||
|
"install_command": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Install command",
|
||||||
|
},
|
||||||
|
"build_command": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Build command",
|
||||||
|
},
|
||||||
|
"start_command": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Start command",
|
||||||
|
},
|
||||||
|
"ports_exposes": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Exposed ports",
|
||||||
|
},
|
||||||
|
"is_auto_deploy_enabled": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Enable auto-deploy on push",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Deploy instantly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_application",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "delete_application",
|
||||||
|
"description": (
|
||||||
|
"Delete a Coolify application permanently. "
|
||||||
|
"Optionally clean up configs, volumes, and networks."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"delete_configurations": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete configurations",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"delete_volumes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete volumes",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"docker_cleanup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Run Docker cleanup",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"delete_connected_networks": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete connected networks",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_application",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "start_application",
|
||||||
|
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"force": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Force rebuild without cache",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Skip deployment queue",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stop_application",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "stop_application",
|
||||||
|
"description": "Stop a running Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"docker_cleanup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Prune networks and volumes",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "restart_application",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "restart_application",
|
||||||
|
"description": "Restart a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_application_logs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "get_application_logs",
|
||||||
|
"description": "Get logs for a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"lines": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of log lines to retrieve",
|
||||||
|
"default": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_application_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "list_application_envs",
|
||||||
|
"description": "List environment variables for a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "create_application_env",
|
||||||
|
"description": "Create an environment variable for a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable key",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable value",
|
||||||
|
},
|
||||||
|
"is_preview": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Preview deployment only",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"is_literal": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Treat as literal (no variable interpolation)",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"is_multiline": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Allow multiline value",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"is_shown_once": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show value only once",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "key", "value"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "update_application_env",
|
||||||
|
"description": "Update an environment variable for a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable key",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "New value",
|
||||||
|
},
|
||||||
|
"is_preview": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Preview deployment only",
|
||||||
|
},
|
||||||
|
"is_literal": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Treat as literal",
|
||||||
|
},
|
||||||
|
"is_multiline": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Allow multiline",
|
||||||
|
},
|
||||||
|
"is_shown_once": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Show only once",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "key", "value"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_application_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "update_application_envs_bulk",
|
||||||
|
"description": "Bulk update environment variables for a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of env var objects with key, value, and flags",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {"type": "string"},
|
||||||
|
"value": {"type": "string"},
|
||||||
|
"is_preview": {"type": "boolean"},
|
||||||
|
"is_literal": {"type": "boolean"},
|
||||||
|
"is_multiline": {"type": "boolean"},
|
||||||
|
"is_shown_once": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": ["key", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "data"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_application_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "delete_application_env",
|
||||||
|
"description": "Delete an environment variable from a Coolify application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"env_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "env_uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_applications(client: CoolifyClient, tag: str | None = None) -> str:
|
||||||
|
"""List all applications."""
|
||||||
|
apps = await client.list_applications(tag=tag)
|
||||||
|
result = {"success": True, "count": len(apps), "applications": apps}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_application(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get application details."""
|
||||||
|
app = await client.get_application(uuid)
|
||||||
|
result = {"success": True, "application": app}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_application_public(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create application from public repository."""
|
||||||
|
app = await client.create_application_public(kwargs)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Application created successfully",
|
||||||
|
"application": app,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_application_dockerfile(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create application from Dockerfile."""
|
||||||
|
app = await client.create_application_dockerfile(kwargs)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Application created from Dockerfile",
|
||||||
|
"application": app,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_application_docker_image(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create application from Docker image."""
|
||||||
|
app = await client.create_application_docker_image(kwargs)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Application created from Docker image",
|
||||||
|
"application": app,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_application_compose(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create application from Docker Compose."""
|
||||||
|
app = await client.create_application_docker_compose(kwargs)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Application created from Docker Compose",
|
||||||
|
"application": app,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_application(client: CoolifyClient, uuid: str, **kwargs) -> str:
|
||||||
|
"""Update application settings."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
|
||||||
|
app = await client.update_application(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Application '{uuid}' updated successfully",
|
||||||
|
"application": app,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_application(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
delete_connected_networks: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Delete an application."""
|
||||||
|
result_data = await client.delete_application(
|
||||||
|
uuid,
|
||||||
|
delete_configurations=delete_configurations,
|
||||||
|
delete_volumes=delete_volumes,
|
||||||
|
docker_cleanup=docker_cleanup,
|
||||||
|
delete_connected_networks=delete_connected_networks,
|
||||||
|
)
|
||||||
|
result = {"success": True, "message": f"Application '{uuid}' deleted", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_application(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
force: bool = False,
|
||||||
|
instant_deploy: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Deploy/start application."""
|
||||||
|
result_data = await client.start_application(uuid, force=force, instant_deploy=instant_deploy)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Application '{uuid}' deployment queued",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_application(client: CoolifyClient, uuid: str, docker_cleanup: bool = True) -> str:
|
||||||
|
"""Stop application."""
|
||||||
|
result_data = await client.stop_application(uuid, docker_cleanup=docker_cleanup)
|
||||||
|
result = {"success": True, "message": f"Application '{uuid}' stopping", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_application(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Restart application."""
|
||||||
|
result_data = await client.restart_application(uuid)
|
||||||
|
result = {"success": True, "message": f"Application '{uuid}' restarting", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_application_logs(client: CoolifyClient, uuid: str, lines: int = 100) -> str:
|
||||||
|
"""Get application logs."""
|
||||||
|
logs = await client.get_application_logs(uuid, lines=lines)
|
||||||
|
result = {"success": True, "logs": logs}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_application_envs(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""List application environment variables."""
|
||||||
|
envs = await client.list_application_envs(uuid)
|
||||||
|
result = {"success": True, "count": len(envs), "envs": envs}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_application_env(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
is_preview: bool = False,
|
||||||
|
is_literal: bool = False,
|
||||||
|
is_multiline: bool = False,
|
||||||
|
is_shown_once: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Create application environment variable."""
|
||||||
|
data = {
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"is_preview": is_preview,
|
||||||
|
"is_literal": is_literal,
|
||||||
|
"is_multiline": is_multiline,
|
||||||
|
"is_shown_once": is_shown_once,
|
||||||
|
}
|
||||||
|
result_data = await client.create_application_env(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Env var '{key}' created",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_application_env(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
is_preview: bool | None = None,
|
||||||
|
is_literal: bool | None = None,
|
||||||
|
is_multiline: bool | None = None,
|
||||||
|
is_shown_once: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update application environment variable."""
|
||||||
|
data = {"key": key, "value": value}
|
||||||
|
if is_preview is not None:
|
||||||
|
data["is_preview"] = is_preview
|
||||||
|
if is_literal is not None:
|
||||||
|
data["is_literal"] = is_literal
|
||||||
|
if is_multiline is not None:
|
||||||
|
data["is_multiline"] = is_multiline
|
||||||
|
if is_shown_once is not None:
|
||||||
|
data["is_shown_once"] = is_shown_once
|
||||||
|
result_data = await client.update_application_env(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Env var '{key}' updated",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_application_envs_bulk(client: CoolifyClient, uuid: str, data: list[dict]) -> str:
|
||||||
|
"""Bulk update application environment variables."""
|
||||||
|
result_data = await client.update_application_envs_bulk(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Bulk update {len(data)} env vars",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_application_env(client: CoolifyClient, uuid: str, env_uuid: str) -> str:
|
||||||
|
"""Delete application environment variable."""
|
||||||
|
await client.delete_application_env(uuid, env_uuid)
|
||||||
|
result = {"success": True, "message": f"Env var '{env_uuid}' deleted"}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
420
plugins/coolify/handlers/databases.py
Normal file
420
plugins/coolify/handlers/databases.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
"""Database Handler — manages Coolify databases, lifecycle, and backups."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def _create_db_spec(db_type: str, description: str) -> dict[str, Any]:
|
||||||
|
"""Generate a create_* tool spec for a database type."""
|
||||||
|
return {
|
||||||
|
"name": f"create_{db_type}",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": f"create_{db_type}",
|
||||||
|
"description": description,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name (e.g., 'production')",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Database name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Database description",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately after creation",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["project_uuid", "server_uuid", "environment_name"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
specs = [
|
||||||
|
{
|
||||||
|
"name": "list_databases",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_databases",
|
||||||
|
"description": "List all Coolify databases.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_database",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "get_database",
|
||||||
|
"description": "Get details of a specific Coolify database by UUID.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_database",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "update_database",
|
||||||
|
"description": "Update a Coolify database settings.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Database name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Database description",
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Docker image",
|
||||||
|
},
|
||||||
|
"is_public": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Make database publicly accessible",
|
||||||
|
},
|
||||||
|
"public_port": {
|
||||||
|
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||||
|
"description": "Public port number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_database",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "delete_database",
|
||||||
|
"description": (
|
||||||
|
"Delete a Coolify database permanently. "
|
||||||
|
"Optionally clean up volumes and Docker resources."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"delete_configurations": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete configurations",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"delete_volumes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete volumes",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"docker_cleanup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Run Docker cleanup",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_database",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "start_database",
|
||||||
|
"description": "Start a Coolify database.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stop_database",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "stop_database",
|
||||||
|
"description": "Stop a running Coolify database.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "restart_database",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "restart_database",
|
||||||
|
"description": "Restart a Coolify database.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add create specs for each database type
|
||||||
|
db_types = [
|
||||||
|
("postgresql", "Create a PostgreSQL database on Coolify."),
|
||||||
|
("mysql", "Create a MySQL database on Coolify."),
|
||||||
|
("mariadb", "Create a MariaDB database on Coolify."),
|
||||||
|
("mongodb", "Create a MongoDB database on Coolify."),
|
||||||
|
("redis", "Create a Redis database on Coolify."),
|
||||||
|
("clickhouse", "Create a ClickHouse database on Coolify."),
|
||||||
|
]
|
||||||
|
for db_type, desc in db_types:
|
||||||
|
specs.append(_create_db_spec(db_type, desc))
|
||||||
|
|
||||||
|
# Backup tools
|
||||||
|
specs.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "get_database_backups",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "get_database_backups",
|
||||||
|
"description": "Get backup configuration and history for a Coolify database.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_database_backup",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "create_database_backup",
|
||||||
|
"description": "Create a manual backup of a Coolify database.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Database UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_backup_executions",
|
||||||
|
"category": "backup",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "list_backup_executions",
|
||||||
|
"description": "List all backup executions across all databases.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_databases(client: CoolifyClient) -> str:
|
||||||
|
"""List all databases."""
|
||||||
|
databases = await client.list_databases()
|
||||||
|
result = {"success": True, "count": len(databases), "databases": databases}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_database(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get database details."""
|
||||||
|
db = await client.get_database(uuid)
|
||||||
|
result = {"success": True, "database": db}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_database(client: CoolifyClient, uuid: str, **kwargs) -> str:
|
||||||
|
"""Update database settings."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
|
||||||
|
db = await client.update_database(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Database '{uuid}' updated successfully",
|
||||||
|
"database": db,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_database(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Delete a database."""
|
||||||
|
result_data = await client.delete_database(
|
||||||
|
uuid,
|
||||||
|
delete_configurations=delete_configurations,
|
||||||
|
delete_volumes=delete_volumes,
|
||||||
|
docker_cleanup=docker_cleanup,
|
||||||
|
)
|
||||||
|
result = {"success": True, "message": f"Database '{uuid}' deleted", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_database(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Start a database."""
|
||||||
|
result_data = await client.start_database(uuid)
|
||||||
|
result = {"success": True, "message": f"Database '{uuid}' starting", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_database(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Stop a database."""
|
||||||
|
result_data = await client.stop_database(uuid)
|
||||||
|
result = {"success": True, "message": f"Database '{uuid}' stopping", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_database(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Restart a database."""
|
||||||
|
result_data = await client.restart_database(uuid)
|
||||||
|
result = {"success": True, "message": f"Database '{uuid}' restarting", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_database(client: CoolifyClient, db_type: str, **kwargs) -> str:
|
||||||
|
"""Create a database of given type."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
db = await client.create_database(db_type, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"{db_type.title()} database created successfully",
|
||||||
|
"database": db,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_postgresql(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a PostgreSQL database."""
|
||||||
|
return await _create_database(client, "postgresql", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mysql(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a MySQL database."""
|
||||||
|
return await _create_database(client, "mysql", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mariadb(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a MariaDB database."""
|
||||||
|
return await _create_database(client, "mariadb", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mongodb(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a MongoDB database."""
|
||||||
|
return await _create_database(client, "mongodb", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_redis(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a Redis database."""
|
||||||
|
return await _create_database(client, "redis", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_clickhouse(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a ClickHouse database."""
|
||||||
|
return await _create_database(client, "clickhouse", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_database_backups(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get database backups."""
|
||||||
|
backups = await client.get_database_backups(uuid)
|
||||||
|
result = {"success": True, "backups": backups}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database_backup(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Create a manual database backup."""
|
||||||
|
result_data = await client.create_database_backup(uuid)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Backup created for database '{uuid}'",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_backup_executions(client: CoolifyClient) -> str:
|
||||||
|
"""List all backup executions."""
|
||||||
|
executions = await client.list_backup_executions()
|
||||||
|
result = {"success": True, "count": len(executions), "executions": executions}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
167
plugins/coolify/handlers/deployments.py
Normal file
167
plugins/coolify/handlers/deployments.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Deployment Handler — manages Coolify deployments."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "list_deployments",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_deployments",
|
||||||
|
"description": "List all running deployments on the Coolify instance.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_deployment",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_deployment",
|
||||||
|
"description": "Get details of a specific deployment by UUID.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Deployment UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cancel_deployment",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "cancel_deployment",
|
||||||
|
"description": "Cancel a running deployment.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Deployment UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deploy",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "deploy",
|
||||||
|
"description": (
|
||||||
|
"Trigger deployment by tag name or resource UUID. "
|
||||||
|
"Can deploy multiple resources at once using comma-separated values."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Tag name(s), comma-separated",
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Resource UUID(s), comma-separated",
|
||||||
|
},
|
||||||
|
"force": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Force rebuild without cache",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_app_deployments",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_app_deployments",
|
||||||
|
"description": "List deployment history for a specific application.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"skip": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of deployments to skip",
|
||||||
|
"default": 0,
|
||||||
|
"minimum": 0,
|
||||||
|
},
|
||||||
|
"take": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of deployments to return",
|
||||||
|
"default": 10,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deployments(client: CoolifyClient) -> str:
|
||||||
|
"""List running deployments."""
|
||||||
|
deployments = await client.list_deployments()
|
||||||
|
result = {"success": True, "count": len(deployments), "deployments": deployments}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_deployment(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get deployment details."""
|
||||||
|
deployment = await client.get_deployment(uuid)
|
||||||
|
result = {"success": True, "deployment": deployment}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_deployment(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Cancel a deployment."""
|
||||||
|
result_data = await client.cancel_deployment(uuid)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Deployment '{uuid}' cancelled",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def deploy(
|
||||||
|
client: CoolifyClient,
|
||||||
|
tag: str | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Deploy by tag or UUID."""
|
||||||
|
result_data = await client.deploy(tag=tag, uuid=uuid, force=force)
|
||||||
|
result = {"success": True, "message": "Deployment triggered", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_app_deployments(
|
||||||
|
client: CoolifyClient, uuid: str, skip: int = 0, take: int = 10
|
||||||
|
) -> str:
|
||||||
|
"""List deployment history for an application."""
|
||||||
|
deployments = await client.list_app_deployments(uuid, skip=skip, take=take)
|
||||||
|
result = {"success": True, "count": len(deployments), "deployments": deployments}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
273
plugins/coolify/handlers/projects.py
Normal file
273
plugins/coolify/handlers/projects.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""Project & Environment Handler — manages Coolify projects and environments."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "list_projects",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_projects",
|
||||||
|
"description": "List all Coolify projects.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_project",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_project",
|
||||||
|
"description": "Get details of a specific Coolify project by UUID.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_project",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_project",
|
||||||
|
"description": (
|
||||||
|
"Create a new Coolify project. "
|
||||||
|
"Projects group applications, databases, and services."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project name",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Project description",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_project",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "update_project",
|
||||||
|
"description": "Update a Coolify project name or description.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "New project name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "New project description",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_project",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "delete_project",
|
||||||
|
"description": (
|
||||||
|
"Delete a Coolify project permanently. "
|
||||||
|
"All resources in the project will be removed."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_environments",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_environments",
|
||||||
|
"description": "List all environments in a Coolify project.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["project_uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_environment",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_environment",
|
||||||
|
"description": ("Get details of a specific environment in a Coolify project by name."),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name (e.g., 'production')",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["project_uuid", "environment_name"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_environment",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_environment",
|
||||||
|
"description": "Create a new environment in a Coolify project.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Environment description",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["project_uuid", "name"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_projects(client: CoolifyClient) -> str:
|
||||||
|
"""List all projects."""
|
||||||
|
projects = await client.list_projects()
|
||||||
|
result = {"success": True, "count": len(projects), "projects": projects}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get project details."""
|
||||||
|
project = await client.get_project(uuid)
|
||||||
|
result = {"success": True, "project": project}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project(client: CoolifyClient, name: str, description: str | None = None) -> str:
|
||||||
|
"""Create a new project."""
|
||||||
|
data = {"name": name}
|
||||||
|
if description is not None:
|
||||||
|
data["description"] = description
|
||||||
|
project = await client.create_project(data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Project created successfully",
|
||||||
|
"project": project,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_project(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update project settings."""
|
||||||
|
data = {}
|
||||||
|
if name is not None:
|
||||||
|
data["name"] = name
|
||||||
|
if description is not None:
|
||||||
|
data["description"] = description
|
||||||
|
project = await client.update_project(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Project '{uuid}' updated successfully",
|
||||||
|
"project": project,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_project(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Delete a project."""
|
||||||
|
result_data = await client.delete_project(uuid)
|
||||||
|
result = {"success": True, "message": f"Project '{uuid}' deleted", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_environments(client: CoolifyClient, project_uuid: str) -> str:
|
||||||
|
"""List environments in a project."""
|
||||||
|
envs = await client.list_environments(project_uuid)
|
||||||
|
result = {"success": True, "count": len(envs), "environments": envs}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_environment(client: CoolifyClient, project_uuid: str, environment_name: str) -> str:
|
||||||
|
"""Get environment details."""
|
||||||
|
env = await client.get_environment(project_uuid, environment_name)
|
||||||
|
result = {"success": True, "environment": env}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_environment(
|
||||||
|
client: CoolifyClient,
|
||||||
|
project_uuid: str,
|
||||||
|
name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new environment in a project."""
|
||||||
|
data = {"name": name}
|
||||||
|
if description is not None:
|
||||||
|
data["description"] = description
|
||||||
|
env = await client.create_environment(project_uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Environment '{name}' created",
|
||||||
|
"environment": env,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
294
plugins/coolify/handlers/servers.py
Normal file
294
plugins/coolify/handlers/servers.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""Server Handler — manages Coolify servers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "list_servers",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_servers",
|
||||||
|
"description": "List all servers registered in the Coolify instance.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_server",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_server",
|
||||||
|
"description": "Get details of a specific server by UUID, including settings.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_server",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "create_server",
|
||||||
|
"description": (
|
||||||
|
"Register a new server in Coolify. "
|
||||||
|
"Requires name, IP, port, user, and a private key UUID for SSH access."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server name",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server IP address",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "SSH port",
|
||||||
|
"default": 22,
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SSH user",
|
||||||
|
"default": "root",
|
||||||
|
},
|
||||||
|
"private_key_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Private key UUID for SSH authentication",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Server description",
|
||||||
|
},
|
||||||
|
"is_build_server": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Use as build server",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"instant_validate": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Validate server immediately after creation",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"proxy_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Proxy type for the server",
|
||||||
|
"enum": ["traefik", "caddy", "none"],
|
||||||
|
"default": "traefik",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "ip", "private_key_uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_server",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "update_server",
|
||||||
|
"description": "Update server configuration.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Server name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Server description",
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Server IP address",
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||||
|
"description": "SSH port",
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "SSH user",
|
||||||
|
},
|
||||||
|
"proxy_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{"type": "string", "enum": ["traefik", "caddy", "none"]},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"description": "Proxy type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_server",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "delete_server",
|
||||||
|
"description": "Delete a server from Coolify. This cannot be undone!",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_server_resources",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_server_resources",
|
||||||
|
"description": (
|
||||||
|
"Get all resources (applications, databases, services) "
|
||||||
|
"deployed on a specific server."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_server_domains",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_server_domains",
|
||||||
|
"description": "Get all domains configured on a specific server.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "validate_server",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "validate_server",
|
||||||
|
"description": "Validate server connectivity and configuration.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_servers(client: CoolifyClient) -> str:
|
||||||
|
"""List all servers."""
|
||||||
|
servers = await client.list_servers()
|
||||||
|
result = {"success": True, "count": len(servers), "servers": servers}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_server(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get server details."""
|
||||||
|
server = await client.get_server(uuid)
|
||||||
|
result = {"success": True, "server": server}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_server(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a new server."""
|
||||||
|
server = await client.create_server(kwargs)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Server created successfully",
|
||||||
|
"server": server,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_server(client: CoolifyClient, uuid: str, **kwargs) -> str:
|
||||||
|
"""Update server configuration."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
|
||||||
|
server = await client.update_server(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Server '{uuid}' updated",
|
||||||
|
"server": server,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_server(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Delete a server."""
|
||||||
|
await client.delete_server(uuid)
|
||||||
|
result = {"success": True, "message": f"Server '{uuid}' deleted"}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_server_resources(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get server resources."""
|
||||||
|
resources = await client.get_server_resources(uuid)
|
||||||
|
result = {"success": True, "count": len(resources), "resources": resources}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_server_domains(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get server domains."""
|
||||||
|
domains = await client.get_server_domains(uuid)
|
||||||
|
result = {"success": True, "domains": domains}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_server(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Validate server."""
|
||||||
|
result_data = await client.validate_server(uuid)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Server validation started",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
501
plugins/coolify/handlers/services.py
Normal file
501
plugins/coolify/handlers/services.py
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
"""Service Handler — manages Coolify services (Docker Compose), lifecycle, and env vars."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""Return tool specifications for ToolGenerator."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "list_services",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "list_services",
|
||||||
|
"description": "List all Coolify services.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_service",
|
||||||
|
"category": "read",
|
||||||
|
"method_name": "get_service",
|
||||||
|
"description": "Get details of a specific Coolify service by UUID.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_service",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "create_service",
|
||||||
|
"description": (
|
||||||
|
"Create a Coolify service from a predefined template. "
|
||||||
|
"Requires project_uuid, server_uuid, environment, and service type."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Project UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"server_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"environment_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment name (e.g., 'production')",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Service type from Coolify templates "
|
||||||
|
"(e.g., 'plausible-analytics', 'minio', 'grafana')"
|
||||||
|
),
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Service name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Service description",
|
||||||
|
},
|
||||||
|
"instant_deploy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Deploy immediately after creation",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project_uuid",
|
||||||
|
"server_uuid",
|
||||||
|
"environment_name",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_service",
|
||||||
|
"category": "crud",
|
||||||
|
"method_name": "update_service",
|
||||||
|
"description": "Update a Coolify service settings.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Service name",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Service description",
|
||||||
|
},
|
||||||
|
"docker_compose_raw": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Raw Docker Compose YAML content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_service",
|
||||||
|
"category": "system",
|
||||||
|
"method_name": "delete_service",
|
||||||
|
"description": (
|
||||||
|
"Delete a Coolify service permanently. "
|
||||||
|
"Optionally clean up volumes and Docker resources."
|
||||||
|
),
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"delete_configurations": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete configurations",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"delete_volumes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Delete volumes",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"docker_cleanup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Run Docker cleanup",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_service",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "start_service",
|
||||||
|
"description": "Start a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stop_service",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "stop_service",
|
||||||
|
"description": "Stop a running Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "restart_service",
|
||||||
|
"category": "lifecycle",
|
||||||
|
"method_name": "restart_service",
|
||||||
|
"description": "Restart a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_service_envs",
|
||||||
|
"category": "read_sensitive",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "list_service_envs",
|
||||||
|
"description": "List environment variables for a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid"],
|
||||||
|
},
|
||||||
|
"scope": "read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "create_service_env",
|
||||||
|
"description": "Create an environment variable for a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable key",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable value",
|
||||||
|
},
|
||||||
|
"is_preview": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Preview deployment only",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "key", "value"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "update_service_env",
|
||||||
|
"description": "Update an environment variable for a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable key",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "New value",
|
||||||
|
},
|
||||||
|
"is_preview": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Preview deployment only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "key", "value"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_service_envs_bulk",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "update_service_envs_bulk",
|
||||||
|
"description": "Bulk update environment variables for a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of env var objects with key and value",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {"type": "string"},
|
||||||
|
"value": {"type": "string"},
|
||||||
|
"is_preview": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": ["key", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "data"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_service_env",
|
||||||
|
"category": "env",
|
||||||
|
"sensitivity": "sensitive",
|
||||||
|
"method_name": "delete_service_env",
|
||||||
|
"description": "Delete an environment variable from a Coolify service.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"env_uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Environment variable UUID",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["uuid", "env_uuid"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Handler Functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def list_services(client: CoolifyClient) -> str:
|
||||||
|
"""List all services."""
|
||||||
|
services = await client.list_services()
|
||||||
|
result = {"success": True, "count": len(services), "services": services}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_service(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Get service details."""
|
||||||
|
service = await client.get_service(uuid)
|
||||||
|
result = {"success": True, "service": service}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_service(client: CoolifyClient, **kwargs) -> str:
|
||||||
|
"""Create a service from template."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
service = await client.create_service(data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": "Service created successfully",
|
||||||
|
"service": service,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_service(client: CoolifyClient, uuid: str, **kwargs) -> str:
|
||||||
|
"""Update service settings."""
|
||||||
|
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
|
||||||
|
service = await client.update_service(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Service '{uuid}' updated successfully",
|
||||||
|
"service": service,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_service(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Delete a service."""
|
||||||
|
result_data = await client.delete_service(
|
||||||
|
uuid,
|
||||||
|
delete_configurations=delete_configurations,
|
||||||
|
delete_volumes=delete_volumes,
|
||||||
|
docker_cleanup=docker_cleanup,
|
||||||
|
)
|
||||||
|
result = {"success": True, "message": f"Service '{uuid}' deleted", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_service(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Start a service."""
|
||||||
|
result_data = await client.start_service(uuid)
|
||||||
|
result = {"success": True, "message": f"Service '{uuid}' starting", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_service(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Stop a service."""
|
||||||
|
result_data = await client.stop_service(uuid)
|
||||||
|
result = {"success": True, "message": f"Service '{uuid}' stopping", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_service(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""Restart a service."""
|
||||||
|
result_data = await client.restart_service(uuid)
|
||||||
|
result = {"success": True, "message": f"Service '{uuid}' restarting", "data": result_data}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_service_envs(client: CoolifyClient, uuid: str) -> str:
|
||||||
|
"""List service environment variables."""
|
||||||
|
envs = await client.list_service_envs(uuid)
|
||||||
|
result = {"success": True, "count": len(envs), "envs": envs}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_service_env(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
is_preview: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Create service environment variable."""
|
||||||
|
data = {"key": key, "value": value, "is_preview": is_preview}
|
||||||
|
result_data = await client.create_service_env(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Env var '{key}' created",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_service_env(
|
||||||
|
client: CoolifyClient,
|
||||||
|
uuid: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
is_preview: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update service environment variable."""
|
||||||
|
data = {"key": key, "value": value}
|
||||||
|
if is_preview is not None:
|
||||||
|
data["is_preview"] = is_preview
|
||||||
|
result_data = await client.update_service_env(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Env var '{key}' updated",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_service_envs_bulk(client: CoolifyClient, uuid: str, data: list[dict]) -> str:
|
||||||
|
"""Bulk update service environment variables."""
|
||||||
|
result_data = await client.update_service_envs_bulk(uuid, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Bulk update {len(data)} env vars",
|
||||||
|
"data": result_data,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_service_env(client: CoolifyClient, uuid: str, env_uuid: str) -> str:
|
||||||
|
"""Delete service environment variable."""
|
||||||
|
await client.delete_service_env(uuid, env_uuid)
|
||||||
|
result = {"success": True, "message": f"Env var '{env_uuid}' deleted"}
|
||||||
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||||
115
plugins/coolify/plugin.py
Normal file
115
plugins/coolify/plugin.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Coolify Plugin — Clean Architecture
|
||||||
|
|
||||||
|
AI-driven deployment management for Coolify instances.
|
||||||
|
Modular handlers for applications, deployments, and servers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins.base import BasePlugin
|
||||||
|
from plugins.coolify import handlers
|
||||||
|
from plugins.coolify.client import CoolifyClient
|
||||||
|
|
||||||
|
|
||||||
|
class CoolifyPlugin(BasePlugin):
|
||||||
|
"""
|
||||||
|
Coolify project plugin — Clean architecture.
|
||||||
|
|
||||||
|
Provides Coolify deployment management capabilities including:
|
||||||
|
- Application management (CRUD, lifecycle, env vars, logs)
|
||||||
|
- Database management (PostgreSQL, MySQL, MariaDB, MongoDB, Redis, ClickHouse, backups)
|
||||||
|
- Deployment control (list, cancel, deploy by tag/UUID)
|
||||||
|
- Project & environment management (CRUD)
|
||||||
|
- Server management (CRUD, resources, domains, validation)
|
||||||
|
- Service management (CRUD, lifecycle, env vars)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_plugin_name() -> str:
|
||||||
|
"""Return plugin type identifier."""
|
||||||
|
return "coolify"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_required_config_keys() -> list[str]:
|
||||||
|
"""Return required configuration keys."""
|
||||||
|
return ["url", "token"]
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||||
|
"""
|
||||||
|
Initialize Coolify plugin with handlers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration dictionary containing:
|
||||||
|
- url: Coolify instance URL
|
||||||
|
- token: API token for Bearer authentication
|
||||||
|
project_id: Optional project ID (auto-generated if not provided)
|
||||||
|
"""
|
||||||
|
super().__init__(config, project_id=project_id)
|
||||||
|
|
||||||
|
self.client = CoolifyClient(
|
||||||
|
site_url=config["url"],
|
||||||
|
token=config["token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Return all tool specifications for ToolGenerator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tool specification dictionaries
|
||||||
|
"""
|
||||||
|
specs = []
|
||||||
|
|
||||||
|
specs.extend(handlers.applications.get_tool_specifications())
|
||||||
|
specs.extend(handlers.databases.get_tool_specifications())
|
||||||
|
specs.extend(handlers.deployments.get_tool_specifications())
|
||||||
|
specs.extend(handlers.projects.get_tool_specifications())
|
||||||
|
specs.extend(handlers.servers.get_tool_specifications())
|
||||||
|
specs.extend(handlers.services.get_tool_specifications())
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
"""
|
||||||
|
Dynamically delegate method calls to appropriate handlers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Method name being called
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Handler function from the appropriate handler module
|
||||||
|
"""
|
||||||
|
handler_modules = [
|
||||||
|
handlers.applications,
|
||||||
|
handlers.databases,
|
||||||
|
handlers.deployments,
|
||||||
|
handlers.projects,
|
||||||
|
handlers.servers,
|
||||||
|
handlers.services,
|
||||||
|
]
|
||||||
|
|
||||||
|
for module in handler_modules:
|
||||||
|
if hasattr(module, name):
|
||||||
|
func = getattr(module, name)
|
||||||
|
|
||||||
|
async def wrapper(_func=func, **kwargs):
|
||||||
|
return await _func(self.client, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
||||||
|
|
||||||
|
async def health_check(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check if Coolify instance is accessible.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing health check result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.client.request("GET", "version")
|
||||||
|
return {"healthy": True, "message": "Coolify instance is accessible"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"healthy": False, "message": f"Coolify health check failed: {str(e)}"}
|
||||||
1
plugins/coolify/schemas/__init__.py
Normal file
1
plugins/coolify/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Coolify Plugin Schemas"""
|
||||||
55
plugins/coolify/schemas/common.py
Normal file
55
plugins/coolify/schemas/common.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Common Pydantic Schemas for Coolify Plugin
|
||||||
|
|
||||||
|
Shared validation schemas used across Coolify handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Site(BaseModel):
|
||||||
|
"""Coolify site configuration."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
site_id: str = Field(..., description="Site identifier")
|
||||||
|
url: str = Field(..., description="Coolify instance URL")
|
||||||
|
token: str = Field(..., description="API token for Bearer authentication")
|
||||||
|
alias: str | None = Field(None, description="Site alias")
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationParams(BaseModel):
|
||||||
|
"""Pagination parameters for list endpoints."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
skip: int = Field(default=0, ge=0, description="Number of items to skip")
|
||||||
|
take: int = Field(default=10, ge=1, le=100, description="Number of items to take")
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standard error response."""
|
||||||
|
|
||||||
|
error: bool = Field(default=True)
|
||||||
|
message: str = Field(..., description="Error message")
|
||||||
|
details: dict[str, Any] | None = Field(None, description="Additional error details")
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
"""Standard success response."""
|
||||||
|
|
||||||
|
success: bool = Field(default=True)
|
||||||
|
message: str = Field(..., description="Success message")
|
||||||
|
data: dict[str, Any] | None = Field(None, description="Response data")
|
||||||
|
|
||||||
|
|
||||||
|
class CoolifyTimestamps(BaseModel):
|
||||||
|
"""Common timestamp fields."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
@@ -295,6 +295,25 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
"scope": "write",
|
"scope": "write",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_label",
|
||||||
|
"method_name": "delete_label",
|
||||||
|
"description": "Delete a label from a repository.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||||
|
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||||
|
"label_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Label ID to delete",
|
||||||
|
"minimum": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["owner", "repo", "label_id"],
|
||||||
|
},
|
||||||
|
"scope": "write",
|
||||||
|
},
|
||||||
# === MILESTONES ===
|
# === MILESTONES ===
|
||||||
{
|
{
|
||||||
"name": "list_milestones",
|
"name": "list_milestones",
|
||||||
@@ -499,6 +518,13 @@ async def create_label(
|
|||||||
return json.dumps(result, indent=2)
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_label(client: GiteaClient, owner: str, repo: str, label_id: int) -> str:
|
||||||
|
"""Delete a label"""
|
||||||
|
await client.delete_label(owner, repo, label_id)
|
||||||
|
result = {"success": True, "message": f"Label {label_id} deleted successfully"}
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
# Milestone operations
|
# Milestone operations
|
||||||
async def list_milestones(
|
async def list_milestones(
|
||||||
client: GiteaClient, owner: str, repo: str, state: str | None = None
|
client: GiteaClient, owner: str, repo: str, state: str | None = None
|
||||||
|
|||||||
@@ -526,12 +526,13 @@ async def merge_pull_request(
|
|||||||
delete_branch_after_merge: bool = False,
|
delete_branch_after_merge: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Merge a pull request"""
|
"""Merge a pull request"""
|
||||||
data = {
|
data: dict = {"Do": method}
|
||||||
"Do": method,
|
if title is not None:
|
||||||
"MergeTitleField": title,
|
data["MergeTitleField"] = title
|
||||||
"MergeMessageField": message,
|
if message is not None:
|
||||||
"delete_branch_after_merge": delete_branch_after_merge,
|
data["MergeMessageField"] = message
|
||||||
}
|
if delete_branch_after_merge:
|
||||||
|
data["delete_branch_after_merge"] = True
|
||||||
merge_result = await client.merge_pull_request(owner, repo, pr_number, data)
|
merge_result = await client.merge_pull_request(owner, repo, pr_number, data)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -159,6 +159,79 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
"scope": "read",
|
"scope": "read",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "update_webhook",
|
||||||
|
"method_name": "update_webhook",
|
||||||
|
"description": "Update an existing webhook's configuration, events, or active status.",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||||
|
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||||
|
"webhook_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Webhook ID to update",
|
||||||
|
"minimum": 1,
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}],
|
||||||
|
"description": "New webhook URL",
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"create",
|
||||||
|
"delete",
|
||||||
|
"fork",
|
||||||
|
"push",
|
||||||
|
"issues",
|
||||||
|
"issue_assign",
|
||||||
|
"issue_label",
|
||||||
|
"issue_milestone",
|
||||||
|
"issue_comment",
|
||||||
|
"pull_request",
|
||||||
|
"pull_request_assign",
|
||||||
|
"pull_request_label",
|
||||||
|
"pull_request_milestone",
|
||||||
|
"pull_request_comment",
|
||||||
|
"pull_request_review_approved",
|
||||||
|
"pull_request_review_rejected",
|
||||||
|
"pull_request_review_comment",
|
||||||
|
"pull_request_sync",
|
||||||
|
"wiki",
|
||||||
|
"repository",
|
||||||
|
"release",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"description": "Updated list of events to trigger webhook",
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{"type": "string", "enum": ["json", "form"]},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"description": "Content type for webhook payload",
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"description": "Secret for webhook signature verification",
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||||
|
"description": "Activate or deactivate webhook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["owner", "repo", "webhook_id"],
|
||||||
|
},
|
||||||
|
"scope": "admin",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -220,3 +293,40 @@ async def get_webhook(client: GiteaClient, owner: str, repo: str, webhook_id: in
|
|||||||
webhook = await client.get_webhook(owner, repo, webhook_id)
|
webhook = await client.get_webhook(owner, repo, webhook_id)
|
||||||
result = {"success": True, "webhook": webhook}
|
result = {"success": True, "webhook": webhook}
|
||||||
return json.dumps(result, indent=2)
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_webhook(
|
||||||
|
client: GiteaClient,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
webhook_id: int,
|
||||||
|
url: str | None = None,
|
||||||
|
events: list[str] | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
active: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Update a webhook"""
|
||||||
|
data: dict = {}
|
||||||
|
if events is not None:
|
||||||
|
data["events"] = events
|
||||||
|
if active is not None:
|
||||||
|
data["active"] = active
|
||||||
|
|
||||||
|
config: dict = {}
|
||||||
|
if url is not None:
|
||||||
|
config["url"] = url
|
||||||
|
if content_type is not None:
|
||||||
|
config["content_type"] = content_type
|
||||||
|
if secret is not None:
|
||||||
|
config["secret"] = secret
|
||||||
|
if config:
|
||||||
|
data["config"] = config
|
||||||
|
|
||||||
|
webhook = await client.update_webhook(owner, repo, webhook_id, data)
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Webhook {webhook_id} updated successfully",
|
||||||
|
"webhook": webhook,
|
||||||
|
}
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
try:
|
try:
|
||||||
health = await client.health_check()
|
health = await client.health_check()
|
||||||
info["health"] = health
|
info["health"] = health
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get current user to verify connectivity
|
# Get current user to verify connectivity
|
||||||
@@ -123,7 +123,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
"email": user.get("email"),
|
"email": user.get("email"),
|
||||||
"role": user.get("role"),
|
"role": user.get("role"),
|
||||||
}
|
}
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
info["instance_url"] = client.site_url
|
info["instance_url"] = client.site_url
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
|||||||
# Variable exists, update it
|
# Variable exists, update it
|
||||||
await client.update_variable(key, value)
|
await client.update_variable(key, value)
|
||||||
updated.append(key)
|
updated.append(key)
|
||||||
except:
|
except Exception:
|
||||||
# Variable doesn't exist, create it
|
# Variable doesn't exist, create it
|
||||||
await client.create_variable(key, value)
|
await client.create_variable(key, value)
|
||||||
created.append(key)
|
created.append(key)
|
||||||
|
|||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user