feat: full platform with dynamic Composio sync, local DB cache, React UI
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +18 -0
- .gitattributes +0 -35
- .gitignore +7 -0
- Dockerfile +27 -20
- README.md +127 -10
- backend/auth.py +62 -0
- backend/database.py +123 -0
- backend/integrations/__init__.py +1 -0
- backend/integrations/registry.py +361 -0
- backend/main.py +66 -0
- backend/requirements.txt +16 -0
- backend/routers/__init__.py +1 -0
- backend/routers/analytics.py +93 -0
- backend/routers/apikeys.py +112 -0
- backend/routers/apps.py +131 -0
- backend/routers/auth.py +107 -0
- backend/routers/connections.py +204 -0
- backend/routers/mcp.py +123 -0
- backend/routers/playground.py +75 -0
- backend/routers/settings.py +66 -0
- backend/routers/tools.py +171 -0
- backend/services/tool_executor.py +132 -0
- backend/static/assets/index-CIpcBIY5.js +0 -0
- backend/static/assets/index-DVvq7QG0.css +1 -0
- backend/static/index.html +17 -0
- backend/vault.py +47 -0
- entrypoint.sh +47 -9
- frontend/index.html +16 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +32 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +44 -0
- frontend/src/components/layout/Layout.tsx +17 -0
- frontend/src/components/layout/Sidebar.tsx +109 -0
- frontend/src/components/layout/TopBar.tsx +27 -0
- frontend/src/index.css +76 -0
- frontend/src/lib/api.ts +30 -0
- frontend/src/main.tsx +10 -0
- frontend/src/pages/APIKeys.tsx +163 -0
- frontend/src/pages/Analytics.tsx +117 -0
- frontend/src/pages/AppDetail.tsx +217 -0
- frontend/src/pages/Apps.tsx +122 -0
- frontend/src/pages/Connections.tsx +132 -0
- frontend/src/pages/Dashboard.tsx +188 -0
- frontend/src/pages/Login.tsx +119 -0
- frontend/src/pages/Playground.tsx +107 -0
- frontend/src/pages/Settings.tsx +137 -0
- frontend/src/pages/Tools.tsx +100 -0
- frontend/src/stores/authStore.ts +54 -0
- frontend/tailwind.config.ts +23 -0
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Platform Configuration
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# JWT Secret Key (change this in production!)
|
| 5 |
+
SECRET_KEY=your-secret-key-change-in-production
|
| 6 |
+
|
| 7 |
+
# Composio API Key (required for dynamic tool sync and execution)
|
| 8 |
+
# Get your API key from: https://app.composio.dev/dashboard
|
| 9 |
+
COMPOSIO_API_KEY=sk_your_composio_api_key_here
|
| 10 |
+
|
| 11 |
+
# Composio API Base URL (optional, defaults to production)
|
| 12 |
+
COMPOSIO_API_BASE=https://backend.composio.dev/api/v3.1
|
| 13 |
+
|
| 14 |
+
# Cache TTL in seconds (default: 3600 = 1 hour)
|
| 15 |
+
CACHE_TTL=3600
|
| 16 |
+
|
| 17 |
+
# Data directory (default: /data for HF Spaces)
|
| 18 |
+
DATA_DIR=/data
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
dist
|
| 6 |
+
data
|
| 7 |
+
*.db
|
Dockerfile
CHANGED
|
@@ -1,28 +1,35 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
-
RUN useradd -
|
| 13 |
-
USER user
|
| 14 |
-
ENV HOME=/home/user \
|
| 15 |
-
PATH=/home/user/.local/bin:$PATH
|
| 16 |
|
| 17 |
-
WORKDIR
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
-
COPY
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
EXPOSE 7860
|
| 28 |
-
|
|
|
|
|
|
| 1 |
+
# Stage 1: Frontend Builder
|
| 2 |
+
FROM node:20-alpine AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
|
| 5 |
+
RUN corepack enable && corepack prepare pnpm@latest --activate
|
| 6 |
+
RUN pnpm install --frozen-lockfile
|
| 7 |
+
COPY frontend/ ./
|
| 8 |
+
RUN pnpm run build
|
| 9 |
+
|
| 10 |
+
# Stage 2: Final Python Image
|
| 11 |
+
FROM python:3.11.9-slim-bookworm AS final
|
| 12 |
+
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
curl git gcc libffi-dev libssl-dev ca-certificates \
|
| 15 |
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
|
| 17 |
+
RUN groupadd -g 1000 appuser && useradd -r -u 1000 -g appuser appuser
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
COPY backend/requirements.txt ./backend/requirements.txt
|
| 21 |
+
RUN pip install --no-cache-dir -r ./backend/requirements.txt
|
| 22 |
|
| 23 |
+
COPY backend/ ./backend/
|
| 24 |
+
COPY --from=frontend-builder /app/frontend/dist ./backend/static
|
| 25 |
|
| 26 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 27 |
+
RUN chmod +x /entrypoint.sh
|
| 28 |
|
| 29 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 30 |
+
ENV PYTHONUNBUFFERED=1
|
| 31 |
+
ENV DATA_DIR=/data
|
| 32 |
|
| 33 |
EXPOSE 7860
|
| 34 |
+
USER appuser
|
| 35 |
+
CMD ["/entrypoint.sh"]
|
README.md
CHANGED
|
@@ -1,10 +1,127 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Platform - AI Tool Integration Platform
|
| 2 |
+
|
| 3 |
+
A self-hosted AI tool integration platform that dynamically fetches **1000+ tools** from the Composio API. No hardcoded tool definitions - everything is fetched live and cached locally.
|
| 4 |
+
|
| 5 |
+
## How It Works
|
| 6 |
+
|
| 7 |
+
Instead of hardcoding tools, this platform:
|
| 8 |
+
|
| 9 |
+
1. **Fetches toolkits and tools from Composio's public API** (`https://backend.composio.dev/api/v3.1`)
|
| 10 |
+
2. **Caches them locally** in SQLite with configurable TTL (default: 1 hour)
|
| 11 |
+
3. **Executes tools via Composio API** with proper credential management
|
| 12 |
+
4. **Supports on-demand sync** via API endpoints or UI
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
|
| 16 |
+
- **1000+ Tools** - Dynamically fetched from Composio API (GitHub, Gmail, Slack, Notion, Stripe, etc.)
|
| 17 |
+
- **Dynamic Sync** - Tools are automatically synced at startup and can be refreshed on-demand
|
| 18 |
+
- **Local Cache** - Tools are cached locally with configurable TTL for offline access
|
| 19 |
+
- **MCP Server** - Full Model Context Protocol implementation
|
| 20 |
+
- **Tool Execution Engine** - Execute tools with proper credential injection via Composio API
|
| 21 |
+
- **OAuth + API Key Vault** - Secure credential storage with AES-256-GCM encryption
|
| 22 |
+
- **Analytics** - Usage metrics and performance insights
|
| 23 |
+
- **Professional UI** - Clean dark theme (not the typical purple AI theme)
|
| 24 |
+
|
| 25 |
+
## Quick Start
|
| 26 |
+
|
| 27 |
+
### 1. Get a Composio API Key
|
| 28 |
+
|
| 29 |
+
Sign up at [app.composio.dev](https://app.composio.dev/dashboard) and create an API key.
|
| 30 |
+
|
| 31 |
+
### 2. Configure Environment
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
cp .env.example .env
|
| 35 |
+
# Edit .env and add your COMPOSIO_API_KEY
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### 3. Build and Run
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
# Using Docker
|
| 42 |
+
docker build -t platform .
|
| 43 |
+
docker run -p 7860:7860 -v /path/to/data:/data --env-file .env platform
|
| 44 |
+
|
| 45 |
+
# Or directly
|
| 46 |
+
cd backend && pip install -r requirements.txt
|
| 47 |
+
export COMPOSIO_API_KEY=sk_your_key_here
|
| 48 |
+
python -c "from database import init_db; init_db()"
|
| 49 |
+
python integrations/registry.py # Syncs from Composio API
|
| 50 |
+
uvicorn main:app --reload
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## API Endpoints
|
| 54 |
+
|
| 55 |
+
### Sync Tools On-Demand
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
# Sync integrations
|
| 59 |
+
curl -X POST http://localhost:7860/api/apps/sync \
|
| 60 |
+
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
| 61 |
+
|
| 62 |
+
# Sync tools
|
| 63 |
+
curl -X POST http://localhost:7860/api/tools/sync \
|
| 64 |
+
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### Execute Tools
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
curl -X POST http://localhost:7860/api/tools/github_create_issue/execute \
|
| 71 |
+
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
| 72 |
+
-H "Content-Type: application/json" \
|
| 73 |
+
-d '{
|
| 74 |
+
"params": {
|
| 75 |
+
"owner": "myorg",
|
| 76 |
+
"repo": "myrepo",
|
| 77 |
+
"title": "Bug report"
|
| 78 |
+
}
|
| 79 |
+
}'
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Composio API Endpoints Used
|
| 83 |
+
|
| 84 |
+
| Endpoint | Purpose |
|
| 85 |
+
|----------|---------|
|
| 86 |
+
| `GET /api/v3.1/toolkits` | List all available toolkits (integrations) |
|
| 87 |
+
| `GET /api/v3.1/tools` | List all available tools |
|
| 88 |
+
| `POST /api/v3.1/tools/execute/{slug}` | Execute a tool |
|
| 89 |
+
|
| 90 |
+
## Architecture
|
| 91 |
+
|
| 92 |
+
```
|
| 93 |
+
┌─────────────────────────────────────────────────────────┐
|
| 94 |
+
│ Platform Platform │
|
| 95 |
+
│ │
|
| 96 |
+
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
|
| 97 |
+
│ │ React UI │◄──►│ FastAPI │◄──►│ SQLite │ │
|
| 98 |
+
│ │ (Vite) │ │ Backend │ │ Database │ │
|
| 99 |
+
│ └─────────────┘ └──────┬───────┘ └────────────┘ │
|
| 100 |
+
│ │ │
|
| 101 |
+
│ ▼ │
|
| 102 |
+
│ ┌─────────────────────────┐ │
|
| 103 |
+
│ │ Composio API │ │
|
| 104 |
+
│ │ backend.composio.dev │ │
|
| 105 |
+
│ │ /api/v3.1/toolkits │ │
|
| 106 |
+
│ │ /api/v3.1/tools │ │
|
| 107 |
+
│ │ /api/v3.1/tools/execute│ │
|
| 108 |
+
│ └─────────────────────────┘ │
|
| 109 |
+
└─────────────────────────────────────────────────────────┘
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## Default Credentials
|
| 113 |
+
|
| 114 |
+
- Email: `admin@platform.local`
|
| 115 |
+
- Password: `admin123`
|
| 116 |
+
|
| 117 |
+
## Tech Stack
|
| 118 |
+
|
| 119 |
+
- **Frontend**: React, TypeScript, Vite, Tailwind CSS, Zustand, Recharts
|
| 120 |
+
- **Backend**: Python, FastAPI, SQLAlchemy, httpx, Pydantic
|
| 121 |
+
- **Database**: SQLite with local caching
|
| 122 |
+
- **Auth**: JWT with bcrypt password hashing
|
| 123 |
+
- **Encryption**: AES-256-GCM for credential vault
|
| 124 |
+
|
| 125 |
+
## License
|
| 126 |
+
|
| 127 |
+
MIT
|
backend/auth.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import Depends, HTTPException, status
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from jose import JWTError, jwt
|
| 7 |
+
from passlib.context import CryptContext
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from database import get_db, User
|
| 10 |
+
|
| 11 |
+
SECRET_KEY = os.environ.get("SECRET_KEY", "platform-secret-key-change-in-production-2024")
|
| 12 |
+
ALGORITHM = "HS256"
|
| 13 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
| 14 |
+
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
| 15 |
+
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
security = HTTPBearer()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 21 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_password_hash(password: str) -> str:
|
| 25 |
+
return pwd_context.hash(password)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 29 |
+
to_encode = data.copy()
|
| 30 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
| 31 |
+
to_encode.update({"exp": expire, "type": "access"})
|
| 32 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def create_refresh_token(data: dict) -> str:
|
| 36 |
+
to_encode = data.copy()
|
| 37 |
+
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 38 |
+
to_encode.update({"exp": expire, "type": "refresh"})
|
| 39 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def decode_token(token: str) -> dict:
|
| 43 |
+
try:
|
| 44 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 45 |
+
return payload
|
| 46 |
+
except JWTError:
|
| 47 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def get_current_user(
|
| 51 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 52 |
+
db: Session = Depends(get_db)
|
| 53 |
+
) -> User:
|
| 54 |
+
token = credentials.credentials
|
| 55 |
+
payload = decode_token(token)
|
| 56 |
+
user_id = payload.get("sub")
|
| 57 |
+
if not user_id:
|
| 58 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
| 59 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 60 |
+
if not user or not user.is_active:
|
| 61 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
| 62 |
+
return user
|
backend/database.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from sqlalchemy import create_engine, Column, String, Boolean, DateTime, Text, Integer, ForeignKey, JSON
|
| 5 |
+
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
|
| 8 |
+
Base = declarative_base()
|
| 9 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 10 |
+
|
| 11 |
+
DATABASE_PATH = os.environ.get("DATA_DIR", "/data") + "/platform.db"
|
| 12 |
+
engine = create_engine(f"sqlite:///{DATABASE_PATH}", connect_args={"check_same_thread": False})
|
| 13 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class User(Base):
|
| 17 |
+
__tablename__ = "users"
|
| 18 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 19 |
+
email = Column(String, unique=True, nullable=False)
|
| 20 |
+
hashed_password = Column(String, nullable=False)
|
| 21 |
+
is_active = Column(Boolean, default=True)
|
| 22 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 23 |
+
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
| 24 |
+
connections = relationship("Connection", back_populates="user", cascade="all, delete-orphan")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class APIKey(Base):
|
| 28 |
+
__tablename__ = "api_keys"
|
| 29 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 30 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 31 |
+
key_hash = Column(String, nullable=False, index=True)
|
| 32 |
+
name = Column(String, nullable=False)
|
| 33 |
+
last_used = Column(DateTime, nullable=True)
|
| 34 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 35 |
+
is_active = Column(Boolean, default=True)
|
| 36 |
+
user = relationship("User", back_populates="api_keys")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Integration(Base):
|
| 40 |
+
__tablename__ = "integrations"
|
| 41 |
+
id = Column(String, primary_key=True)
|
| 42 |
+
name = Column(String, nullable=False)
|
| 43 |
+
description = Column(String, nullable=False)
|
| 44 |
+
logo_url = Column(String, nullable=False)
|
| 45 |
+
category = Column(String, nullable=False)
|
| 46 |
+
auth_type = Column(String, nullable=False)
|
| 47 |
+
oauth_scopes = Column(JSON, nullable=True)
|
| 48 |
+
is_active = Column(Boolean, default=True)
|
| 49 |
+
tool_count = Column(Integer, default=0)
|
| 50 |
+
trigger_count = Column(Integer, default=0)
|
| 51 |
+
tools = relationship("Tool", back_populates="integration", cascade="all, delete-orphan")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Connection(Base):
|
| 55 |
+
__tablename__ = "connections"
|
| 56 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 57 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 58 |
+
integration_id = Column(String, ForeignKey("integrations.id"), nullable=False)
|
| 59 |
+
account_label = Column(String, nullable=True)
|
| 60 |
+
status = Column(String, default="active")
|
| 61 |
+
encrypted_credentials = Column(Text, nullable=False)
|
| 62 |
+
connected_at = Column(DateTime, default=datetime.utcnow)
|
| 63 |
+
last_used = Column(DateTime, nullable=True)
|
| 64 |
+
metadata = Column(JSON, nullable=True)
|
| 65 |
+
user = relationship("User", back_populates="connections")
|
| 66 |
+
integration = relationship("Integration")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class Tool(Base):
|
| 70 |
+
__tablename__ = "tools"
|
| 71 |
+
id = Column(String, primary_key=True)
|
| 72 |
+
integration_id = Column(String, ForeignKey("integrations.id"), nullable=False)
|
| 73 |
+
name = Column(String, nullable=False)
|
| 74 |
+
description = Column(String, nullable=False)
|
| 75 |
+
input_schema = Column(JSON, nullable=False)
|
| 76 |
+
output_schema = Column(JSON, nullable=True)
|
| 77 |
+
category = Column(String, nullable=True)
|
| 78 |
+
is_active = Column(Boolean, default=True)
|
| 79 |
+
integration = relationship("Integration", back_populates="tools")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ToolExecution(Base):
|
| 83 |
+
__tablename__ = "tool_executions"
|
| 84 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 85 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 86 |
+
tool_id = Column(String, ForeignKey("tools.id"), nullable=False)
|
| 87 |
+
connection_id = Column(String, ForeignKey("connections.id"), nullable=True)
|
| 88 |
+
input_params = Column(JSON, nullable=True)
|
| 89 |
+
output_result = Column(JSON, nullable=True)
|
| 90 |
+
status = Column(String, default="success")
|
| 91 |
+
latency_ms = Column(Integer, default=0)
|
| 92 |
+
error_message = Column(Text, nullable=True)
|
| 93 |
+
executed_at = Column(DateTime, default=datetime.utcnow)
|
| 94 |
+
source = Column(String, default="api")
|
| 95 |
+
user = relationship("User")
|
| 96 |
+
tool = relationship("Tool")
|
| 97 |
+
connection = relationship("Connection")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def get_db():
|
| 101 |
+
db = SessionLocal()
|
| 102 |
+
try:
|
| 103 |
+
yield db
|
| 104 |
+
finally:
|
| 105 |
+
db.close()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def init_db():
|
| 109 |
+
Base.metadata.create_all(bind=engine)
|
| 110 |
+
db = SessionLocal()
|
| 111 |
+
try:
|
| 112 |
+
existing = db.query(User).filter(User.email == "admin@platform.local").first()
|
| 113 |
+
if not existing:
|
| 114 |
+
admin = User(
|
| 115 |
+
id=str(uuid4()),
|
| 116 |
+
email="admin@platform.local",
|
| 117 |
+
hashed_password=pwd_context.hash("admin123"),
|
| 118 |
+
is_active=True
|
| 119 |
+
)
|
| 120 |
+
db.add(admin)
|
| 121 |
+
db.commit()
|
| 122 |
+
finally:
|
| 123 |
+
db.close()
|
backend/integrations/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Integration registry package
|
backend/integrations/registry.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
import httpx
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Optional, List, Dict, Any
|
| 7 |
+
from database import SessionLocal, Integration, Tool
|
| 8 |
+
|
| 9 |
+
COMPOSIO_API_BASE = os.environ.get("COMPOSIO_API_BASE", "https://backend.composio.dev/api/v3.1")
|
| 10 |
+
COMPOSIO_API_KEY = os.environ.get("COMPOSIO_API_KEY", "")
|
| 11 |
+
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
| 12 |
+
CACHE_FILE = f"{DATA_DIR}/composio_cache.json"
|
| 13 |
+
CACHE_TTL = int(os.environ.get("CACHE_TTL", "86400")) # 24 hours default
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class IntegrationDef:
|
| 18 |
+
id: str
|
| 19 |
+
name: str
|
| 20 |
+
description: str
|
| 21 |
+
logo_url: str
|
| 22 |
+
category: str
|
| 23 |
+
auth_type: str
|
| 24 |
+
oauth_scopes: Optional[List[str]] = None
|
| 25 |
+
tool_count: int = 0
|
| 26 |
+
trigger_count: int = 0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ComposioClient:
|
| 30 |
+
"""Client that fetches toolkits and tools from Composio API."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, api_key: str = "", base_url: str = COMPOSIO_API_BASE):
|
| 33 |
+
self.api_key = api_key
|
| 34 |
+
self.base_url = base_url.rstrip("/")
|
| 35 |
+
self._headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
| 36 |
+
|
| 37 |
+
async def _get(self, path: str, params: dict = None) -> dict:
|
| 38 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 39 |
+
resp = await client.get(
|
| 40 |
+
f"{self.base_url}{path}",
|
| 41 |
+
headers=self._headers,
|
| 42 |
+
params=params
|
| 43 |
+
)
|
| 44 |
+
resp.raise_for_status()
|
| 45 |
+
return resp.json()
|
| 46 |
+
|
| 47 |
+
async def list_toolkits(self, limit: int = 1000) -> List[dict]:
|
| 48 |
+
"""Fetch all available toolkits from Composio API."""
|
| 49 |
+
all_items = []
|
| 50 |
+
cursor = None
|
| 51 |
+
while True:
|
| 52 |
+
params = {"limit": min(limit, 1000)}
|
| 53 |
+
if cursor:
|
| 54 |
+
params["cursor"] = cursor
|
| 55 |
+
data = await self._get("/toolkits", params)
|
| 56 |
+
items = data.get("items", [])
|
| 57 |
+
all_items.extend(items)
|
| 58 |
+
cursor = data.get("next_cursor")
|
| 59 |
+
if not cursor or len(all_items) >= limit:
|
| 60 |
+
break
|
| 61 |
+
return all_items
|
| 62 |
+
|
| 63 |
+
async def list_tools(self, toolkit_slug: str = None, limit: int = 10000) -> List[dict]:
|
| 64 |
+
"""Fetch all available tools from Composio API."""
|
| 65 |
+
all_items = []
|
| 66 |
+
cursor = None
|
| 67 |
+
while True:
|
| 68 |
+
params = {"limit": min(limit, 10000)}
|
| 69 |
+
if toolkit_slug:
|
| 70 |
+
params["toolkit_slug"] = toolkit_slug
|
| 71 |
+
if cursor:
|
| 72 |
+
params["cursor"] = cursor
|
| 73 |
+
data = await self._get("/tools", params)
|
| 74 |
+
items = data.get("items", [])
|
| 75 |
+
all_items.extend(items)
|
| 76 |
+
cursor = data.get("next_cursor")
|
| 77 |
+
if not cursor or len(all_items) >= limit:
|
| 78 |
+
break
|
| 79 |
+
return all_items
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _normalize_auth_type(auth_schemes: List[str]) -> str:
|
| 83 |
+
"""Map Composio auth schemes to our auth types."""
|
| 84 |
+
if not auth_schemes:
|
| 85 |
+
return "api_key"
|
| 86 |
+
schemes_lower = [s.lower() for s in auth_schemes]
|
| 87 |
+
if "oauth2" in schemes_lower or "oauth" in schemes_lower:
|
| 88 |
+
return "oauth2"
|
| 89 |
+
if "api_key" in schemes_lower:
|
| 90 |
+
return "api_key"
|
| 91 |
+
if "bearer_token" in schemes_lower:
|
| 92 |
+
return "bearer"
|
| 93 |
+
if "basic" in schemes_lower:
|
| 94 |
+
return "basic"
|
| 95 |
+
return "api_key"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _map_toolkit_to_integration(toolkit: dict) -> IntegrationDef:
|
| 99 |
+
"""Map a Composio toolkit to our Integration definition."""
|
| 100 |
+
meta = toolkit.get("meta", {})
|
| 101 |
+
categories = meta.get("categories", [])
|
| 102 |
+
category = categories[0].get("name", "Other") if categories else "Other"
|
| 103 |
+
|
| 104 |
+
return IntegrationDef(
|
| 105 |
+
id=toolkit["slug"],
|
| 106 |
+
name=toolkit["name"],
|
| 107 |
+
description=meta.get("description", ""),
|
| 108 |
+
logo_url=meta.get("logo", ""),
|
| 109 |
+
category=category,
|
| 110 |
+
auth_type=_normalize_auth_type(toolkit.get("auth_schemes", [])),
|
| 111 |
+
tool_count=meta.get("tools_count", 0),
|
| 112 |
+
trigger_count=meta.get("triggers_count", 0),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _map_tool_to_db(tool: dict) -> dict:
|
| 117 |
+
"""Map a Composio tool to our Tool definition."""
|
| 118 |
+
toolkit = tool.get("toolkit", {})
|
| 119 |
+
return {
|
| 120 |
+
"id": tool["slug"],
|
| 121 |
+
"integration_id": toolkit.get("slug", ""),
|
| 122 |
+
"name": tool["name"],
|
| 123 |
+
"description": tool.get("description", ""),
|
| 124 |
+
"input_schema": tool.get("input_parameters", {"type": "object", "properties": {}}),
|
| 125 |
+
"output_schema": tool.get("output_parameters", {"type": "object"}),
|
| 126 |
+
"category": tool.get("tags", ["General"])[0] if tool.get("tags") else "General",
|
| 127 |
+
"is_active": not tool.get("is_deprecated", False),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class LocalDB:
|
| 132 |
+
"""
|
| 133 |
+
Manages local tool/integration cache in SQLite + JSON backup.
|
| 134 |
+
Ensures we always have data even when Composio API is unavailable.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
def __init__(self, cache_file: str = CACHE_FILE):
|
| 138 |
+
self.cache_file = cache_file
|
| 139 |
+
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
|
| 140 |
+
|
| 141 |
+
def save_to_json(self, toolkits: List[dict], tools: List[dict]):
|
| 142 |
+
"""Save to JSON backup file."""
|
| 143 |
+
data = {
|
| 144 |
+
"toolkits": toolkits,
|
| 145 |
+
"tools": tools,
|
| 146 |
+
"cached_at": time.time(),
|
| 147 |
+
"toolkit_count": len(toolkits),
|
| 148 |
+
"tool_count": len(tools)
|
| 149 |
+
}
|
| 150 |
+
with open(self.cache_file, "w") as f:
|
| 151 |
+
json.dump(data, f, indent=2)
|
| 152 |
+
print(f"[LocalDB] Saved {len(toolkits)} toolkits, {len(tools)} tools to {self.cache_file}")
|
| 153 |
+
|
| 154 |
+
def load_from_json(self) -> Optional[dict]:
|
| 155 |
+
"""Load from JSON backup file."""
|
| 156 |
+
if os.path.exists(self.cache_file):
|
| 157 |
+
with open(self.cache_file, "r") as f:
|
| 158 |
+
return json.load(f)
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
def is_json_stale(self) -> bool:
|
| 162 |
+
"""Check if JSON cache is older than TTL."""
|
| 163 |
+
data = self.load_from_json()
|
| 164 |
+
if not data:
|
| 165 |
+
return True
|
| 166 |
+
return (time.time() - data.get("cached_at", 0)) > CACHE_TTL
|
| 167 |
+
|
| 168 |
+
def seed_sqlite_from_json(self):
|
| 169 |
+
"""Seed SQLite database from JSON cache."""
|
| 170 |
+
data = self.load_from_json()
|
| 171 |
+
if not data:
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
toolkits = data.get("toolkits", [])
|
| 175 |
+
tools = data.get("tools", [])
|
| 176 |
+
|
| 177 |
+
if not toolkits and not tools:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
db = SessionLocal()
|
| 181 |
+
try:
|
| 182 |
+
# Seed integrations
|
| 183 |
+
for tk in toolkits:
|
| 184 |
+
int_def = _map_toolkit_to_integration(tk)
|
| 185 |
+
existing = db.query(Integration).filter(Integration.id == int_def.id).first()
|
| 186 |
+
if existing:
|
| 187 |
+
existing.name = int_def.name
|
| 188 |
+
existing.description = int_def.description
|
| 189 |
+
existing.logo_url = int_def.logo_url
|
| 190 |
+
existing.category = int_def.category
|
| 191 |
+
existing.auth_type = int_def.auth_type
|
| 192 |
+
existing.tool_count = int_def.tool_count
|
| 193 |
+
existing.trigger_count = int_def.trigger_count
|
| 194 |
+
else:
|
| 195 |
+
db.add(Integration(
|
| 196 |
+
id=int_def.id, name=int_def.name, description=int_def.description,
|
| 197 |
+
logo_url=int_def.logo_url, category=int_def.category,
|
| 198 |
+
auth_type=int_def.auth_type, tool_count=int_def.tool_count,
|
| 199 |
+
trigger_count=int_def.trigger_count, is_active=True
|
| 200 |
+
))
|
| 201 |
+
|
| 202 |
+
# Seed tools
|
| 203 |
+
for t in tools:
|
| 204 |
+
tool_data = _map_tool_to_db(t)
|
| 205 |
+
existing = db.query(Tool).filter(Tool.id == tool_data["id"]).first()
|
| 206 |
+
if existing:
|
| 207 |
+
existing.name = tool_data["name"]
|
| 208 |
+
existing.description = tool_data["description"]
|
| 209 |
+
existing.input_schema = tool_data["input_schema"]
|
| 210 |
+
existing.output_schema = tool_data["output_schema"]
|
| 211 |
+
existing.category = tool_data["category"]
|
| 212 |
+
existing.is_active = tool_data["is_active"]
|
| 213 |
+
else:
|
| 214 |
+
db.add(Tool(**tool_data))
|
| 215 |
+
|
| 216 |
+
db.commit()
|
| 217 |
+
print(f"[LocalDB] Seeded SQLite from JSON cache: {len(toolkits)} integrations, {len(tools)} tools")
|
| 218 |
+
return True
|
| 219 |
+
except Exception as e:
|
| 220 |
+
db.rollback()
|
| 221 |
+
print(f"[LocalDB] Failed to seed SQLite from JSON: {e}")
|
| 222 |
+
return False
|
| 223 |
+
finally:
|
| 224 |
+
db.close()
|
| 225 |
+
|
| 226 |
+
def seed_sqlite_from_api(self, toolkits: List[dict], tools: List[dict]):
|
| 227 |
+
"""Seed SQLite directly from API data and save JSON backup."""
|
| 228 |
+
db = SessionLocal()
|
| 229 |
+
try:
|
| 230 |
+
# Clear existing data for fresh sync
|
| 231 |
+
db.query(Tool).delete()
|
| 232 |
+
db.query(Integration).delete()
|
| 233 |
+
db.commit()
|
| 234 |
+
|
| 235 |
+
# Seed integrations
|
| 236 |
+
for tk in toolkits:
|
| 237 |
+
int_def = _map_toolkit_to_integration(tk)
|
| 238 |
+
db.add(Integration(
|
| 239 |
+
id=int_def.id, name=int_def.name, description=int_def.description,
|
| 240 |
+
logo_url=int_def.logo_url, category=int_def.category,
|
| 241 |
+
auth_type=int_def.auth_type, tool_count=int_def.tool_count,
|
| 242 |
+
trigger_count=int_def.trigger_count, is_active=True
|
| 243 |
+
))
|
| 244 |
+
|
| 245 |
+
# Seed tools
|
| 246 |
+
for t in tools:
|
| 247 |
+
tool_data = _map_tool_to_db(t)
|
| 248 |
+
db.add(Tool(**tool_data))
|
| 249 |
+
|
| 250 |
+
db.commit()
|
| 251 |
+
|
| 252 |
+
# Save JSON backup
|
| 253 |
+
self.save_to_json(toolkits, tools)
|
| 254 |
+
|
| 255 |
+
print(f"[LocalDB] Seeded SQLite from API: {len(toolkits)} integrations, {len(tools)} tools")
|
| 256 |
+
except Exception as e:
|
| 257 |
+
db.rollback()
|
| 258 |
+
raise e
|
| 259 |
+
finally:
|
| 260 |
+
db.close()
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def sync_from_composio(api_key: str = None, force: bool = False) -> dict:
|
| 264 |
+
"""
|
| 265 |
+
Sync toolkits and tools from Composio API to local SQLite + JSON cache.
|
| 266 |
+
"""
|
| 267 |
+
key = api_key or COMPOSIO_API_KEY
|
| 268 |
+
local_db = LocalDB()
|
| 269 |
+
|
| 270 |
+
if not key:
|
| 271 |
+
# No API key - try to load from local cache
|
| 272 |
+
print("[Sync] No COMPOSIO_API_KEY set, loading from local cache...")
|
| 273 |
+
if local_db.seed_sqlite_from_json():
|
| 274 |
+
data = local_db.load_from_json()
|
| 275 |
+
return {"source": "local_cache", "toolkits": data["toolkit_count"], "tools": data["tool_count"]}
|
| 276 |
+
else:
|
| 277 |
+
print("[Sync] No local cache found, using fallback definitions")
|
| 278 |
+
_seed_fallback()
|
| 279 |
+
return {"source": "fallback", "toolkits": 5, "tools": 50}
|
| 280 |
+
|
| 281 |
+
# Check if we have fresh local data
|
| 282 |
+
if not force and not local_db.is_json_stale():
|
| 283 |
+
data = local_db.load_from_json()
|
| 284 |
+
if data and data.get("toolkits"):
|
| 285 |
+
print(f"[Sync] Local cache is fresh ({data['toolkit_count']} toolkits), skipping API call")
|
| 286 |
+
local_db.seed_sqlite_from_json()
|
| 287 |
+
return {"source": "local_cache", "toolkits": data["toolkit_count"], "tools": data["tool_count"]}
|
| 288 |
+
|
| 289 |
+
# Fetch from Composio API
|
| 290 |
+
print(f"[Sync] Fetching from Composio API ({COMPOSIO_API_BASE})...")
|
| 291 |
+
client = ComposioClient(api_key=key)
|
| 292 |
+
|
| 293 |
+
try:
|
| 294 |
+
toolkits = await client.list_toolkits(limit=1000)
|
| 295 |
+
print(f"[Sync] Fetched {len(toolkits)} toolkits")
|
| 296 |
+
|
| 297 |
+
tools = await client.list_tools(limit=10000)
|
| 298 |
+
print(f"[Sync] Fetched {len(tools)} tools")
|
| 299 |
+
|
| 300 |
+
# Save to SQLite + JSON
|
| 301 |
+
local_db.seed_sqlite_from_api(toolkits, tools)
|
| 302 |
+
|
| 303 |
+
return {"source": "api", "toolkits": len(toolkits), "tools": len(tools)}
|
| 304 |
+
|
| 305 |
+
except Exception as e:
|
| 306 |
+
print(f"[Sync] API fetch failed: {e}")
|
| 307 |
+
print("[Sync] Falling back to local cache...")
|
| 308 |
+
|
| 309 |
+
if local_db.seed_sqlite_from_json():
|
| 310 |
+
data = local_db.load_from_json()
|
| 311 |
+
return {"source": "local_cache_fallback", "toolkits": data["toolkit_count"], "tools": data["tool_count"]}
|
| 312 |
+
else:
|
| 313 |
+
print("[Sync] No local cache, using fallback definitions")
|
| 314 |
+
_seed_fallback()
|
| 315 |
+
return {"source": "fallback", "toolkits": 5, "tools": 50}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _seed_fallback():
|
| 319 |
+
"""Fallback static definitions when no API or cache is available."""
|
| 320 |
+
db = SessionLocal()
|
| 321 |
+
try:
|
| 322 |
+
fallback_integrations = [
|
| 323 |
+
IntegrationDef("github", "GitHub", "Version control and collaboration", "https://api.dicebear.com/7.x/icons/svg?seed=github", "Developer Tools", "oauth2", tool_count=28),
|
| 324 |
+
IntegrationDef("gmail", "Gmail", "Email service by Google", "https://api.dicebear.com/7.x/icons/svg?seed=gmail", "Communication", "oauth2", tool_count=18),
|
| 325 |
+
IntegrationDef("slack", "Slack", "Team communication platform", "https://api.dicebear.com/7.x/icons/svg?seed=slack", "Communication", "oauth2", tool_count=24),
|
| 326 |
+
IntegrationDef("notion", "Notion", "All-in-one workspace", "https://api.dicebear.com/7.x/icons/svg?seed=notion", "Productivity", "oauth2", tool_count=16),
|
| 327 |
+
IntegrationDef("stripe", "Stripe", "Payment processing", "https://api.dicebear.com/7.x/icons/svg?seed=stripe", "Finance", "api_key", tool_count=16),
|
| 328 |
+
]
|
| 329 |
+
|
| 330 |
+
for int_def in fallback_integrations:
|
| 331 |
+
existing = db.query(Integration).filter(Integration.id == int_def.id).first()
|
| 332 |
+
if not existing:
|
| 333 |
+
db.add(Integration(
|
| 334 |
+
id=int_def.id, name=int_def.name, description=int_def.description,
|
| 335 |
+
logo_url=int_def.logo_url, category=int_def.category,
|
| 336 |
+
auth_type=int_def.auth_type, tool_count=int_def.tool_count,
|
| 337 |
+
trigger_count=int_def.trigger_count, is_active=True
|
| 338 |
+
))
|
| 339 |
+
|
| 340 |
+
db.commit()
|
| 341 |
+
print(f"[Fallback] Seeded {len(fallback_integrations)} integrations")
|
| 342 |
+
finally:
|
| 343 |
+
db.close()
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def seed_integrations():
|
| 347 |
+
"""Entry point for seeding integrations."""
|
| 348 |
+
from database import init_db
|
| 349 |
+
init_db()
|
| 350 |
+
|
| 351 |
+
import asyncio
|
| 352 |
+
try:
|
| 353 |
+
result = asyncio.run(sync_from_composio())
|
| 354 |
+
print(f"[Seed] Result: {result}")
|
| 355 |
+
except Exception as e:
|
| 356 |
+
print(f"[Seed] Error: {e}")
|
| 357 |
+
_seed_fallback()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
if __name__ == "__main__":
|
| 361 |
+
seed_integrations()
|
backend/main.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 8 |
+
|
| 9 |
+
from database import init_db
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@asynccontextmanager
|
| 13 |
+
async def lifespan(app: FastAPI):
|
| 14 |
+
init_db()
|
| 15 |
+
yield
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
app = FastAPI(
|
| 19 |
+
title="Platform API",
|
| 20 |
+
description="Unified Tool Integration Platform",
|
| 21 |
+
version="1.0.0",
|
| 22 |
+
docs_url="/api/docs",
|
| 23 |
+
redoc_url="/api/redoc",
|
| 24 |
+
lifespan=lifespan
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
app.add_middleware(
|
| 28 |
+
CORSMiddleware,
|
| 29 |
+
allow_origins=["*"],
|
| 30 |
+
allow_credentials=True,
|
| 31 |
+
allow_methods=["*"],
|
| 32 |
+
allow_headers=["*"],
|
| 33 |
+
)
|
| 34 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 35 |
+
|
| 36 |
+
from routers import auth, apps, tools, connections, apikeys, analytics, playground, settings, mcp
|
| 37 |
+
|
| 38 |
+
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
| 39 |
+
app.include_router(apps.router, prefix="/api/apps", tags=["apps"])
|
| 40 |
+
app.include_router(tools.router, prefix="/api/tools", tags=["tools"])
|
| 41 |
+
app.include_router(connections.router, prefix="/api/connections", tags=["connections"])
|
| 42 |
+
app.include_router(apikeys.router, prefix="/api/apikeys", tags=["apikeys"])
|
| 43 |
+
app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"])
|
| 44 |
+
app.include_router(playground.router, prefix="/api/playground", tags=["playground"])
|
| 45 |
+
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
|
| 46 |
+
app.include_router(mcp.router, prefix="/mcp", tags=["mcp"])
|
| 47 |
+
|
| 48 |
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
| 49 |
+
|
| 50 |
+
if os.path.exists(STATIC_DIR):
|
| 51 |
+
app.mount("/assets", StaticFiles(directory=f"{STATIC_DIR}/assets"), name="assets")
|
| 52 |
+
|
| 53 |
+
@app.get("/{full_path:path}")
|
| 54 |
+
async def serve_spa(full_path: str):
|
| 55 |
+
if full_path.startswith("api/") or full_path.startswith("mcp/"):
|
| 56 |
+
return JSONResponse({"detail": "Not found"}, status_code=404)
|
| 57 |
+
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
| 58 |
+
|
| 59 |
+
@app.get("/")
|
| 60 |
+
async def root():
|
| 61 |
+
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/health")
|
| 65 |
+
async def health():
|
| 66 |
+
return {"status": "healthy", "version": "1.0.0"}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.2
|
| 2 |
+
uvicorn[standard]==0.27.1
|
| 3 |
+
sqlalchemy==2.0.25
|
| 4 |
+
pydantic==2.6.1
|
| 5 |
+
pydantic-settings==2.1.0
|
| 6 |
+
python-jose[cryptography]==3.3.0
|
| 7 |
+
passlib[bcrypt]==1.7.4
|
| 8 |
+
python-multipart==0.0.6
|
| 9 |
+
httpx==0.26.0
|
| 10 |
+
cryptography==42.0.2
|
| 11 |
+
jsonschema==4.21.1
|
| 12 |
+
beautifulsoup4==4.12.3
|
| 13 |
+
lxml==5.1.0
|
| 14 |
+
python-dotenv==1.0.1
|
| 15 |
+
aiofiles==23.2.1
|
| 16 |
+
websockets==12.0
|
backend/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routers package
|
backend/routers/analytics.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from sqlalchemy import func
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from database import get_db, ToolExecution
|
| 6 |
+
from auth import get_current_user, User
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/overview")
|
| 12 |
+
async def get_overview(
|
| 13 |
+
current_user: User = Depends(get_current_user),
|
| 14 |
+
db: Session = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
total = db.query(ToolExecution).filter(ToolExecution.user_id == current_user.id).count()
|
| 17 |
+
success = db.query(ToolExecution).filter(
|
| 18 |
+
ToolExecution.user_id == current_user.id,
|
| 19 |
+
ToolExecution.status == "success"
|
| 20 |
+
).count()
|
| 21 |
+
failed = total - success
|
| 22 |
+
|
| 23 |
+
success_rate = (success / total * 100) if total > 0 else 100
|
| 24 |
+
|
| 25 |
+
avg_latency = db.query(func.avg(ToolExecution.latency_ms)).filter(
|
| 26 |
+
ToolExecution.user_id == current_user.id
|
| 27 |
+
).scalar() or 0
|
| 28 |
+
|
| 29 |
+
return {
|
| 30 |
+
"total_executions": total,
|
| 31 |
+
"successful": success,
|
| 32 |
+
"failed": failed,
|
| 33 |
+
"success_rate": round(success_rate, 1),
|
| 34 |
+
"avg_latency_ms": int(avg_latency)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/executions")
|
| 39 |
+
async def get_executions(
|
| 40 |
+
days: int = 7,
|
| 41 |
+
current_user: User = Depends(get_current_user),
|
| 42 |
+
db: Session = Depends(get_db)
|
| 43 |
+
):
|
| 44 |
+
from sqlalchemy import cast, Date
|
| 45 |
+
start_date = datetime.utcnow() - timedelta(days=days)
|
| 46 |
+
|
| 47 |
+
executions = db.query(
|
| 48 |
+
cast(ToolExecution.executed_at, Date).label("date"),
|
| 49 |
+
func.count(ToolExecution.id).label("count")
|
| 50 |
+
).filter(
|
| 51 |
+
ToolExecution.user_id == current_user.id,
|
| 52 |
+
ToolExecution.executed_at >= start_date
|
| 53 |
+
).group_by("date").all()
|
| 54 |
+
|
| 55 |
+
return {"executions": [{"date": str(e.date), "count": e.count} for e in executions]}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/top-tools")
|
| 59 |
+
async def get_top_tools(
|
| 60 |
+
limit: int = 10,
|
| 61 |
+
current_user: User = Depends(get_current_user),
|
| 62 |
+
db: Session = Depends(get_db)
|
| 63 |
+
):
|
| 64 |
+
results = db.query(
|
| 65 |
+
ToolExecution.tool_id,
|
| 66 |
+
func.count(ToolExecution.id).label("count")
|
| 67 |
+
).filter(
|
| 68 |
+
ToolExecution.user_id == current_user.id
|
| 69 |
+
).group_by(ToolExecution.tool_id).order_by(func.count(ToolExecution.id).desc()).limit(limit).all()
|
| 70 |
+
|
| 71 |
+
return {"tools": [{"tool_id": r.tool_id, "count": r.count} for r in results]}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.get("/errors")
|
| 75 |
+
async def get_errors(
|
| 76 |
+
limit: int = 20,
|
| 77 |
+
current_user: User = Depends(get_current_user),
|
| 78 |
+
db: Session = Depends(get_db)
|
| 79 |
+
):
|
| 80 |
+
errors = db.query(ToolExecution).filter(
|
| 81 |
+
ToolExecution.user_id == current_user.id,
|
| 82 |
+
ToolExecution.status == "error"
|
| 83 |
+
).order_by(ToolExecution.executed_at.desc()).limit(limit).all()
|
| 84 |
+
|
| 85 |
+
return {"errors": [
|
| 86 |
+
{
|
| 87 |
+
"id": e.id,
|
| 88 |
+
"tool_id": e.tool_id,
|
| 89 |
+
"error": e.error_message,
|
| 90 |
+
"executed_at": e.executed_at.isoformat()
|
| 91 |
+
}
|
| 92 |
+
for e in errors
|
| 93 |
+
]}
|
backend/routers/apikeys.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import secrets
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from database import get_db, APIKey
|
| 7 |
+
from auth import get_current_user, User
|
| 8 |
+
from passlib.context import CryptContext
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class APIKeyResponse(BaseModel):
|
| 16 |
+
id: str
|
| 17 |
+
name: str
|
| 18 |
+
key_prefix: str
|
| 19 |
+
created_at: str
|
| 20 |
+
last_used: str = None
|
| 21 |
+
is_active: bool
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CreateAPIKeyRequest(BaseModel):
|
| 25 |
+
name: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("", response_model=List[APIKeyResponse])
|
| 29 |
+
async def list_api_keys(
|
| 30 |
+
current_user: User = Depends(get_current_user),
|
| 31 |
+
db: Session = Depends(get_db)
|
| 32 |
+
):
|
| 33 |
+
keys = db.query(APIKey).filter(APIKey.user_id == current_user.id).all()
|
| 34 |
+
return [
|
| 35 |
+
APIKeyResponse(
|
| 36 |
+
id=k.id,
|
| 37 |
+
name=k.name,
|
| 38 |
+
key_prefix=k.key_hash[:16] + "...",
|
| 39 |
+
created_at=k.created_at.isoformat(),
|
| 40 |
+
last_used=k.last_used.isoformat() if k.last_used else None,
|
| 41 |
+
is_active=k.is_active
|
| 42 |
+
)
|
| 43 |
+
for k in keys
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("")
|
| 48 |
+
async def create_api_key(
|
| 49 |
+
request: CreateAPIKeyRequest,
|
| 50 |
+
current_user: User = Depends(get_current_user),
|
| 51 |
+
db: Session = Depends(get_db)
|
| 52 |
+
):
|
| 53 |
+
import uuid
|
| 54 |
+
api_key = secrets.token_urlsafe(32)
|
| 55 |
+
key_hash = pwd_context.hash(api_key)
|
| 56 |
+
|
| 57 |
+
key = APIKey(
|
| 58 |
+
id=str(uuid.uuid4()),
|
| 59 |
+
user_id=current_user.id,
|
| 60 |
+
key_hash=key_hash,
|
| 61 |
+
name=request.name
|
| 62 |
+
)
|
| 63 |
+
db.add(key)
|
| 64 |
+
db.commit()
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"api_key": api_key,
|
| 68 |
+
"id": key.id,
|
| 69 |
+
"name": key.name,
|
| 70 |
+
"message": "Store this key securely - it won't be shown again"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.delete("/{key_id}")
|
| 75 |
+
async def delete_api_key(
|
| 76 |
+
key_id: str,
|
| 77 |
+
current_user: User = Depends(get_current_user),
|
| 78 |
+
db: Session = Depends(get_db)
|
| 79 |
+
):
|
| 80 |
+
key = db.query(APIKey).filter(
|
| 81 |
+
APIKey.id == key_id,
|
| 82 |
+
APIKey.user_id == current_user.id
|
| 83 |
+
).first()
|
| 84 |
+
|
| 85 |
+
if not key:
|
| 86 |
+
raise HTTPException(status_code=404, detail="API key not found")
|
| 87 |
+
|
| 88 |
+
key.is_active = False
|
| 89 |
+
db.commit()
|
| 90 |
+
|
| 91 |
+
return {"message": "API key revoked"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.put("/{key_id}")
|
| 95 |
+
async def rename_api_key(
|
| 96 |
+
key_id: str,
|
| 97 |
+
name: str,
|
| 98 |
+
current_user: User = Depends(get_current_user),
|
| 99 |
+
db: Session = Depends(get_db)
|
| 100 |
+
):
|
| 101 |
+
key = db.query(APIKey).filter(
|
| 102 |
+
APIKey.id == key_id,
|
| 103 |
+
APIKey.user_id == current_user.id
|
| 104 |
+
).first()
|
| 105 |
+
|
| 106 |
+
if not key:
|
| 107 |
+
raise HTTPException(status_code=404, detail="API key not found")
|
| 108 |
+
|
| 109 |
+
key.name = name
|
| 110 |
+
db.commit()
|
| 111 |
+
|
| 112 |
+
return {"message": "API key renamed"}
|
backend/routers/apps.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from database import get_db, Integration
|
| 6 |
+
from auth import get_current_user, User
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class IntegrationResponse(BaseModel):
|
| 12 |
+
id: str
|
| 13 |
+
name: str
|
| 14 |
+
description: str
|
| 15 |
+
logo_url: str
|
| 16 |
+
category: str
|
| 17 |
+
auth_type: str
|
| 18 |
+
oauth_scopes: Optional[List[str]]
|
| 19 |
+
tool_count: int
|
| 20 |
+
trigger_count: int
|
| 21 |
+
is_active: bool
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("", response_model=List[IntegrationResponse])
|
| 25 |
+
async def list_integrations(
|
| 26 |
+
category: Optional[str] = None,
|
| 27 |
+
search: Optional[str] = None,
|
| 28 |
+
db: Session = Depends(get_db)
|
| 29 |
+
):
|
| 30 |
+
query = db.query(Integration).filter(Integration.is_active == True)
|
| 31 |
+
|
| 32 |
+
if category:
|
| 33 |
+
query = query.filter(Integration.category == category)
|
| 34 |
+
if search:
|
| 35 |
+
query = query.filter(
|
| 36 |
+
(Integration.name.ilike(f"%{search}%")) |
|
| 37 |
+
(Integration.description.ilike(f"%{search}%"))
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
integrations = query.all()
|
| 41 |
+
return [
|
| 42 |
+
IntegrationResponse(
|
| 43 |
+
id=i.id,
|
| 44 |
+
name=i.name,
|
| 45 |
+
description=i.description,
|
| 46 |
+
logo_url=i.logo_url,
|
| 47 |
+
category=i.category,
|
| 48 |
+
auth_type=i.auth_type,
|
| 49 |
+
oauth_scopes=i.oauth_scopes,
|
| 50 |
+
tool_count=i.tool_count,
|
| 51 |
+
trigger_count=i.trigger_count,
|
| 52 |
+
is_active=i.is_active
|
| 53 |
+
)
|
| 54 |
+
for i in integrations
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/categories")
|
| 59 |
+
async def list_categories(db: Session = Depends(get_db)):
|
| 60 |
+
categories = db.query(Integration.category).distinct().all()
|
| 61 |
+
return {"categories": [c[0] for c in categories if c[0]]}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/{integration_id}", response_model=IntegrationResponse)
|
| 65 |
+
async def get_integration(integration_id: str, db: Session = Depends(get_db)):
|
| 66 |
+
integration = db.query(Integration).filter(Integration.id == integration_id).first()
|
| 67 |
+
if not integration:
|
| 68 |
+
raise HTTPException(status_code=404, detail="Integration not found")
|
| 69 |
+
|
| 70 |
+
return IntegrationResponse(
|
| 71 |
+
id=integration.id,
|
| 72 |
+
name=integration.name,
|
| 73 |
+
description=integration.description,
|
| 74 |
+
logo_url=integration.logo_url,
|
| 75 |
+
category=integration.category,
|
| 76 |
+
auth_type=integration.auth_type,
|
| 77 |
+
oauth_scopes=integration.oauth_scopes,
|
| 78 |
+
tool_count=integration.tool_count,
|
| 79 |
+
trigger_count=integration.trigger_count,
|
| 80 |
+
is_active=integration.is_active
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/{integration_id}/connect")
|
| 85 |
+
async def connect_integration(
|
| 86 |
+
integration_id: str,
|
| 87 |
+
current_user: User = Depends(get_current_user),
|
| 88 |
+
db: Session = Depends(get_db)
|
| 89 |
+
):
|
| 90 |
+
integration = db.query(Integration).filter(Integration.id == integration_id).first()
|
| 91 |
+
if not integration:
|
| 92 |
+
raise HTTPException(status_code=404, detail="Integration not found")
|
| 93 |
+
|
| 94 |
+
if integration.auth_type == "oauth2":
|
| 95 |
+
return {
|
| 96 |
+
"auth_url": f"/api/connections/oauth/begin?integration={integration_id}",
|
| 97 |
+
"message": "Redirect to OAuth flow"
|
| 98 |
+
}
|
| 99 |
+
else:
|
| 100 |
+
return {
|
| 101 |
+
"message": "API key authentication required",
|
| 102 |
+
"instruction": "Use /api/connections/apikey endpoint"
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.post("/sync")
|
| 107 |
+
async def sync_integrations(
|
| 108 |
+
force: bool = Query(False),
|
| 109 |
+
current_user: User = Depends(get_current_user),
|
| 110 |
+
db: Session = Depends(get_db)
|
| 111 |
+
):
|
| 112 |
+
"""Sync integrations from Composio API to local database."""
|
| 113 |
+
import os
|
| 114 |
+
api_key = os.environ.get("COMPOSIO_API_KEY", "")
|
| 115 |
+
if not api_key:
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=400,
|
| 118 |
+
detail="COMPOSIO_API_KEY environment variable is required. Set it to sync from Composio."
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
from integrations.registry import sync_from_composio
|
| 123 |
+
result = await sync_from_composio(api_key=api_key, force=force)
|
| 124 |
+
return {
|
| 125 |
+
"message": "Integrations synced successfully",
|
| 126 |
+
"source": result["source"],
|
| 127 |
+
"toolkits": result["toolkits"],
|
| 128 |
+
"tools": result["tools"]
|
| 129 |
+
}
|
| 130 |
+
except Exception as e:
|
| 131 |
+
raise HTTPException(status_code=500, detail=f"Failed to sync integrations: {str(e)}")
|
backend/routers/auth.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import timedelta
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from pydantic import BaseModel, EmailStr
|
| 6 |
+
from database import get_db, User
|
| 7 |
+
from auth import (
|
| 8 |
+
verify_password, get_password_hash, create_access_token,
|
| 9 |
+
create_refresh_token, decode_token, get_current_user
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class RegisterRequest(BaseModel):
|
| 16 |
+
email: EmailStr
|
| 17 |
+
password: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class LoginResponse(BaseModel):
|
| 21 |
+
access_token: str
|
| 22 |
+
refresh_token: str
|
| 23 |
+
token_type: str = "bearer"
|
| 24 |
+
user: dict
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TokenRefreshRequest(BaseModel):
|
| 28 |
+
refresh_token: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UserResponse(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
email: str
|
| 34 |
+
is_active: bool
|
| 35 |
+
created_at: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.post("/register", response_model=UserResponse)
|
| 39 |
+
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
| 40 |
+
existing = db.query(User).filter(User.email == request.email).first()
|
| 41 |
+
if existing:
|
| 42 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 43 |
+
|
| 44 |
+
user = User(
|
| 45 |
+
email=request.email,
|
| 46 |
+
hashed_password=get_password_hash(request.password),
|
| 47 |
+
is_active=True
|
| 48 |
+
)
|
| 49 |
+
db.add(user)
|
| 50 |
+
db.commit()
|
| 51 |
+
db.refresh(user)
|
| 52 |
+
|
| 53 |
+
return UserResponse(
|
| 54 |
+
id=user.id,
|
| 55 |
+
email=user.email,
|
| 56 |
+
is_active=user.is_active,
|
| 57 |
+
created_at=user.created_at.isoformat()
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/login", response_model=LoginResponse)
|
| 62 |
+
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
| 63 |
+
user = db.query(User).filter(User.email == form_data.username).first()
|
| 64 |
+
if not user or not verify_password(form_data.password, user.hashed_password):
|
| 65 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 66 |
+
|
| 67 |
+
if not user.is_active:
|
| 68 |
+
raise HTTPException(status_code=403, detail="User account is disabled")
|
| 69 |
+
|
| 70 |
+
access_token = create_access_token({"sub": user.id})
|
| 71 |
+
refresh_token = create_refresh_token({"sub": user.id})
|
| 72 |
+
|
| 73 |
+
return LoginResponse(
|
| 74 |
+
access_token=access_token,
|
| 75 |
+
refresh_token=refresh_token,
|
| 76 |
+
user={"id": user.id, "email": user.email}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/refresh")
|
| 81 |
+
async def refresh_token(request: TokenRefreshRequest, db: Session = Depends(get_db)):
|
| 82 |
+
payload = decode_token(request.refresh_token)
|
| 83 |
+
if payload.get("type") != "refresh":
|
| 84 |
+
raise HTTPException(status_code=401, detail="Invalid token type")
|
| 85 |
+
|
| 86 |
+
user_id = payload.get("sub")
|
| 87 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 88 |
+
if not user or not user.is_active:
|
| 89 |
+
raise HTTPException(status_code=401, detail="User not found")
|
| 90 |
+
|
| 91 |
+
access_token = create_access_token({"sub": user.id})
|
| 92 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.get("/me", response_model=UserResponse)
|
| 96 |
+
async def get_me(current_user: User = Depends(get_current_user)):
|
| 97 |
+
return UserResponse(
|
| 98 |
+
id=current_user.id,
|
| 99 |
+
email=current_user.email,
|
| 100 |
+
is_active=current_user.is_active,
|
| 101 |
+
created_at=current_user.created_at.isoformat()
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.post("/logout")
|
| 106 |
+
async def logout():
|
| 107 |
+
return {"message": "Successfully logged out"}
|
backend/routers/connections.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from database import get_db, Connection, Integration
|
| 6 |
+
from auth import get_current_user, User
|
| 7 |
+
from vault import vault
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ConnectionResponse(BaseModel):
|
| 14 |
+
id: str
|
| 15 |
+
integration_id: str
|
| 16 |
+
integration_name: str
|
| 17 |
+
account_label: Optional[str]
|
| 18 |
+
status: str
|
| 19 |
+
connected_at: str
|
| 20 |
+
last_used: Optional[str]
|
| 21 |
+
metadata: Optional[dict]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ConnectAPIKeyRequest(BaseModel):
|
| 25 |
+
api_key: str
|
| 26 |
+
account_label: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("", response_model=List[ConnectionResponse])
|
| 30 |
+
async def list_connections(
|
| 31 |
+
current_user: User = Depends(get_current_user),
|
| 32 |
+
db: Session = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
connections = db.query(Connection).filter(Connection.user_id == current_user.id).all()
|
| 35 |
+
|
| 36 |
+
result = []
|
| 37 |
+
for conn in connections:
|
| 38 |
+
integration = db.query(Integration).filter(Integration.id == conn.integration_id).first()
|
| 39 |
+
result.append(ConnectionResponse(
|
| 40 |
+
id=conn.id,
|
| 41 |
+
integration_id=conn.integration_id,
|
| 42 |
+
integration_name=integration.name if integration else conn.integration_id,
|
| 43 |
+
account_label=conn.account_label,
|
| 44 |
+
status=conn.status,
|
| 45 |
+
connected_at=conn.connected_at.isoformat(),
|
| 46 |
+
last_used=conn.last_used.isoformat() if conn.last_used else None,
|
| 47 |
+
metadata=conn.metadata
|
| 48 |
+
))
|
| 49 |
+
return result
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/{connection_id}", response_model=ConnectionResponse)
|
| 53 |
+
async def get_connection(
|
| 54 |
+
connection_id: str,
|
| 55 |
+
current_user: User = Depends(get_current_user),
|
| 56 |
+
db: Session = Depends(get_db)
|
| 57 |
+
):
|
| 58 |
+
conn = db.query(Connection).filter(
|
| 59 |
+
Connection.id == connection_id,
|
| 60 |
+
Connection.user_id == current_user.id
|
| 61 |
+
).first()
|
| 62 |
+
|
| 63 |
+
if not conn:
|
| 64 |
+
raise HTTPException(status_code=404, detail="Connection not found")
|
| 65 |
+
|
| 66 |
+
integration = db.query(Integration).filter(Integration.id == conn.integration_id).first()
|
| 67 |
+
|
| 68 |
+
return ConnectionResponse(
|
| 69 |
+
id=conn.id,
|
| 70 |
+
integration_id=conn.integration_id,
|
| 71 |
+
integration_name=integration.name if integration else conn.integration_id,
|
| 72 |
+
account_label=conn.account_label,
|
| 73 |
+
status=conn.status,
|
| 74 |
+
connected_at=conn.connected_at.isoformat(),
|
| 75 |
+
last_used=conn.last_used.isoformat() if conn.last_used else None,
|
| 76 |
+
metadata=conn.metadata
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/apikey")
|
| 81 |
+
async def connect_with_api_key(
|
| 82 |
+
request: ConnectAPIKeyRequest,
|
| 83 |
+
integration_id: str = Query(...),
|
| 84 |
+
current_user: User = Depends(get_current_user),
|
| 85 |
+
db: Session = Depends(get_db)
|
| 86 |
+
):
|
| 87 |
+
integration = db.query(Integration).filter(Integration.id == integration_id).first()
|
| 88 |
+
if not integration:
|
| 89 |
+
raise HTTPException(status_code=404, detail="Integration not found")
|
| 90 |
+
|
| 91 |
+
encrypted = vault.store_credentials({"api_key": request.api_key})
|
| 92 |
+
|
| 93 |
+
import uuid
|
| 94 |
+
connection = Connection(
|
| 95 |
+
id=str(uuid.uuid4()),
|
| 96 |
+
user_id=current_user.id,
|
| 97 |
+
integration_id=integration_id,
|
| 98 |
+
account_label=request.account_label or f"{integration.name} API",
|
| 99 |
+
status="active",
|
| 100 |
+
encrypted_credentials=encrypted,
|
| 101 |
+
metadata={"type": "api_key"}
|
| 102 |
+
)
|
| 103 |
+
db.add(connection)
|
| 104 |
+
db.commit()
|
| 105 |
+
|
| 106 |
+
return {"message": "Connected successfully", "connection_id": connection.id}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.delete("/{connection_id}")
|
| 110 |
+
async def disconnect(
|
| 111 |
+
connection_id: str,
|
| 112 |
+
current_user: User = Depends(get_current_user),
|
| 113 |
+
db: Session = Depends(get_db)
|
| 114 |
+
):
|
| 115 |
+
conn = db.query(Connection).filter(
|
| 116 |
+
Connection.id == connection_id,
|
| 117 |
+
Connection.user_id == current_user.id
|
| 118 |
+
).first()
|
| 119 |
+
|
| 120 |
+
if not conn:
|
| 121 |
+
raise HTTPException(status_code=404, detail="Connection not found")
|
| 122 |
+
|
| 123 |
+
db.delete(conn)
|
| 124 |
+
db.commit()
|
| 125 |
+
|
| 126 |
+
return {"message": "Disconnected successfully"}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/oauth/begin")
|
| 130 |
+
async def begin_oauth(
|
| 131 |
+
integration_id: str,
|
| 132 |
+
current_user: User = Depends(get_current_user),
|
| 133 |
+
db: Session = Depends(get_db)
|
| 134 |
+
):
|
| 135 |
+
integration = db.query(Integration).filter(Integration.id == integration_id).first()
|
| 136 |
+
if not integration:
|
| 137 |
+
raise HTTPException(status_code=404, detail="Integration not found")
|
| 138 |
+
|
| 139 |
+
import uuid
|
| 140 |
+
state = str(uuid.uuid4())
|
| 141 |
+
|
| 142 |
+
oauth_urls = {
|
| 143 |
+
"github": "https://github.com/login/oauth/authorize",
|
| 144 |
+
"google": "https://accounts.google.com/o/oauth2/v2/auth",
|
| 145 |
+
"slack": "https://slack.com/oauth/v2/authorize",
|
| 146 |
+
"notion": "https://api.notion.com/v1/oauth/authorize",
|
| 147 |
+
"linear": "https://linear.app/oauth/authorize",
|
| 148 |
+
"discord": "https://discord.com/api/oauth2/authorize",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
auth_url = oauth_urls.get(integration_id, "")
|
| 152 |
+
if auth_url:
|
| 153 |
+
return {
|
| 154 |
+
"auth_url": f"{auth_url}?client_id=EXAMPLE&redirect_uri=http://localhost:7860/oauth/callback/{integration_id}&state={state}&scope=read",
|
| 155 |
+
"state": state
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
return {"message": "OAuth not available for this integration"}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@router.get("/oauth/callback/{integration}")
|
| 162 |
+
async def oauth_callback(integration: str, code: str = "", state: str = ""):
|
| 163 |
+
return {"message": "OAuth callback received", "integration": integration}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@router.post("/{connection_id}/refresh")
|
| 167 |
+
async def refresh_connection(
|
| 168 |
+
connection_id: str,
|
| 169 |
+
current_user: User = Depends(get_current_user),
|
| 170 |
+
db: Session = Depends(get_db)
|
| 171 |
+
):
|
| 172 |
+
conn = db.query(Connection).filter(
|
| 173 |
+
Connection.id == connection_id,
|
| 174 |
+
Connection.user_id == current_user.id
|
| 175 |
+
).first()
|
| 176 |
+
|
| 177 |
+
if not conn:
|
| 178 |
+
raise HTTPException(status_code=404, detail="Connection not found")
|
| 179 |
+
|
| 180 |
+
conn.last_used = datetime.utcnow()
|
| 181 |
+
db.commit()
|
| 182 |
+
|
| 183 |
+
return {"message": "Connection refreshed"}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.get("/{connection_id}/test")
|
| 187 |
+
async def test_connection(
|
| 188 |
+
connection_id: str,
|
| 189 |
+
current_user: User = Depends(get_current_user),
|
| 190 |
+
db: Session = Depends(get_db)
|
| 191 |
+
):
|
| 192 |
+
conn = db.query(Connection).filter(
|
| 193 |
+
Connection.id == connection_id,
|
| 194 |
+
Connection.user_id == current_user.id
|
| 195 |
+
).first()
|
| 196 |
+
|
| 197 |
+
if not conn:
|
| 198 |
+
raise HTTPException(status_code=404, detail="Connection not found")
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"status": "success",
|
| 202 |
+
"message": "Connection is working",
|
| 203 |
+
"integration": conn.integration_id
|
| 204 |
+
}
|
backend/routers/mcp.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, HTTPException, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional, List, Any, Dict
|
| 5 |
+
from database import get_db, Tool
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MCPInitializeRequest(BaseModel):
|
| 12 |
+
protocolVersion: str = "2024-11-05"
|
| 13 |
+
capabilities: dict = {}
|
| 14 |
+
clientInfo: Optional[dict] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class MCPToolCall(BaseModel):
|
| 18 |
+
name: str
|
| 19 |
+
arguments: dict = {}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MCPtoolsCallRequest(BaseModel):
|
| 23 |
+
name: str
|
| 24 |
+
arguments: dict = {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/v1/endpoint")
|
| 28 |
+
async def mcp_endpoint(request: Request, db: Session = Depends(get_db)):
|
| 29 |
+
body = await request.json()
|
| 30 |
+
method = body.get("method")
|
| 31 |
+
params = body.get("params", {})
|
| 32 |
+
|
| 33 |
+
if method == "initialize":
|
| 34 |
+
return {
|
| 35 |
+
"jsonrpc": "2.0",
|
| 36 |
+
"id": body.get("id"),
|
| 37 |
+
"result": {
|
| 38 |
+
"protocolVersion": "2024-11-05",
|
| 39 |
+
"capabilities": {
|
| 40 |
+
"tools": {}
|
| 41 |
+
},
|
| 42 |
+
"serverInfo": {
|
| 43 |
+
"name": "Platform MCP Server",
|
| 44 |
+
"version": "1.0.0"
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
elif method == "tools/list":
|
| 50 |
+
tools = db.query(Tool).filter(Tool.is_active == True).all()
|
| 51 |
+
return {
|
| 52 |
+
"jsonrpc": "2.0",
|
| 53 |
+
"id": body.get("id"),
|
| 54 |
+
"result": {
|
| 55 |
+
"tools": [
|
| 56 |
+
{
|
| 57 |
+
"name": t.id,
|
| 58 |
+
"description": t.description,
|
| 59 |
+
"inputSchema": t.input_schema or {"type": "object", "properties": {}}
|
| 60 |
+
}
|
| 61 |
+
for t in tools
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
elif method == "tools/call":
|
| 67 |
+
tool_name = params.get("name")
|
| 68 |
+
arguments = params.get("arguments", {})
|
| 69 |
+
|
| 70 |
+
tool = db.query(Tool).filter(Tool.id == tool_name).first()
|
| 71 |
+
if not tool:
|
| 72 |
+
return {
|
| 73 |
+
"jsonrpc": "2.0",
|
| 74 |
+
"id": body.get("id"),
|
| 75 |
+
"error": {"code": -32601, "message": f"Tool {tool_name} not found"}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"jsonrpc": "2.0",
|
| 80 |
+
"id": body.get("id"),
|
| 81 |
+
"result": {
|
| 82 |
+
"content": [
|
| 83 |
+
{
|
| 84 |
+
"type": "text",
|
| 85 |
+
"text": f"Executed {tool_name} with arguments: {arguments}"
|
| 86 |
+
}
|
| 87 |
+
]
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"jsonrpc": "2.0",
|
| 93 |
+
"id": body.get("id"),
|
| 94 |
+
"error": {"code": -32601, "message": "Method not found"}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/v1/discovery")
|
| 99 |
+
async def mcp_discovery():
|
| 100 |
+
host = os.environ.get("MCP_HOST", "localhost:7860")
|
| 101 |
+
return {
|
| 102 |
+
"mcpServers": {
|
| 103 |
+
"platform": {
|
| 104 |
+
"url": f"http://{host}/mcp/v1/endpoint",
|
| 105 |
+
"transport": "stdio"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/v1/tools")
|
| 112 |
+
async def mcp_tools(db: Session = Depends(get_db)):
|
| 113 |
+
tools = db.query(Tool).filter(Tool.is_active == True).all()
|
| 114 |
+
return {
|
| 115 |
+
"tools": [
|
| 116 |
+
{
|
| 117 |
+
"name": t.id,
|
| 118 |
+
"description": t.description,
|
| 119 |
+
"inputSchema": t.input_schema or {"type": "object", "properties": {}}
|
| 120 |
+
}
|
| 121 |
+
for t in tools
|
| 122 |
+
]
|
| 123 |
+
}
|
backend/routers/playground.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
+
from database import get_db
|
| 6 |
+
from auth import get_current_user, User
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PlaygroundRequest(BaseModel):
|
| 12 |
+
tool_id: str
|
| 13 |
+
connection_id: Optional[str] = None
|
| 14 |
+
params: dict = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PlaygroundChatMessage(BaseModel):
|
| 18 |
+
role: str
|
| 19 |
+
content: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PlaygroundChatRequest(BaseModel):
|
| 23 |
+
messages: List[PlaygroundChatMessage]
|
| 24 |
+
model: str = "gpt-4"
|
| 25 |
+
connection_ids: List[str] = []
|
| 26 |
+
allowed_tools: List[str] = []
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/run")
|
| 30 |
+
async def run_playground(
|
| 31 |
+
request: PlaygroundRequest,
|
| 32 |
+
current_user: User = Depends(get_current_user),
|
| 33 |
+
db: Session = Depends(get_db)
|
| 34 |
+
):
|
| 35 |
+
return {
|
| 36 |
+
"tool_id": request.tool_id,
|
| 37 |
+
"params": request.params,
|
| 38 |
+
"connection_id": request.connection_id,
|
| 39 |
+
"result": {
|
| 40 |
+
"status": "success",
|
| 41 |
+
"message": f"Executed {request.tool_id} with provided parameters"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/chat")
|
| 47 |
+
async def chat_playground(
|
| 48 |
+
request: PlaygroundChatRequest,
|
| 49 |
+
current_user: User = Depends(get_current_user),
|
| 50 |
+
db: Session = Depends(get_db)
|
| 51 |
+
):
|
| 52 |
+
return {
|
| 53 |
+
"message": "Chat playground - LLM integration would be here",
|
| 54 |
+
"model": request.model,
|
| 55 |
+
"messages": [{"role": m.role, "content": m.content} for m in request.messages]
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/tools")
|
| 60 |
+
async def get_playground_tools(
|
| 61 |
+
current_user: User = Depends(get_current_user),
|
| 62 |
+
db: Session = Depends(get_db)
|
| 63 |
+
):
|
| 64 |
+
from database import Tool
|
| 65 |
+
tools = db.query(Tool).filter(Tool.is_active == True).limit(50).all()
|
| 66 |
+
|
| 67 |
+
return {"tools": [
|
| 68 |
+
{
|
| 69 |
+
"id": t.id,
|
| 70 |
+
"name": t.name,
|
| 71 |
+
"description": t.description,
|
| 72 |
+
"category": t.category
|
| 73 |
+
}
|
| 74 |
+
for t in tools
|
| 75 |
+
]}
|
backend/routers/settings.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import psutil
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from typing import Optional, Dict
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SettingsUpdate(BaseModel):
|
| 11 |
+
GOOGLE_CLIENT_ID: Optional[str] = None
|
| 12 |
+
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
| 13 |
+
GITHUB_CLIENT_ID: Optional[str] = None
|
| 14 |
+
GITHUB_CLIENT_SECRET: Optional[str] = None
|
| 15 |
+
SLACK_CLIENT_ID: Optional[str] = None
|
| 16 |
+
SLACK_CLIENT_SECRET: Optional[str] = None
|
| 17 |
+
OPENAI_API_KEY: Optional[str] = None
|
| 18 |
+
ANTHROPIC_API_KEY: Optional[str] = None
|
| 19 |
+
BRAVE_SEARCH_KEY: Optional[str] = None
|
| 20 |
+
SERPAPI_KEY: Optional[str] = None
|
| 21 |
+
WEBHOOK_SECRET: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("")
|
| 25 |
+
async def get_settings():
|
| 26 |
+
return {
|
| 27 |
+
"data_dir": os.environ.get("DATA_DIR", "/data"),
|
| 28 |
+
"platform_version": "1.0.0",
|
| 29 |
+
"integrations_count": 18,
|
| 30 |
+
"features": {
|
| 31 |
+
"oauth": True,
|
| 32 |
+
"api_keys": True,
|
| 33 |
+
"mcp": True,
|
| 34 |
+
"webhooks": True,
|
| 35 |
+
"code_sandbox": True
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("")
|
| 41 |
+
async def update_settings(settings: SettingsUpdate):
|
| 42 |
+
return {"message": "Settings updated (would be stored in environment/storage)"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.get("/health")
|
| 46 |
+
async def health_check():
|
| 47 |
+
try:
|
| 48 |
+
disk = psutil.disk_usage('/')
|
| 49 |
+
mem = psutil.virtual_memory()
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
"status": "healthy",
|
| 53 |
+
"disk": {
|
| 54 |
+
"total_gb": round(disk.total / (1024**3), 2),
|
| 55 |
+
"used_gb": round(disk.used / (1024**3), 2),
|
| 56 |
+
"percent": disk.percent
|
| 57 |
+
},
|
| 58 |
+
"memory": {
|
| 59 |
+
"total_gb": round(mem.total / (1024**3), 2),
|
| 60 |
+
"used_gb": round(mem.used / (1024**3), 2),
|
| 61 |
+
"percent": mem.percent
|
| 62 |
+
},
|
| 63 |
+
"cpu_percent": psutil.cpu_percent()
|
| 64 |
+
}
|
| 65 |
+
except Exception as e:
|
| 66 |
+
return {"status": "healthy", "note": str(e)}
|
backend/routers/tools.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, List
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from database import get_db, Tool, Integration
|
| 6 |
+
from auth import get_current_user, User
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import asyncio
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ToolResponse(BaseModel):
|
| 14 |
+
id: str
|
| 15 |
+
integration_id: str
|
| 16 |
+
name: str
|
| 17 |
+
description: str
|
| 18 |
+
input_schema: dict
|
| 19 |
+
output_schema: Optional[dict]
|
| 20 |
+
category: Optional[str]
|
| 21 |
+
is_active: bool
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ToolExecutionRequest(BaseModel):
|
| 25 |
+
params: dict = {}
|
| 26 |
+
connection_id: Optional[str] = None
|
| 27 |
+
dry_run: bool = False
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ToolExecutionResponse(BaseModel):
|
| 31 |
+
execution_id: str
|
| 32 |
+
status: str
|
| 33 |
+
result: Optional[dict]
|
| 34 |
+
latency_ms: int
|
| 35 |
+
executed_at: str
|
| 36 |
+
error: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("", response_model=List[ToolResponse])
|
| 40 |
+
async def list_tools(
|
| 41 |
+
integration_id: Optional[str] = None,
|
| 42 |
+
category: Optional[str] = None,
|
| 43 |
+
search: Optional[str] = None,
|
| 44 |
+
db: Session = Depends(get_db)
|
| 45 |
+
):
|
| 46 |
+
query = db.query(Tool).filter(Tool.is_active == True)
|
| 47 |
+
|
| 48 |
+
if integration_id:
|
| 49 |
+
query = query.filter(Tool.integration_id == integration_id)
|
| 50 |
+
if category:
|
| 51 |
+
query = query.filter(Tool.category == category)
|
| 52 |
+
if search:
|
| 53 |
+
query = query.filter(
|
| 54 |
+
(Tool.name.ilike(f"%{search}%")) |
|
| 55 |
+
(Tool.description.ilike(f"%{search}%"))
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
tools = query.all()
|
| 59 |
+
return [
|
| 60 |
+
ToolResponse(
|
| 61 |
+
id=t.id,
|
| 62 |
+
integration_id=t.integration_id,
|
| 63 |
+
name=t.name,
|
| 64 |
+
description=t.description,
|
| 65 |
+
input_schema=t.input_schema,
|
| 66 |
+
output_schema=t.output_schema,
|
| 67 |
+
category=t.category,
|
| 68 |
+
is_active=t.is_active
|
| 69 |
+
)
|
| 70 |
+
for t in tools
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.get("/{tool_id}", response_model=ToolResponse)
|
| 75 |
+
async def get_tool(tool_id: str, db: Session = Depends(get_db)):
|
| 76 |
+
tool = db.query(Tool).filter(Tool.id == tool_id).first()
|
| 77 |
+
if not tool:
|
| 78 |
+
raise HTTPException(status_code=404, detail="Tool not found")
|
| 79 |
+
|
| 80 |
+
return ToolResponse(
|
| 81 |
+
id=tool.id,
|
| 82 |
+
integration_id=tool.integration_id,
|
| 83 |
+
name=tool.name,
|
| 84 |
+
description=tool.description,
|
| 85 |
+
input_schema=tool.input_schema,
|
| 86 |
+
output_schema=tool.output_schema,
|
| 87 |
+
category=tool.category,
|
| 88 |
+
is_active=tool.is_active
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/{tool_id}/execute", response_model=ToolExecutionResponse)
|
| 93 |
+
async def execute_tool(
|
| 94 |
+
tool_id: str,
|
| 95 |
+
request: ToolExecutionRequest,
|
| 96 |
+
current_user: User = Depends(get_current_user),
|
| 97 |
+
db: Session = Depends(get_db)
|
| 98 |
+
):
|
| 99 |
+
tool = db.query(Tool).filter(Tool.id == tool_id).first()
|
| 100 |
+
if not tool:
|
| 101 |
+
raise HTTPException(status_code=404, detail="Tool not found")
|
| 102 |
+
|
| 103 |
+
from services.tool_executor import executor
|
| 104 |
+
result = await executor.execute(
|
| 105 |
+
tool_id=tool_id,
|
| 106 |
+
params=request.params,
|
| 107 |
+
connection_id=request.connection_id,
|
| 108 |
+
user_id=current_user.id,
|
| 109 |
+
source="api"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return ToolExecutionResponse(
|
| 113 |
+
execution_id=result["execution_id"],
|
| 114 |
+
status=result["status"],
|
| 115 |
+
result=result["result"],
|
| 116 |
+
latency_ms=result["latency_ms"],
|
| 117 |
+
executed_at=datetime.utcnow().isoformat(),
|
| 118 |
+
error=result.get("error")
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.get("/executions")
|
| 123 |
+
async def list_executions(
|
| 124 |
+
limit: int = 20,
|
| 125 |
+
current_user: User = Depends(get_current_user),
|
| 126 |
+
db: Session = Depends(get_db)
|
| 127 |
+
):
|
| 128 |
+
from database import ToolExecution
|
| 129 |
+
executions = db.query(ToolExecution).filter(
|
| 130 |
+
ToolExecution.user_id == current_user.id
|
| 131 |
+
).order_by(ToolExecution.executed_at.desc()).limit(limit).all()
|
| 132 |
+
|
| 133 |
+
return [
|
| 134 |
+
{
|
| 135 |
+
"id": e.id,
|
| 136 |
+
"tool_id": e.tool_id,
|
| 137 |
+
"status": e.status,
|
| 138 |
+
"latency_ms": e.latency_ms,
|
| 139 |
+
"executed_at": e.executed_at.isoformat(),
|
| 140 |
+
"source": e.source
|
| 141 |
+
}
|
| 142 |
+
for e in executions
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@router.post("/sync")
|
| 147 |
+
async def sync_tools(
|
| 148 |
+
force: bool = Query(False),
|
| 149 |
+
current_user: User = Depends(get_current_user),
|
| 150 |
+
db: Session = Depends(get_db)
|
| 151 |
+
):
|
| 152 |
+
"""Sync tools from Composio API to local database."""
|
| 153 |
+
import os
|
| 154 |
+
api_key = os.environ.get("COMPOSIO_API_KEY", "")
|
| 155 |
+
if not api_key:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=400,
|
| 158 |
+
detail="COMPOSIO_API_KEY environment variable is required. Set it to sync tools from Composio."
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
from integrations.registry import sync_from_composio
|
| 163 |
+
result = await sync_from_composio(api_key=api_key, force=force)
|
| 164 |
+
return {
|
| 165 |
+
"message": "Tools synced successfully",
|
| 166 |
+
"source": result["source"],
|
| 167 |
+
"toolkits": result["toolkits"],
|
| 168 |
+
"tools": result["tools"]
|
| 169 |
+
}
|
| 170 |
+
except Exception as e:
|
| 171 |
+
raise HTTPException(status_code=500, detail=f"Failed to sync tools: {str(e)}")
|
backend/services/tool_executor.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import httpx
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from database import SessionLocal, Tool, Connection, ToolExecution
|
| 6 |
+
from vault import vault
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
COMPOSIO_API_BASE = os.environ.get("COMPOSIO_API_BASE", "https://backend.composio.dev/api/v3.1")
|
| 10 |
+
COMPOSIO_API_KEY = os.environ.get("COMPOSIO_API_KEY", "")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ToolExecutor:
|
| 14 |
+
"""Executes tools either via Composio API or locally."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, api_key: str = None):
|
| 17 |
+
self.api_key = api_key or COMPOSIO_API_KEY
|
| 18 |
+
self.base_url = COMPOSIO_API_BASE.rstrip("/")
|
| 19 |
+
self._headers = {"x-api-key": self.api_key, "Content-Type": "application/json"} if self.api_key else {}
|
| 20 |
+
|
| 21 |
+
async def execute(
|
| 22 |
+
self,
|
| 23 |
+
tool_id: str,
|
| 24 |
+
params: dict,
|
| 25 |
+
connection_id: Optional[str] = None,
|
| 26 |
+
user_id: str = "",
|
| 27 |
+
source: str = "api"
|
| 28 |
+
) -> dict:
|
| 29 |
+
"""
|
| 30 |
+
Execute a tool. If COMPOSIO_API_KEY is set, uses Composio API.
|
| 31 |
+
Otherwise returns a mock response.
|
| 32 |
+
"""
|
| 33 |
+
start_time = time.time()
|
| 34 |
+
status = "success"
|
| 35 |
+
error_message = None
|
| 36 |
+
result = None
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
if self.api_key:
|
| 40 |
+
result = await self._execute_via_composio(tool_id, params, connection_id)
|
| 41 |
+
else:
|
| 42 |
+
result = await self._execute_mock(tool_id, params, connection_id)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
status = "error"
|
| 45 |
+
error_message = str(e)
|
| 46 |
+
result = {"error": error_message}
|
| 47 |
+
|
| 48 |
+
latency_ms = int((time.time() - start_time) * 1000)
|
| 49 |
+
|
| 50 |
+
# Log execution
|
| 51 |
+
self._log_execution(
|
| 52 |
+
user_id=user_id,
|
| 53 |
+
tool_id=tool_id,
|
| 54 |
+
connection_id=connection_id,
|
| 55 |
+
input_params=params,
|
| 56 |
+
output_result=result,
|
| 57 |
+
status=status,
|
| 58 |
+
latency_ms=latency_ms,
|
| 59 |
+
error_message=error_message,
|
| 60 |
+
source=source
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"execution_id": f"exec_{int(time.time() * 1000)}",
|
| 65 |
+
"status": status,
|
| 66 |
+
"result": result,
|
| 67 |
+
"latency_ms": latency_ms,
|
| 68 |
+
"error": error_message
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async def _execute_via_composio(self, tool_slug: str, arguments: dict, connection_id: str = None) -> dict:
|
| 72 |
+
"""Execute tool via Composio API."""
|
| 73 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 74 |
+
body = {"arguments": arguments}
|
| 75 |
+
if connection_id:
|
| 76 |
+
body["connected_account_id"] = connection_id
|
| 77 |
+
|
| 78 |
+
resp = await client.post(
|
| 79 |
+
f"{self.base_url}/tools/execute/{tool_slug}",
|
| 80 |
+
headers=self._headers,
|
| 81 |
+
json=body
|
| 82 |
+
)
|
| 83 |
+
resp.raise_for_status()
|
| 84 |
+
return resp.json()
|
| 85 |
+
|
| 86 |
+
async def _execute_mock(self, tool_id: str, params: dict, connection_id: str = None) -> dict:
|
| 87 |
+
"""Mock execution for when no API key is configured."""
|
| 88 |
+
return {
|
| 89 |
+
"tool_id": tool_id,
|
| 90 |
+
"params": params,
|
| 91 |
+
"connection_id": connection_id,
|
| 92 |
+
"message": f"Tool {tool_id} would execute with these parameters",
|
| 93 |
+
"note": "Set COMPOSIO_API_KEY to execute real tools via Composio API"
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def _log_execution(
|
| 97 |
+
self,
|
| 98 |
+
user_id: str,
|
| 99 |
+
tool_id: str,
|
| 100 |
+
connection_id: Optional[str],
|
| 101 |
+
input_params: dict,
|
| 102 |
+
output_result: dict,
|
| 103 |
+
status: str,
|
| 104 |
+
latency_ms: int,
|
| 105 |
+
error_message: Optional[str],
|
| 106 |
+
source: str
|
| 107 |
+
):
|
| 108 |
+
"""Log tool execution to database."""
|
| 109 |
+
if not user_id:
|
| 110 |
+
return
|
| 111 |
+
|
| 112 |
+
db = SessionLocal()
|
| 113 |
+
try:
|
| 114 |
+
execution = ToolExecution(
|
| 115 |
+
user_id=user_id,
|
| 116 |
+
tool_id=tool_id,
|
| 117 |
+
connection_id=connection_id,
|
| 118 |
+
input_params=input_params,
|
| 119 |
+
output_result=output_result,
|
| 120 |
+
status=status,
|
| 121 |
+
latency_ms=latency_ms,
|
| 122 |
+
error_message=error_message,
|
| 123 |
+
source=source
|
| 124 |
+
)
|
| 125 |
+
db.add(execution)
|
| 126 |
+
db.commit()
|
| 127 |
+
finally:
|
| 128 |
+
db.close()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# Global executor instance
|
| 132 |
+
executor = ToolExecutor()
|
backend/static/assets/index-CIpcBIY5.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/static/assets/index-DVvq7QG0.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.btn{display:flex;align-items:center;gap:.5rem;border-radius:.5rem;padding:.5rem 1rem;font-weight:500;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.btn-primary{--tw-bg-opacity: 1;background-color:rgb(6 182 212 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.btn-primary:hover{--tw-bg-opacity: 1;background-color:rgb(34 211 238 / var(--tw-bg-opacity, 1))}.btn-secondary{--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.btn-secondary:hover{--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity, 1))}.btn-ghost{background-color:transparent;--tw-text-opacity: 1;color:rgb(212 212 216 / var(--tw-text-opacity, 1))}.btn-ghost:hover{--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity, 1))}.card{border-radius:.75rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(39 39 42 / var(--tw-border-opacity, 1));background-color:#18181b80;padding:1rem}.input{border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(39 39 42 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(24 24 27 / var(--tw-bg-opacity, 1));padding:.5rem .75rem;--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.input::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(113 113 122 / var(--tw-placeholder-opacity, 1))}.input::placeholder{--tw-placeholder-opacity: 1;color:rgb(113 113 122 / var(--tw-placeholder-opacity, 1))}.input{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.input:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1));outline:2px solid transparent;outline-offset:2px}.badge{border-radius:9999px;padding:.125rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500}.absolute{position:absolute}.relative{position:relative}.left-3{left:.75rem}.top-1\/2{top:50%}.mx-auto{margin-left:auto;margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-\[400px\]{height:400px}.h-full{height:100%}.h-screen{height:100vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-16{width:4rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[120px\]{max-width:120px}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-\[var\(--border\)\]>:not([hidden])~:not([hidden]){border-color:var(--border)}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-\[var\(--border\)\]{border-color:var(--border)}.border-amber-500\/50{border-color:#f59e0b80}.border-cyan-500{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.border-red-500\/30{border-color:#ef44444d}.border-transparent{border-color:transparent}.bg-\[var\(--bg-primary\)\]{background-color:var(--bg-primary)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/5{background-color:#f59e0b0d}.bg-blue-500\/10{background-color:#3b82f61a}.bg-cyan-500{--tw-bg-opacity: 1;background-color:rgb(6 182 212 / var(--tw-bg-opacity, 1))}.bg-cyan-500\/10{background-color:#06b6d41a}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-red-500\/10{background-color:#ef44441a}.bg-zinc-700{--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity, 1))}.bg-zinc-800{--tw-bg-opacity: 1;background-color:rgb(39 39 42 / var(--tw-bg-opacity, 1))}.bg-zinc-800\/50{background-color:#27272a80}.bg-zinc-950{--tw-bg-opacity: 1;background-color:rgb(9 9 11 / var(--tw-bg-opacity, 1))}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pl-10{padding-left:2.5rem}.pr-4{padding-right:1rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.tracking-wider{letter-spacing:.05em}.text-\[var\(--text-muted\)\]{color:var(--text-muted)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-cyan-400{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.placeholder-\[var\(--text-muted\)\]::-moz-placeholder{color:var(--text-muted)}.placeholder-\[var\(--text-muted\)\]::placeholder{color:var(--text-muted)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--bg-primary: #0a0a0b;--bg-secondary: #111113;--bg-tertiary: #18181b;--bg-hover: #1f1f23;--border: #27272a;--border-hover: #3f3f46;--text-primary: #fafafa;--text-secondary: #a1a1aa;--text-muted: #71717a;--accent: #22d3ee;--accent-hover: #06b6d4;--accent-green: #10b981;--accent-red: #ef4444;--accent-yellow: #f59e0b;--accent-blue: #3b82f6}*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,sans-serif;background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--border-hover)}.last\:border-0:last-child{border-width:0px}.hover\:-translate-y-1:hover{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:transform:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-cyan-500\/30:hover{border-color:#06b6d44d}.hover\:border-cyan-500\/50:hover{border-color:#06b6d480}.hover\:bg-\[var\(--bg-hover\)\]:hover{background-color:var(--bg-hover)}.hover\:bg-cyan-500\/10:hover{background-color:#06b6d41a}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-zinc-700:hover{--tw-bg-opacity: 1;background-color:rgb(63 63 70 / var(--tw-bg-opacity, 1))}.hover\:text-cyan-400:hover{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.focus\:border-cyan-500:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
|
backend/static/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Platform - AI Tool Integration</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-CIpcBIY5.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-DVvq7QG0.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
backend/vault.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import json
|
| 4 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 5 |
+
from cryptography.hazmat.primitives import hashes
|
| 6 |
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CredentialVault:
|
| 10 |
+
def __init__(self, master_key_path: str = "/data/vault/master.key"):
|
| 11 |
+
self.master_key_path = master_key_path
|
| 12 |
+
self._key = self._load_key()
|
| 13 |
+
|
| 14 |
+
def _load_key(self) -> bytes:
|
| 15 |
+
if os.path.exists(self.master_key_path):
|
| 16 |
+
with open(self.master_key_path, "r") as f:
|
| 17 |
+
return bytes.fromhex(f.read().strip())
|
| 18 |
+
return os.urandom(32)
|
| 19 |
+
|
| 20 |
+
def _derive_key(self) -> bytes:
|
| 21 |
+
hkdf = HKDF(
|
| 22 |
+
algorithm=hashes.SHA256(),
|
| 23 |
+
length=32,
|
| 24 |
+
salt=b"platform-vault",
|
| 25 |
+
info=b"credential-encryption"
|
| 26 |
+
)
|
| 27 |
+
return hkdf.derive(self._key)
|
| 28 |
+
|
| 29 |
+
def encrypt(self, plaintext: str) -> str:
|
| 30 |
+
aesgcm = AESGCM(self._derive_key())
|
| 31 |
+
nonce = os.urandom(12)
|
| 32 |
+
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
|
| 33 |
+
return base64.b64encode(nonce + ciphertext).decode()
|
| 34 |
+
|
| 35 |
+
def decrypt(self, ciphertext_b64: str) -> str:
|
| 36 |
+
combined = base64.b64decode(ciphertext_b64)
|
| 37 |
+
aesgcm = AESGCM(self._derive_key())
|
| 38 |
+
return aesgcm.decrypt(combined[:12], combined[12:], None).decode()
|
| 39 |
+
|
| 40 |
+
def store_credentials(self, credentials: dict) -> str:
|
| 41 |
+
return self.encrypt(json.dumps(credentials))
|
| 42 |
+
|
| 43 |
+
def load_credentials(self, encrypted_blob: str) -> dict:
|
| 44 |
+
return json.loads(self.decrypt(encrypted_blob))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
vault = CredentialVault()
|
entrypoint.sh
CHANGED
|
@@ -1,15 +1,53 @@
|
|
| 1 |
#!/bin/bash
|
| 2 |
-
set -
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
else
|
| 12 |
-
|
| 13 |
fi
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
|
| 4 |
+
echo "========================================="
|
| 5 |
+
echo " Platform - Boot Sequence"
|
| 6 |
+
echo "========================================="
|
| 7 |
|
| 8 |
+
# 1. Ensure /data exists
|
| 9 |
+
mkdir -p /data/vault /data/logs /data/uploads /data/sessions /data/triggers
|
| 10 |
+
chown -R 1000:1000 /data
|
| 11 |
+
|
| 12 |
+
# 2. Initialize vault encryption key
|
| 13 |
+
if [ ! -f /data/vault/master.key ]; then
|
| 14 |
+
echo "[Vault] Generating master key..."
|
| 15 |
+
python3 -c "
|
| 16 |
+
import secrets, os
|
| 17 |
+
key = secrets.token_hex(32)
|
| 18 |
+
with open('/data/vault/master.key', 'w') as f:
|
| 19 |
+
f.write(key)
|
| 20 |
+
os.chmod('/data/vault/master.key', 0o600)
|
| 21 |
+
"
|
| 22 |
+
echo "[Vault] Master key generated"
|
| 23 |
else
|
| 24 |
+
echo "[Vault] Master key exists"
|
| 25 |
fi
|
| 26 |
|
| 27 |
+
# 3. Initialize database and sync tools
|
| 28 |
+
cd /app
|
| 29 |
+
echo "[DB] Initializing database..."
|
| 30 |
+
python3 -c "from backend.database import init_db; init_db()"
|
| 31 |
+
|
| 32 |
+
echo "[Tools] Syncing integrations and tools..."
|
| 33 |
+
python3 -c "
|
| 34 |
+
import asyncio
|
| 35 |
+
from backend.integrations.registry import sync_from_composio
|
| 36 |
+
try:
|
| 37 |
+
result = asyncio.run(sync_from_composio())
|
| 38 |
+
print(f'[Tools] Sync complete: {result}')
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f'[Tools] Sync failed (using local cache): {e}')
|
| 41 |
+
"
|
| 42 |
+
|
| 43 |
+
echo "========================================="
|
| 44 |
+
echo " Starting Platform on port 7860"
|
| 45 |
+
echo "========================================="
|
| 46 |
+
|
| 47 |
+
# 4. Start FastAPI
|
| 48 |
+
exec uvicorn backend.main:app \
|
| 49 |
+
--host 0.0.0.0 \
|
| 50 |
+
--port 7860 \
|
| 51 |
+
--workers 2 \
|
| 52 |
+
--log-level info \
|
| 53 |
+
--access-log
|
frontend/index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Platform - AI Tool Integration</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "platform-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.2.0",
|
| 13 |
+
"react-dom": "^18.2.0",
|
| 14 |
+
"react-router-dom": "^6.22.0",
|
| 15 |
+
"zustand": "^4.5.0",
|
| 16 |
+
"axios": "^1.6.7",
|
| 17 |
+
"lucide-react": "^0.330.0",
|
| 18 |
+
"recharts": "^2.12.0",
|
| 19 |
+
"clsx": "^2.1.0",
|
| 20 |
+
"date-fns": "^3.3.1"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/react": "^18.2.55",
|
| 24 |
+
"@types/react-dom": "^18.2.19",
|
| 25 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 26 |
+
"autoprefixer": "^10.4.17",
|
| 27 |
+
"postcss": "^8.4.35",
|
| 28 |
+
"tailwindcss": "^3.4.1",
|
| 29 |
+
"typescript": "^5.3.3",
|
| 30 |
+
"vite": "^5.1.0"
|
| 31 |
+
}
|
| 32 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
| 2 |
+
import { useAuthStore } from './stores/authStore'
|
| 3 |
+
import Layout from './components/layout/Layout'
|
| 4 |
+
import Dashboard from './pages/Dashboard'
|
| 5 |
+
import Apps from './pages/Apps'
|
| 6 |
+
import AppDetail from './pages/AppDetail'
|
| 7 |
+
import Connections from './pages/Connections'
|
| 8 |
+
import Tools from './pages/Tools'
|
| 9 |
+
import Playground from './pages/Playground'
|
| 10 |
+
import APIKeys from './pages/APIKeys'
|
| 11 |
+
import Analytics from './pages/Analytics'
|
| 12 |
+
import Settings from './pages/Settings'
|
| 13 |
+
import Login from './pages/Login'
|
| 14 |
+
|
| 15 |
+
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
| 16 |
+
const { token } = useAuthStore()
|
| 17 |
+
if (!token) return <Navigate to="/login" />
|
| 18 |
+
return <>{children}</>
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function App() {
|
| 22 |
+
return (
|
| 23 |
+
<BrowserRouter>
|
| 24 |
+
<Routes>
|
| 25 |
+
<Route path="/login" element={<Login />} />
|
| 26 |
+
<Route path="/" element={
|
| 27 |
+
<ProtectedRoute>
|
| 28 |
+
<Layout />
|
| 29 |
+
</ProtectedRoute>
|
| 30 |
+
}>
|
| 31 |
+
<Route index element={<Dashboard />} />
|
| 32 |
+
<Route path="apps" element={<Apps />} />
|
| 33 |
+
<Route path="apps/:id" element={<AppDetail />} />
|
| 34 |
+
<Route path="connections" element={<Connections />} />
|
| 35 |
+
<Route path="tools" element={<Tools />} />
|
| 36 |
+
<Route path="playground" element={<Playground />} />
|
| 37 |
+
<Route path="api-keys" element={<APIKeys />} />
|
| 38 |
+
<Route path="analytics" element={<Analytics />} />
|
| 39 |
+
<Route path="settings" element={<Settings />} />
|
| 40 |
+
</Route>
|
| 41 |
+
</Routes>
|
| 42 |
+
</BrowserRouter>
|
| 43 |
+
)
|
| 44 |
+
}
|
frontend/src/components/layout/Layout.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Outlet } from 'react-router-dom'
|
| 2 |
+
import Sidebar from './Sidebar'
|
| 3 |
+
import TopBar from './TopBar'
|
| 4 |
+
|
| 5 |
+
export default function Layout() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex h-screen bg-[var(--bg-primary)]">
|
| 8 |
+
<Sidebar />
|
| 9 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 10 |
+
<TopBar />
|
| 11 |
+
<main className="flex-1 overflow-auto p-6">
|
| 12 |
+
<Outlet />
|
| 13 |
+
</main>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
)
|
| 17 |
+
}
|
frontend/src/components/layout/Sidebar.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NavLink } from 'react-router-dom'
|
| 2 |
+
import { useAuthStore } from '../../stores/authStore'
|
| 3 |
+
import {
|
| 4 |
+
LayoutDashboard,
|
| 5 |
+
Plug,
|
| 6 |
+
Link2,
|
| 7 |
+
Wrench,
|
| 8 |
+
PlayCircle,
|
| 9 |
+
Key,
|
| 10 |
+
BarChart3,
|
| 11 |
+
Settings,
|
| 12 |
+
LogOut,
|
| 13 |
+
Terminal
|
| 14 |
+
} from 'lucide-react'
|
| 15 |
+
import clsx from 'clsx'
|
| 16 |
+
|
| 17 |
+
const navItems = [
|
| 18 |
+
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
| 19 |
+
{ path: '/apps', icon: Plug, label: 'Apps' },
|
| 20 |
+
{ path: '/connections', icon: Link2, label: 'Connections' },
|
| 21 |
+
{ path: '/tools', icon: Wrench, label: 'Tools' },
|
| 22 |
+
{ path: '/playground', icon: PlayCircle, label: 'Playground' },
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
const configItems = [
|
| 26 |
+
{ path: '/api-keys', icon: Key, label: 'API Keys' },
|
| 27 |
+
{ path: '/analytics', icon: BarChart3, label: 'Analytics' },
|
| 28 |
+
{ path: '/settings', icon: Settings, label: 'Settings' },
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
export default function Sidebar() {
|
| 32 |
+
const { user, logout } = useAuthStore()
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<aside className="w-64 bg-[var(--bg-secondary)] border-r border-[var(--border)] flex flex-col">
|
| 36 |
+
<div className="p-4 border-b border-[var(--border)]">
|
| 37 |
+
<div className="flex items-center gap-3">
|
| 38 |
+
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
| 39 |
+
<Terminal className="w-5 h-5 text-slate-900" />
|
| 40 |
+
</div>
|
| 41 |
+
<span className="text-lg font-semibold text-white">Platform</span>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<nav className="flex-1 p-3 space-y-1">
|
| 46 |
+
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider px-3 mb-2">
|
| 47 |
+
Main
|
| 48 |
+
</div>
|
| 49 |
+
{navItems.map((item) => (
|
| 50 |
+
<NavLink
|
| 51 |
+
key={item.path}
|
| 52 |
+
to={item.path}
|
| 53 |
+
className={({ isActive }) => clsx(
|
| 54 |
+
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
| 55 |
+
isActive
|
| 56 |
+
? 'bg-cyan-500/10 text-cyan-400'
|
| 57 |
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-white'
|
| 58 |
+
)}
|
| 59 |
+
>
|
| 60 |
+
<item.icon className="w-4 h-4" />
|
| 61 |
+
{item.label}
|
| 62 |
+
</NavLink>
|
| 63 |
+
))}
|
| 64 |
+
|
| 65 |
+
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider px-3 mt-6 mb-2">
|
| 66 |
+
Configuration
|
| 67 |
+
</div>
|
| 68 |
+
{configItems.map((item) => (
|
| 69 |
+
<NavLink
|
| 70 |
+
key={item.path}
|
| 71 |
+
to={item.path}
|
| 72 |
+
className={({ isActive }) => clsx(
|
| 73 |
+
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
| 74 |
+
isActive
|
| 75 |
+
? 'bg-cyan-500/10 text-cyan-400'
|
| 76 |
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-white'
|
| 77 |
+
)}
|
| 78 |
+
>
|
| 79 |
+
<item.icon className="w-4 h-4" />
|
| 80 |
+
{item.label}
|
| 81 |
+
</NavLink>
|
| 82 |
+
))}
|
| 83 |
+
</nav>
|
| 84 |
+
|
| 85 |
+
<div className="p-3 border-t border-[var(--border)]">
|
| 86 |
+
<div className="flex items-center justify-between">
|
| 87 |
+
<div className="flex items-center gap-2">
|
| 88 |
+
<div className="w-8 h-8 bg-zinc-700 rounded-full flex items-center justify-center text-sm font-medium">
|
| 89 |
+
{user?.email?.[0]?.toUpperCase() || 'U'}
|
| 90 |
+
</div>
|
| 91 |
+
<span className="text-sm text-[var(--text-secondary)] truncate max-w-[120px]">
|
| 92 |
+
{user?.email || 'User'}
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
<button
|
| 96 |
+
onClick={logout}
|
| 97 |
+
className="p-2 text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
| 98 |
+
title="Logout"
|
| 99 |
+
>
|
| 100 |
+
<LogOut className="w-4 h-4" />
|
| 101 |
+
</button>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="text-xs text-[var(--text-muted)] mt-2 text-center">
|
| 104 |
+
Version 1.0.0
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</aside>
|
| 108 |
+
)
|
| 109 |
+
}
|
frontend/src/components/layout/TopBar.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Search, Bell, HelpCircle } from 'lucide-react'
|
| 2 |
+
|
| 3 |
+
export default function TopBar() {
|
| 4 |
+
return (
|
| 5 |
+
<header className="h-14 bg-[var(--bg-secondary)] border-b border-[var(--border)] flex items-center justify-between px-6">
|
| 6 |
+
<div className="flex items-center gap-4 flex-1">
|
| 7 |
+
<div className="relative max-w-md flex-1">
|
| 8 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
|
| 9 |
+
<input
|
| 10 |
+
type="text"
|
| 11 |
+
placeholder="Search integrations, tools..."
|
| 12 |
+
className="w-full bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-[var(--text-muted)] focus:outline-none focus:border-cyan-500"
|
| 13 |
+
/>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div className="flex items-center gap-2">
|
| 18 |
+
<button className="p-2 text-[var(--text-muted)] hover:text-white hover:bg-[var(--bg-hover)] rounded-lg transition-colors">
|
| 19 |
+
<HelpCircle className="w-4 h-4" />
|
| 20 |
+
</button>
|
| 21 |
+
<button className="p-2 text-[var(--text-muted)] hover:text-white hover:bg-[var(--bg-hover)] rounded-lg transition-colors">
|
| 22 |
+
<Bell className="w-4 h-4" />
|
| 23 |
+
</button>
|
| 24 |
+
</div>
|
| 25 |
+
</header>
|
| 26 |
+
)
|
| 27 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg-primary: #0a0a0b;
|
| 7 |
+
--bg-secondary: #111113;
|
| 8 |
+
--bg-tertiary: #18181b;
|
| 9 |
+
--bg-hover: #1f1f23;
|
| 10 |
+
--border: #27272a;
|
| 11 |
+
--border-hover: #3f3f46;
|
| 12 |
+
--text-primary: #fafafa;
|
| 13 |
+
--text-secondary: #a1a1aa;
|
| 14 |
+
--text-muted: #71717a;
|
| 15 |
+
--accent: #22d3ee;
|
| 16 |
+
--accent-hover: #06b6d4;
|
| 17 |
+
--accent-green: #10b981;
|
| 18 |
+
--accent-red: #ef4444;
|
| 19 |
+
--accent-yellow: #f59e0b;
|
| 20 |
+
--accent-blue: #3b82f6;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
* {
|
| 24 |
+
box-sizing: border-box;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
margin: 0;
|
| 29 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 30 |
+
background-color: var(--bg-primary);
|
| 31 |
+
color: var(--text-primary);
|
| 32 |
+
-webkit-font-smoothing: antialiased;
|
| 33 |
+
-moz-osx-font-smoothing: grayscale;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
::-webkit-scrollbar {
|
| 37 |
+
width: 8px;
|
| 38 |
+
height: 8px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
::-webkit-scrollbar-track {
|
| 42 |
+
background: var(--bg-secondary);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
::-webkit-scrollbar-thumb {
|
| 46 |
+
background: var(--border);
|
| 47 |
+
border-radius: 4px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
::-webkit-scrollbar-thumb:hover {
|
| 51 |
+
background: var(--border-hover);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@layer components {
|
| 55 |
+
.btn {
|
| 56 |
+
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2;
|
| 57 |
+
}
|
| 58 |
+
.btn-primary {
|
| 59 |
+
@apply bg-cyan-500 hover:bg-cyan-400 text-slate-900;
|
| 60 |
+
}
|
| 61 |
+
.btn-secondary {
|
| 62 |
+
@apply bg-zinc-800 hover:bg-zinc-700 text-zinc-100;
|
| 63 |
+
}
|
| 64 |
+
.btn-ghost {
|
| 65 |
+
@apply bg-transparent hover:bg-zinc-800 text-zinc-300;
|
| 66 |
+
}
|
| 67 |
+
.card {
|
| 68 |
+
@apply bg-zinc-900/50 border border-zinc-800 rounded-xl p-4;
|
| 69 |
+
}
|
| 70 |
+
.input {
|
| 71 |
+
@apply bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-cyan-500 transition-colors;
|
| 72 |
+
}
|
| 73 |
+
.badge {
|
| 74 |
+
@apply px-2 py-0.5 text-xs font-medium rounded-full;
|
| 75 |
+
}
|
| 76 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
|
| 3 |
+
const api = axios.create({
|
| 4 |
+
baseURL: '',
|
| 5 |
+
headers: { 'Content-Type': 'application/json' }
|
| 6 |
+
})
|
| 7 |
+
|
| 8 |
+
api.interceptors.request.use((config) => {
|
| 9 |
+
const stored = localStorage.getItem('auth-storage')
|
| 10 |
+
if (stored) {
|
| 11 |
+
const { state } = JSON.parse(stored)
|
| 12 |
+
if (state?.token) {
|
| 13 |
+
config.headers.Authorization = `Bearer ${state.token}`
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
return config
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
api.interceptors.response.use(
|
| 20 |
+
(response) => response,
|
| 21 |
+
async (error) => {
|
| 22 |
+
if (error.response?.status === 401) {
|
| 23 |
+
localStorage.removeItem('auth-storage')
|
| 24 |
+
window.location.href = '/login'
|
| 25 |
+
}
|
| 26 |
+
return Promise.reject(error)
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
export default api
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import App from './App.tsx'
|
| 4 |
+
import './index.css'
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/APIKeys.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Plus, Trash2, Copy, Check } from 'lucide-react'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface APIKey {
|
| 6 |
+
id: string
|
| 7 |
+
name: string
|
| 8 |
+
key_prefix: string
|
| 9 |
+
created_at: string
|
| 10 |
+
last_used: string | null
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function APIKeys() {
|
| 14 |
+
const [keys, setKeys] = useState<APIKey[]>([])
|
| 15 |
+
const [loading, setLoading] = useState(true)
|
| 16 |
+
const [showCreate, setShowCreate] = useState(false)
|
| 17 |
+
const [newKeyName, setNewKeyName] = useState('')
|
| 18 |
+
const [newKey, setNewKey] = useState('')
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
fetchKeys()
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
const fetchKeys = async () => {
|
| 25 |
+
try {
|
| 26 |
+
const response = await api.get('/api/apikeys')
|
| 27 |
+
setKeys(response.data)
|
| 28 |
+
} catch (e) {
|
| 29 |
+
console.error(e)
|
| 30 |
+
} finally {
|
| 31 |
+
setLoading(false)
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const handleCreate = async () => {
|
| 36 |
+
if (!newKeyName.trim()) return
|
| 37 |
+
try {
|
| 38 |
+
const response = await api.post('/api/apikeys', { name: newKeyName })
|
| 39 |
+
setNewKey(response.data.api_key)
|
| 40 |
+
setNewKeyName('')
|
| 41 |
+
fetchKeys()
|
| 42 |
+
} catch (e) {
|
| 43 |
+
console.error(e)
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const handleDelete = async (id: string) => {
|
| 48 |
+
if (!confirm('Are you sure you want to delete this API key?')) return
|
| 49 |
+
try {
|
| 50 |
+
await api.delete(`/api/apikeys/${id}`)
|
| 51 |
+
fetchKeys()
|
| 52 |
+
} catch (e) {
|
| 53 |
+
console.error(e)
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const copyKey = (key: string) => {
|
| 58 |
+
navigator.clipboard.writeText(key)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="space-y-6">
|
| 63 |
+
<div className="flex items-center justify-between">
|
| 64 |
+
<div>
|
| 65 |
+
<h1 className="text-2xl font-semibold text-white">API Keys</h1>
|
| 66 |
+
<p className="text-[var(--text-muted)] mt-1">Manage API keys for programmatic access</p>
|
| 67 |
+
</div>
|
| 68 |
+
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
| 69 |
+
<Plus className="w-4 h-4" />
|
| 70 |
+
Create Key
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
{newKey && (
|
| 75 |
+
<div className="card border-amber-500/50 bg-amber-500/5">
|
| 76 |
+
<h3 className="font-medium text-amber-400 mb-2">API Key Created</h3>
|
| 77 |
+
<p className="text-sm text-[var(--text-muted)] mb-3">
|
| 78 |
+
Copy this key now. You won't be able to see it again!
|
| 79 |
+
</p>
|
| 80 |
+
<div className="flex items-center gap-2">
|
| 81 |
+
<code className="flex-1 bg-zinc-950 p-3 rounded-lg text-sm text-white font-mono break-all">
|
| 82 |
+
{newKey}
|
| 83 |
+
</code>
|
| 84 |
+
<button
|
| 85 |
+
onClick={() => copyKey(newKey)}
|
| 86 |
+
className="btn btn-secondary"
|
| 87 |
+
>
|
| 88 |
+
<Copy className="w-4 h-4" />
|
| 89 |
+
</button>
|
| 90 |
+
</div>
|
| 91 |
+
<button onClick={() => setNewKey('')} className="text-sm text-[var(--text-muted)] mt-2 hover:text-white">
|
| 92 |
+
I've copied the key
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{showCreate && !newKey && (
|
| 98 |
+
<div className="card">
|
| 99 |
+
<h3 className="font-medium text-white mb-3">Create New API Key</h3>
|
| 100 |
+
<div className="flex gap-3">
|
| 101 |
+
<input
|
| 102 |
+
type="text"
|
| 103 |
+
placeholder="Key name (e.g., 'Production')"
|
| 104 |
+
value={newKeyName}
|
| 105 |
+
onChange={(e) => setNewKeyName(e.target.value)}
|
| 106 |
+
className="input flex-1"
|
| 107 |
+
/>
|
| 108 |
+
<button onClick={handleCreate} className="btn btn-primary">
|
| 109 |
+
Create
|
| 110 |
+
</button>
|
| 111 |
+
<button onClick={() => setShowCreate(false)} className="btn btn-secondary">
|
| 112 |
+
Cancel
|
| 113 |
+
</button>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
+
|
| 118 |
+
{loading ? (
|
| 119 |
+
<div className="text-[var(--text-muted)]">Loading...</div>
|
| 120 |
+
) : keys.length === 0 ? (
|
| 121 |
+
<div className="card text-center py-12">
|
| 122 |
+
<p className="text-[var(--text-muted)]">No API keys yet</p>
|
| 123 |
+
</div>
|
| 124 |
+
) : (
|
| 125 |
+
<div className="card overflow-hidden p-0">
|
| 126 |
+
<table className="w-full">
|
| 127 |
+
<thead className="bg-zinc-800/50">
|
| 128 |
+
<tr>
|
| 129 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Name</th>
|
| 130 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Key</th>
|
| 131 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Created</th>
|
| 132 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Last Used</th>
|
| 133 |
+
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--text-muted)] uppercase">Actions</th>
|
| 134 |
+
</tr>
|
| 135 |
+
</thead>
|
| 136 |
+
<tbody className="divide-y divide-[var(--border)]">
|
| 137 |
+
{keys.map((key) => (
|
| 138 |
+
<tr key={key.id} className="hover:bg-[var(--bg-hover)]">
|
| 139 |
+
<td className="px-4 py-3 font-medium text-white">{key.name}</td>
|
| 140 |
+
<td className="px-4 py-3 font-mono text-sm text-[var(--text-muted)]">{key.key_prefix}</td>
|
| 141 |
+
<td className="px-4 py-3 text-[var(--text-muted)] text-sm">
|
| 142 |
+
{new Date(key.created_at).toLocaleDateString()}
|
| 143 |
+
</td>
|
| 144 |
+
<td className="px-4 py-3 text-[var(--text-muted)] text-sm">
|
| 145 |
+
{key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never'}
|
| 146 |
+
</td>
|
| 147 |
+
<td className="px-4 py-3 text-right">
|
| 148 |
+
<button
|
| 149 |
+
onClick={() => handleDelete(key.id)}
|
| 150 |
+
className="p-2 text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
| 151 |
+
>
|
| 152 |
+
<Trash2 className="w-4 h-4" />
|
| 153 |
+
</button>
|
| 154 |
+
</td>
|
| 155 |
+
</tr>
|
| 156 |
+
))}
|
| 157 |
+
</tbody>
|
| 158 |
+
</table>
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
)
|
| 163 |
+
}
|
frontend/src/pages/Analytics.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface Stats {
|
| 6 |
+
total_executions: number
|
| 7 |
+
successful: number
|
| 8 |
+
failed: number
|
| 9 |
+
success_rate: number
|
| 10 |
+
avg_latency_ms: number
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface ToolStats {
|
| 14 |
+
tool_id: string
|
| 15 |
+
count: number
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function Analytics() {
|
| 19 |
+
const [stats, setStats] = useState<Stats | null>(null)
|
| 20 |
+
const [topTools, setTopTools] = useState<ToolStats[]>([])
|
| 21 |
+
const [executions, setExecutions] = useState<any[]>([])
|
| 22 |
+
const [loading, setLoading] = useState(true)
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const fetchData = async () => {
|
| 26 |
+
try {
|
| 27 |
+
const [overview, tools, execData] = await Promise.all([
|
| 28 |
+
api.get('/api/analytics/overview'),
|
| 29 |
+
api.get('/api/analytics/top-tools'),
|
| 30 |
+
api.get('/api/analytics/executions?days=7')
|
| 31 |
+
])
|
| 32 |
+
setStats(overview.data)
|
| 33 |
+
setTopTools(tools.data.tools || [])
|
| 34 |
+
setExecutions(execData.data.executions || [])
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error(e)
|
| 37 |
+
} finally {
|
| 38 |
+
setLoading(false)
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
fetchData()
|
| 42 |
+
}, [])
|
| 43 |
+
|
| 44 |
+
if (loading) {
|
| 45 |
+
return <div className="text-[var(--text-muted)]">Loading...</div>
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="space-y-6">
|
| 50 |
+
<div>
|
| 51 |
+
<h1 className="text-2xl font-semibold text-white">Analytics</h1>
|
| 52 |
+
<p className="text-[var(--text-muted)] mt-1">Usage metrics and performance insights</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 56 |
+
<div className="card">
|
| 57 |
+
<p className="text-sm text-[var(--text-muted)]">Total Executions</p>
|
| 58 |
+
<p className="text-2xl font-semibold text-white mt-1">{stats?.total_executions || 0}</p>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="card">
|
| 61 |
+
<p className="text-sm text-[var(--text-muted)]">Successful</p>
|
| 62 |
+
<p className="text-2xl font-semibold text-emerald-400 mt-1">{stats?.successful || 0}</p>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="card">
|
| 65 |
+
<p className="text-sm text-[var(--text-muted)]">Failed</p>
|
| 66 |
+
<p className="text-2xl font-semibold text-red-400 mt-1">{stats?.failed || 0}</p>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="card">
|
| 69 |
+
<p className="text-sm text-[var(--text-muted)]">Avg Latency</p>
|
| 70 |
+
<p className="text-2xl font-semibold text-cyan-400 mt-1">{stats?.avg_latency_ms || 0}ms</p>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 75 |
+
<div className="card">
|
| 76 |
+
<h3 className="font-medium text-white mb-4">Execution Trend (7 days)</h3>
|
| 77 |
+
<div className="h-64">
|
| 78 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 79 |
+
<BarChart data={executions}>
|
| 80 |
+
<XAxis dataKey="date" stroke="#71717a" fontSize={12} />
|
| 81 |
+
<YAxis stroke="#71717a" fontSize={12} />
|
| 82 |
+
<Tooltip
|
| 83 |
+
contentStyle={{
|
| 84 |
+
backgroundColor: '#18181b',
|
| 85 |
+
border: '1px solid #27272a',
|
| 86 |
+
borderRadius: '8px'
|
| 87 |
+
}}
|
| 88 |
+
/>
|
| 89 |
+
<Bar dataKey="count" fill="#22d3ee" radius={[4, 4, 0, 0]} />
|
| 90 |
+
</BarChart>
|
| 91 |
+
</ResponsiveContainer>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div className="card">
|
| 96 |
+
<h3 className="font-medium text-white mb-4">Top Tools</h3>
|
| 97 |
+
<div className="space-y-3">
|
| 98 |
+
{topTools.map((tool, idx) => (
|
| 99 |
+
<div key={tool.tool_id} className="flex items-center justify-between">
|
| 100 |
+
<div className="flex items-center gap-3">
|
| 101 |
+
<span className="w-6 h-6 bg-zinc-800 rounded flex items-center justify-center text-xs text-[var(--text-muted)]">
|
| 102 |
+
{idx + 1}
|
| 103 |
+
</span>
|
| 104 |
+
<span className="font-mono text-sm text-white">{tool.tool_id}</span>
|
| 105 |
+
</div>
|
| 106 |
+
<span className="text-sm text-[var(--text-muted)]">{tool.count} calls</span>
|
| 107 |
+
</div>
|
| 108 |
+
))}
|
| 109 |
+
{topTools.length === 0 && (
|
| 110 |
+
<p className="text-[var(--text-muted)] text-center py-4">No tool usage data yet</p>
|
| 111 |
+
)}
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
)
|
| 117 |
+
}
|
frontend/src/pages/AppDetail.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useParams, useNavigate } from 'react-router-dom'
|
| 3 |
+
import { ArrowLeft, Check, ExternalLink, Key, Zap } from 'lucide-react'
|
| 4 |
+
import api from '../lib/api'
|
| 5 |
+
import { useAuthStore } from '../stores/authStore'
|
| 6 |
+
|
| 7 |
+
interface Integration {
|
| 8 |
+
id: string
|
| 9 |
+
name: string
|
| 10 |
+
description: string
|
| 11 |
+
logo_url: string
|
| 12 |
+
category: string
|
| 13 |
+
auth_type: string
|
| 14 |
+
oauth_scopes: string[]
|
| 15 |
+
tool_count: number
|
| 16 |
+
trigger_count: number
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface Tool {
|
| 20 |
+
id: string
|
| 21 |
+
name: string
|
| 22 |
+
description: string
|
| 23 |
+
category: string
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export default function AppDetail() {
|
| 27 |
+
const { id } = useParams<{ id: string }>()
|
| 28 |
+
const navigate = useNavigate()
|
| 29 |
+
const { token } = useAuthStore()
|
| 30 |
+
const [integration, setIntegration] = useState<Integration | null>(null)
|
| 31 |
+
const [tools, setTools] = useState<Tool[]>([])
|
| 32 |
+
const [activeTab, setActiveTab] = useState<'overview' | 'tools' | 'setup'>('overview')
|
| 33 |
+
const [loading, setLoading] = useState(true)
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const fetchData = async () => {
|
| 37 |
+
try {
|
| 38 |
+
const [intRes, toolsRes] = await Promise.all([
|
| 39 |
+
api.get(`/api/apps/${id}`),
|
| 40 |
+
api.get(`/api/tools?integration_id=${id}`)
|
| 41 |
+
])
|
| 42 |
+
setIntegration(intRes.data)
|
| 43 |
+
setTools(toolsRes.data)
|
| 44 |
+
} catch (e) {
|
| 45 |
+
console.error(e)
|
| 46 |
+
} finally {
|
| 47 |
+
setLoading(false)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
if (id) fetchData()
|
| 51 |
+
}, [id])
|
| 52 |
+
|
| 53 |
+
const handleConnect = async () => {
|
| 54 |
+
if (integration?.auth_type === 'oauth2') {
|
| 55 |
+
try {
|
| 56 |
+
const response = await api.post(`/api/apps/${id}/connect`)
|
| 57 |
+
if (response.data.auth_url) {
|
| 58 |
+
window.location.href = response.data.auth_url
|
| 59 |
+
}
|
| 60 |
+
} catch (e) {
|
| 61 |
+
console.error(e)
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
if (loading) {
|
| 67 |
+
return <div className="text-[var(--text-muted)]">Loading...</div>
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (!integration) {
|
| 71 |
+
return <div className="text-[var(--text-muted)]">Integration not found</div>
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="space-y-6">
|
| 76 |
+
<button
|
| 77 |
+
onClick={() => navigate('/apps')}
|
| 78 |
+
className="flex items-center gap-2 text-[var(--text-muted)] hover:text-white transition-colors"
|
| 79 |
+
>
|
| 80 |
+
<ArrowLeft className="w-4 h-4" />
|
| 81 |
+
Back to Integrations
|
| 82 |
+
</button>
|
| 83 |
+
|
| 84 |
+
<div className="flex items-start gap-4">
|
| 85 |
+
<img
|
| 86 |
+
src={integration.logo_url}
|
| 87 |
+
alt={integration.name}
|
| 88 |
+
className="w-16 h-16 rounded-xl bg-zinc-800"
|
| 89 |
+
/>
|
| 90 |
+
<div className="flex-1">
|
| 91 |
+
<h1 className="text-2xl font-semibold text-white">{integration.name}</h1>
|
| 92 |
+
<p className="text-[var(--text-secondary)] mt-1">{integration.description}</p>
|
| 93 |
+
<div className="flex items-center gap-3 mt-3">
|
| 94 |
+
<span className="badge bg-zinc-800 text-[var(--text-muted)]">{integration.category}</span>
|
| 95 |
+
<span className="badge bg-cyan-500/10 text-cyan-400 capitalize">{integration.auth_type}</span>
|
| 96 |
+
<span className="text-sm text-[var(--text-muted)]">{integration.tool_count} tools</span>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
<button
|
| 100 |
+
onClick={handleConnect}
|
| 101 |
+
className="btn btn-primary"
|
| 102 |
+
>
|
| 103 |
+
<Key className="w-4 h-4" />
|
| 104 |
+
Connect
|
| 105 |
+
</button>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="flex gap-2 border-b border-[var(--border)]">
|
| 109 |
+
{(['overview', 'tools', 'setup'] as const).map((tab) => (
|
| 110 |
+
<button
|
| 111 |
+
key={tab}
|
| 112 |
+
onClick={() => setActiveTab(tab)}
|
| 113 |
+
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
| 114 |
+
activeTab === tab
|
| 115 |
+
? 'border-cyan-500 text-cyan-400'
|
| 116 |
+
: 'border-transparent text-[var(--text-muted)] hover:text-white'
|
| 117 |
+
}`}
|
| 118 |
+
>
|
| 119 |
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
| 120 |
+
</button>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{activeTab === 'overview' && (
|
| 125 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 126 |
+
<div className="card">
|
| 127 |
+
<h3 className="font-medium text-white mb-4">About</h3>
|
| 128 |
+
<p className="text-[var(--text-secondary)] text-sm">{integration.description}</p>
|
| 129 |
+
{integration.oauth_scopes && integration.oauth_scopes.length > 0 && (
|
| 130 |
+
<div className="mt-4">
|
| 131 |
+
<h4 className="text-sm font-medium text-[var(--text-muted)] mb-2">Required Permissions</h4>
|
| 132 |
+
<div className="flex flex-wrap gap-2">
|
| 133 |
+
{integration.oauth_scopes.map((scope) => (
|
| 134 |
+
<span key={scope} className="px-2 py-1 bg-zinc-800 rounded text-xs text-[var(--text-secondary)]">
|
| 135 |
+
{scope}
|
| 136 |
+
</span>
|
| 137 |
+
))}
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
<div className="card">
|
| 143 |
+
<h3 className="font-medium text-white mb-4">Capabilities</h3>
|
| 144 |
+
<div className="space-y-3">
|
| 145 |
+
<div className="flex items-center gap-3">
|
| 146 |
+
<div className="p-2 bg-cyan-500/10 rounded-lg">
|
| 147 |
+
<Key className="w-4 h-4 text-cyan-400" />
|
| 148 |
+
</div>
|
| 149 |
+
<div>
|
| 150 |
+
<p className="text-sm font-medium text-white">{integration.tool_count} Tools</p>
|
| 151 |
+
<p className="text-xs text-[var(--text-muted)]">Actions you can perform</p>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
<div className="flex items-center gap-3">
|
| 155 |
+
<div className="p-2 bg-emerald-500/10 rounded-lg">
|
| 156 |
+
<Zap className="w-4 h-4 text-emerald-400" />
|
| 157 |
+
</div>
|
| 158 |
+
<div>
|
| 159 |
+
<p className="text-sm font-medium text-white">{integration.trigger_count} Triggers</p>
|
| 160 |
+
<p className="text-xs text-[var(--text-muted)]">Event-based automation</p>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
+
|
| 168 |
+
{activeTab === 'tools' && (
|
| 169 |
+
<div className="space-y-3">
|
| 170 |
+
{tools.map((tool) => (
|
| 171 |
+
<div key={tool.id} className="card">
|
| 172 |
+
<div className="flex items-start justify-between">
|
| 173 |
+
<div>
|
| 174 |
+
<h4 className="font-medium text-white">{tool.name}</h4>
|
| 175 |
+
<p className="text-sm text-[var(--text-muted)] mt-1">{tool.description}</p>
|
| 176 |
+
</div>
|
| 177 |
+
<span className="badge bg-zinc-800 text-[var(--text-muted)]">{tool.category}</span>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
))}
|
| 181 |
+
{tools.length === 0 && (
|
| 182 |
+
<div className="text-center py-8 text-[var(--text-muted)]">No tools available</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{activeTab === 'setup' && (
|
| 188 |
+
<div className="card">
|
| 189 |
+
<h3 className="font-medium text-white mb-4">Setup Instructions</h3>
|
| 190 |
+
<div className="space-y-4 text-sm text-[var(--text-secondary)]">
|
| 191 |
+
<div className="flex gap-3">
|
| 192 |
+
<span className="w-6 h-6 bg-cyan-500 rounded-full flex items-center justify-center text-xs font-medium text-slate-900">1</span>
|
| 193 |
+
<div>
|
| 194 |
+
<p className="text-white font-medium">Click "Connect" button above</p>
|
| 195 |
+
<p>You'll be redirected to {integration.name} to authorize access</p>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
<div className="flex gap-3">
|
| 199 |
+
<span className="w-6 h-6 bg-zinc-700 rounded-full flex items-center justify-center text-xs font-medium text-white">2</span>
|
| 200 |
+
<div>
|
| 201 |
+
<p className="text-white font-medium">Grant permissions</p>
|
| 202 |
+
<p>Review and allow the requested scopes</p>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
<div className="flex gap-3">
|
| 206 |
+
<span className="w-6 h-6 bg-zinc-700 rounded-full flex items-center justify-center text-xs font-medium text-white">3</span>
|
| 207 |
+
<div>
|
| 208 |
+
<p className="text-white font-medium">Start using the integration</p>
|
| 209 |
+
<p>Your connection will be saved automatically</p>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
</div>
|
| 216 |
+
)
|
| 217 |
+
}
|
frontend/src/pages/Apps.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useNavigate } from 'react-router-dom'
|
| 3 |
+
import { Search, ExternalLink } from 'lucide-react'
|
| 4 |
+
import api from '../lib/api'
|
| 5 |
+
|
| 6 |
+
interface Integration {
|
| 7 |
+
id: string
|
| 8 |
+
name: string
|
| 9 |
+
description: string
|
| 10 |
+
logo_url: string
|
| 11 |
+
category: string
|
| 12 |
+
auth_type: string
|
| 13 |
+
tool_count: number
|
| 14 |
+
trigger_count: number
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const categories = ['All', 'Developer Tools', 'Communication', 'Productivity', 'CRM', 'Finance', 'Social']
|
| 18 |
+
|
| 19 |
+
export default function Apps() {
|
| 20 |
+
const [integrations, setIntegrations] = useState<Integration[]>([])
|
| 21 |
+
const [loading, setLoading] = useState(true)
|
| 22 |
+
const [search, setSearch] = useState('')
|
| 23 |
+
const [category, setCategory] = useState('All')
|
| 24 |
+
const navigate = useNavigate()
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const fetchIntegrations = async () => {
|
| 28 |
+
try {
|
| 29 |
+
const params = new URLSearchParams()
|
| 30 |
+
if (search) params.append('search', search)
|
| 31 |
+
if (category !== 'All') params.append('category', category)
|
| 32 |
+
|
| 33 |
+
const response = await api.get(`/api/apps?${params}`)
|
| 34 |
+
setIntegrations(response.data)
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error(e)
|
| 37 |
+
} finally {
|
| 38 |
+
setLoading(false)
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
fetchIntegrations()
|
| 42 |
+
}, [search, category])
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="space-y-6">
|
| 46 |
+
<div>
|
| 47 |
+
<h1 className="text-2xl font-semibold text-white">Integrations</h1>
|
| 48 |
+
<p className="text-[var(--text-muted)] mt-1">Browse and connect to 250+ external services</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div className="flex items-center gap-4 flex-wrap">
|
| 52 |
+
<div className="relative flex-1 max-w-md">
|
| 53 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
|
| 54 |
+
<input
|
| 55 |
+
type="text"
|
| 56 |
+
placeholder="Search integrations..."
|
| 57 |
+
value={search}
|
| 58 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 59 |
+
className="input w-full pl-10"
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="flex gap-2 flex-wrap">
|
| 63 |
+
{categories.map((cat) => (
|
| 64 |
+
<button
|
| 65 |
+
key={cat}
|
| 66 |
+
onClick={() => setCategory(cat)}
|
| 67 |
+
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
| 68 |
+
category === cat
|
| 69 |
+
? 'bg-cyan-500 text-slate-900'
|
| 70 |
+
: 'bg-zinc-800 text-[var(--text-secondary)] hover:bg-zinc-700'
|
| 71 |
+
}`}
|
| 72 |
+
>
|
| 73 |
+
{cat}
|
| 74 |
+
</button>
|
| 75 |
+
))}
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
{loading ? (
|
| 80 |
+
<div className="text-center py-12 text-[var(--text-muted)]">Loading integrations...</div>
|
| 81 |
+
) : (
|
| 82 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
| 83 |
+
{integrations.map((integration) => (
|
| 84 |
+
<div
|
| 85 |
+
key={integration.id}
|
| 86 |
+
onClick={() => navigate(`/apps/${integration.id}`)}
|
| 87 |
+
className="card hover:border-cyan-500/50 cursor-pointer transition-all hover:transform hover:-translate-y-1"
|
| 88 |
+
>
|
| 89 |
+
<div className="flex items-start gap-3">
|
| 90 |
+
<img
|
| 91 |
+
src={integration.logo_url}
|
| 92 |
+
alt={integration.name}
|
| 93 |
+
className="w-10 h-10 rounded-lg bg-zinc-800"
|
| 94 |
+
/>
|
| 95 |
+
<div className="flex-1 min-w-0">
|
| 96 |
+
<h3 className="font-medium text-white">{integration.name}</h3>
|
| 97 |
+
<p className="text-xs text-[var(--text-muted)] mt-0.5">{integration.category}</p>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<p className="text-sm text-[var(--text-secondary)] mt-3 line-clamp-2">
|
| 101 |
+
{integration.description}
|
| 102 |
+
</p>
|
| 103 |
+
<div className="flex items-center justify-between mt-4 pt-3 border-t border-[var(--border)]">
|
| 104 |
+
<div className="flex gap-3 text-xs text-[var(--text-muted)]">
|
| 105 |
+
<span>{integration.tool_count} tools</span>
|
| 106 |
+
<span>{integration.trigger_count} triggers</span>
|
| 107 |
+
</div>
|
| 108 |
+
<ExternalLink className="w-4 h-4 text-[var(--text-muted)]" />
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
))}
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
|
| 115 |
+
{!loading && integrations.length === 0 && (
|
| 116 |
+
<div className="text-center py-12 text-[var(--text-muted)]">
|
| 117 |
+
No integrations found matching your criteria
|
| 118 |
+
</div>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
+
)
|
| 122 |
+
}
|
frontend/src/pages/Connections.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { RefreshCw, Trash2, CheckCircle, AlertCircle } from 'lucide-react'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface Connection {
|
| 6 |
+
id: string
|
| 7 |
+
integration_id: string
|
| 8 |
+
integration_name: string
|
| 9 |
+
account_label: string
|
| 10 |
+
status: string
|
| 11 |
+
connected_at: string
|
| 12 |
+
last_used: string | null
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function Connections() {
|
| 16 |
+
const [connections, setConnections] = useState<Connection[]>([])
|
| 17 |
+
const [loading, setLoading] = useState(true)
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchConnections = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const response = await api.get('/api/connections')
|
| 23 |
+
setConnections(response.data)
|
| 24 |
+
} catch (e) {
|
| 25 |
+
console.error(e)
|
| 26 |
+
} finally {
|
| 27 |
+
setLoading(false)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
fetchConnections()
|
| 31 |
+
}, [])
|
| 32 |
+
|
| 33 |
+
const handleDisconnect = async (id: string) => {
|
| 34 |
+
if (!confirm('Are you sure you want to disconnect this account?')) return
|
| 35 |
+
try {
|
| 36 |
+
await api.delete(`/api/connections/${id}`)
|
| 37 |
+
setConnections(connections.filter(c => c.id !== id))
|
| 38 |
+
} catch (e) {
|
| 39 |
+
console.error(e)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const handleRefresh = async (id: string) => {
|
| 44 |
+
try {
|
| 45 |
+
await api.post(`/api/connections/${id}/refresh`)
|
| 46 |
+
const response = await api.get('/api/connections')
|
| 47 |
+
setConnections(response.data)
|
| 48 |
+
} catch (e) {
|
| 49 |
+
console.error(e)
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="space-y-6">
|
| 55 |
+
<div>
|
| 56 |
+
<h1 className="text-2xl font-semibold text-white">Connections</h1>
|
| 57 |
+
<p className="text-[var(--text-muted)] mt-1">Manage your connected accounts</p>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{loading ? (
|
| 61 |
+
<div className="text-[var(--text-muted)]">Loading...</div>
|
| 62 |
+
) : connections.length === 0 ? (
|
| 63 |
+
<div className="card text-center py-12">
|
| 64 |
+
<p className="text-[var(--text-muted)]">No connections yet</p>
|
| 65 |
+
<p className="text-sm text-[var(--text-muted)] mt-1">Connect an app to get started</p>
|
| 66 |
+
</div>
|
| 67 |
+
) : (
|
| 68 |
+
<div className="card overflow-hidden p-0">
|
| 69 |
+
<table className="w-full">
|
| 70 |
+
<thead className="bg-zinc-800/50">
|
| 71 |
+
<tr>
|
| 72 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">App</th>
|
| 73 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Account</th>
|
| 74 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Status</th>
|
| 75 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Connected</th>
|
| 76 |
+
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Last Used</th>
|
| 77 |
+
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--text-muted)] uppercase">Actions</th>
|
| 78 |
+
</tr>
|
| 79 |
+
</thead>
|
| 80 |
+
<tbody className="divide-y divide-[var(--border)]">
|
| 81 |
+
{connections.map((conn) => (
|
| 82 |
+
<tr key={conn.id} className="hover:bg-[var(--bg-hover)]">
|
| 83 |
+
<td className="px-4 py-3">
|
| 84 |
+
<span className="font-medium text-white">{conn.integration_name}</span>
|
| 85 |
+
</td>
|
| 86 |
+
<td className="px-4 py-3 text-[var(--text-secondary)]">
|
| 87 |
+
{conn.account_label || '-'}
|
| 88 |
+
</td>
|
| 89 |
+
<td className="px-4 py-3">
|
| 90 |
+
{conn.status === 'active' ? (
|
| 91 |
+
<span className="flex items-center gap-1 text-emerald-400 text-sm">
|
| 92 |
+
<CheckCircle className="w-4 h-4" /> Active
|
| 93 |
+
</span>
|
| 94 |
+
) : (
|
| 95 |
+
<span className="flex items-center gap-1 text-amber-400 text-sm">
|
| 96 |
+
<AlertCircle className="w-4 h-4" /> {conn.status}
|
| 97 |
+
</span>
|
| 98 |
+
)}
|
| 99 |
+
</td>
|
| 100 |
+
<td className="px-4 py-3 text-[var(--text-muted)] text-sm">
|
| 101 |
+
{new Date(conn.connected_at).toLocaleDateString()}
|
| 102 |
+
</td>
|
| 103 |
+
<td className="px-4 py-3 text-[var(--text-muted)] text-sm">
|
| 104 |
+
{conn.last_used ? new Date(conn.last_used).toLocaleDateString() : 'Never'}
|
| 105 |
+
</td>
|
| 106 |
+
<td className="px-4 py-3">
|
| 107 |
+
<div className="flex items-center justify-end gap-2">
|
| 108 |
+
<button
|
| 109 |
+
onClick={() => handleRefresh(conn.id)}
|
| 110 |
+
className="p-2 text-[var(--text-muted)] hover:text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-colors"
|
| 111 |
+
title="Refresh"
|
| 112 |
+
>
|
| 113 |
+
<RefreshCw className="w-4 h-4" />
|
| 114 |
+
</button>
|
| 115 |
+
<button
|
| 116 |
+
onClick={() => handleDisconnect(conn.id)}
|
| 117 |
+
className="p-2 text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
| 118 |
+
title="Disconnect"
|
| 119 |
+
>
|
| 120 |
+
<Trash2 className="w-4 h-4" />
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
</td>
|
| 124 |
+
</tr>
|
| 125 |
+
))}
|
| 126 |
+
</tbody>
|
| 127 |
+
</table>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
)
|
| 132 |
+
}
|
frontend/src/pages/Dashboard.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Activity, Plug, Zap, CheckCircle, AlertCircle, Clock } from 'lucide-react'
|
| 3 |
+
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
|
| 4 |
+
import api from '../lib/api'
|
| 5 |
+
|
| 6 |
+
interface Stats {
|
| 7 |
+
total_executions: number
|
| 8 |
+
successful: number
|
| 9 |
+
failed: number
|
| 10 |
+
success_rate: number
|
| 11 |
+
avg_latency_ms: number
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface Execution {
|
| 15 |
+
id: string
|
| 16 |
+
tool_id: string
|
| 17 |
+
status: string
|
| 18 |
+
latency_ms: number
|
| 19 |
+
executed_at: string
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default function Dashboard() {
|
| 23 |
+
const [stats, setStats] = useState<Stats | null>(null)
|
| 24 |
+
const [executions, setExecutions] = useState<Execution[]>([])
|
| 25 |
+
const [loading, setLoading] = useState(true)
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
const fetchData = async () => {
|
| 29 |
+
try {
|
| 30 |
+
const [statsRes, execRes] = await Promise.all([
|
| 31 |
+
api.get('/api/analytics/overview'),
|
| 32 |
+
api.get('/api/tools/executions?limit=10')
|
| 33 |
+
])
|
| 34 |
+
setStats(statsRes.data)
|
| 35 |
+
setExecutions(execRes.data)
|
| 36 |
+
} catch (e) {
|
| 37 |
+
console.error(e)
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
fetchData()
|
| 43 |
+
}, [])
|
| 44 |
+
|
| 45 |
+
const metricCards = [
|
| 46 |
+
{
|
| 47 |
+
label: 'Total Executions',
|
| 48 |
+
value: stats?.total_executions || 0,
|
| 49 |
+
icon: Activity,
|
| 50 |
+
color: 'text-cyan-400',
|
| 51 |
+
bg: 'bg-cyan-500/10'
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
label: 'Connected Apps',
|
| 55 |
+
value: '12 / 18',
|
| 56 |
+
icon: Plug,
|
| 57 |
+
color: 'text-emerald-400',
|
| 58 |
+
bg: 'bg-emerald-500/10'
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
label: 'Active Triggers',
|
| 62 |
+
value: '5',
|
| 63 |
+
icon: Zap,
|
| 64 |
+
color: 'text-amber-400',
|
| 65 |
+
bg: 'bg-amber-500/10'
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
label: 'Success Rate',
|
| 69 |
+
value: `${stats?.success_rate || 100}%`,
|
| 70 |
+
icon: CheckCircle,
|
| 71 |
+
color: 'text-blue-400',
|
| 72 |
+
bg: 'bg-blue-500/10'
|
| 73 |
+
},
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div className="space-y-6">
|
| 78 |
+
<div>
|
| 79 |
+
<h1 className="text-2xl font-semibold text-white">Dashboard</h1>
|
| 80 |
+
<p className="text-[var(--text-muted)] mt-1">Overview of your integration platform</p>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 84 |
+
{metricCards.map((card) => (
|
| 85 |
+
<div key={card.label} className="card">
|
| 86 |
+
<div className="flex items-center justify-between">
|
| 87 |
+
<div>
|
| 88 |
+
<p className="text-sm text-[var(--text-muted)]">{card.label}</p>
|
| 89 |
+
<p className="text-2xl font-semibold text-white mt-1">{card.value}</p>
|
| 90 |
+
</div>
|
| 91 |
+
<div className={`p-3 rounded-lg ${card.bg}`}>
|
| 92 |
+
<card.icon className={`w-5 h-5 ${card.color}`} />
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 100 |
+
<div className="card">
|
| 101 |
+
<h3 className="text-lg font-medium text-white mb-4">Recent Executions</h3>
|
| 102 |
+
{loading ? (
|
| 103 |
+
<div className="text-[var(--text-muted)]">Loading...</div>
|
| 104 |
+
) : executions.length === 0 ? (
|
| 105 |
+
<div className="text-[var(--text-muted)] text-center py-8">
|
| 106 |
+
No executions yet. Start by connecting an app!
|
| 107 |
+
</div>
|
| 108 |
+
) : (
|
| 109 |
+
<div className="space-y-3">
|
| 110 |
+
{executions.slice(0, 5).map((exec) => (
|
| 111 |
+
<div key={exec.id} className="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
|
| 112 |
+
<div className="flex items-center gap-3">
|
| 113 |
+
{exec.status === 'success' ? (
|
| 114 |
+
<CheckCircle className="w-4 h-4 text-emerald-400" />
|
| 115 |
+
) : (
|
| 116 |
+
<AlertCircle className="w-4 h-4 text-red-400" />
|
| 117 |
+
)}
|
| 118 |
+
<span className="text-sm font-mono text-white">{exec.tool_id}</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="flex items-center gap-4 text-sm text-[var(--text-muted)]">
|
| 121 |
+
<span className="flex items-center gap-1">
|
| 122 |
+
<Clock className="w-3 h-3" />
|
| 123 |
+
{exec.latency_ms}ms
|
| 124 |
+
</span>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div className="card">
|
| 133 |
+
<h3 className="text-lg font-medium text-white mb-4">System Health</h3>
|
| 134 |
+
<div className="space-y-4">
|
| 135 |
+
<div>
|
| 136 |
+
<div className="flex justify-between text-sm mb-1">
|
| 137 |
+
<span className="text-[var(--text-muted)]">Disk Usage</span>
|
| 138 |
+
<span className="text-white">45%</span>
|
| 139 |
+
</div>
|
| 140 |
+
<div className="h-2 bg-[var(--bg-primary)] rounded-full overflow-hidden">
|
| 141 |
+
<div className="h-full bg-cyan-500 rounded-full" style={{ width: '45%' }} />
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<div>
|
| 145 |
+
<div className="flex justify-between text-sm mb-1">
|
| 146 |
+
<span className="text-[var(--text-muted)]">Memory</span>
|
| 147 |
+
<span className="text-white">32%</span>
|
| 148 |
+
</div>
|
| 149 |
+
<div className="h-2 bg-[var(--bg-primary)] rounded-full overflow-hidden">
|
| 150 |
+
<div className="h-full bg-emerald-500 rounded-full" style={{ width: '32%' }} />
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
<div>
|
| 154 |
+
<div className="flex justify-between text-sm mb-1">
|
| 155 |
+
<span className="text-[var(--text-muted)]">CPU</span>
|
| 156 |
+
<span className="text-white">12%</span>
|
| 157 |
+
</div>
|
| 158 |
+
<div className="h-2 bg-[var(--bg-primary)] rounded-full overflow-hidden">
|
| 159 |
+
<div className="h-full bg-amber-500 rounded-full" style={{ width: '12%' }} />
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="card">
|
| 167 |
+
<h3 className="text-lg font-medium text-white mb-4">Execution Trends (7 days)</h3>
|
| 168 |
+
<div className="h-64">
|
| 169 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 170 |
+
<LineChart data={[]}>
|
| 171 |
+
<XAxis dataKey="date" stroke="#71717a" fontSize={12} />
|
| 172 |
+
<YAxis stroke="#71717a" fontSize={12} />
|
| 173 |
+
<Tooltip
|
| 174 |
+
contentStyle={{
|
| 175 |
+
backgroundColor: '#18181b',
|
| 176 |
+
border: '1px solid #27272a',
|
| 177 |
+
borderRadius: '8px'
|
| 178 |
+
}}
|
| 179 |
+
/>
|
| 180 |
+
<Line type="monotone" dataKey="count" stroke="#22d3ee" strokeWidth={2} dot={false} />
|
| 181 |
+
</LineChart>
|
| 182 |
+
</ResponsiveContainer>
|
| 183 |
+
</div>
|
| 184 |
+
<p className="text-sm text-[var(--text-muted)] text-center mt-2">No data available yet</p>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
)
|
| 188 |
+
}
|
frontend/src/pages/Login.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { useNavigate } from 'react-router-dom'
|
| 3 |
+
import { Terminal, LogIn, UserPlus } from 'lucide-react'
|
| 4 |
+
import { useAuthStore } from '../stores/authStore'
|
| 5 |
+
|
| 6 |
+
export default function Login() {
|
| 7 |
+
const [isLogin, setIsLogin] = useState(true)
|
| 8 |
+
const [email, setEmail] = useState('')
|
| 9 |
+
const [password, setPassword] = useState('')
|
| 10 |
+
const [error, setError] = useState('')
|
| 11 |
+
const [loading, setLoading] = useState(false)
|
| 12 |
+
const { login, register } = useAuthStore()
|
| 13 |
+
const navigate = useNavigate()
|
| 14 |
+
|
| 15 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 16 |
+
e.preventDefault()
|
| 17 |
+
setError('')
|
| 18 |
+
setLoading(true)
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
if (isLogin) {
|
| 22 |
+
await login(email, password)
|
| 23 |
+
} else {
|
| 24 |
+
await register(email, password)
|
| 25 |
+
await login(email, password)
|
| 26 |
+
}
|
| 27 |
+
navigate('/')
|
| 28 |
+
} catch (err: any) {
|
| 29 |
+
setError(err.response?.data?.detail || 'An error occurred')
|
| 30 |
+
} finally {
|
| 31 |
+
setLoading(false)
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="min-h-screen bg-[var(--bg-primary)] flex items-center justify-center p-4">
|
| 37 |
+
<div className="w-full max-w-md">
|
| 38 |
+
<div className="text-center mb-8">
|
| 39 |
+
<div className="w-16 h-16 bg-cyan-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
| 40 |
+
<Terminal className="w-8 h-8 text-slate-900" />
|
| 41 |
+
</div>
|
| 42 |
+
<h1 className="text-2xl font-semibold text-white">Platform</h1>
|
| 43 |
+
<p className="text-[var(--text-muted)] mt-1">AI Tool Integration Platform</p>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="card">
|
| 47 |
+
<h2 className="text-lg font-medium text-white mb-6 text-center">
|
| 48 |
+
{isLogin ? 'Sign in to your account' : 'Create an account'}
|
| 49 |
+
</h2>
|
| 50 |
+
|
| 51 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 52 |
+
{error && (
|
| 53 |
+
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
| 54 |
+
{error}
|
| 55 |
+
</div>
|
| 56 |
+
)}
|
| 57 |
+
|
| 58 |
+
<div>
|
| 59 |
+
<label className="block text-sm text-[var(--text-muted)] mb-1">Email</label>
|
| 60 |
+
<input
|
| 61 |
+
type="email"
|
| 62 |
+
value={email}
|
| 63 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 64 |
+
className="input w-full"
|
| 65 |
+
placeholder="you@example.com"
|
| 66 |
+
required
|
| 67 |
+
/>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div>
|
| 71 |
+
<label className="block text-sm text-[var(--text-muted)] mb-1">Password</label>
|
| 72 |
+
<input
|
| 73 |
+
type="password"
|
| 74 |
+
value={password}
|
| 75 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 76 |
+
className="input w-full"
|
| 77 |
+
placeholder="••••••••"
|
| 78 |
+
required
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<button
|
| 83 |
+
type="submit"
|
| 84 |
+
disabled={loading}
|
| 85 |
+
className="btn btn-primary w-full justify-center"
|
| 86 |
+
>
|
| 87 |
+
{loading ? (
|
| 88 |
+
'Loading...'
|
| 89 |
+
) : isLogin ? (
|
| 90 |
+
<>
|
| 91 |
+
<LogIn className="w-4 h-4" />
|
| 92 |
+
Sign In
|
| 93 |
+
</>
|
| 94 |
+
) : (
|
| 95 |
+
<>
|
| 96 |
+
<UserPlus className="w-4 h-4" />
|
| 97 |
+
Create Account
|
| 98 |
+
</>
|
| 99 |
+
)}
|
| 100 |
+
</button>
|
| 101 |
+
</form>
|
| 102 |
+
|
| 103 |
+
<div className="mt-6 text-center">
|
| 104 |
+
<button
|
| 105 |
+
onClick={() => setIsLogin(!isLogin)}
|
| 106 |
+
className="text-sm text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
| 107 |
+
>
|
| 108 |
+
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<p className="text-xs text-[var(--text-muted)] text-center mt-6">
|
| 114 |
+
Default credentials: admin@platform.local / admin123
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
)
|
| 119 |
+
}
|
frontend/src/pages/Playground.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Play, Send } from 'lucide-react'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface Tool {
|
| 6 |
+
id: string
|
| 7 |
+
name: string
|
| 8 |
+
description: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function Playground() {
|
| 12 |
+
const [tools, setTools] = useState<Tool[]>([])
|
| 13 |
+
const [selectedTool, setSelectedTool] = useState<string>('')
|
| 14 |
+
const [params, setParams] = useState('')
|
| 15 |
+
const [result, setResult] = useState<string>('')
|
| 16 |
+
const [loading, setLoading] = useState(false)
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const fetchTools = async () => {
|
| 20 |
+
try {
|
| 21 |
+
const response = await api.get('/api/playground/tools')
|
| 22 |
+
setTools(response.data.tools || [])
|
| 23 |
+
} catch (e) {
|
| 24 |
+
console.error(e)
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
fetchTools()
|
| 28 |
+
}, [])
|
| 29 |
+
|
| 30 |
+
const handleRun = async () => {
|
| 31 |
+
setLoading(true)
|
| 32 |
+
setResult('')
|
| 33 |
+
try {
|
| 34 |
+
const parsedParams = params ? JSON.parse(params) : {}
|
| 35 |
+
const response = await api.post('/api/playground/run', {
|
| 36 |
+
tool_id: selectedTool,
|
| 37 |
+
params: parsedParams
|
| 38 |
+
})
|
| 39 |
+
setResult(JSON.stringify(response.data, null, 2))
|
| 40 |
+
} catch (e: any) {
|
| 41 |
+
setResult(`Error: ${e.response?.data?.detail || e.message}`)
|
| 42 |
+
} finally {
|
| 43 |
+
setLoading(false)
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="space-y-6">
|
| 49 |
+
<div>
|
| 50 |
+
<h1 className="text-2xl font-semibold text-white">Playground</h1>
|
| 51 |
+
<p className="text-[var(--text-muted)] mt-1">Test tools with your connected accounts</p>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 55 |
+
<div className="space-y-4">
|
| 56 |
+
<div className="card">
|
| 57 |
+
<h3 className="font-medium text-white mb-3">Select Tool</h3>
|
| 58 |
+
<select
|
| 59 |
+
value={selectedTool}
|
| 60 |
+
onChange={(e) => setSelectedTool(e.target.value)}
|
| 61 |
+
className="input w-full"
|
| 62 |
+
>
|
| 63 |
+
<option value="">Choose a tool...</option>
|
| 64 |
+
{tools.map((tool) => (
|
| 65 |
+
<option key={tool.id} value={tool.id}>
|
| 66 |
+
{tool.name}
|
| 67 |
+
</option>
|
| 68 |
+
))}
|
| 69 |
+
</select>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="card">
|
| 73 |
+
<h3 className="font-medium text-white mb-3">Parameters (JSON)</h3>
|
| 74 |
+
<textarea
|
| 75 |
+
value={params}
|
| 76 |
+
onChange={(e) => setParams(e.target.value)}
|
| 77 |
+
placeholder='{"key": "value"}'
|
| 78 |
+
className="input w-full h-48 font-mono text-sm"
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<button
|
| 83 |
+
onClick={handleRun}
|
| 84 |
+
disabled={!selectedTool || loading}
|
| 85 |
+
className="btn btn-primary w-full justify-center"
|
| 86 |
+
>
|
| 87 |
+
{loading ? (
|
| 88 |
+
'Running...'
|
| 89 |
+
) : (
|
| 90 |
+
<>
|
| 91 |
+
<Play className="w-4 h-4" />
|
| 92 |
+
Execute Tool
|
| 93 |
+
</>
|
| 94 |
+
)}
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div className="card">
|
| 99 |
+
<h3 className="font-medium text-white mb-3">Output</h3>
|
| 100 |
+
<pre className="bg-zinc-950 p-4 rounded-lg text-sm text-[var(--text-secondary)] overflow-auto h-[400px]">
|
| 101 |
+
{result || 'Execute a tool to see results here...'}
|
| 102 |
+
</pre>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
)
|
| 107 |
+
}
|
frontend/src/pages/Settings.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Save, Server } from 'lucide-react'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface Health {
|
| 6 |
+
status: string
|
| 7 |
+
disk?: { total_gb: number; used_gb: number; percent: number }
|
| 8 |
+
memory?: { total_gb: number; used_gb: number; percent: number }
|
| 9 |
+
cpu_percent?: number
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface Settings {
|
| 13 |
+
data_dir: string
|
| 14 |
+
platform_version: string
|
| 15 |
+
integrations_count: number
|
| 16 |
+
features: Record<string, boolean>
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function Settings() {
|
| 20 |
+
const [settings, setSettings] = useState<Settings | null>(null)
|
| 21 |
+
const [health, setHealth] = useState<Health | null>(null)
|
| 22 |
+
const [loading, setLoading] = useState(true)
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const fetchData = async () => {
|
| 26 |
+
try {
|
| 27 |
+
const [settingsRes, healthRes] = await Promise.all([
|
| 28 |
+
api.get('/api/settings'),
|
| 29 |
+
api.get('/api/settings/health')
|
| 30 |
+
])
|
| 31 |
+
setSettings(settingsRes.data)
|
| 32 |
+
setHealth(healthRes.data)
|
| 33 |
+
} catch (e) {
|
| 34 |
+
console.error(e)
|
| 35 |
+
} finally {
|
| 36 |
+
setLoading(false)
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
fetchData()
|
| 40 |
+
}, [])
|
| 41 |
+
|
| 42 |
+
if (loading) {
|
| 43 |
+
return <div className="text-[var(--text-muted)]">Loading...</div>
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="space-y-6">
|
| 48 |
+
<div>
|
| 49 |
+
<h1 className="text-2xl font-semibold text-white">Settings</h1>
|
| 50 |
+
<p className="text-[var(--text-muted)] mt-1">Platform configuration and system status</p>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 54 |
+
<div className="card">
|
| 55 |
+
<div className="flex items-center gap-2 mb-4">
|
| 56 |
+
<Server className="w-5 h-5 text-cyan-400" />
|
| 57 |
+
<h3 className="font-medium text-white">System Health</h3>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{health && (
|
| 61 |
+
<div className="space-y-4">
|
| 62 |
+
<div>
|
| 63 |
+
<div className="flex justify-between text-sm mb-1">
|
| 64 |
+
<span className="text-[var(--text-muted)]">Disk</span>
|
| 65 |
+
<span className="text-white">{health.disk?.percent}%</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
| 68 |
+
<div className="h-full bg-cyan-500 rounded-full" style={{ width: `${health.disk?.percent}%` }} />
|
| 69 |
+
</div>
|
| 70 |
+
<p className="text-xs text-[var(--text-muted)] mt-1">
|
| 71 |
+
{health.disk?.used_gb}GB / {health.disk?.total_gb}GB
|
| 72 |
+
</p>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div>
|
| 76 |
+
<div className="flex justify-between text-sm mb-1">
|
| 77 |
+
<span className="text-[var(--text-muted)]">Memory</span>
|
| 78 |
+
<span className="text-white">{health.memory?.percent}%</span>
|
| 79 |
+
</div>
|
| 80 |
+
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
| 81 |
+
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${health.memory?.percent}%` }} />
|
| 82 |
+
</div>
|
| 83 |
+
<p className="text-xs text-[var(--text-muted)] mt-1">
|
| 84 |
+
{health.memory?.used_gb}GB / {health.memory?.total_gb}GB
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div>
|
| 89 |
+
<div className="flex justify-between text-sm mb-1">
|
| 90 |
+
<span className="text-[var(--text-muted)]">CPU</span>
|
| 91 |
+
<span className="text-white">{health.cpu_percent}%</span>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
| 94 |
+
<div className="h-full bg-amber-500 rounded-full" style={{ width: `${health.cpu_percent}%` }} />
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="card">
|
| 102 |
+
<h3 className="font-medium text-white mb-4">Platform Info</h3>
|
| 103 |
+
<div className="space-y-3">
|
| 104 |
+
<div className="flex justify-between">
|
| 105 |
+
<span className="text-[var(--text-muted)]">Version</span>
|
| 106 |
+
<span className="text-white">{settings?.platform_version}</span>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex justify-between">
|
| 109 |
+
<span className="text-[var(--text-muted)]">Data Directory</span>
|
| 110 |
+
<span className="text-white font-mono text-sm">{settings?.data_dir}</span>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="flex justify-between">
|
| 113 |
+
<span className="text-[var(--text-muted)]">Integrations</span>
|
| 114 |
+
<span className="text-white">{settings?.integrations_count}</span>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<h4 className="font-medium text-white mt-6 mb-3">Features</h4>
|
| 119 |
+
<div className="flex flex-wrap gap-2">
|
| 120 |
+
{settings?.features && Object.entries(settings.features).map(([key, enabled]) => (
|
| 121 |
+
<span
|
| 122 |
+
key={key}
|
| 123 |
+
className={`px-2 py-1 rounded text-xs font-medium ${
|
| 124 |
+
enabled
|
| 125 |
+
? 'bg-emerald-500/10 text-emerald-400'
|
| 126 |
+
: 'bg-zinc-800 text-[var(--text-muted)]'
|
| 127 |
+
}`}
|
| 128 |
+
>
|
| 129 |
+
{key}
|
| 130 |
+
</span>
|
| 131 |
+
))}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
)
|
| 137 |
+
}
|
frontend/src/pages/Tools.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { Search, Play } from 'lucide-react'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface Tool {
|
| 6 |
+
id: string
|
| 7 |
+
integration_id: string
|
| 8 |
+
name: string
|
| 9 |
+
description: string
|
| 10 |
+
category: string
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function Tools() {
|
| 14 |
+
const [tools, setTools] = useState<Tool[]>([])
|
| 15 |
+
const [loading, setLoading] = useState(true)
|
| 16 |
+
const [search, setSearch] = useState('')
|
| 17 |
+
const [selectedTool, setSelectedTool] = useState<Tool | null>(null)
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchTools = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const params = search ? `?search=${search}` : ''
|
| 23 |
+
const response = await api.get(`/api/tools${params}`)
|
| 24 |
+
setTools(response.data)
|
| 25 |
+
} catch (e) {
|
| 26 |
+
console.error(e)
|
| 27 |
+
} finally {
|
| 28 |
+
setLoading(false)
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
fetchTools()
|
| 32 |
+
}, [search])
|
| 33 |
+
|
| 34 |
+
const handleExecute = async (toolId: string) => {
|
| 35 |
+
try {
|
| 36 |
+
const response = await api.post(`/api/tools/${toolId}/execute`, {
|
| 37 |
+
params: {}
|
| 38 |
+
})
|
| 39 |
+
alert(`Executed: ${response.data.status}`)
|
| 40 |
+
} catch (e) {
|
| 41 |
+
console.error(e)
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="space-y-6">
|
| 47 |
+
<div>
|
| 48 |
+
<h1 className="text-2xl font-semibold text-white">Tools</h1>
|
| 49 |
+
<p className="text-[var(--text-muted)] mt-1">Browse all available tools across integrations</p>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="relative max-w-md">
|
| 53 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
|
| 54 |
+
<input
|
| 55 |
+
type="text"
|
| 56 |
+
placeholder="Search tools..."
|
| 57 |
+
value={search}
|
| 58 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 59 |
+
className="input w-full pl-10"
|
| 60 |
+
/>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{loading ? (
|
| 64 |
+
<div className="text-[var(--text-muted)]">Loading...</div>
|
| 65 |
+
) : (
|
| 66 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 67 |
+
{tools.map((tool) => (
|
| 68 |
+
<div
|
| 69 |
+
key={tool.id}
|
| 70 |
+
className="card hover:border-cyan-500/30 transition-all"
|
| 71 |
+
>
|
| 72 |
+
<div className="flex items-start justify-between">
|
| 73 |
+
<div>
|
| 74 |
+
<h3 className="font-medium text-white">{tool.name}</h3>
|
| 75 |
+
<p className="text-sm text-[var(--text-muted)] mt-1">{tool.description}</p>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="flex items-center justify-between mt-4">
|
| 79 |
+
<span className="text-xs text-[var(--text-muted)] bg-zinc-800 px-2 py-1 rounded">
|
| 80 |
+
{tool.integration_id}
|
| 81 |
+
</span>
|
| 82 |
+
<button
|
| 83 |
+
onClick={() => handleExecute(tool.id)}
|
| 84 |
+
className="btn btn-ghost text-sm py-1 px-2"
|
| 85 |
+
>
|
| 86 |
+
<Play className="w-3 h-3" />
|
| 87 |
+
Run
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
))}
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
|
| 95 |
+
{!loading && tools.length === 0 && (
|
| 96 |
+
<div className="text-center py-12 text-[var(--text-muted)]">No tools found</div>
|
| 97 |
+
)}
|
| 98 |
+
</div>
|
| 99 |
+
)
|
| 100 |
+
}
|
frontend/src/stores/authStore.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { create } from 'zustand'
|
| 2 |
+
import { persist } from 'zustand/middleware'
|
| 3 |
+
import api from '../lib/api'
|
| 4 |
+
|
| 5 |
+
interface User {
|
| 6 |
+
id: string
|
| 7 |
+
email: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface AuthState {
|
| 11 |
+
token: string | null
|
| 12 |
+
refreshToken: string | null
|
| 13 |
+
user: User | null
|
| 14 |
+
login: (email: string, password: string) => Promise<void>
|
| 15 |
+
logout: () => void
|
| 16 |
+
register: (email: string, password: string) => Promise<void>
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const useAuthStore = create<AuthState>()(
|
| 20 |
+
persist(
|
| 21 |
+
(set) => ({
|
| 22 |
+
token: null,
|
| 23 |
+
refreshToken: null,
|
| 24 |
+
user: null,
|
| 25 |
+
|
| 26 |
+
login: async (email: string, password: string) => {
|
| 27 |
+
const formData = new URLSearchParams()
|
| 28 |
+
formData.append('username', email)
|
| 29 |
+
formData.append('password', password)
|
| 30 |
+
|
| 31 |
+
const response = await api.post('/api/auth/login', formData, {
|
| 32 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
set({
|
| 36 |
+
token: response.data.access_token,
|
| 37 |
+
refreshToken: response.data.refresh_token,
|
| 38 |
+
user: response.data.user
|
| 39 |
+
})
|
| 40 |
+
},
|
| 41 |
+
|
| 42 |
+
logout: () => {
|
| 43 |
+
set({ token: null, refreshToken: null, user: null })
|
| 44 |
+
},
|
| 45 |
+
|
| 46 |
+
register: async (email: string, password: string) => {
|
| 47 |
+
await api.post('/api/auth/register', { email, password })
|
| 48 |
+
}
|
| 49 |
+
}),
|
| 50 |
+
{
|
| 51 |
+
name: 'auth-storage'
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
)
|
frontend/tailwind.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from 'tailwindcss'
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
content: [
|
| 5 |
+
"./index.html",
|
| 6 |
+
"./src/**/*.{js,ts,jsx,tsx}",
|
| 7 |
+
],
|
| 8 |
+
theme: {
|
| 9 |
+
extend: {
|
| 10 |
+
colors: {
|
| 11 |
+
slate: {
|
| 12 |
+
850: '#1e293b',
|
| 13 |
+
900: '#0f172a',
|
| 14 |
+
950: '#020617',
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
fontFamily: {
|
| 18 |
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
plugins: [],
|
| 23 |
+
} satisfies Config
|