From 71b341ef77eadc0c5f8fe8b71e2d7187d843801e Mon Sep 17 00:00:00 2001 From: Juan Denis <13461850+jhd3197@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:58:34 -0400 Subject: [PATCH 01/87] Add plugin system (backend + frontend) Introduce a plugin installation and management system. Backend: add InstalledPlugin model, plugin_service with download/install/enable/disable/uninstall, dynamic blueprint registration and frontend manifest generation, and plugins API endpoints (list/get/install/enable/disable/uninstall). Register plugins blueprint and attempt to hot-load installed plugin blueprints at app startup. Frontend: add PluginLoader component (Vite import.meta.glob) to render plugin widgets, extend Marketplace UI to install/manage plugins, add API client methods for plugins, and update styles for the marketplace plugin UI. This enables installing plugins from GitHub/repos/zips and managing them from the app. --- backend/app/__init__.py | 12 + backend/app/api/plugins.py | 138 ++++++ backend/app/models/plugin.py | 89 ++++ backend/app/plugins/__init__.py | 0 backend/app/services/plugin_service.py | 482 ++++++++++++++++++++ frontend/src/layouts/DashboardLayout.jsx | 3 + frontend/src/pages/Marketplace.jsx | 102 ++++- frontend/src/plugins/PluginLoader.jsx | 77 ++++ frontend/src/services/api/index.js | 2 + frontend/src/services/api/plugins.js | 35 ++ frontend/src/styles/pages/_marketplace.scss | 39 ++ 11 files changed, 978 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/plugins.py create mode 100644 backend/app/models/plugin.py create mode 100644 backend/app/plugins/__init__.py create mode 100644 backend/app/services/plugin_service.py create mode 100644 frontend/src/plugins/PluginLoader.jsx create mode 100644 frontend/src/services/api/plugins.js diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 1abe149e..d4b7d856 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -276,6 +276,18 @@ def create_app(config_name=None): from app.api.marketplace import marketplace_bp app.register_blueprint(marketplace_bp, url_prefix='/api/v1/marketplace') + # Register blueprints - Plugins + from app.api.plugins import plugins_bp + app.register_blueprint(plugins_bp, url_prefix='/api/v1/plugins') + + # Load installed plugins (dynamic blueprints) + try: + from app.services.plugin_service import load_all_plugins + load_all_plugins(app) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f'Plugin loader: {e}') + # Handle database migrations (Alembic) with app.app_context(): from app.services.migration_service import MigrationService diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py new file mode 100644 index 00000000..df75c197 --- /dev/null +++ b/backend/app/api/plugins.py @@ -0,0 +1,138 @@ +""" +Plugins API - Install, manage, and uninstall ServerKit plugins. + +Supports installing plugins from: + - GitHub repo URLs (resolves latest release automatically) + - GitHub release URLs (specific version) + - Direct zip download URLs +""" +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required +from app.services.audit_service import AuditService +from app.models.audit_log import AuditLog + +plugins_bp = Blueprint('plugins', __name__) + + +def get_current_user(): + from flask_jwt_extended import get_jwt_identity + from app.models.user import User + return User.query.get(get_jwt_identity()) + + +@plugins_bp.route('/', methods=['GET']) +@jwt_required() +def list_plugins(): + """List all installed plugins.""" + from app.services.plugin_service import list_plugins + status = request.args.get('status') + plugins = list_plugins(status=status) + return jsonify({'plugins': [p.to_dict() for p in plugins]}) + + +@plugins_bp.route('/', methods=['GET']) +@jwt_required() +def get_plugin(plugin_id): + """Get details of an installed plugin.""" + from app.services.plugin_service import get_plugin + plugin = get_plugin(plugin_id) + if not plugin: + return jsonify({'error': 'Plugin not found'}), 404 + return jsonify(plugin.to_dict()) + + +@plugins_bp.route('/install', methods=['POST']) +@jwt_required() +def install_plugin(): + """Install a plugin from a URL. + + Body: { "url": "https://github.com/user/repo" } + + Accepts: + - GitHub repo URL (downloads latest release) + - GitHub release URL (specific version) + - Direct .zip URL + """ + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() + if not data or 'url' not in data: + return jsonify({'error': 'url required'}), 400 + + url = data['url'].strip() + if not url: + return jsonify({'error': 'url cannot be empty'}), 400 + + from app.services.plugin_service import install_from_url + try: + plugin = install_from_url(url, user_id=user.id) + AuditService.log( + action=AuditLog.ACTION_RESOURCE_CREATE, + user_id=user.id, + target_type='plugin', + target_id=plugin.id, + details={'name': plugin.name, 'version': plugin.version, 'url': url} + ) + return jsonify(plugin.to_dict()), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': f'Installation failed: {e}'}), 500 + + +@plugins_bp.route('/', methods=['DELETE']) +@jwt_required() +def uninstall_plugin(plugin_id): + """Uninstall a plugin (removes files and DB record).""" + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + from app.services.plugin_service import uninstall_plugin, get_plugin + plugin = get_plugin(plugin_id) + if not plugin: + return jsonify({'error': 'Plugin not found'}), 404 + + plugin_name = plugin.name + uninstall_plugin(plugin_id) + + AuditService.log( + action=AuditLog.ACTION_RESOURCE_DELETE, + user_id=user.id, + target_type='plugin', + target_id=plugin_id, + details={'name': plugin_name} + ) + return jsonify({'message': f'Plugin {plugin_name} uninstalled. Restart to fully unload backend routes.'}) + + +@plugins_bp.route('//enable', methods=['POST']) +@jwt_required() +def enable_plugin(plugin_id): + """Enable a disabled plugin.""" + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + from app.services.plugin_service import enable_plugin + plugin = enable_plugin(plugin_id) + if not plugin: + return jsonify({'error': 'Plugin not found'}), 404 + return jsonify(plugin.to_dict()) + + +@plugins_bp.route('//disable', methods=['POST']) +@jwt_required() +def disable_plugin(plugin_id): + """Disable a plugin without removing it.""" + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + from app.services.plugin_service import disable_plugin + plugin = disable_plugin(plugin_id) + if not plugin: + return jsonify({'error': 'Plugin not found'}), 404 + return jsonify(plugin.to_dict()) diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py new file mode 100644 index 00000000..b98b1170 --- /dev/null +++ b/backend/app/models/plugin.py @@ -0,0 +1,89 @@ +"""Installed plugin tracking model.""" +from datetime import datetime +from app import db +import json + + +class InstalledPlugin(db.Model): + """Tracks plugins installed from external sources (zips/URLs).""" + __tablename__ = 'installed_plugins' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False, unique=True) + display_name = db.Column(db.String(256), nullable=False) + slug = db.Column(db.String(128), nullable=False, unique=True) + version = db.Column(db.String(32), nullable=False) + description = db.Column(db.Text) + author = db.Column(db.String(128)) + homepage = db.Column(db.String(512)) + repository = db.Column(db.String(512)) + license = db.Column(db.String(64)) + category = db.Column(db.String(64)) + + # Where it came from + source_url = db.Column(db.String(1024)) + source_type = db.Column(db.String(32), default='url') # url, local, marketplace + + # Paths relative to ServerKit root + backend_path = db.Column(db.String(512)) # e.g. app/plugins/serverkit-ai + frontend_path = db.Column(db.String(512)) # e.g. src/plugins/serverkit-ai + + # Blueprint registration info from manifest + entry_point = db.Column(db.String(256)) # e.g. blueprint:ai_assistant_bp + url_prefix = db.Column(db.String(256)) # e.g. /api/v1/ai-assistant + + # Frontend entry from manifest + frontend_entry = db.Column(db.String(256)) # e.g. components/AiAssistant.jsx + + # Full manifest stored as JSON + manifest_json = db.Column(db.Text) + + # Status + STATUS_ACTIVE = 'active' + STATUS_DISABLED = 'disabled' + STATUS_ERROR = 'error' + STATUS_INSTALLING = 'installing' + status = db.Column(db.String(32), default=STATUS_INSTALLING) + error_message = db.Column(db.Text) + + # Has frontend component that needs rebuild + has_frontend = db.Column(db.Boolean, default=False) + # Has backend blueprint + has_backend = db.Column(db.Boolean, default=False) + + installed_by = db.Column(db.Integer, db.ForeignKey('users.id')) + installed_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + @property + def manifest(self): + return json.loads(self.manifest_json) if self.manifest_json else {} + + @manifest.setter + def manifest(self, v): + self.manifest_json = json.dumps(v) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'display_name': self.display_name, + 'slug': self.slug, + 'version': self.version, + 'description': self.description, + 'author': self.author, + 'homepage': self.homepage, + 'repository': self.repository, + 'license': self.license, + 'category': self.category, + 'source_url': self.source_url, + 'source_type': self.source_type, + 'entry_point': self.entry_point, + 'url_prefix': self.url_prefix, + 'has_frontend': self.has_frontend, + 'has_backend': self.has_backend, + 'status': self.status, + 'error_message': self.error_message, + 'installed_at': self.installed_at.isoformat() if self.installed_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/backend/app/plugins/__init__.py b/backend/app/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/plugin_service.py b/backend/app/services/plugin_service.py new file mode 100644 index 00000000..cf33fa4d --- /dev/null +++ b/backend/app/services/plugin_service.py @@ -0,0 +1,482 @@ +""" +Plugin Service - Download, install, and manage ServerKit plugins from URLs. + +Plugins are zip files containing a plugin.json manifest, optional backend/ +and frontend/ directories. They get extracted into the ServerKit plugins +directories and auto-registered at startup. +""" +import io +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import requests + +from app import db +from app.models.plugin import InstalledPlugin + +logger = logging.getLogger(__name__) + +# Resolve paths relative to the backend directory +# __file__ = backend/app/services/plugin_service.py +# _APP_DIR = backend/app/ +# _BACKEND_ROOT = backend/ +# _PROJECT_ROOT = ServerKit/ +_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_BACKEND_ROOT = os.path.dirname(_APP_DIR) +_PROJECT_ROOT = os.path.dirname(_BACKEND_ROOT) +BACKEND_PLUGINS_DIR = os.path.join(_APP_DIR, 'plugins') +FRONTEND_PLUGINS_DIR = os.path.join(_PROJECT_ROOT, 'frontend', 'src', 'plugins') + + +def _ensure_dirs(): + os.makedirs(BACKEND_PLUGINS_DIR, exist_ok=True) + os.makedirs(FRONTEND_PLUGINS_DIR, exist_ok=True) + # Ensure __init__.py exists in backend plugins dir + init_path = os.path.join(BACKEND_PLUGINS_DIR, '__init__.py') + if not os.path.exists(init_path): + with open(init_path, 'w') as f: + f.write('') + + +def _resolve_github_url(url): + """Convert a GitHub repo URL to the latest release zip download URL. + + Handles: + - https://github.com/user/repo -> latest release zip + - https://github.com/user/repo/releases/tag/v1.0.0 -> that release zip + - Direct zip URLs pass through unchanged + """ + if url.endswith('.zip'): + return url + + # Match github.com/owner/repo patterns + gh_match = re.match( + r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/releases/tag/([^/]+))?/?$', + url, + ) + if not gh_match: + return url + + owner, repo, tag = gh_match.groups() + + if tag: + # Specific release - get its assets + api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}' + else: + # Latest release + api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest' + + try: + resp = requests.get(api_url, timeout=15, headers={'Accept': 'application/vnd.github+json'}) + resp.raise_for_status() + release = resp.json() + + # Look for a .zip asset (prefer plugin zip over source) + for asset in release.get('assets', []): + if asset['name'].endswith('.zip'): + return asset['browser_download_url'] + + # Fallback to source zipball + return release.get('zipball_url', url) + except Exception as e: + logger.warning(f'Could not resolve GitHub release URL: {e}') + # Fallback: try the zipball endpoint directly + if tag: + return f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' + return f'https://api.github.com/repos/{owner}/{repo}/zipball' + + +def _download_zip(url): + """Download a zip file from URL and return bytes.""" + resolved = _resolve_github_url(url) + logger.info(f'Downloading plugin from: {resolved}') + resp = requests.get(resolved, timeout=120, stream=True, headers={ + 'Accept': 'application/octet-stream', + 'User-Agent': 'ServerKit-Plugin-Installer/1.0', + }) + resp.raise_for_status() + + buf = io.BytesIO() + for chunk in resp.iter_content(chunk_size=8192): + buf.write(chunk) + buf.seek(0) + return buf + + +def _find_manifest(zf): + """Find plugin.json inside the zip, handling nested directories (GitHub zipball nesting).""" + for name in zf.namelist(): + basename = os.path.basename(name) + if basename == 'plugin.json': + # Return the directory prefix so we can strip it + prefix = name[: -len('plugin.json')] + return name, prefix + return None, None + + +def _validate_manifest(manifest): + """Validate required fields in plugin manifest.""" + required = ['name', 'display_name', 'version'] + missing = [f for f in required if f not in manifest] + if missing: + raise ValueError(f"Manifest missing required fields: {', '.join(missing)}") + + # Sanitize the name for use as a directory + name = manifest['name'] + if not re.match(r'^[a-zA-Z0-9_-]+$', name): + raise ValueError(f"Plugin name must be alphanumeric/dashes/underscores: {name}") + + return True + + +def install_from_url(url, user_id=None): + """Download and install a plugin from a URL. + + Args: + url: GitHub repo URL, release URL, or direct zip URL + user_id: ID of the user performing the install + + Returns: + InstalledPlugin instance + """ + _ensure_dirs() + + # Download + try: + buf = _download_zip(url) + except Exception as e: + raise ValueError(f'Failed to download plugin: {e}') + + # Open zip + try: + zf = zipfile.ZipFile(buf) + except zipfile.BadZipFile: + raise ValueError('Downloaded file is not a valid zip archive') + + # Find and read manifest + manifest_path, prefix = _find_manifest(zf) + if not manifest_path: + raise ValueError('No plugin.json found in archive') + + manifest = json.loads(zf.read(manifest_path)) + _validate_manifest(manifest) + + slug = manifest['name'] + + # Check if already installed + existing = InstalledPlugin.query.filter_by(slug=slug).first() + if existing and existing.status in ('active', 'installing'): + raise ValueError(f"Plugin '{slug}' is already installed (v{existing.version}). Uninstall first to reinstall.") + + # Create DB record early so we can track errors + if existing: + plugin = existing + plugin.status = InstalledPlugin.STATUS_INSTALLING + plugin.error_message = None + plugin.version = manifest['version'] + plugin.source_url = url + plugin.manifest = manifest + else: + plugin = InstalledPlugin( + name=manifest['name'], + display_name=manifest['display_name'], + slug=slug, + version=manifest['version'], + description=manifest.get('description', ''), + author=manifest.get('author', ''), + homepage=manifest.get('homepage', ''), + repository=manifest.get('repository', ''), + license=manifest.get('license', ''), + category=manifest.get('category', 'utility'), + source_url=url, + source_type='url', + installed_by=user_id, + status=InstalledPlugin.STATUS_INSTALLING, + ) + plugin.manifest = manifest + db.session.add(plugin) + + db.session.commit() + + try: + # Extract backend files + backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug) + frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug) + + has_backend = False + has_frontend = False + + # Clean old install + if os.path.exists(backend_dest): + shutil.rmtree(backend_dest) + if os.path.exists(frontend_dest): + shutil.rmtree(frontend_dest) + + for member in zf.namelist(): + # Strip the GitHub zipball prefix + rel_path = member[len(prefix):] if prefix else member + if not rel_path or rel_path.endswith('/'): + continue + + if rel_path.startswith('backend/'): + has_backend = True + out_path = os.path.join(backend_dest, rel_path[len('backend/'):]) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with zf.open(member) as src, open(out_path, 'wb') as dst: + dst.write(src.read()) + + elif rel_path.startswith('frontend/'): + has_frontend = True + out_path = os.path.join(frontend_dest, rel_path[len('frontend/'):]) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with zf.open(member) as src, open(out_path, 'wb') as dst: + dst.write(src.read()) + + elif rel_path == 'requirements.txt': + # Install Python dependencies + req_content = zf.read(member).decode('utf-8') + _install_requirements(req_content, slug) + + # Also write the manifest into the backend plugin dir for runtime access + if has_backend: + manifest_out = os.path.join(backend_dest, 'plugin.json') + with open(manifest_out, 'w') as f: + json.dump(manifest, f, indent=2) + + # Also write manifest to frontend dir + if has_frontend: + manifest_out = os.path.join(frontend_dest, 'plugin.json') + with open(manifest_out, 'w') as f: + json.dump(manifest, f, indent=2) + + # Determine blueprint info from manifest + entry_point = manifest.get('entry_point', '') + url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}') + + plugin.has_backend = has_backend + plugin.has_frontend = has_frontend + plugin.backend_path = f'app/plugins/{slug}' if has_backend else None + plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None + plugin.entry_point = entry_point + plugin.url_prefix = url_prefix + plugin.frontend_entry = manifest.get('frontend_entry', '') + plugin.status = InstalledPlugin.STATUS_ACTIVE + db.session.commit() + + # Try to register the blueprint immediately (hot-load) + if has_backend and entry_point: + try: + _register_plugin_blueprint(plugin) + except Exception as e: + logger.warning(f'Blueprint hot-load failed for {slug} (will load on restart): {e}') + + # Regenerate frontend plugin manifest + if has_frontend: + _regenerate_frontend_manifest() + + logger.info(f'Plugin {slug} v{manifest["version"]} installed successfully') + return plugin + + except Exception as e: + plugin.status = InstalledPlugin.STATUS_ERROR + plugin.error_message = str(e) + db.session.commit() + raise + + +def _install_requirements(req_content, plugin_name): + """Install Python requirements for a plugin.""" + if not req_content.strip(): + return + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(req_content) + req_path = f.name + + try: + logger.info(f'Installing requirements for plugin {plugin_name}') + subprocess.check_call( + [sys.executable, '-m', 'pip', 'install', '-r', req_path, '--quiet'], + timeout=300, + ) + except subprocess.CalledProcessError as e: + logger.error(f'Failed to install requirements for {plugin_name}: {e}') + raise ValueError(f'Failed to install Python dependencies: {e}') + finally: + os.unlink(req_path) + + +def _register_plugin_blueprint(plugin): + """Dynamically register a plugin's Flask blueprint into the running app.""" + from flask import current_app + import importlib + + if not plugin.entry_point: + return + + # entry_point format: "blueprint:ai_assistant_bp" + parts = plugin.entry_point.split(':') + if len(parts) != 2: + raise ValueError(f'Invalid entry_point format: {plugin.entry_point}') + + module_name, bp_name = parts + full_module = f'app.plugins.{plugin.slug}.{module_name}' + + try: + mod = importlib.import_module(full_module) + bp = getattr(mod, bp_name) + current_app.register_blueprint(bp, url_prefix=plugin.url_prefix) + logger.info(f'Registered blueprint {bp_name} at {plugin.url_prefix}') + except Exception as e: + raise ValueError(f'Failed to register blueprint: {e}') + + +def _regenerate_frontend_manifest(): + """Generate a plugins-manifest.json for the frontend build system. + + This file tells the frontend which plugins are installed and where + their components/styles live so Vite can include them. + """ + manifest_path = os.path.join(FRONTEND_PLUGINS_DIR, 'plugins-manifest.json') + + plugins = InstalledPlugin.query.filter( + InstalledPlugin.has_frontend == True, + InstalledPlugin.status.in_(['active']), + ).all() + + entries = [] + for p in plugins: + entry = { + 'name': p.name, + 'slug': p.slug, + 'display_name': p.display_name, + 'version': p.version, + 'frontend_entry': p.frontend_entry, + 'path': p.slug, + } + # Check for styles + style_dir = os.path.join(FRONTEND_PLUGINS_DIR, p.slug, 'styles') + if os.path.isdir(style_dir): + styles = [ + f for f in os.listdir(style_dir) + if f.endswith('.scss') or f.endswith('.css') or f.endswith('.less') + ] + entry['styles'] = [f'plugins/{p.slug}/styles/{s}' for s in styles] + entries.append(entry) + + with open(manifest_path, 'w') as f: + json.dump({'plugins': entries}, f, indent=2) + + logger.info(f'Frontend plugin manifest regenerated with {len(entries)} plugin(s)') + + +def load_all_plugins(app): + """Load all active plugin blueprints at app startup. + + Called from create_app() to register all installed plugin blueprints. + """ + _ensure_dirs() + + with app.app_context(): + plugins = InstalledPlugin.query.filter_by( + status=InstalledPlugin.STATUS_ACTIVE, + has_backend=True, + ).all() + + for plugin in plugins: + if not plugin.entry_point: + continue + try: + parts = plugin.entry_point.split(':') + if len(parts) != 2: + continue + + module_name, bp_name = parts + full_module = f'app.plugins.{plugin.slug}.{module_name}' + + import importlib + mod = importlib.import_module(full_module) + bp = getattr(mod, bp_name) + app.register_blueprint(bp, url_prefix=plugin.url_prefix) + logger.info(f'Loaded plugin: {plugin.display_name} v{plugin.version} at {plugin.url_prefix}') + except Exception as e: + logger.error(f'Failed to load plugin {plugin.slug}: {e}') + plugin.status = InstalledPlugin.STATUS_ERROR + plugin.error_message = f'Failed to load: {e}' + db.session.commit() + + +def uninstall_plugin(plugin_id): + """Uninstall a plugin by removing its files and DB record.""" + plugin = InstalledPlugin.query.get(plugin_id) + if not plugin: + return False + + slug = plugin.slug + + # Remove backend files + backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug) + if os.path.exists(backend_dest): + shutil.rmtree(backend_dest) + + # Remove frontend files + frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug) + if os.path.exists(frontend_dest): + shutil.rmtree(frontend_dest) + + db.session.delete(plugin) + db.session.commit() + + # Regenerate frontend manifest + _regenerate_frontend_manifest() + + logger.info(f'Plugin {slug} uninstalled') + return True + + +def enable_plugin(plugin_id): + """Enable a disabled plugin.""" + plugin = InstalledPlugin.query.get(plugin_id) + if not plugin: + return None + plugin.status = InstalledPlugin.STATUS_ACTIVE + plugin.error_message = None + db.session.commit() + _regenerate_frontend_manifest() + return plugin + + +def disable_plugin(plugin_id): + """Disable a plugin without removing files.""" + plugin = InstalledPlugin.query.get(plugin_id) + if not plugin: + return None + plugin.status = InstalledPlugin.STATUS_DISABLED + db.session.commit() + _regenerate_frontend_manifest() + return plugin + + +def list_plugins(status=None): + """List installed plugins.""" + query = InstalledPlugin.query + if status: + query = query.filter_by(status=status) + return query.order_by(InstalledPlugin.display_name).all() + + +def get_plugin(plugin_id): + """Get a single plugin by ID.""" + return InstalledPlugin.query.get(plugin_id) + + +def get_plugin_by_slug(slug): + """Get a plugin by its slug.""" + return InstalledPlugin.query.filter_by(slug=slug).first() diff --git a/frontend/src/layouts/DashboardLayout.jsx b/frontend/src/layouts/DashboardLayout.jsx index ab00f66a..04d5342e 100644 --- a/frontend/src/layouts/DashboardLayout.jsx +++ b/frontend/src/layouts/DashboardLayout.jsx @@ -4,6 +4,8 @@ import Sidebar from '../components/Sidebar'; import CommandPalette from '../components/CommandPalette'; import LogsDrawer from '../components/LogsDrawer'; import { LogsDrawerProvider } from '../contexts/LogsDrawerContext'; +import PluginLoader from '../plugins/PluginLoader'; +import api from '../services/api'; const DashboardLayout = () => { const [paletteOpen, setPaletteOpen] = useState(false); @@ -29,6 +31,7 @@ const DashboardLayout = () => { setPaletteOpen(false)} /> + ); diff --git a/frontend/src/pages/Marketplace.jsx b/frontend/src/pages/Marketplace.jsx index 50660892..800869c2 100644 --- a/frontend/src/pages/Marketplace.jsx +++ b/frontend/src/pages/Marketplace.jsx @@ -9,23 +9,28 @@ const Marketplace = () => { const { user } = useAuth(); const [extensions, setExtensions] = useState([]); const [myExtensions, setMyExtensions] = useState([]); + const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [category, setCategory] = useState(''); const [tab, setTab] = useState('browse'); const [showSubmit, setShowSubmit] = useState(false); + const [pluginUrl, setPluginUrl] = useState(''); + const [installing, setInstalling] = useState(false); const [form, setForm] = useState({ name: '', display_name: '', description: '', category: 'utility', version: '1.0.0', author: '' }); const categories = ['monitoring', 'security', 'deployment', 'integration', 'ui', 'utility']; const loadExtensions = useCallback(async () => { try { - const [eData, mData] = await Promise.all([ + const [eData, mData, pData] = await Promise.all([ api.getMarketplaceExtensions(category, search), api.getMyExtensions(), + api.getInstalledPlugins().catch(() => ({ plugins: [] })), ]); setExtensions(eData.extensions || []); setMyExtensions(mData.extensions || []); + setPlugins(pData.plugins || []); } catch (err) { toast.error('Failed to load extensions'); } finally { @@ -51,6 +56,42 @@ const Marketplace = () => { } catch (err) { toast.error(err.message); } }; + const handlePluginInstall = async () => { + if (!pluginUrl.trim()) return; + setInstalling(true); + try { + const result = await api.installPlugin(pluginUrl.trim()); + toast.success(`Plugin "${result.display_name}" installed. Restart backend to activate routes.`); + setPluginUrl(''); + loadExtensions(); + } catch (err) { + toast.error(err.message || 'Plugin installation failed'); + } finally { + setInstalling(false); + } + }; + + const handlePluginUninstall = async (pluginId) => { + try { + await api.uninstallPlugin(pluginId); + toast.success('Plugin uninstalled'); + loadExtensions(); + } catch (err) { toast.error(err.message); } + }; + + const handlePluginToggle = async (plugin) => { + try { + if (plugin.status === 'active') { + await api.disablePlugin(plugin.id); + toast.success('Plugin disabled'); + } else { + await api.enablePlugin(plugin.id); + toast.success('Plugin enabled'); + } + loadExtensions(); + } catch (err) { toast.error(err.message); } + }; + const handleSubmit = async () => { try { await api.createMarketplaceExtension(form); @@ -84,6 +125,7 @@ const Marketplace = () => {
+
{tab === 'browse' && ( @@ -142,6 +184,64 @@ const Marketplace = () => { )} + {tab === 'plugins' && ( +
+
+

Install Plugin from URL

+

Paste a GitHub repo URL, release URL, or direct zip link.

+
+ setPluginUrl(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handlePluginInstall()} + disabled={installing} + /> + +
+
+ +
+ {plugins.map(plugin => ( +
+
+ {plugin.display_name} + v{plugin.version} + + {plugin.status} + + {plugin.has_backend && Backend} + {plugin.has_frontend && Frontend} +
+ {plugin.description &&

{plugin.description}

} + {plugin.error_message &&

{plugin.error_message}

} +
+ + +
+
+ ))} + {plugins.length === 0 && ( +
+

No plugins installed. Use the form above to install one from a URL.

+
+ )} +
+
+ )} + {showSubmit && (
setShowSubmit(false)}>
e.stopPropagation()}> diff --git a/frontend/src/plugins/PluginLoader.jsx b/frontend/src/plugins/PluginLoader.jsx new file mode 100644 index 00000000..34cc093e --- /dev/null +++ b/frontend/src/plugins/PluginLoader.jsx @@ -0,0 +1,77 @@ +/** + * PluginLoader - Dynamically loads installed plugin components. + * + * Uses Vite's import.meta.glob to discover plugin entry points at build time. + * Plugins installed via the plugin system are placed in src/plugins// + * and their components are auto-discovered here. + * + * Each plugin's index.js should export: + * - A default component (the widget/UI to render) + * - Optionally a Provider component for context wrapping + */ +import React, { Suspense, useMemo } from 'react'; + +// Vite glob import: discovers all plugin index.js files at build time +// Each returns { default: Component, Provider?: Component } +const pluginModules = import.meta.glob('./**/index.js', { eager: true }); + +/** + * Get all discovered plugins with their components. + */ +export function getInstalledPlugins() { + const plugins = []; + + for (const [path, mod] of Object.entries(pluginModules)) { + // path looks like "./serverkit-ai/index.js" + const match = path.match(/^\.\/([^/]+)\/index\.js$/); + if (!match) continue; + + const slug = match[1]; + // Skip internal files + if (slug === 'PluginLoader') continue; + + plugins.push({ + slug, + Component: mod.default || mod.AiAssistant || null, + Provider: mod.AiAssistantProvider || mod.Provider || null, + module: mod, + }); + } + + return plugins; +} + +/** + * Renders all installed plugin widgets. + * Wraps each in its Provider if one is exported. + * + * @param {object} props + * @param {object} props.api - The ApiService instance to pass to plugins + */ +const PluginLoader = ({ api }) => { + const plugins = useMemo(() => getInstalledPlugins(), []); + + if (plugins.length === 0) return null; + + return ( + <> + {plugins.map(({ slug, Component, Provider }) => { + if (!Component) return null; + + const widget = ; + + if (Provider) { + return ( + + {widget} + + ); + } + + return widget; + })} + + ); +}; + +export default PluginLoader; diff --git a/frontend/src/services/api/index.js b/frontend/src/services/api/index.js index 017f7a8d..eecbb3f7 100644 --- a/frontend/src/services/api/index.js +++ b/frontend/src/services/api/index.js @@ -9,6 +9,7 @@ import * as systemMethods from './system.js'; import * as securityMethods from './security.js'; import * as fileMethods from './files.js'; import * as dnsMethods from './dns.js'; +import * as pluginMethods from './plugins.js'; class ApiService extends ApiClient { constructor() { @@ -25,6 +26,7 @@ class ApiService extends ApiClient { securityMethods, fileMethods, dnsMethods, + pluginMethods, ]; for (const mod of modules) { for (const [key, fn] of Object.entries(mod)) { diff --git a/frontend/src/services/api/plugins.js b/frontend/src/services/api/plugins.js new file mode 100644 index 00000000..d8de32d0 --- /dev/null +++ b/frontend/src/services/api/plugins.js @@ -0,0 +1,35 @@ +// Plugin management API methods + +export async function getInstalledPlugins(status) { + const params = status ? `?status=${status}` : ''; + return this.request(`/plugins${params}`); +} + +export async function getPlugin(pluginId) { + return this.request(`/plugins/${pluginId}`); +} + +export async function installPlugin(url) { + return this.request('/plugins/install', { + method: 'POST', + body: JSON.stringify({ url }), + }); +} + +export async function uninstallPlugin(pluginId) { + return this.request(`/plugins/${pluginId}`, { + method: 'DELETE', + }); +} + +export async function enablePlugin(pluginId) { + return this.request(`/plugins/${pluginId}/enable`, { + method: 'POST', + }); +} + +export async function disablePlugin(pluginId) { + return this.request(`/plugins/${pluginId}/disable`, { + method: 'POST', + }); +} diff --git a/frontend/src/styles/pages/_marketplace.scss b/frontend/src/styles/pages/_marketplace.scss index 2bd63f80..be7958d5 100644 --- a/frontend/src/styles/pages/_marketplace.scss +++ b/frontend/src/styles/pages/_marketplace.scss @@ -74,11 +74,50 @@ justify-content: space-between; align-items: center; padding: $spacing-md; + flex-wrap: wrap; &__info { display: flex; align-items: baseline; gap: $spacing-sm; + flex-wrap: wrap; + } + + &__actions { + display: flex; + gap: $spacing-sm; + } + + &--error { + border-color: rgba(239, 68, 68, 0.3); + } + } + + // Plugin install section + .plugins-section { + margin-top: $spacing-lg; + } + + .plugin-install-form { + padding: $spacing-lg; + margin-bottom: $spacing-lg; + + h3 { + margin: 0 0 $space-1; + } + + p { + margin: 0 0 $spacing-md; + font-size: $font-size-sm; + } + } + + .plugin-install-row { + display: flex; + gap: $spacing-sm; + + .form-input { + flex: 1; } } } From 808078f10c66c8503bb49182340ede4d22a95256 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Mar 2026 19:58:44 +0000 Subject: [PATCH 02/87] chore: bump version to 1.4.14 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index acd81d7f..323afbcd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.13 +1.4.14 From 9c053c907dc255e7f627a26a59b5e57b18b5e4f5 Mon Sep 17 00:00:00 2001 From: Juan Denis <13461850+jhd3197@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:25:41 -0400 Subject: [PATCH 03/87] Add developer Style Guide page and styles Introduce a comprehensive Style Guide (frontend/src/pages/StyleGuide.jsx) as a developer-only design system reference, plus new SCSS modules (_style-guide.scss, _alerts.scss, _tables.scss) and updates to existing component styles. Register the page route and title in App.jsx and add a DEV-only sidebar link (import.meta.env.DEV) in Sidebar.jsx; also import SIDEBAR_ITEMS from sidebarItems. These changes centralize UI patterns, components and utilities for easier visual QA and consistent styling without exposing the guide in production builds. --- frontend/src/App.jsx | 3 + frontend/src/components/Sidebar.jsx | 19 +- frontend/src/pages/StyleGuide.jsx | 1351 +++++++++++++++++++ frontend/src/styles/components/_alerts.scss | 60 + frontend/src/styles/components/_build.scss | 30 - frontend/src/styles/components/_cards.scss | 26 +- frontend/src/styles/components/_deploy.scss | 6 +- frontend/src/styles/components/_tables.scss | 53 + frontend/src/styles/main.scss | 3 + frontend/src/styles/pages/_security.scss | 3 +- frontend/src/styles/pages/_settings.scss | 33 +- frontend/src/styles/pages/_style-guide.scss | 124 ++ 12 files changed, 1636 insertions(+), 75 deletions(-) create mode 100644 frontend/src/pages/StyleGuide.jsx create mode 100644 frontend/src/styles/components/_alerts.scss create mode 100644 frontend/src/styles/components/_tables.scss create mode 100644 frontend/src/styles/pages/_style-guide.scss diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 45fba18e..f590ce54 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -49,6 +49,7 @@ import DNSZones from './pages/DNSZones'; import StatusPages from './pages/StatusPages'; import CloudProvision from './pages/CloudProvision'; import Marketplace from './pages/Marketplace'; +import StyleGuide from './pages/StyleGuide'; // Page title mapping const PAGE_TITLES = { @@ -88,6 +89,7 @@ const PAGE_TITLES = { '/status-pages': 'Status Pages', '/cloud': 'Cloud Provisioning', '/marketplace': 'Marketplace', + '/style-guide': 'Style Guide', }; function PageTitleUpdater() { @@ -231,6 +233,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index ab6825ad..04713744 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -5,7 +5,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronDown, ChevronUp, Layers, Palette, PanelLeft, Check } from 'lucide-react'; import { api } from '../services/api'; import ServerKitLogo from './ServerKitLogo'; -import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getVisibleItems } from './sidebarItems'; +import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getVisibleItems, SIDEBAR_ITEMS } from './sidebarItems'; const Sidebar = () => { const { user, logout, updateUser } = useAuth(); @@ -240,6 +240,23 @@ const Sidebar = () => { })}
+ {import.meta.env.DEV && ( + <> +
Dev Tools
+ + + )} +
{menuOpen && (
diff --git a/frontend/src/pages/StyleGuide.jsx b/frontend/src/pages/StyleGuide.jsx new file mode 100644 index 00000000..cd7445b2 --- /dev/null +++ b/frontend/src/pages/StyleGuide.jsx @@ -0,0 +1,1351 @@ +import React, { useState } from 'react'; +import { + Palette, Type, Box, Layout, Square, ToggleLeft, AlertTriangle, + Info, CheckCircle, XCircle, Bell, Search, Plus, Trash2, Edit3, + Download, Upload, RefreshCw, Settings, Eye, EyeOff, Copy, Star, + ChevronDown, ChevronRight, ExternalLink, Server, Database, Globe, + Shield, Lock, Zap, Activity, BarChart3, Cloud, Terminal, Layers, + Inbox, Table, AlertCircle, FileText, Monitor, Key, FolderOpen, + GitBranch, Package, HardDrive, Wifi, WifiOff +} from 'lucide-react'; +import Modal from '../components/Modal'; +import { ConfirmDialog } from '../components/ConfirmDialog'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import { Spinner } from '../components/Spinner'; + +export default function StyleGuide() { + const [activeSection, setActiveSection] = useState('colors'); + const [modalOpen, setModalOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmVariant, setConfirmVariant] = useState('danger'); + const [tabActive, setTabActive] = useState('tab1'); + const [inputValue, setInputValue] = useState(''); + const [selectValue, setSelectValue] = useState(''); + const [checkValue, setCheckValue] = useState(false); + + const sections = [ + { id: 'colors', label: 'Colors', icon: Palette }, + { id: 'typography', label: 'Typography', icon: Type }, + { id: 'spacing', label: 'Spacing & Radius', icon: Box }, + { id: 'buttons', label: 'Buttons', icon: Square }, + { id: 'forms', label: 'Forms', icon: ToggleLeft }, + { id: 'tables', label: 'Tables', icon: Table }, + { id: 'cards', label: 'Cards & Stats', icon: Layout }, + { id: 'badges', label: 'Badges & Status', icon: Shield }, + { id: 'alerts', label: 'Alerts & Errors', icon: AlertCircle }, + { id: 'modals', label: 'Modals & Dialogs', icon: Layers }, + { id: 'tabs', label: 'Tabs', icon: ChevronRight }, + { id: 'lists', label: 'Lists & Info', icon: Database }, + { id: 'feedback', label: 'Feedback & Loading', icon: Activity }, + { id: 'empty', label: 'Empty States', icon: Inbox }, + { id: 'pageheaders', label: 'Page Headers', icon: FileText }, + { id: 'patterns', label: 'Page Patterns', icon: Monitor }, + { id: 'utilities', label: 'Utilities', icon: Zap }, + ]; + + return ( +
+
+
+

Style Guide

+

Design system reference — dev only

+
+
+ +
+ {sections.map(s => ( + + ))} +
+ +
+ + {/* ── COLORS ── */} + {activeSection === 'colors' && ( +
+ +
+ + + + + + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + + + + +
+ + +
+ + + + + + +
+
+ )} + + {/* ── TYPOGRAPHY ── */} + {activeSection === 'typography' && ( +
+ +
+

+ $font-main:
+ The quick brown fox jumps over the lazy dog — Inter +

+

+ $font-mono:
+ {'const server = createApp(); // JetBrains Mono'} +

+
+ + +
+ {[ + ['$font-size-xs', '10px'], ['$font-size-sm', '12px'], + ['$font-size-base', '14px'], ['$font-size-md', '16px'], + ['$font-size-lg', '18px'], ['$font-size-xl', '20px'], + ['$font-size-2xl', '24px'], ['$font-size-3xl', '30px'], + ].map(([token, size]) => ( +
+ {token} + {size} — The quick brown fox +
+ ))} +
+ + +
+ {[['Normal (400)', 400], ['Medium (500)', 500], ['Semibold (600)', 600], ['Bold (700)', 700]].map(([label, weight]) => ( +

+ {label} — The quick brown fox jumps over the lazy dog +

+ ))} +
+ + +
+

h1 — Page Title

+

h2 — Section Title

+

h3 — Card Title

+

h4 — Subsection

+
h5 — Minor heading
+

p — Body text paragraph with normal weight and base font size.

+

p.text-secondary — Secondary paragraph text.

+

p.text-tertiary — Tertiary/muted paragraph text.

+
+ + +
+

.text-primary

+

.text-secondary

+

.text-tertiary

+

.text-success

+

.text-warning

+

.text-danger

+

.text-accent

+
+
+ )} + + {/* ── SPACING & RADIUS ── */} + {activeSection === 'spacing' && ( +
+ +
+ {[ + ['$space-1', 4], ['$space-2', 8], ['$space-3', 12], ['$space-4', 16], + ['$space-5', 20], ['$space-6', 24], ['$space-8', 32], ['$space-10', 40], + ['$space-12', 48], ['$space-16', 64], + ].map(([token, px]) => ( +
+ {token} + {px}px +
+
+ ))} +
+ + +
+ {[ + ['$radius-sm', '4px'], ['$radius-md', '6px'], ['$radius-lg', '8px'], + ['$radius-xl', '12px'], ['$radius-2xl', '16px'], ['$radius-full', '9999px'], + ].map(([token, val]) => ( +
+
+ {token} + {val} +
+ ))} +
+ + +
+ {['sm', 'md', 'lg'].map(size => ( +
+
+ $shadow-{size} +
+ ))} +
+
+ )} + + {/* ── BUTTONS ── */} + {activeSection === 'buttons' && ( +
+ +
+
+ + + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + + + + +
+
+ + +
+ +
+ + +
+
+
+ )} + + {/* ── FORMS ── */} + {activeSection === 'forms' && ( +
+ +
+
+ + setInputValue(e.target.value)} /> + This is a hint text below the input +
+
+ + +
+
+ + +
+
+ + +
+
+ +