Complete frontend rebuild: 11 pages, professional composio-level design, connections/playground pages, fixed tailwind config
Browse files- backend/routers/playground.py +45 -3
- frontend/app/analytics/page.tsx +85 -18
- frontend/app/api-keys/page.tsx +160 -11
- frontend/app/apps/page.tsx +100 -34
- frontend/app/connect/page.tsx +116 -71
- frontend/app/connections/page.tsx +122 -0
- frontend/app/globals.css +48 -13
- frontend/app/layout.tsx +5 -5
- frontend/app/login/page.tsx +43 -16
- frontend/app/page.tsx +199 -42
- frontend/app/playground/page.tsx +125 -0
- frontend/app/settings/page.tsx +230 -21
- frontend/app/tools/page.tsx +160 -37
- frontend/components/sidebar.tsx +82 -54
- frontend/lib/api.ts +61 -7
- frontend/tailwind.config.ts +2 -1
backend/routers/playground.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 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
|
|
@@ -26,8 +26,50 @@ class PlaygroundChatRequest(BaseModel):
|
|
| 26 |
allowed_tools: List[str] = []
|
| 27 |
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
request: PlaygroundRequest,
|
| 32 |
current_user: User = Depends(get_current_user),
|
| 33 |
db: Session = Depends(get_db)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from typing import List, Optional, Dict, Any
|
|
|
|
| 26 |
allowed_tools: List[str] = []
|
| 27 |
|
| 28 |
|
| 29 |
+
class EvaluateRequest(BaseModel):
|
| 30 |
+
code: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post("/evaluate")
|
| 34 |
+
async def evaluate_code(
|
| 35 |
+
request: EvaluateRequest,
|
| 36 |
+
current_user: User = Depends(get_current_user),
|
| 37 |
+
db: Session = Depends(get_db)
|
| 38 |
+
):
|
| 39 |
+
"""Evaluate sandboxed code for the playground."""
|
| 40 |
+
import json
|
| 41 |
+
import re
|
| 42 |
+
|
| 43 |
+
code = request.code
|
| 44 |
+
|
| 45 |
+
# Parse composio_execute_tool calls
|
| 46 |
+
matches = re.findall(r'composio_execute_tool\("(\w+)",\s*"(\w+)",\s*({[^}]+})\)', code)
|
| 47 |
+
|
| 48 |
+
results = []
|
| 49 |
+
for integration_id, tool_id, params_str in matches:
|
| 50 |
+
try:
|
| 51 |
+
params = json.loads(params_str)
|
| 52 |
+
except json.JSONDecodeError:
|
| 53 |
+
params = {"raw": params_str}
|
| 54 |
+
|
| 55 |
+
from backend.database import Tool
|
| 56 |
+
tool = db.query(Tool).filter(Tool.id == tool_id).first()
|
| 57 |
+
|
| 58 |
+
results.append({
|
| 59 |
+
"integration_id": integration_id,
|
| 60 |
+
"tool_id": tool_id,
|
| 61 |
+
"tool_name": tool.name if tool else tool_id,
|
| 62 |
+
"params": params,
|
| 63 |
+
"status": "parsed",
|
| 64 |
+
"message": f"Would execute {tool_id} with params: {json.dumps(params)}"
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"status": "success",
|
| 69 |
+
"evaluated": True,
|
| 70 |
+
"calls_found": len(matches),
|
| 71 |
+
"results": results
|
| 72 |
+
}
|
| 73 |
request: PlaygroundRequest,
|
| 74 |
current_user: User = Depends(get_current_user),
|
| 75 |
db: Session = Depends(get_db)
|
frontend/app/analytics/page.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
import { api, type Stats } from '@/lib/api'
|
| 6 |
-
import { Activity, CheckCircle, XCircle, Clock } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function AnalyticsPage() {
|
| 9 |
const [stats, setStats] = useState<Stats | null>(null)
|
|
@@ -16,44 +16,111 @@ export default function AnalyticsPage() {
|
|
| 16 |
}, [])
|
| 17 |
|
| 18 |
const metrics = [
|
| 19 |
-
{ label: 'Executions', value: stats?.total_executions ?? 0, icon: Activity, color: 'text-accent-cyan' },
|
| 20 |
-
{ label: 'Successful', value: stats?.successful ?? 0, icon: CheckCircle, color: 'text-accent-green' },
|
| 21 |
-
{ label: 'Failed', value: stats?.failed ?? 0, icon: XCircle, color: 'text-accent-red' },
|
| 22 |
-
{ label: 'Avg Latency', value: `${stats?.avg_latency_ms ?? 0}ms`, icon: Clock, color: 'text-accent-amber' },
|
| 23 |
]
|
| 24 |
|
| 25 |
return (
|
| 26 |
<div className="flex min-h-screen">
|
| 27 |
<Sidebar />
|
| 28 |
<main className="flex-1 flex flex-col">
|
| 29 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6">
|
| 30 |
-
<
|
|
|
|
|
|
|
| 31 |
</header>
|
| 32 |
<div className="flex-1 p-6 space-y-6 overflow-auto">
|
| 33 |
-
<div className="grid grid-cols-4 gap-4">
|
| 34 |
{metrics.map((m) => (
|
| 35 |
-
<div key={m.label} className="bg-bg-tertiary border border-border rounded-xl p-5">
|
| 36 |
<div className="flex items-center justify-between">
|
| 37 |
<div>
|
| 38 |
<p className="text-sm text-text-muted">{m.label}</p>
|
| 39 |
-
<p className=
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
-
<m.icon className={`w-5 h-5 ${m.color}`} />
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
))}
|
| 45 |
</div>
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
-
<span className="text-sm font-medium">{stats?.success_rate ?? 100}%</span>
|
| 53 |
</div>
|
| 54 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
</main>
|
| 57 |
</div>
|
| 58 |
)
|
| 59 |
-
}
|
|
|
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
import { api, type Stats } from '@/lib/api'
|
| 6 |
+
import { Activity, CheckCircle, XCircle, Clock, TrendingUp, BarChart3 } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function AnalyticsPage() {
|
| 9 |
const [stats, setStats] = useState<Stats | null>(null)
|
|
|
|
| 16 |
}, [])
|
| 17 |
|
| 18 |
const metrics = [
|
| 19 |
+
{ label: 'Total Executions', value: stats?.total_executions ?? 0, icon: Activity, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' },
|
| 20 |
+
{ label: 'Successful', value: stats?.successful ?? 0, icon: CheckCircle, color: 'text-accent-green', bg: 'bg-accent-green/10' },
|
| 21 |
+
{ label: 'Failed', value: stats?.failed ?? 0, icon: XCircle, color: 'text-accent-red', bg: 'bg-accent-red/10' },
|
| 22 |
+
{ label: 'Avg Latency', value: `${stats?.avg_latency_ms ?? 0}ms`, icon: Clock, color: 'text-accent-amber', bg: 'bg-accent-amber/10' },
|
| 23 |
]
|
| 24 |
|
| 25 |
return (
|
| 26 |
<div className="flex min-h-screen">
|
| 27 |
<Sidebar />
|
| 28 |
<main className="flex-1 flex flex-col">
|
| 29 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center px-6 sticky top-0 z-10">
|
| 30 |
+
<div className="flex items-center gap-3">
|
| 31 |
+
<h1 className="text-base font-semibold">Analytics</h1>
|
| 32 |
+
</div>
|
| 33 |
</header>
|
| 34 |
<div className="flex-1 p-6 space-y-6 overflow-auto">
|
| 35 |
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
| 36 |
{metrics.map((m) => (
|
| 37 |
+
<div key={m.label} className="bg-bg-tertiary border border-border rounded-xl p-5 card-hover">
|
| 38 |
<div className="flex items-center justify-between">
|
| 39 |
<div>
|
| 40 |
<p className="text-sm text-text-muted">{m.label}</p>
|
| 41 |
+
<p className="text-2xl font-bold mt-1">{loading ? <span className="bg-bg-hover rounded w-16 h-8 block animate-pulse" /> : m.value}</p>
|
| 42 |
+
</div>
|
| 43 |
+
<div className={`w-10 h-10 ${m.bg} rounded-lg flex items-center justify-center`}>
|
| 44 |
+
<m.icon className={`w-5 h-5 ${m.color}`} />
|
| 45 |
</div>
|
|
|
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
))}
|
| 49 |
</div>
|
| 50 |
+
|
| 51 |
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
| 52 |
+
{/* Success Rate */}
|
| 53 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-6">
|
| 54 |
+
<h3 className="text-sm font-semibold flex items-center gap-2 mb-5">
|
| 55 |
+
<CheckCircle className="w-4 h-4 text-accent-green" />
|
| 56 |
+
Success Rate
|
| 57 |
+
</h3>
|
| 58 |
+
<div className="flex items-center gap-4">
|
| 59 |
+
<div className="flex-1 h-4 bg-bg-secondary rounded-full overflow-hidden">
|
| 60 |
+
<div className="h-full bg-gradient-to-r from-accent-green to-accent-cyan rounded-full transition-all duration-500" style={{ width: `${stats?.success_rate ?? 100}%` }} />
|
| 61 |
+
</div>
|
| 62 |
+
<span className="text-lg font-bold">{stats?.success_rate ?? 100}%</span>
|
| 63 |
+
</div>
|
| 64 |
+
{stats && (
|
| 65 |
+
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border text-xs text-text-muted">
|
| 66 |
+
<span>{stats.successful} successful</span>
|
| 67 |
+
<span>{stats.failed} failed</span>
|
| 68 |
+
</div>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{/* Latency */}
|
| 73 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-6">
|
| 74 |
+
<h3 className="text-sm font-semibold flex items-center gap-2 mb-5">
|
| 75 |
+
<Clock className="w-4 h-4 text-accent-amber" />
|
| 76 |
+
Average Latency
|
| 77 |
+
</h3>
|
| 78 |
+
<div className="flex items-center gap-4">
|
| 79 |
+
<div className="flex-1 h-4 bg-bg-secondary rounded-full overflow-hidden">
|
| 80 |
+
<div className="h-full bg-gradient-to-r from-accent-amber to-accent-cyan rounded-full transition-all duration-500" style={{ width: `${Math.min((stats?.avg_latency_ms ?? 0) / 20, 100)}%` }} />
|
| 81 |
+
</div>
|
| 82 |
+
<span className="text-lg font-bold">{stats?.avg_latency_ms ?? 0}ms</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div className="mt-4 pt-4 border-t border-border">
|
| 85 |
+
<div className="flex items-center gap-2 text-xs text-text-muted">
|
| 86 |
+
<BarChart3 className="w-3.5 h-3.5" />
|
| 87 |
+
Performance metrics are updated in real-time
|
| 88 |
+
</div>
|
| 89 |
</div>
|
|
|
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
+
|
| 93 |
+
{/* Quick Stats */}
|
| 94 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-6">
|
| 95 |
+
<h3 className="text-sm font-semibold flex items-center gap-2 mb-4">
|
| 96 |
+
<TrendingUp className="w-4 h-4 text-accent-cyan" />
|
| 97 |
+
Usage Summary
|
| 98 |
+
</h3>
|
| 99 |
+
{loading ? (
|
| 100 |
+
<div className="animate-pulse space-y-3">
|
| 101 |
+
{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-4 bg-bg-hover rounded w-full" />)}
|
| 102 |
+
</div>
|
| 103 |
+
) : stats ? (
|
| 104 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 105 |
+
<div className="bg-bg-secondary border border-border rounded-xl p-4">
|
| 106 |
+
<p className="text-[10px] text-text-muted uppercase tracking-wider font-medium mb-1">Success Rate</p>
|
| 107 |
+
<p className="text-xl font-bold text-accent-green">{stats.success_rate}%</p>
|
| 108 |
+
</div>
|
| 109 |
+
<div className="bg-bg-secondary border border-border rounded-xl p-4">
|
| 110 |
+
<p className="text-[10px] text-text-muted uppercase tracking-wider font-medium mb-1">Total Calls</p>
|
| 111 |
+
<p className="text-xl font-bold text-accent-cyan">{stats.total_executions}</p>
|
| 112 |
+
</div>
|
| 113 |
+
<div className="bg-bg-secondary border border-border rounded-xl p-4">
|
| 114 |
+
<p className="text-[10px] text-text-muted uppercase tracking-wider font-medium mb-1">Avg Latency</p>
|
| 115 |
+
<p className="text-xl font-bold text-accent-amber">{stats.avg_latency_ms}ms</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
) : (
|
| 119 |
+
<p className="text-text-muted text-sm">No analytics data yet</p>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
</div>
|
| 123 |
</main>
|
| 124 |
</div>
|
| 125 |
)
|
| 126 |
+
}
|
frontend/app/api-keys/page.tsx
CHANGED
|
@@ -1,26 +1,175 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { Sidebar } from '@/components/sidebar'
|
| 4 |
-
import {
|
|
|
|
| 5 |
|
| 6 |
export default function APIKeysPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
return (
|
| 8 |
<div className="flex min-h-screen">
|
| 9 |
<Sidebar />
|
| 10 |
<main className="flex-1 flex flex-col">
|
| 11 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
</header>
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</div>
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</div>
|
| 23 |
</main>
|
| 24 |
</div>
|
| 25 |
)
|
| 26 |
-
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
+
import { api, type APIKey } from '@/lib/api'
|
| 6 |
+
import { Key, Plus, Copy, Check, Trash2, Eye, EyeOff, Calendar, Clock } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function APIKeysPage() {
|
| 9 |
+
const [keys, setKeys] = useState<APIKey[]>([])
|
| 10 |
+
const [loading, setLoading] = useState(true)
|
| 11 |
+
const [showCreate, setShowCreate] = useState(false)
|
| 12 |
+
const [newKeyName, setNewKeyName] = useState('')
|
| 13 |
+
const [newKeyValue, setNewKeyValue] = useState('')
|
| 14 |
+
const [copied, setCopied] = useState(false)
|
| 15 |
+
const [showKey, setShowKey] = useState(false)
|
| 16 |
+
|
| 17 |
+
const fetchKeys = () => {
|
| 18 |
+
api.get<APIKey[]>('/api/apikeys')
|
| 19 |
+
.then(setKeys)
|
| 20 |
+
.catch(() => setKeys([]))
|
| 21 |
+
.finally(() => setLoading(false))
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
useEffect(() => { fetchKeys() }, [])
|
| 25 |
+
|
| 26 |
+
const handleCreate = async () => {
|
| 27 |
+
if (!newKeyName.trim()) return
|
| 28 |
+
const data = await api.post<{ id: string; key: string; name: string }>('/api/apikeys', { name: newKeyName })
|
| 29 |
+
setNewKeyValue(data.key)
|
| 30 |
+
setShowCreate(false)
|
| 31 |
+
setNewKeyName('')
|
| 32 |
+
fetchKeys()
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const handleDelete = async (id: string) => {
|
| 36 |
+
await api.delete(`/api/apikeys/${id}`)
|
| 37 |
+
fetchKeys()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const handleCopy = () => {
|
| 41 |
+
navigator.clipboard.writeText(newKeyValue)
|
| 42 |
+
setCopied(true)
|
| 43 |
+
setTimeout(() => setCopied(false), 2000)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
return (
|
| 47 |
<div className="flex min-h-screen">
|
| 48 |
<Sidebar />
|
| 49 |
<main className="flex-1 flex flex-col">
|
| 50 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 51 |
+
<div className="flex items-center gap-3">
|
| 52 |
+
<h1 className="text-base font-semibold">API Keys</h1>
|
| 53 |
+
<span className="text-xs text-text-muted bg-bg-tertiary border border-border px-2 py-0.5 rounded-md">{keys.length} keys</span>
|
| 54 |
+
</div>
|
| 55 |
+
<button onClick={() => setShowCreate(true)}
|
| 56 |
+
className="px-3 py-1.5 bg-accent-cyan hover:bg-accent-cyanLight text-bg-primary text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5 shadow-lg shadow-accent-cyan/20"
|
| 57 |
+
>
|
| 58 |
+
<Plus className="w-3.5 h-3.5" /> Create Key
|
| 59 |
+
</button>
|
| 60 |
</header>
|
| 61 |
+
|
| 62 |
+
<div className="flex-1 p-6 overflow-auto">
|
| 63 |
+
{/* Create Modal */}
|
| 64 |
+
{showCreate && (
|
| 65 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
| 66 |
+
<div className="bg-bg-tertiary border border-border rounded-2xl p-6 w-full max-w-md shadow-2xl animate-fade-in">
|
| 67 |
+
<h3 className="text-base font-semibold mb-4">Create API Key</h3>
|
| 68 |
+
<input
|
| 69 |
+
type="text"
|
| 70 |
+
value={newKeyName}
|
| 71 |
+
onChange={(e) => setNewKeyName(e.target.value)}
|
| 72 |
+
className="w-full px-3.5 py-2.5 bg-bg-secondary border border-border rounded-xl text-sm text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan"
|
| 73 |
+
placeholder="Key name (e.g., Production, Dev)"
|
| 74 |
+
autoFocus
|
| 75 |
+
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
| 76 |
+
/>
|
| 77 |
+
<div className="flex justify-end gap-2 mt-4">
|
| 78 |
+
<button onClick={() => setShowCreate(false)}
|
| 79 |
+
className="px-4 py-2 bg-bg-secondary border border-border text-text-secondary text-xs font-medium rounded-lg hover:text-text-primary transition-colors">
|
| 80 |
+
Cancel
|
| 81 |
+
</button>
|
| 82 |
+
<button onClick={handleCreate}
|
| 83 |
+
className="px-4 py-2 bg-accent-cyan hover:bg-accent-cyanLight text-bg-primary text-xs font-semibold rounded-lg transition-colors">
|
| 84 |
+
Create
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
</div>
|
| 89 |
+
)}
|
| 90 |
+
|
| 91 |
+
{/* New Key Display */}
|
| 92 |
+
{newKeyValue && (
|
| 93 |
+
<div className="mb-4 p-4 bg-accent-green/5 border border-accent-green/20 rounded-xl animate-fade-in">
|
| 94 |
+
<div className="flex items-center gap-2 mb-2">
|
| 95 |
+
<Key className="w-4 h-4 text-accent-green" />
|
| 96 |
+
<span className="text-sm font-semibold text-accent-green">Key Created</span>
|
| 97 |
+
</div>
|
| 98 |
+
<p className="text-xs text-text-muted mb-3">Copy this key now. You won't be able to see it again.</p>
|
| 99 |
+
<div className="flex items-center gap-2">
|
| 100 |
+
<div className="flex-1 bg-bg-secondary border border-border rounded-lg px-3 py-2">
|
| 101 |
+
<span className="text-xs font-mono">{showKey ? newKeyValue : '••••••••••••••••'}</span>
|
| 102 |
+
</div>
|
| 103 |
+
<button onClick={() => setShowKey(!showKey)} className="p-2 text-text-muted hover:text-text-primary rounded-lg hover:bg-bg-hover">
|
| 104 |
+
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 105 |
+
</button>
|
| 106 |
+
<button onClick={handleCopy} className="p-2 text-text-muted hover:text-text-primary rounded-lg hover:bg-bg-hover">
|
| 107 |
+
{copied ? <Check className="w-4 h-4 text-accent-green" /> : <Copy className="w-4 h-4" />}
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
{/* Keys List */}
|
| 114 |
+
{loading ? (
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 117 |
+
<div key={i} className="bg-bg-tertiary border border-border rounded-xl p-5 animate-pulse">
|
| 118 |
+
<div className="flex items-center gap-4"><div className="flex-1"><div className="h-3 bg-bg-hover rounded w-32 mb-1" /><div className="h-2 bg-bg-hover rounded w-20" /></div></div>
|
| 119 |
+
</div>
|
| 120 |
+
))}
|
| 121 |
+
</div>
|
| 122 |
+
) : keys.length === 0 && !newKeyValue ? (
|
| 123 |
+
<div className="flex flex-col items-center justify-center py-20">
|
| 124 |
+
<div className="w-16 h-16 bg-bg-tertiary border border-border rounded-2xl flex items-center justify-center mb-4">
|
| 125 |
+
<Key className="w-6 h-6 text-text-dim" />
|
| 126 |
+
</div>
|
| 127 |
+
<p className="text-text-muted font-medium">No API keys</p>
|
| 128 |
+
<p className="text-text-dim text-sm mt-1">Create a key for programmatic access</p>
|
| 129 |
+
<button onClick={() => setShowCreate(true)}
|
| 130 |
+
className="mt-4 px-4 py-2 bg-accent-cyan hover:bg-accent-cyanLight text-bg-primary text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5">
|
| 131 |
+
<Plus className="w-3.5 h-3.5" /> Create First Key
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
) : (
|
| 135 |
+
<div className="space-y-2">
|
| 136 |
+
{keys.map((k) => (
|
| 137 |
+
<div key={k.id} className="bg-bg-tertiary border border-border rounded-xl p-5 card-hover">
|
| 138 |
+
<div className="flex items-center justify-between">
|
| 139 |
+
<div className="flex items-center gap-4">
|
| 140 |
+
<div className="w-10 h-10 bg-accent-cyan/10 rounded-lg flex items-center justify-center">
|
| 141 |
+
<Key className="w-5 h-5 text-accent-cyan" />
|
| 142 |
+
</div>
|
| 143 |
+
<div>
|
| 144 |
+
<p className="text-sm font-semibold">{k.name}</p>
|
| 145 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 146 |
+
<span className="text-xs font-mono text-text-muted">{k.key_preview}</span>
|
| 147 |
+
<span className="text-text-dim">·</span>
|
| 148 |
+
<span className="text-[10px] text-text-muted flex items-center gap-1">
|
| 149 |
+
<Calendar className="w-3 h-3" /> {new Date(k.created_at).toLocaleDateString()}
|
| 150 |
+
</span>
|
| 151 |
+
{k.last_used && (
|
| 152 |
+
<>
|
| 153 |
+
<span className="text-text-dim">·</span>
|
| 154 |
+
<span className="text-[10px] text-text-muted flex items-center gap-1">
|
| 155 |
+
<Clock className="w-3 h-3" /> Last used {new Date(k.last_used).toLocaleDateString()}
|
| 156 |
+
</span>
|
| 157 |
+
</>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<button onClick={() => handleDelete(k.id)}
|
| 163 |
+
className="p-2 text-text-muted hover:text-accent-red hover:bg-accent-red/10 rounded-lg transition-colors">
|
| 164 |
+
<Trash2 className="w-4 h-4" />
|
| 165 |
+
</button>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
))}
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
</div>
|
| 172 |
</main>
|
| 173 |
</div>
|
| 174 |
)
|
| 175 |
+
}
|
frontend/app/apps/page.tsx
CHANGED
|
@@ -4,15 +4,14 @@ import { useEffect, useState } from 'react'
|
|
| 4 |
import Link from 'next/link'
|
| 5 |
import { Sidebar } from '@/components/sidebar'
|
| 6 |
import { api, type Integration } from '@/lib/api'
|
| 7 |
-
import { Search, ArrowUpRight } from 'lucide-react'
|
| 8 |
-
|
| 9 |
-
const categories = ['All', 'Developer Tools', 'Communication', 'Productivity', 'CRM', 'Finance', 'Social', 'Other']
|
| 10 |
|
| 11 |
export default function AppsPage() {
|
| 12 |
const [integrations, setIntegrations] = useState<Integration[]>([])
|
| 13 |
const [loading, setLoading] = useState(true)
|
| 14 |
-
const [category, setCategory] = useState('
|
| 15 |
const [search, setSearch] = useState('')
|
|
|
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
api.get<Integration[]>('/api/apps')
|
|
@@ -20,8 +19,10 @@ export default function AppsPage() {
|
|
| 20 |
.catch(() => setLoading(false))
|
| 21 |
}, [])
|
| 22 |
|
|
|
|
|
|
|
| 23 |
const filtered = integrations.filter((i) => {
|
| 24 |
-
const matchCat = category === '
|
| 25 |
const matchSearch = !search || i.name.toLowerCase().includes(search.toLowerCase()) || i.description.toLowerCase().includes(search.toLowerCase())
|
| 26 |
return matchCat && matchSearch
|
| 27 |
})
|
|
@@ -30,61 +31,126 @@ export default function AppsPage() {
|
|
| 30 |
<div className="flex min-h-screen">
|
| 31 |
<Sidebar integrationCount={integrations.length} />
|
| 32 |
<main className="flex-1 flex flex-col">
|
| 33 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6
|
| 34 |
-
<
|
| 35 |
-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
</header>
|
| 45 |
<div className="flex-1 p-6 overflow-auto">
|
|
|
|
| 46 |
<div className="flex gap-2 mb-6 flex-wrap">
|
| 47 |
{categories.map((c) => (
|
| 48 |
<button key={c} onClick={() => setCategory(c)}
|
| 49 |
-
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
))}
|
| 52 |
</div>
|
|
|
|
|
|
|
| 53 |
{loading ? (
|
| 54 |
-
<div className=
|
| 55 |
{Array.from({ length: 8 }).map((_, i) => (
|
| 56 |
<div key={i} className="bg-bg-tertiary border border-border rounded-xl p-5 animate-pulse">
|
| 57 |
<div className="flex items-center gap-3 mb-3">
|
| 58 |
-
<div className="w-
|
| 59 |
-
<div className="flex-1"><div className="h-3 bg-bg-hover rounded w-20 mb-1" /><div className="h-2 bg-bg-hover rounded w-14" /></div>
|
| 60 |
</div>
|
| 61 |
<div className="h-2 bg-bg-hover rounded w-full mb-2" /><div className="h-2 bg-bg-hover rounded w-3/4" />
|
| 62 |
</div>
|
| 63 |
))}
|
| 64 |
</div>
|
| 65 |
) : filtered.length === 0 ? (
|
| 66 |
-
<div className="
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
{filtered.map((i) => (
|
| 70 |
-
<
|
|
|
|
|
|
|
| 71 |
<div className="flex items-center gap-3 mb-3">
|
| 72 |
-
<div className="w-10 h-10 bg-bg-secondary rounded-lg flex items-center justify-center overflow-hidden border border-border">
|
| 73 |
<img src={i.logo_url} alt={i.name} className="w-6 h-6" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 74 |
</div>
|
| 75 |
-
<div>
|
| 76 |
-
<p className="text-sm font-semibold">{i.name}</p>
|
| 77 |
-
<p className="text-[
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
-
<p className="text-xs text-text-secondary leading-relaxed line-clamp-2 mb-4">{i.description}</p>
|
| 81 |
<div className="flex items-center justify-between pt-3 border-t border-border">
|
| 82 |
-
<
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
Connect <ArrowUpRight className="w-3 h-3" />
|
| 85 |
-
</
|
| 86 |
</div>
|
| 87 |
-
</
|
| 88 |
))}
|
| 89 |
</div>
|
| 90 |
)}
|
|
@@ -92,4 +158,4 @@ export default function AppsPage() {
|
|
| 92 |
</main>
|
| 93 |
</div>
|
| 94 |
)
|
| 95 |
-
}
|
|
|
|
| 4 |
import Link from 'next/link'
|
| 5 |
import { Sidebar } from '@/components/sidebar'
|
| 6 |
import { api, type Integration } from '@/lib/api'
|
| 7 |
+
import { Search, ArrowUpRight, Grid3X3, List } from 'lucide-react'
|
|
|
|
|
|
|
| 8 |
|
| 9 |
export default function AppsPage() {
|
| 10 |
const [integrations, setIntegrations] = useState<Integration[]>([])
|
| 11 |
const [loading, setLoading] = useState(true)
|
| 12 |
+
const [category, setCategory] = useState('all')
|
| 13 |
const [search, setSearch] = useState('')
|
| 14 |
+
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
| 15 |
|
| 16 |
useEffect(() => {
|
| 17 |
api.get<Integration[]>('/api/apps')
|
|
|
|
| 19 |
.catch(() => setLoading(false))
|
| 20 |
}, [])
|
| 21 |
|
| 22 |
+
const categories = ['all', ...new Set(integrations.map(i => i.category).filter(Boolean))].slice(0, 12)
|
| 23 |
+
|
| 24 |
const filtered = integrations.filter((i) => {
|
| 25 |
+
const matchCat = category === 'all' || i.category === category
|
| 26 |
const matchSearch = !search || i.name.toLowerCase().includes(search.toLowerCase()) || i.description.toLowerCase().includes(search.toLowerCase())
|
| 27 |
return matchCat && matchSearch
|
| 28 |
})
|
|
|
|
| 31 |
<div className="flex min-h-screen">
|
| 32 |
<Sidebar integrationCount={integrations.length} />
|
| 33 |
<main className="flex-1 flex flex-col">
|
| 34 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 35 |
+
<div className="flex items-center gap-3">
|
| 36 |
+
<h1 className="text-base font-semibold">Apps</h1>
|
| 37 |
+
{!loading && <span className="text-xs text-text-muted bg-bg-tertiary border border-border px-2 py-0.5 rounded-md">{integrations.length} total</span>}
|
| 38 |
+
</div>
|
| 39 |
+
<div className="flex items-center gap-3">
|
| 40 |
+
<div className="relative">
|
| 41 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-dim" />
|
| 42 |
+
<input
|
| 43 |
+
className="pl-8 pr-4 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan w-56"
|
| 44 |
+
placeholder="Search apps..."
|
| 45 |
+
value={search}
|
| 46 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 47 |
+
/>
|
| 48 |
+
</div>
|
| 49 |
+
<div className="flex border border-border rounded-lg overflow-hidden">
|
| 50 |
+
<button onClick={() => setViewMode('grid')} className={`p-1.5 ${viewMode === 'grid' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'text-text-muted hover:text-text-primary'}`}>
|
| 51 |
+
<Grid3X3 className="w-3.5 h-3.5" />
|
| 52 |
+
</button>
|
| 53 |
+
<button onClick={() => setViewMode('list')} className={`p-1.5 ${viewMode === 'list' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'text-text-muted hover:text-text-primary'}`}>
|
| 54 |
+
<List className="w-3.5 h-3.5" />
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
</div>
|
| 58 |
</header>
|
| 59 |
<div className="flex-1 p-6 overflow-auto">
|
| 60 |
+
{/* Categories */}
|
| 61 |
<div className="flex gap-2 mb-6 flex-wrap">
|
| 62 |
{categories.map((c) => (
|
| 63 |
<button key={c} onClick={() => setCategory(c)}
|
| 64 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all capitalize ${category === c
|
| 65 |
+
? 'bg-accent-cyan text-bg-primary shadow-lg shadow-accent-cyan/20'
|
| 66 |
+
: 'bg-bg-tertiary border border-border text-text-secondary hover:text-text-primary hover:border-accent-cyan/30'
|
| 67 |
+
}`}
|
| 68 |
+
>
|
| 69 |
+
{c.replace(/_/g, ' ')}
|
| 70 |
+
</button>
|
| 71 |
))}
|
| 72 |
</div>
|
| 73 |
+
|
| 74 |
+
{/* Grid */}
|
| 75 |
{loading ? (
|
| 76 |
+
<div className={viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4' : 'space-y-2'}>
|
| 77 |
{Array.from({ length: 8 }).map((_, i) => (
|
| 78 |
<div key={i} className="bg-bg-tertiary border border-border rounded-xl p-5 animate-pulse">
|
| 79 |
<div className="flex items-center gap-3 mb-3">
|
| 80 |
+
<div className="w-10 h-10 bg-bg-hover rounded-lg shrink-0" />
|
| 81 |
+
<div className="flex-1"><div className="h-3 bg-bg-hover rounded w-20 mb-1.5" /><div className="h-2 bg-bg-hover rounded w-14" /></div>
|
| 82 |
</div>
|
| 83 |
<div className="h-2 bg-bg-hover rounded w-full mb-2" /><div className="h-2 bg-bg-hover rounded w-3/4" />
|
| 84 |
</div>
|
| 85 |
))}
|
| 86 |
</div>
|
| 87 |
) : filtered.length === 0 ? (
|
| 88 |
+
<div className="flex flex-col items-center justify-center py-20">
|
| 89 |
+
<div className="w-16 h-16 bg-bg-tertiary border border-border rounded-2xl flex items-center justify-center mb-4">
|
| 90 |
+
<Search className="w-6 h-6 text-text-dim" />
|
| 91 |
+
</div>
|
| 92 |
+
<p className="text-text-muted font-medium">No apps found</p>
|
| 93 |
+
<p className="text-text-dim text-sm mt-1">Try adjusting your search or filters</p>
|
| 94 |
+
</div>
|
| 95 |
+
) : viewMode === 'grid' ? (
|
| 96 |
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 97 |
{filtered.map((i) => (
|
| 98 |
+
<Link key={i.id} href={`/connect?id=${i.id}`}
|
| 99 |
+
className="bg-bg-tertiary border border-border rounded-xl p-5 card-hover group"
|
| 100 |
+
>
|
| 101 |
<div className="flex items-center gap-3 mb-3">
|
| 102 |
+
<div className="w-10 h-10 bg-bg-secondary rounded-lg flex items-center justify-center overflow-hidden border border-border shrink-0">
|
| 103 |
<img src={i.logo_url} alt={i.name} className="w-6 h-6" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 104 |
</div>
|
| 105 |
+
<div className="min-w-0">
|
| 106 |
+
<p className="text-sm font-semibold truncate">{i.name}</p>
|
| 107 |
+
<p className="text-[10px] text-text-muted capitalize truncate">{i.category?.replace(/_/g, ' ')}</p>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
+
<p className="text-xs text-text-secondary leading-relaxed line-clamp-2 mb-4 h-8">{i.description}</p>
|
| 111 |
<div className="flex items-center justify-between pt-3 border-t border-border">
|
| 112 |
+
<div className="flex items-center gap-2">
|
| 113 |
+
<span className="text-[10px] text-text-dim">{i.tool_count} tools</span>
|
| 114 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
| 115 |
+
i.auth_type === 'oauth2'
|
| 116 |
+
? 'bg-accent-cyan/10 text-accent-cyanLight'
|
| 117 |
+
: i.auth_type === 'api_key'
|
| 118 |
+
? 'bg-accent-green/10 text-accent-green'
|
| 119 |
+
: 'bg-accent-amber/10 text-accent-amber'
|
| 120 |
+
}`}>
|
| 121 |
+
{i.auth_type}
|
| 122 |
+
</span>
|
| 123 |
+
</div>
|
| 124 |
+
<span className="text-[10px] text-accent-cyan opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 font-medium">
|
| 125 |
+
Connect <ArrowUpRight className="w-2.5 h-2.5" />
|
| 126 |
+
</span>
|
| 127 |
+
</div>
|
| 128 |
+
</Link>
|
| 129 |
+
))}
|
| 130 |
+
</div>
|
| 131 |
+
) : (
|
| 132 |
+
<div className="space-y-1">
|
| 133 |
+
{filtered.map((i) => (
|
| 134 |
+
<Link key={i.id} href={`/connect?id=${i.id}`}
|
| 135 |
+
className="flex items-center gap-4 px-4 py-3 bg-bg-tertiary border border-border rounded-lg card-hover group"
|
| 136 |
+
>
|
| 137 |
+
<div className="w-9 h-9 bg-bg-secondary rounded-lg flex items-center justify-center overflow-hidden border border-border shrink-0">
|
| 138 |
+
<img src={i.logo_url} alt={i.name} className="w-5 h-5" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 139 |
+
</div>
|
| 140 |
+
<div className="flex-1 min-w-0">
|
| 141 |
+
<p className="text-sm font-medium truncate">{i.name}</p>
|
| 142 |
+
<p className="text-xs text-text-muted truncate">{i.description}</p>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="flex items-center gap-3 shrink-0">
|
| 145 |
+
<span className="text-xs text-text-dim">{i.tool_count} tools</span>
|
| 146 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
| 147 |
+
i.auth_type === 'oauth2' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'bg-accent-green/10 text-accent-green'
|
| 148 |
+
}`}>{i.auth_type}</span>
|
| 149 |
+
<span className="text-xs text-accent-cyan font-medium opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5">
|
| 150 |
Connect <ArrowUpRight className="w-3 h-3" />
|
| 151 |
+
</span>
|
| 152 |
</div>
|
| 153 |
+
</Link>
|
| 154 |
))}
|
| 155 |
</div>
|
| 156 |
)}
|
|
|
|
| 158 |
</main>
|
| 159 |
</div>
|
| 160 |
)
|
| 161 |
+
}
|
frontend/app/connect/page.tsx
CHANGED
|
@@ -3,49 +3,34 @@
|
|
| 3 |
import { useEffect, useState, Suspense } from 'react'
|
| 4 |
import { useRouter, useSearchParams } from 'next/navigation'
|
| 5 |
import { Sidebar } from '@/components/sidebar'
|
| 6 |
-
import {
|
| 7 |
-
|
| 8 |
-
interface AuthField {
|
| 9 |
-
name: string
|
| 10 |
-
label: string
|
| 11 |
-
type: string
|
| 12 |
-
required: boolean
|
| 13 |
-
description: string
|
| 14 |
-
default: string
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
interface AuthConfig {
|
| 18 |
-
integration_id: string
|
| 19 |
-
auth_type: string
|
| 20 |
-
fields: AuthField[]
|
| 21 |
-
oauth_authorize_url: string | null
|
| 22 |
-
oauth_token_url: string | null
|
| 23 |
-
}
|
| 24 |
|
| 25 |
function ConnectContent() {
|
| 26 |
const router = useRouter()
|
| 27 |
const searchParams = useSearchParams()
|
| 28 |
const id = searchParams.get('id') || ''
|
| 29 |
const [config, setConfig] = useState<AuthConfig | null>(null)
|
| 30 |
-
const [
|
| 31 |
const [values, setValues] = useState<Record<string, string>>({})
|
| 32 |
const [loading, setLoading] = useState(true)
|
| 33 |
const [submitting, setSubmitting] = useState(false)
|
| 34 |
const [error, setError] = useState('')
|
| 35 |
const [success, setSuccess] = useState(false)
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
if (!id) { router.push('/apps'); return }
|
| 39 |
Promise.all([
|
| 40 |
-
|
| 41 |
-
|
| 42 |
]).then(([app, cfg]) => {
|
| 43 |
-
|
| 44 |
setConfig(cfg)
|
| 45 |
setLoading(false)
|
| 46 |
if (cfg) {
|
| 47 |
const init: Record<string, string> = {}
|
| 48 |
-
cfg.fields.forEach((f
|
| 49 |
setValues(init)
|
| 50 |
}
|
| 51 |
}).catch(() => setLoading(false))
|
|
@@ -56,91 +41,143 @@ function ConnectContent() {
|
|
| 56 |
setError('')
|
| 57 |
setSubmitting(true)
|
| 58 |
try {
|
| 59 |
-
const
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
body: JSON.stringify({ credentials: values }),
|
| 63 |
})
|
| 64 |
-
const data = await res.json()
|
| 65 |
-
if (!res.ok) { setError(data.detail || 'Connection failed'); return }
|
| 66 |
setSuccess(true)
|
| 67 |
-
setTimeout(() => router.push('/
|
| 68 |
-
} catch
|
| 69 |
-
|
|
|
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
| 72 |
return (
|
| 73 |
<div className="flex min-h-screen">
|
| 74 |
<Sidebar />
|
| 75 |
<main className="flex-1 flex flex-col">
|
| 76 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6">
|
| 77 |
-
<button onClick={() => router.back()} className="flex items-center gap-2 text-text-muted hover:text-text-primary transition-colors mr-4">
|
| 78 |
<ArrowLeft className="w-4 h-4" />
|
| 79 |
</button>
|
| 80 |
-
<h1 className="text-base font-
|
| 81 |
</header>
|
| 82 |
-
<div className="flex-1 flex items-
|
| 83 |
{loading ? (
|
| 84 |
-
<div className="text-text-muted">
|
|
|
|
|
|
|
|
|
|
| 85 |
) : success ? (
|
| 86 |
-
<div className="bg-bg-tertiary border border-accent-green/
|
| 87 |
-
<div className="w-
|
| 88 |
-
<Check className="w-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
-
<h2 className="text-lg font-semibold mb-2">Connected Successfully</h2>
|
| 91 |
-
<p className="text-sm text-text-muted">{integrationName} is now connected</p>
|
| 92 |
</div>
|
| 93 |
) : !config ? (
|
| 94 |
-
<div className="bg-bg-tertiary border border-border rounded-2xl p-
|
| 95 |
-
<
|
| 96 |
-
|
| 97 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
) : (
|
| 100 |
-
<div className="bg-bg-tertiary border border-border rounded-2xl p-8 w-full max-w-
|
| 101 |
-
<div className="flex items-center gap-
|
| 102 |
-
<div className="w-
|
| 103 |
-
<
|
| 104 |
</div>
|
| 105 |
<div>
|
| 106 |
-
<h2 className="text-base font-semibold">{
|
| 107 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 112 |
{config.fields.map((field) => (
|
| 113 |
<div key={field.name}>
|
| 114 |
-
<label className="block text-xs text-text-muted mb-1.5">
|
| 115 |
-
{field.label}
|
|
|
|
| 116 |
</label>
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
</div>
|
| 126 |
))}
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
<a
|
| 131 |
href={`${config.oauth_authorize_url}?client_id=${values['client_id'] || ''}&redirect_uri=${typeof window !== 'undefined' ? window.location.origin : ''}/oauth/callback/${id}&response_type=code`}
|
| 132 |
-
className="w-full flex items-center justify-center gap-2 py-2.5 bg-bg-secondary border border-border hover:border-accent-cyan/
|
| 133 |
>
|
|
|
|
| 134 |
Continue with OAuth
|
| 135 |
</a>
|
| 136 |
</div>
|
| 137 |
)}
|
|
|
|
| 138 |
<button
|
| 139 |
type="submit"
|
| 140 |
disabled={submitting}
|
| 141 |
-
className="w-full py-2.5 bg-accent-cyan hover:
|
| 142 |
>
|
| 143 |
-
{submitting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> :
|
| 144 |
</button>
|
| 145 |
</form>
|
| 146 |
</div>
|
|
@@ -151,10 +188,18 @@ function ConnectContent() {
|
|
| 151 |
)
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
export default function ConnectPage() {
|
| 155 |
return (
|
| 156 |
-
<Suspense fallback={<div className="flex min-h-screen"><div className="flex-1 flex items-center justify-center text-text-muted">
|
| 157 |
<ConnectContent />
|
| 158 |
</Suspense>
|
| 159 |
)
|
| 160 |
-
}
|
|
|
|
| 3 |
import { useEffect, useState, Suspense } from 'react'
|
| 4 |
import { useRouter, useSearchParams } from 'next/navigation'
|
| 5 |
import { Sidebar } from '@/components/sidebar'
|
| 6 |
+
import { api, type AuthConfig, type Integration } from '@/lib/api'
|
| 7 |
+
import { ArrowLeft, Loader2, Check, Key, ExternalLink, Shield, Eye, EyeOff } from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
function ConnectContent() {
|
| 10 |
const router = useRouter()
|
| 11 |
const searchParams = useSearchParams()
|
| 12 |
const id = searchParams.get('id') || ''
|
| 13 |
const [config, setConfig] = useState<AuthConfig | null>(null)
|
| 14 |
+
const [integration, setIntegration] = useState<Integration | null>(null)
|
| 15 |
const [values, setValues] = useState<Record<string, string>>({})
|
| 16 |
const [loading, setLoading] = useState(true)
|
| 17 |
const [submitting, setSubmitting] = useState(false)
|
| 18 |
const [error, setError] = useState('')
|
| 19 |
const [success, setSuccess] = useState(false)
|
| 20 |
+
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
if (!id) { router.push('/apps'); return }
|
| 24 |
Promise.all([
|
| 25 |
+
api.get<Integration>(`/api/apps/${id}`).catch(() => null),
|
| 26 |
+
api.get<AuthConfig>(`/api/auth-configs/integration/${id}`).catch(() => null),
|
| 27 |
]).then(([app, cfg]) => {
|
| 28 |
+
setIntegration(app)
|
| 29 |
setConfig(cfg)
|
| 30 |
setLoading(false)
|
| 31 |
if (cfg) {
|
| 32 |
const init: Record<string, string> = {}
|
| 33 |
+
cfg.fields.forEach((f) => { init[f.name] = f.default || '' })
|
| 34 |
setValues(init)
|
| 35 |
}
|
| 36 |
}).catch(() => setLoading(false))
|
|
|
|
| 41 |
setError('')
|
| 42 |
setSubmitting(true)
|
| 43 |
try {
|
| 44 |
+
const data = await api.post<{ message: string; connection_id: string }>(`/api/connections/connect?integration_id=${id}`, {
|
| 45 |
+
credentials: values,
|
| 46 |
+
account_label: `${integration?.name || id}`,
|
|
|
|
| 47 |
})
|
|
|
|
|
|
|
| 48 |
setSuccess(true)
|
| 49 |
+
setTimeout(() => router.push('/connections'), 1500)
|
| 50 |
+
} catch (err: unknown) {
|
| 51 |
+
setError(err instanceof Error ? err.message : 'Connection failed')
|
| 52 |
+
} finally { setSubmitting(false) }
|
| 53 |
}
|
| 54 |
|
| 55 |
+
if (!id) return null
|
| 56 |
+
|
| 57 |
return (
|
| 58 |
<div className="flex min-h-screen">
|
| 59 |
<Sidebar />
|
| 60 |
<main className="flex-1 flex flex-col">
|
| 61 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center px-6 sticky top-0 z-10">
|
| 62 |
+
<button onClick={() => router.back()} className="flex items-center gap-2 text-text-muted hover:text-text-primary transition-colors mr-4 p-1 -ml-1">
|
| 63 |
<ArrowLeft className="w-4 h-4" />
|
| 64 |
</button>
|
| 65 |
+
<h1 className="text-base font-semibold">Connect {integration?.name || id}</h1>
|
| 66 |
</header>
|
| 67 |
+
<div className="flex-1 flex items-start justify-center p-6 pt-12">
|
| 68 |
{loading ? (
|
| 69 |
+
<div className="flex flex-col items-center gap-3 text-text-muted">
|
| 70 |
+
<Loader2 className="w-6 h-6 animate-spin" />
|
| 71 |
+
<span className="text-sm">Loading configuration...</span>
|
| 72 |
+
</div>
|
| 73 |
) : success ? (
|
| 74 |
+
<div className="bg-bg-tertiary border border-accent-green/20 rounded-2xl p-10 text-center max-w-md animate-fade-in">
|
| 75 |
+
<div className="w-14 h-14 bg-accent-green/10 rounded-full flex items-center justify-center mx-auto mb-5 border border-accent-green/20">
|
| 76 |
+
<Check className="w-7 h-7 text-accent-green" />
|
| 77 |
+
</div>
|
| 78 |
+
<h2 className="text-lg font-semibold mb-2">Connected!</h2>
|
| 79 |
+
<p className="text-sm text-text-muted mb-6">{integration?.name} has been connected successfully</p>
|
| 80 |
+
<div className="flex gap-3 justify-center">
|
| 81 |
+
<button onClick={() => router.push('/connections')} className="px-4 py-2 bg-accent-cyan text-bg-primary text-xs font-medium rounded-lg hover:bg-accent-cyanLight transition-colors">
|
| 82 |
+
View Connections
|
| 83 |
+
</button>
|
| 84 |
+
<button onClick={() => router.push('/apps')} className="px-4 py-2 bg-bg-secondary border border-border text-text-secondary text-xs font-medium rounded-lg hover:text-text-primary transition-colors">
|
| 85 |
+
Browse Apps
|
| 86 |
+
</button>
|
| 87 |
</div>
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
) : !config ? (
|
| 90 |
+
<div className="bg-bg-tertiary border border-border rounded-2xl p-10 text-center max-w-md">
|
| 91 |
+
<div className="w-14 h-14 bg-accent-amber/10 rounded-full flex items-center justify-center mx-auto mb-5 border border-accent-amber/20">
|
| 92 |
+
<Key className="w-7 h-7 text-accent-amber" />
|
| 93 |
+
</div>
|
| 94 |
+
<h2 className="text-lg font-semibold mb-2">No Auth Configuration</h2>
|
| 95 |
+
<p className="text-sm text-text-muted mb-2">This integration doesn't have a credential schema configured.</p>
|
| 96 |
+
<p className="text-xs text-text-dim mb-6">Define what credentials are needed in Settings</p>
|
| 97 |
+
<button onClick={() => router.push('/settings')} className="px-4 py-2 bg-accent-cyan text-bg-primary text-xs font-medium rounded-lg hover:bg-accent-cyanLight transition-colors">
|
| 98 |
+
Configure in Settings
|
| 99 |
+
</button>
|
| 100 |
</div>
|
| 101 |
) : (
|
| 102 |
+
<div className="bg-bg-tertiary border border-border rounded-2xl p-8 w-full max-w-lg animate-fade-in">
|
| 103 |
+
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-border">
|
| 104 |
+
<div className="w-12 h-12 bg-bg-secondary border border-border rounded-xl flex items-center justify-center overflow-hidden shrink-0">
|
| 105 |
+
<img src={integration?.logo_url} alt={integration?.name} className="w-7 h-7" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 106 |
</div>
|
| 107 |
<div>
|
| 108 |
+
<h2 className="text-base font-semibold">{integration?.name || id}</h2>
|
| 109 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 110 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
| 111 |
+
config.auth_type === 'oauth2' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'bg-accent-green/10 text-accent-green'
|
| 112 |
+
}`}>{config.auth_type}</span>
|
| 113 |
+
<span className="text-xs text-text-muted">{integration?.tool_count || 0} tools</span>
|
| 114 |
+
</div>
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
+
|
| 118 |
+
{error && (
|
| 119 |
+
<div className="mb-5 p-3.5 bg-accent-red/10 border border-accent-red/20 rounded-xl text-accent-red text-xs flex items-start gap-2.5">
|
| 120 |
+
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
| 121 |
+
<span>{error}</span>
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
|
| 125 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 126 |
{config.fields.map((field) => (
|
| 127 |
<div key={field.name}>
|
| 128 |
+
<label className="block text-xs text-text-muted mb-1.5 font-medium">
|
| 129 |
+
{field.label}
|
| 130 |
+
{field.required && <span className="text-accent-red ml-0.5">*</span>}
|
| 131 |
</label>
|
| 132 |
+
<div className="relative">
|
| 133 |
+
<input
|
| 134 |
+
type={field.type === 'password' && !showSecrets[field.name] ? 'password' : 'text'}
|
| 135 |
+
value={values[field.name] || ''}
|
| 136 |
+
onChange={(e) => setValues(prev => ({ ...prev, [field.name]: e.target.value }))}
|
| 137 |
+
className="w-full px-3.5 py-2.5 bg-bg-secondary border border-border rounded-xl text-sm text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan transition-colors pr-10"
|
| 138 |
+
placeholder={field.description || field.label}
|
| 139 |
+
required={field.required}
|
| 140 |
+
/>
|
| 141 |
+
{field.type === 'password' && (
|
| 142 |
+
<button type="button" onClick={() => setShowSecrets(prev => ({ ...prev, [field.name]: !prev[field.name] }))}
|
| 143 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
| 144 |
+
>
|
| 145 |
+
{showSecrets[field.name] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 146 |
+
</button>
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
+
{field.description && values[field.name] && (
|
| 150 |
+
<p className="text-[10px] text-text-dim mt-1">{field.description}</p>
|
| 151 |
+
)}
|
| 152 |
</div>
|
| 153 |
))}
|
| 154 |
+
|
| 155 |
+
{config.auth_type === 'oauth2' && (
|
| 156 |
+
<div className="pt-4">
|
| 157 |
+
<div className="relative">
|
| 158 |
+
<div className="absolute inset-0 flex items-center">
|
| 159 |
+
<div className="w-full border-t border-border" />
|
| 160 |
+
</div>
|
| 161 |
+
<div className="relative flex justify-center text-xs">
|
| 162 |
+
<span className="px-3 bg-bg-tertiary text-text-muted">or connect via OAuth</span>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
<a
|
| 166 |
href={`${config.oauth_authorize_url}?client_id=${values['client_id'] || ''}&redirect_uri=${typeof window !== 'undefined' ? window.location.origin : ''}/oauth/callback/${id}&response_type=code`}
|
| 167 |
+
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 bg-bg-secondary border border-border hover:border-accent-cyan/40 text-text-primary text-sm font-medium rounded-xl transition-all hover:bg-bg-hover"
|
| 168 |
>
|
| 169 |
+
<ExternalLink className="w-4 h-4" />
|
| 170 |
Continue with OAuth
|
| 171 |
</a>
|
| 172 |
</div>
|
| 173 |
)}
|
| 174 |
+
|
| 175 |
<button
|
| 176 |
type="submit"
|
| 177 |
disabled={submitting}
|
| 178 |
+
className="w-full py-2.5 bg-gradient-to-r from-accent-cyan to-accent-blue hover:from-accent-cyanLight hover:to-accent-blue text-bg-primary font-semibold text-sm rounded-xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 shadow-lg shadow-accent-cyan/20"
|
| 179 |
>
|
| 180 |
+
{submitting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Shield className="w-4 h-4" /> Connect {integration?.name || id}</>}
|
| 181 |
</button>
|
| 182 |
</form>
|
| 183 |
</div>
|
|
|
|
| 188 |
)
|
| 189 |
}
|
| 190 |
|
| 191 |
+
function XCircle(props: { className?: string }) {
|
| 192 |
+
return (
|
| 193 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
|
| 194 |
+
<circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" />
|
| 195 |
+
</svg>
|
| 196 |
+
)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
export default function ConnectPage() {
|
| 200 |
return (
|
| 201 |
+
<Suspense fallback={<div className="flex min-h-screen"><div className="flex-1 flex items-center justify-center"><Loader2 className="w-6 h-6 animate-spin text-text-muted" /></div></div>}>
|
| 202 |
<ConnectContent />
|
| 203 |
</Suspense>
|
| 204 |
)
|
| 205 |
+
}
|
frontend/app/connections/page.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import Link from 'next/link'
|
| 5 |
+
import { Sidebar } from '@/components/sidebar'
|
| 6 |
+
import { api, type Connection } from '@/lib/api'
|
| 7 |
+
import { Link2, Search, ExternalLink, Trash2, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'
|
| 8 |
+
|
| 9 |
+
export default function ConnectionsPage() {
|
| 10 |
+
const [connections, setConnections] = useState<Connection[]>([])
|
| 11 |
+
const [loading, setLoading] = useState(true)
|
| 12 |
+
const [search, setSearch] = useState('')
|
| 13 |
+
|
| 14 |
+
const fetchConnections = () => {
|
| 15 |
+
setLoading(true)
|
| 16 |
+
api.get<Connection[]>('/api/connections')
|
| 17 |
+
.then(setConnections)
|
| 18 |
+
.catch(() => setConnections([]))
|
| 19 |
+
.finally(() => setLoading(false))
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
useEffect(() => { fetchConnections() }, [])
|
| 23 |
+
|
| 24 |
+
const handleDisconnect = async (id: string) => {
|
| 25 |
+
await api.delete(`/api/connections/${id}`)
|
| 26 |
+
fetchConnections()
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const filtered = connections.filter(c =>
|
| 30 |
+
!search || c.integration_name.toLowerCase().includes(search.toLowerCase()) || c.account_label?.toLowerCase().includes(search.toLowerCase())
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div className="flex min-h-screen">
|
| 35 |
+
<Sidebar connectionCount={connections.length} />
|
| 36 |
+
<main className="flex-1 flex flex-col">
|
| 37 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 38 |
+
<div className="flex items-center gap-3">
|
| 39 |
+
<h1 className="text-base font-semibold">Connections</h1>
|
| 40 |
+
<span className="text-xs text-text-muted bg-bg-tertiary border border-border px-2 py-0.5 rounded-md">{connections.length} active</span>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="flex items-center gap-3">
|
| 43 |
+
<div className="relative">
|
| 44 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-dim" />
|
| 45 |
+
<input className="pl-8 pr-4 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan w-56" placeholder="Search connections..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
| 46 |
+
</div>
|
| 47 |
+
<button onClick={fetchConnections} className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors" title="Refresh">
|
| 48 |
+
<RefreshCw className="w-4 h-4" />
|
| 49 |
+
</button>
|
| 50 |
+
</div>
|
| 51 |
+
</header>
|
| 52 |
+
<div className="flex-1 p-6 overflow-auto">
|
| 53 |
+
{loading ? (
|
| 54 |
+
<div className="space-y-2">
|
| 55 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 56 |
+
<div key={i} className="bg-bg-tertiary border border-border rounded-xl p-5 animate-pulse">
|
| 57 |
+
<div className="flex items-center gap-4"><div className="w-10 h-10 bg-bg-hover rounded-lg" /><div className="flex-1"><div className="h-3 bg-bg-hover rounded w-32 mb-1" /><div className="h-2 bg-bg-hover rounded w-20" /></div></div>
|
| 58 |
+
</div>
|
| 59 |
+
))}
|
| 60 |
+
</div>
|
| 61 |
+
) : filtered.length === 0 ? (
|
| 62 |
+
<div className="flex flex-col items-center justify-center py-20">
|
| 63 |
+
<div className="w-16 h-16 bg-bg-tertiary border border-border rounded-2xl flex items-center justify-center mb-4">
|
| 64 |
+
<Link2 className="w-6 h-6 text-text-dim" />
|
| 65 |
+
</div>
|
| 66 |
+
<p className="text-text-muted font-medium">{connections.length === 0 ? 'No connections yet' : 'No matches'}</p>
|
| 67 |
+
<p className="text-text-dim text-sm mt-1">
|
| 68 |
+
{connections.length === 0 ? 'Connect your first app to get started' : 'Try adjusting your search'}
|
| 69 |
+
</p>
|
| 70 |
+
{connections.length === 0 && (
|
| 71 |
+
<Link href="/apps" className="mt-4 px-4 py-2 bg-accent-cyan hover:bg-accent-cyanLight text-bg-primary text-xs font-medium rounded-lg transition-colors">
|
| 72 |
+
Browse Apps
|
| 73 |
+
</Link>
|
| 74 |
+
)}
|
| 75 |
+
</div>
|
| 76 |
+
) : (
|
| 77 |
+
<div className="space-y-2">
|
| 78 |
+
{filtered.map((c) => (
|
| 79 |
+
<div key={c.id} className="bg-bg-tertiary border border-border rounded-xl p-5 card-hover">
|
| 80 |
+
<div className="flex items-center justify-between">
|
| 81 |
+
<div className="flex items-center gap-4">
|
| 82 |
+
<div className="w-10 h-10 bg-bg-secondary border border-border rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
| 83 |
+
<img src={`https://logos.composio.dev/api/${c.integration_id}`} alt={c.integration_name} className="w-6 h-6" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 84 |
+
</div>
|
| 85 |
+
<div>
|
| 86 |
+
<p className="text-sm font-semibold">{c.account_label || c.integration_name}</p>
|
| 87 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 88 |
+
<span className="text-xs text-text-muted">{c.integration_name}</span>
|
| 89 |
+
<span className="text-text-dim">·</span>
|
| 90 |
+
<span className={`text-[10px] flex items-center gap-1 ${c.status === 'active' ? 'text-accent-green' : 'text-text-muted'}`}>
|
| 91 |
+
{c.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
|
| 92 |
+
{c.status}
|
| 93 |
+
</span>
|
| 94 |
+
<span className="text-text-dim">·</span>
|
| 95 |
+
<span className="text-[10px] text-text-muted flex items-center gap-1">
|
| 96 |
+
<Clock className="w-3 h-3" />
|
| 97 |
+
{new Date(c.connected_at).toLocaleDateString()}
|
| 98 |
+
</span>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
<div className="flex items-center gap-2">
|
| 103 |
+
{c.metadata?.auth_type === 'oauth2' && (
|
| 104 |
+
<span className="text-[10px] bg-accent-cyan/10 text-accent-cyanLight px-2 py-0.5 rounded font-medium">OAuth 2.0</span>
|
| 105 |
+
)}
|
| 106 |
+
<Link href={`/connect?id=${c.integration_id}`} className="p-2 text-text-muted hover:text-accent-cyan hover:bg-accent-cyan/10 rounded-lg transition-colors" title="Reconnect">
|
| 107 |
+
<ExternalLink className="w-3.5 h-3.5" />
|
| 108 |
+
</Link>
|
| 109 |
+
<button onClick={() => handleDisconnect(c.id)} className="p-2 text-text-muted hover:text-accent-red hover:bg-accent-red/10 rounded-lg transition-colors" title="Disconnect">
|
| 110 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
))}
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
</main>
|
| 120 |
+
</div>
|
| 121 |
+
)
|
| 122 |
+
}
|
frontend/app/globals.css
CHANGED
|
@@ -2,21 +2,56 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
-
::-webkit-scrollbar { width: 6px; }
|
| 20 |
-
::-webkit-scrollbar-track { background:
|
| 21 |
-
::-webkit-scrollbar-thumb { background:
|
| 22 |
-
::-webkit-scrollbar-thumb:hover { background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--bg-primary: 9 9 11;
|
| 8 |
+
--bg-secondary: 17 17 19;
|
| 9 |
+
--bg-tertiary: 24 24 27;
|
| 10 |
+
--bg-hover: 31 31 35;
|
| 11 |
+
--border: 39 39 42;
|
| 12 |
+
--border-focus: 63 63 70;
|
| 13 |
+
--text-primary: 250 250 250;
|
| 14 |
+
--text-secondary: 161 161 170;
|
| 15 |
+
--text-muted: 113 113 122;
|
| 16 |
+
--text-dim: 82 82 91;
|
| 17 |
+
--accent-cyan: 6 182 212;
|
| 18 |
+
--accent-cyanLight: 34 211 238;
|
| 19 |
+
--accent-green: 16 185 129;
|
| 20 |
+
--accent-amber: 245 158 11;
|
| 21 |
+
--accent-blue: 59 130 246;
|
| 22 |
+
--accent-red: 239 68 68;
|
| 23 |
+
}
|
| 24 |
}
|
| 25 |
|
| 26 |
+
@layer base {
|
| 27 |
+
* { @apply border-border; }
|
| 28 |
+
body {
|
| 29 |
+
@apply bg-bg-primary text-text-primary antialiased;
|
| 30 |
+
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
|
| 34 |
+
@layer utilities {
|
| 35 |
+
.glass {
|
| 36 |
+
@apply bg-bg-tertiary/80 backdrop-blur-xl border border-border;
|
| 37 |
+
}
|
| 38 |
+
.card-hover {
|
| 39 |
+
@apply transition-all duration-200 hover:border-accent-cyan/30 hover:shadow-lg hover:shadow-accent-cyan/5;
|
| 40 |
+
}
|
| 41 |
+
.text-gradient {
|
| 42 |
+
@apply bg-gradient-to-r from-accent-cyan via-accent-cyanLight to-accent-blue bg-clip-text text-transparent;
|
| 43 |
+
}
|
| 44 |
}
|
| 45 |
|
| 46 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 47 |
+
::-webkit-scrollbar-track { background: rgb(var(--bg-secondary)); }
|
| 48 |
+
::-webkit-scrollbar-thumb { background: rgb(var(--border)); border-radius: 3px; }
|
| 49 |
+
::-webkit-scrollbar-thumb:hover { background: rgb(var(--border-focus)); }
|
| 50 |
+
|
| 51 |
+
@keyframes fadeIn {
|
| 52 |
+
from { opacity: 0; transform: translateY(4px); }
|
| 53 |
+
to { opacity: 1; transform: translateY(0); }
|
| 54 |
+
}
|
| 55 |
+
.animate-fade-in {
|
| 56 |
+
animation: fadeIn 0.3s ease-out;
|
| 57 |
+
}
|
frontend/app/layout.tsx
CHANGED
|
@@ -5,16 +5,16 @@ import './globals.css'
|
|
| 5 |
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
| 6 |
|
| 7 |
export const metadata: Metadata = {
|
| 8 |
-
title: 'Compost - AI Tool Platform',
|
| 9 |
-
description: 'Unified AI tool integration
|
| 10 |
}
|
| 11 |
|
| 12 |
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 13 |
return (
|
| 14 |
-
<html lang="en">
|
| 15 |
-
<body className={`${inter.variable} font-sans
|
| 16 |
{children}
|
| 17 |
</body>
|
| 18 |
</html>
|
| 19 |
)
|
| 20 |
-
}
|
|
|
|
| 5 |
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
| 6 |
|
| 7 |
export const metadata: Metadata = {
|
| 8 |
+
title: 'Compost - AI Tool Integration Platform',
|
| 9 |
+
description: 'Unified platform for AI agent tool integration. Connect 1000+ apps with managed auth, execute tools, and build automations.',
|
| 10 |
}
|
| 11 |
|
| 12 |
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 13 |
return (
|
| 14 |
+
<html lang="en" className="dark">
|
| 15 |
+
<body className={`${inter.variable} font-sans`}>
|
| 16 |
{children}
|
| 17 |
</body>
|
| 18 |
</html>
|
| 19 |
)
|
| 20 |
+
}
|
frontend/app/login/page.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useState } from 'react'
|
|
|
|
| 4 |
|
| 5 |
export default function LoginPage() {
|
| 6 |
-
const [email, setEmail] = useState('')
|
| 7 |
-
const [password, setPassword] = useState('')
|
| 8 |
const [error, setError] = useState('')
|
| 9 |
const [loading, setLoading] = useState(false)
|
|
|
|
| 10 |
|
| 11 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 12 |
e.preventDefault()
|
|
@@ -31,31 +33,56 @@ export default function LoginPage() {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
return (
|
| 34 |
-
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
| 35 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
<div className="text-center mb-8">
|
| 37 |
-
<div className="w-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
<p className="text-sm text-text-muted mt-1">AI Tool Integration Platform</p>
|
| 40 |
</div>
|
| 41 |
-
<div className="bg-bg-tertiary border border-border rounded-2xl p-6">
|
| 42 |
-
{error &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 44 |
<div>
|
| 45 |
-
<label className="block text-xs text-text-muted mb-1.5">Email</label>
|
| 46 |
-
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
<div>
|
| 49 |
-
<label className="block text-xs text-text-muted mb-1.5">Password</label>
|
| 50 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
-
<button type="submit" disabled={loading}
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
</button>
|
| 55 |
</form>
|
| 56 |
</div>
|
| 57 |
-
<p className="text-center text-xs text-text-dim mt-4">
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
)
|
| 61 |
-
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useState } from 'react'
|
| 4 |
+
import { Plug, Eye, EyeOff } from 'lucide-react'
|
| 5 |
|
| 6 |
export default function LoginPage() {
|
| 7 |
+
const [email, setEmail] = useState('admin@platform.local')
|
| 8 |
+
const [password, setPassword] = useState('admin123')
|
| 9 |
const [error, setError] = useState('')
|
| 10 |
const [loading, setLoading] = useState(false)
|
| 11 |
+
const [showPwd, setShowPwd] = useState(false)
|
| 12 |
|
| 13 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 14 |
e.preventDefault()
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
return (
|
| 36 |
+
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
| 37 |
+
<div className="absolute inset-0 overflow-hidden">
|
| 38 |
+
<div className="absolute -top-40 -right-40 w-80 h-80 bg-accent-cyan/5 rounded-full blur-3xl" />
|
| 39 |
+
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-accent-blue/5 rounded-full blur-3xl" />
|
| 40 |
+
</div>
|
| 41 |
+
<div className="w-full max-w-sm relative z-10">
|
| 42 |
<div className="text-center mb-8">
|
| 43 |
+
<div className="w-16 h-16 bg-gradient-to-br from-accent-cyan to-accent-blue rounded-2xl flex items-center justify-center mx-auto mb-5 shadow-lg shadow-accent-cyan/20">
|
| 44 |
+
<span className="font-bold text-2xl text-bg-primary">C</span>
|
| 45 |
+
</div>
|
| 46 |
+
<h1 className="text-2xl font-bold">Compost</h1>
|
| 47 |
<p className="text-sm text-text-muted mt-1">AI Tool Integration Platform</p>
|
| 48 |
</div>
|
| 49 |
+
<div className="bg-bg-tertiary/80 backdrop-blur-xl border border-border rounded-2xl p-6 shadow-xl">
|
| 50 |
+
{error && (
|
| 51 |
+
<div className="mb-4 p-3 bg-accent-red/10 border border-accent-red/20 rounded-xl text-accent-red text-xs flex items-center gap-2.5">
|
| 52 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
| 53 |
+
{error}
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 57 |
<div>
|
| 58 |
+
<label className="block text-xs text-text-muted mb-1.5 font-medium">Email</label>
|
| 59 |
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
| 60 |
+
className="w-full px-3.5 py-2.5 bg-bg-secondary border border-border rounded-xl text-sm text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan transition-colors"
|
| 61 |
+
placeholder="admin@platform.local" required />
|
| 62 |
</div>
|
| 63 |
<div>
|
| 64 |
+
<label className="block text-xs text-text-muted mb-1.5 font-medium">Password</label>
|
| 65 |
+
<div className="relative">
|
| 66 |
+
<input type={showPwd ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)}
|
| 67 |
+
className="w-full px-3.5 py-2.5 bg-bg-secondary border border-border rounded-xl text-sm text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan transition-colors pr-10"
|
| 68 |
+
placeholder="••••••••" required />
|
| 69 |
+
<button type="button" onClick={() => setShowPwd(!showPwd)}
|
| 70 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary">
|
| 71 |
+
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 72 |
+
</button>
|
| 73 |
+
</div>
|
| 74 |
</div>
|
| 75 |
+
<button type="submit" disabled={loading}
|
| 76 |
+
className="w-full py-2.5 bg-gradient-to-r from-accent-cyan to-accent-blue hover:from-accent-cyanLight hover:to-accent-blue text-bg-primary font-semibold text-sm rounded-xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 shadow-lg shadow-accent-cyan/20"
|
| 77 |
+
>
|
| 78 |
+
{loading ? <><span className="w-4 h-4 border-2 border-bg-primary border-t-transparent rounded-full animate-spin" /> Signing in...</> : <><Plug className="w-4 h-4" /> Sign In</>}
|
| 79 |
</button>
|
| 80 |
</form>
|
| 81 |
</div>
|
| 82 |
+
<p className="text-center text-xs text-text-dim mt-4">
|
| 83 |
+
Default credentials: <span className="text-text-muted font-mono">admin@platform.local</span> / <span className="text-text-muted font-mono">admin123</span>
|
| 84 |
+
</p>
|
| 85 |
</div>
|
| 86 |
</div>
|
| 87 |
)
|
| 88 |
+
}
|
frontend/app/page.tsx
CHANGED
|
@@ -1,90 +1,247 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
|
|
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
-
import { api, type Integration, type Stats } from '@/lib/api'
|
| 6 |
-
import { Activity, Plug, Zap,
|
| 7 |
|
| 8 |
export default function DashboardPage() {
|
| 9 |
const [integrations, setIntegrations] = useState<Integration[]>([])
|
|
|
|
| 10 |
const [stats, setStats] = useState<Stats | null>(null)
|
| 11 |
const [loading, setLoading] = useState(true)
|
| 12 |
|
| 13 |
useEffect(() => {
|
| 14 |
Promise.all([
|
| 15 |
api.get<Integration[]>('/api/apps').catch(() => []),
|
|
|
|
| 16 |
api.get<Stats>('/api/analytics/overview').catch(() => null),
|
| 17 |
-
]).then(([apps, s]) => {
|
| 18 |
setIntegrations(apps)
|
|
|
|
| 19 |
setStats(s)
|
| 20 |
setLoading(false)
|
| 21 |
})
|
| 22 |
}, [])
|
| 23 |
|
| 24 |
const metrics = [
|
| 25 |
-
{ label: 'Total Executions', value: stats?.total_executions ?? 0, icon: Activity, color: 'text-accent-cyan' },
|
| 26 |
-
{ label: 'Integrations', value: integrations.length, icon:
|
| 27 |
-
{ label: '
|
| 28 |
-
{ label: '
|
| 29 |
]
|
| 30 |
|
|
|
|
|
|
|
| 31 |
return (
|
| 32 |
<div className="flex min-h-screen">
|
| 33 |
-
<Sidebar integrationCount={integrations.length} />
|
| 34 |
<main className="flex-1 flex flex-col">
|
| 35 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6">
|
| 36 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</header>
|
|
|
|
| 38 |
<div className="flex-1 p-6 space-y-6 overflow-auto">
|
| 39 |
-
|
|
|
|
| 40 |
{metrics.map((m) => (
|
| 41 |
-
<div key={m.label} className="bg-bg-tertiary border border-border rounded-xl p-5">
|
| 42 |
<div className="flex items-center justify-between">
|
| 43 |
<div>
|
| 44 |
<p className="text-sm text-text-muted">{m.label}</p>
|
| 45 |
-
<p className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
-
<m.icon className={`w-5 h-5 ${m.color}`} />
|
| 48 |
</div>
|
| 49 |
</div>
|
| 50 |
))}
|
| 51 |
</div>
|
| 52 |
|
| 53 |
-
<div className="
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
<
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<
|
| 69 |
</div>
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</div>
|
| 74 |
-
|
| 75 |
-
<div className="flex items-center justify-between pt-3 border-t border-border">
|
| 76 |
-
<span className="text-[11px] text-text-muted">{i.tool_count} tools</span>
|
| 77 |
-
<span className={`text-[11px] px-2 py-0.5 rounded ${i.auth_type === 'oauth2' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'bg-accent-green/10 text-accent-green'}`}>
|
| 78 |
-
{i.auth_type}
|
| 79 |
-
</span>
|
| 80 |
-
</div>
|
| 81 |
</div>
|
| 82 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</div>
|
| 84 |
-
|
| 85 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
</main>
|
| 88 |
</div>
|
| 89 |
)
|
| 90 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
+
import Link from 'next/link'
|
| 5 |
import { Sidebar } from '@/components/sidebar'
|
| 6 |
+
import { api, type Integration, type Connection, type Stats } from '@/lib/api'
|
| 7 |
+
import { Activity, Plug, CheckCircle, Clock, ArrowUpRight, Zap, Server, TrendingUp, BarChart3 } from 'lucide-react'
|
| 8 |
|
| 9 |
export default function DashboardPage() {
|
| 10 |
const [integrations, setIntegrations] = useState<Integration[]>([])
|
| 11 |
+
const [connections, setConnections] = useState<Connection[]>([])
|
| 12 |
const [stats, setStats] = useState<Stats | null>(null)
|
| 13 |
const [loading, setLoading] = useState(true)
|
| 14 |
|
| 15 |
useEffect(() => {
|
| 16 |
Promise.all([
|
| 17 |
api.get<Integration[]>('/api/apps').catch(() => []),
|
| 18 |
+
api.get<Connection[]>('/api/connections').catch(() => []),
|
| 19 |
api.get<Stats>('/api/analytics/overview').catch(() => null),
|
| 20 |
+
]).then(([apps, conns, s]) => {
|
| 21 |
setIntegrations(apps)
|
| 22 |
+
setConnections(conns)
|
| 23 |
setStats(s)
|
| 24 |
setLoading(false)
|
| 25 |
})
|
| 26 |
}, [])
|
| 27 |
|
| 28 |
const metrics = [
|
| 29 |
+
{ label: 'Total Executions', value: stats?.total_executions ?? 0, icon: Activity, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' },
|
| 30 |
+
{ label: 'Integrations', value: integrations.length, icon: Server, color: 'text-accent-blue', bg: 'bg-accent-blue/10' },
|
| 31 |
+
{ label: 'Connections', value: connections.length, icon: Plug, color: 'text-accent-green', bg: 'bg-accent-green/10' },
|
| 32 |
+
{ label: 'Success Rate', value: stats?.success_rate != null ? `${stats.success_rate}%` : '-', icon: CheckCircle, color: stats?.success_rate != null && stats.success_rate > 90 ? 'text-accent-green' : 'text-accent-amber', bg: 'bg-accent-amber/10' },
|
| 33 |
]
|
| 34 |
|
| 35 |
+
const recentConnections = connections.slice(0, 5)
|
| 36 |
+
|
| 37 |
return (
|
| 38 |
<div className="flex min-h-screen">
|
| 39 |
+
<Sidebar integrationCount={integrations.length} connectionCount={connections.length} />
|
| 40 |
<main className="flex-1 flex flex-col">
|
| 41 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 42 |
+
<div className="flex items-center gap-3">
|
| 43 |
+
<h1 className="text-base font-semibold">Dashboard</h1>
|
| 44 |
+
</div>
|
| 45 |
+
<div className="flex items-center gap-3">
|
| 46 |
+
<span className="text-[11px] text-text-muted bg-bg-tertiary border border-border px-3 py-1 rounded-full">
|
| 47 |
+
<span className="inline-block w-1.5 h-1.5 bg-accent-green rounded-full mr-1.5 animate-pulse" />
|
| 48 |
+
All systems operational
|
| 49 |
+
</span>
|
| 50 |
+
</div>
|
| 51 |
</header>
|
| 52 |
+
|
| 53 |
<div className="flex-1 p-6 space-y-6 overflow-auto">
|
| 54 |
+
{/* Metrics Grid */}
|
| 55 |
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
| 56 |
{metrics.map((m) => (
|
| 57 |
+
<div key={m.label} className="bg-bg-tertiary border border-border rounded-xl p-5 card-hover">
|
| 58 |
<div className="flex items-center justify-between">
|
| 59 |
<div>
|
| 60 |
<p className="text-sm text-text-muted">{m.label}</p>
|
| 61 |
+
<p className={`text-2xl font-bold mt-1 ${loading ? 'animate-pulse' : ''}`}>
|
| 62 |
+
{loading ? <span className="bg-bg-hover rounded w-16 h-8 block" /> : m.value}
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
<div className={`w-10 h-10 ${m.bg} rounded-lg flex items-center justify-center`}>
|
| 66 |
+
<m.icon className={`w-5 h-5 ${m.color}`} />
|
| 67 |
</div>
|
|
|
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
))}
|
| 71 |
</div>
|
| 72 |
|
| 73 |
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
| 74 |
+
{/* Quick Connect */}
|
| 75 |
+
<div className="bg-bg-tertiary border border-border rounded-xl">
|
| 76 |
+
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
| 77 |
+
<h3 className="text-sm font-semibold flex items-center gap-2">
|
| 78 |
+
<Zap className="w-4 h-4 text-accent-cyan" />
|
| 79 |
+
Quick Connect
|
| 80 |
+
</h3>
|
| 81 |
+
<Link href="/apps" className="text-xs text-accent-cyan hover:text-accent-cyanLight flex items-center gap-1">
|
| 82 |
+
View all <ArrowUpRight className="w-3 h-3" />
|
| 83 |
+
</Link>
|
| 84 |
</div>
|
| 85 |
+
<div className="p-6">
|
| 86 |
+
{loading ? (
|
| 87 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
| 88 |
+
{Array.from({ length: 4 }).map((_, i) => (
|
| 89 |
+
<div key={i} className="bg-bg-secondary border border-border rounded-lg p-4 animate-pulse">
|
| 90 |
+
<div className="w-8 h-8 bg-bg-hover rounded-lg mb-2" />
|
| 91 |
+
<div className="h-3 bg-bg-hover rounded w-16" />
|
| 92 |
</div>
|
| 93 |
+
))}
|
| 94 |
+
</div>
|
| 95 |
+
) : integrations.length === 0 ? (
|
| 96 |
+
<div className="text-center py-8">
|
| 97 |
+
<Plug className="w-10 h-10 text-text-dim mx-auto mb-3" />
|
| 98 |
+
<p className="text-text-muted text-sm">No integrations available</p>
|
| 99 |
+
<p className="text-text-dim text-xs mt-1">Set COMPOSIO_API_KEY to sync all 1000+ tools</p>
|
| 100 |
+
</div>
|
| 101 |
+
) : (
|
| 102 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
| 103 |
+
{integrations.slice(0, 8).map((i) => {
|
| 104 |
+
const isConnected = connections.some(c => c.integration_id === i.id)
|
| 105 |
+
return (
|
| 106 |
+
<Link key={i.id} href={`/connect?id=${i.id}`}
|
| 107 |
+
className="bg-bg-secondary border border-border rounded-lg p-3.5 card-hover group"
|
| 108 |
+
>
|
| 109 |
+
<div className="flex items-center gap-2.5 mb-2.5">
|
| 110 |
+
<div className="w-8 h-8 bg-bg-tertiary rounded-lg flex items-center justify-center overflow-hidden border border-border">
|
| 111 |
+
<img src={i.logo_url} alt={i.name} className="w-5 h-5" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 112 |
+
</div>
|
| 113 |
+
<div className="min-w-0">
|
| 114 |
+
<p className="text-xs font-medium truncate">{i.name}</p>
|
| 115 |
+
<p className="text-[10px] text-text-muted truncate">{i.category}</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="flex items-center justify-between">
|
| 119 |
+
<span className="text-[10px] text-text-dim">{i.tool_count} tools</span>
|
| 120 |
+
{isConnected ? (
|
| 121 |
+
<span className="text-[10px] text-accent-green flex items-center gap-1">
|
| 122 |
+
<span className="w-1.5 h-1.5 bg-accent-green rounded-full" />
|
| 123 |
+
Connected
|
| 124 |
+
</span>
|
| 125 |
+
) : (
|
| 126 |
+
<span className="text-[10px] text-accent-cyan group-hover:text-accent-cyanLight font-medium">
|
| 127 |
+
Connect
|
| 128 |
+
</span>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
</Link>
|
| 132 |
+
)
|
| 133 |
+
})}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* Recent Connections */}
|
| 140 |
+
<div className="bg-bg-tertiary border border-border rounded-xl">
|
| 141 |
+
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
| 142 |
+
<h3 className="text-sm font-semibold flex items-center gap-2">
|
| 143 |
+
<Plug className="w-4 h-4 text-accent-green" />
|
| 144 |
+
Recent Connections
|
| 145 |
+
</h3>
|
| 146 |
+
<Link href="/connections" className="text-xs text-accent-cyan hover:text-accent-cyanLight flex items-center gap-1">
|
| 147 |
+
View all <ArrowUpRight className="w-3 h-3" />
|
| 148 |
+
</Link>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="p-6">
|
| 151 |
+
{loading ? (
|
| 152 |
+
<div className="space-y-3">
|
| 153 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 154 |
+
<div key={i} className="flex items-center gap-3 animate-pulse">
|
| 155 |
+
<div className="w-9 h-9 bg-bg-hover rounded-lg" />
|
| 156 |
+
<div className="flex-1"><div className="h-3 bg-bg-hover rounded w-24 mb-1" /><div className="h-2 bg-bg-hover rounded w-16" /></div>
|
| 157 |
</div>
|
| 158 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
+
) : recentConnections.length === 0 ? (
|
| 161 |
+
<div className="text-center py-6">
|
| 162 |
+
<Link2 className="w-8 h-8 text-text-dim mx-auto mb-2" />
|
| 163 |
+
<p className="text-text-muted text-sm">No connections yet</p>
|
| 164 |
+
<p className="text-text-dim text-xs mt-1">Connect your first app</p>
|
| 165 |
+
</div>
|
| 166 |
+
) : (
|
| 167 |
+
<div className="space-y-2">
|
| 168 |
+
{recentConnections.map((c) => (
|
| 169 |
+
<Link key={c.id} href={`/connections`}
|
| 170 |
+
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-hover transition-colors group"
|
| 171 |
+
>
|
| 172 |
+
<div className="w-9 h-9 bg-bg-secondary border border-border rounded-lg flex items-center justify-center">
|
| 173 |
+
<img src={`https://logos.composio.dev/api/${c.integration_id}`} alt={c.integration_name} className="w-5 h-5" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 174 |
+
</div>
|
| 175 |
+
<div className="flex-1 min-w-0">
|
| 176 |
+
<p className="text-sm font-medium truncate">{c.account_label || c.integration_name}</p>
|
| 177 |
+
<p className="text-[11px] text-text-muted">
|
| 178 |
+
{c.status === 'active' ? 'Connected' : c.status} · {new Date(c.connected_at).toLocaleDateString()}
|
| 179 |
+
</p>
|
| 180 |
+
</div>
|
| 181 |
+
<span className="text-[10px] text-accent-green flex items-center gap-1.5">
|
| 182 |
+
<span className="w-1.5 h-1.5 bg-accent-green rounded-full" />
|
| 183 |
+
Active
|
| 184 |
+
</span>
|
| 185 |
+
</Link>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
</div>
|
| 190 |
+
</div>
|
| 191 |
</div>
|
| 192 |
+
|
| 193 |
+
{/* Success Rate Bar */}
|
| 194 |
+
{stats && (
|
| 195 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-6">
|
| 196 |
+
<div className="flex items-center justify-between mb-4">
|
| 197 |
+
<h3 className="text-sm font-semibold flex items-center gap-2">
|
| 198 |
+
<TrendingUp className="w-4 h-4 text-accent-cyan" />
|
| 199 |
+
Performance Overview
|
| 200 |
+
</h3>
|
| 201 |
+
<span className="text-xs text-text-muted">Last 24 hours</span>
|
| 202 |
+
</div>
|
| 203 |
+
<div className="grid grid-cols-3 gap-6">
|
| 204 |
+
<div>
|
| 205 |
+
<div className="flex items-center justify-between mb-2">
|
| 206 |
+
<span className="text-xs text-text-muted">Success Rate</span>
|
| 207 |
+
<span className="text-sm font-semibold">{stats.success_rate}%</span>
|
| 208 |
+
</div>
|
| 209 |
+
<div className="h-2 bg-bg-secondary rounded-full overflow-hidden">
|
| 210 |
+
<div className="h-full bg-gradient-to-r from-accent-green to-accent-cyan rounded-full transition-all" style={{ width: `${stats.success_rate}%` }} />
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
<div>
|
| 214 |
+
<div className="flex items-center justify-between mb-2">
|
| 215 |
+
<span className="text-xs text-text-muted">Avg Latency</span>
|
| 216 |
+
<span className="text-sm font-semibold">{stats.avg_latency_ms}ms</span>
|
| 217 |
+
</div>
|
| 218 |
+
<div className="h-2 bg-bg-secondary rounded-full overflow-hidden">
|
| 219 |
+
<div className="h-full bg-accent-amber rounded-full" style={{ width: `${Math.min(stats.avg_latency_ms / 20, 100)}%` }} />
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
<div>
|
| 223 |
+
<div className="flex items-center justify-between mb-2">
|
| 224 |
+
<span className="text-xs text-text-muted">Total Calls</span>
|
| 225 |
+
<span className="text-sm font-semibold">{stats.total_executions}</span>
|
| 226 |
+
</div>
|
| 227 |
+
<div className="h-2 bg-bg-secondary rounded-full overflow-hidden">
|
| 228 |
+
<div className="h-full bg-accent-blue rounded-full" style={{ width: `${Math.min((stats.total_executions / 1000) * 100, 100)}%` }} />
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
</div>
|
| 235 |
</main>
|
| 236 |
</div>
|
| 237 |
)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function Link2(props: { className?: string }) {
|
| 241 |
+
return (
|
| 242 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
|
| 243 |
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
| 244 |
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
| 245 |
+
</svg>
|
| 246 |
+
)
|
| 247 |
+
}
|
frontend/app/playground/page.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useRef } from 'react'
|
| 4 |
+
import { Sidebar } from '@/components/sidebar'
|
| 5 |
+
import { api, type ToolExecution } from '@/lib/api'
|
| 6 |
+
import { Play, Terminal, Upload, Trash2, Copy, Check } from 'lucide-react'
|
| 7 |
+
|
| 8 |
+
export default function PlaygroundPage() {
|
| 9 |
+
const [code, setCode] = useState(`# Try executing a tool
|
| 10 |
+
# Example: call composio_execute_tool("github", "GITHUB_GET_USER", {"username": "octocat"})
|
| 11 |
+
|
| 12 |
+
composio_execute_tool("github", "GITHUB_LIST_REPOS", {"owner": "octocat"})
|
| 13 |
+
`)
|
| 14 |
+
const [output, setOutput] = useState('')
|
| 15 |
+
const [executing, setExecuting] = useState(false)
|
| 16 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 17 |
+
|
| 18 |
+
const handleExecute = async () => {
|
| 19 |
+
setExecuting(true)
|
| 20 |
+
setOutput('')
|
| 21 |
+
try {
|
| 22 |
+
const match = code.match(/composio_execute_tool\("(\w+)",\s*"(\w+)",\s*({[^}]+})\)/)
|
| 23 |
+
if (match) {
|
| 24 |
+
const [_, integrationId, toolId, paramsStr] = match
|
| 25 |
+
let params = {}
|
| 26 |
+
try { params = JSON.parse(paramsStr) } catch { params = { raw: paramsStr } }
|
| 27 |
+
const result = await api.post<ToolExecution>(`/api/tools/${toolId}/execute`, { params })
|
| 28 |
+
setOutput(JSON.stringify(result, null, 2))
|
| 29 |
+
} else {
|
| 30 |
+
// Try direct tool execution
|
| 31 |
+
const lines = code.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'))
|
| 32 |
+
if (lines.length > 0) {
|
| 33 |
+
try {
|
| 34 |
+
const evalResult = await api.post<Record<string, unknown>>('/api/playground/evaluate', { code })
|
| 35 |
+
setOutput(JSON.stringify(evalResult, null, 2))
|
| 36 |
+
} catch {
|
| 37 |
+
setOutput(`Error: Could not parse tool call.\n\nExpected format:\ncomposio_execute_tool("integration", "TOOL_NAME", {"param": "value"})`)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
} catch (err: unknown) {
|
| 42 |
+
setOutput(`Error: ${err instanceof Error ? err.message : 'Execution failed'}`)
|
| 43 |
+
} finally { setExecuting(false) }
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 47 |
+
const file = e.target.files?.[0]
|
| 48 |
+
if (file) {
|
| 49 |
+
const reader = new FileReader()
|
| 50 |
+
reader.onload = (ev) => setCode(ev.target?.result as string)
|
| 51 |
+
reader.readAsText(file)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const [copied, setCopied] = useState(false)
|
| 56 |
+
const handleCopy = () => {
|
| 57 |
+
navigator.clipboard.writeText(output)
|
| 58 |
+
setCopied(true)
|
| 59 |
+
setTimeout(() => setCopied(false), 2000)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="flex min-h-screen">
|
| 64 |
+
<Sidebar />
|
| 65 |
+
<main className="flex-1 flex flex-col">
|
| 66 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 67 |
+
<div className="flex items-center gap-3">
|
| 68 |
+
<h1 className="text-base font-semibold">Playground</h1>
|
| 69 |
+
</div>
|
| 70 |
+
<div className="flex items-center gap-2">
|
| 71 |
+
<input type="file" ref={fileInputRef} onChange={handleFileUpload} className="hidden" accept=".py,.js,.txt,.json" />
|
| 72 |
+
<button onClick={() => fileInputRef.current?.click()} className="px-3 py-1.5 bg-bg-tertiary border border-border text-text-secondary hover:text-text-primary text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5">
|
| 73 |
+
<Upload className="w-3.5 h-3.5" /> Import
|
| 74 |
+
</button>
|
| 75 |
+
<button onClick={() => setCode('')} className="px-3 py-1.5 bg-bg-tertiary border border-border text-text-secondary hover:text-text-primary text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5">
|
| 76 |
+
<Trash2 className="w-3.5 h-3.5" /> Clear
|
| 77 |
+
</button>
|
| 78 |
+
<button
|
| 79 |
+
onClick={handleExecute}
|
| 80 |
+
disabled={executing}
|
| 81 |
+
className="px-4 py-1.5 bg-gradient-to-r from-accent-cyan to-accent-blue hover:from-accent-cyanLight hover:to-accent-blue text-bg-primary text-xs font-semibold rounded-lg transition-all disabled:opacity-50 flex items-center gap-1.5 shadow-lg shadow-accent-cyan/20"
|
| 82 |
+
>
|
| 83 |
+
{executing ? <><span className="w-3 h-3 border-2 border-bg-primary border-t-transparent rounded-full animate-spin" /> Running...</> : <><Play className="w-3.5 h-3.5" /> Execute</>}
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
</header>
|
| 87 |
+
<div className="flex-1 flex overflow-hidden">
|
| 88 |
+
{/* Code Editor */}
|
| 89 |
+
<div className="w-1/2 border-r border-border flex flex-col">
|
| 90 |
+
<div className="px-4 py-2.5 border-b border-border bg-bg-tertiary flex items-center justify-between">
|
| 91 |
+
<div className="flex items-center gap-2">
|
| 92 |
+
<Terminal className="w-3.5 h-3.5 text-accent-cyan" />
|
| 93 |
+
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">Input</span>
|
| 94 |
+
</div>
|
| 95 |
+
<span className="text-[10px] text-text-dim">Python · Sandbox</span>
|
| 96 |
+
</div>
|
| 97 |
+
<textarea
|
| 98 |
+
value={code}
|
| 99 |
+
onChange={(e) => setCode(e.target.value)}
|
| 100 |
+
className="flex-1 w-full p-4 bg-bg-secondary text-xs font-mono text-text-primary resize-none focus:outline-none leading-relaxed"
|
| 101 |
+
spellCheck={false}
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
{/* Output */}
|
| 105 |
+
<div className="w-1/2 flex flex-col">
|
| 106 |
+
<div className="px-4 py-2.5 border-b border-border bg-bg-tertiary flex items-center justify-between">
|
| 107 |
+
<div className="flex items-center gap-2">
|
| 108 |
+
<Terminal className="w-3.5 h-3.5 text-accent-green" />
|
| 109 |
+
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">Output</span>
|
| 110 |
+
</div>
|
| 111 |
+
{output && (
|
| 112 |
+
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary transition-colors">
|
| 113 |
+
{copied ? <><Check className="w-3 h-3" /> Copied</> : <><Copy className="w-3 h-3" /> Copy</>}
|
| 114 |
+
</button>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
<pre className="flex-1 p-4 bg-bg-secondary text-xs font-mono text-text-primary overflow-auto whitespace-pre-wrap leading-relaxed">
|
| 118 |
+
{output || 'Output will appear here...'}
|
| 119 |
+
</pre>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</main>
|
| 123 |
+
</div>
|
| 124 |
+
)
|
| 125 |
+
}
|
frontend/app/settings/page.tsx
CHANGED
|
@@ -1,40 +1,249 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { Sidebar } from '@/components/sidebar'
|
| 4 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function SettingsPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
return (
|
| 8 |
<div className="flex min-h-screen">
|
| 9 |
<Sidebar />
|
| 10 |
<main className="flex-1 flex flex-col">
|
| 11 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6">
|
| 12 |
-
<h1 className="text-base font-
|
| 13 |
</header>
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<div className="flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
<
|
| 26 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
<div className="flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
</main>
|
| 38 |
</div>
|
| 39 |
)
|
| 40 |
-
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
+
import { api, type AuthConfig, type Integration } from '@/lib/api'
|
| 6 |
+
import { Server, Database, Shield, Key, Edit3, Plus, X, Check, ExternalLink, Save } from 'lucide-react'
|
| 7 |
+
|
| 8 |
+
interface AuthField {
|
| 9 |
+
name: string
|
| 10 |
+
label: string
|
| 11 |
+
type: string
|
| 12 |
+
required: boolean
|
| 13 |
+
description: string
|
| 14 |
+
default: string
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface ConfigWithIntegration {
|
| 18 |
+
id: string
|
| 19 |
+
integration_id: string
|
| 20 |
+
integration_name: string
|
| 21 |
+
auth_type: string
|
| 22 |
+
fields: AuthField[]
|
| 23 |
+
oauth_authorize_url: string | null
|
| 24 |
+
oauth_token_url: string | null
|
| 25 |
+
is_active: boolean
|
| 26 |
+
}
|
| 27 |
|
| 28 |
export default function SettingsPage() {
|
| 29 |
+
const [configs, setConfigs] = useState<ConfigWithIntegration[]>([])
|
| 30 |
+
const [loading, setLoading] = useState(true)
|
| 31 |
+
const [editing, setEditing] = useState<string | null>(null)
|
| 32 |
+
const [editForm, setEditForm] = useState<Partial<ConfigWithIntegration>>({})
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
api.get<ConfigWithIntegration[]>('/api/auth-configs').then(setConfigs).catch(() => setConfigs([])).finally(() => setLoading(false))
|
| 36 |
+
}, [])
|
| 37 |
+
|
| 38 |
+
const startEdit = (c: ConfigWithIntegration) => {
|
| 39 |
+
setEditing(c.id)
|
| 40 |
+
setEditForm({ ...c, fields: c.fields.map(f => ({ ...f })) })
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const saveEdit = async () => {
|
| 44 |
+
if (!editForm.integration_id) return
|
| 45 |
+
await api.post(`/api/auth-configs`, editForm)
|
| 46 |
+
setEditing(null)
|
| 47 |
+
api.get<ConfigWithIntegration[]>('/api/auth-configs').then(setConfigs)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const addField = () => {
|
| 51 |
+
setEditForm(prev => ({
|
| 52 |
+
...prev,
|
| 53 |
+
fields: [...(prev.fields || []), { name: '', label: '', type: 'text', required: false, description: '', default: '' }]
|
| 54 |
+
}))
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const removeField = (idx: number) => {
|
| 58 |
+
setEditForm(prev => ({
|
| 59 |
+
...prev,
|
| 60 |
+
fields: prev.fields?.filter((_, i) => i !== idx) || []
|
| 61 |
+
}))
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const updateField = (idx: number, key: keyof AuthField, value: string | boolean) => {
|
| 65 |
+
setEditForm(prev => ({
|
| 66 |
+
...prev,
|
| 67 |
+
fields: prev.fields?.map((f, i) => i === idx ? { ...f, [key]: value } : f) || []
|
| 68 |
+
}))
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
return (
|
| 72 |
<div className="flex min-h-screen">
|
| 73 |
<Sidebar />
|
| 74 |
<main className="flex-1 flex flex-col">
|
| 75 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center px-6 sticky top-0 z-10">
|
| 76 |
+
<h1 className="text-base font-semibold">Settings</h1>
|
| 77 |
</header>
|
| 78 |
+
|
| 79 |
+
<div className="flex-1 p-6 space-y-6 overflow-auto">
|
| 80 |
+
{/* System Info */}
|
| 81 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 82 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-5">
|
| 83 |
+
<div className="flex items-center gap-3 mb-4">
|
| 84 |
+
<div className="w-9 h-9 bg-accent-cyan/10 rounded-lg flex items-center justify-center"><Server className="w-5 h-5 text-accent-cyan" /></div>
|
| 85 |
+
<h3 className="text-sm font-semibold">System</h3>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="space-y-2.5 text-sm">
|
| 88 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Version</span><span className="text-xs font-mono">1.0.0</span></div>
|
| 89 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Data Dir</span><span className="text-xs font-mono text-accent-cyan">/data</span></div>
|
| 90 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Status</span><span className="text-xs text-accent-green flex items-center gap-1"><span className="w-1.5 h-1.5 bg-accent-green rounded-full" /> Healthy</span></div>
|
| 91 |
+
</div>
|
| 92 |
</div>
|
| 93 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-5">
|
| 94 |
+
<div className="flex items-center gap-3 mb-4">
|
| 95 |
+
<div className="w-9 h-9 bg-accent-green/10 rounded-lg flex items-center justify-center"><Database className="w-5 h-5 text-accent-green" /></div>
|
| 96 |
+
<h3 className="text-sm font-semibold">Database</h3>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="space-y-2.5 text-sm">
|
| 99 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Engine</span><span className="text-xs font-mono">SQLite</span></div>
|
| 100 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Path</span><span className="text-xs font-mono text-accent-cyan">/data/platform.db</span></div>
|
| 101 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Cache</span><span className="text-xs text-accent-green">Active</span></div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="bg-bg-tertiary border border-border rounded-xl p-5">
|
| 105 |
+
<div className="flex items-center gap-3 mb-4">
|
| 106 |
+
<div className="w-9 h-9 bg-accent-amber/10 rounded-lg flex items-center justify-center"><Shield className="w-5 h-5 text-accent-amber" /></div>
|
| 107 |
+
<h3 className="text-sm font-semibold">Security</h3>
|
| 108 |
+
</div>
|
| 109 |
+
<div className="space-y-2.5 text-sm">
|
| 110 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Auth</span><span className="text-xs font-mono">JWT (HS256)</span></div>
|
| 111 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Vault</span><span className="text-xs font-mono">AES-256-GCM</span></div>
|
| 112 |
+
<div className="flex justify-between"><span className="text-text-muted text-xs">Encryption</span><span className="text-xs text-accent-green">End-to-end</span></div>
|
| 113 |
+
</div>
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
+
|
| 117 |
+
{/* Auth Configs */}
|
| 118 |
+
<div className="bg-bg-tertiary border border-border rounded-xl">
|
| 119 |
+
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
| 120 |
+
<div className="flex items-center gap-3">
|
| 121 |
+
<Key className="w-4 h-4 text-accent-cyan" />
|
| 122 |
+
<h3 className="text-sm font-semibold">Credential Schemas</h3>
|
| 123 |
+
<span className="text-xs text-text-muted bg-bg-secondary border border-border px-2 py-0.5 rounded-md">{configs.length} integrations</span>
|
| 124 |
+
</div>
|
| 125 |
+
<p className="text-[10px] text-text-dim">Define what credentials each integration needs</p>
|
| 126 |
+
</div>
|
| 127 |
+
<div className="p-4">
|
| 128 |
+
{loading ? (
|
| 129 |
+
<div className="space-y-2">
|
| 130 |
+
{Array.from({ length: 4 }).map((_, i) => (
|
| 131 |
+
<div key={i} className="h-16 bg-bg-secondary border border-border rounded-xl animate-pulse" />
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
) : configs.length === 0 ? (
|
| 135 |
+
<div className="text-center py-10 text-text-muted text-sm">No credential schemas configured</div>
|
| 136 |
+
) : (
|
| 137 |
+
<div className="space-y-2">
|
| 138 |
+
{configs.map((c) => (
|
| 139 |
+
<div key={c.id}>
|
| 140 |
+
{editing === c.id ? (
|
| 141 |
+
<div className="bg-bg-secondary border border-accent-cyan/30 rounded-xl p-5 space-y-4">
|
| 142 |
+
<div className="flex items-center justify-between">
|
| 143 |
+
<div className="flex items-center gap-3">
|
| 144 |
+
<div className="w-8 h-8 bg-accent-cyan/10 rounded-lg flex items-center justify-center"><Key className="w-4 h-4 text-accent-cyan" /></div>
|
| 145 |
+
<span className="text-sm font-semibold">{c.integration_name} ({c.integration_id})</span>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="flex items-center gap-2">
|
| 148 |
+
<button onClick={saveEdit} className="p-1.5 text-accent-green hover:bg-accent-green/10 rounded-lg transition-colors"><Check className="w-4 h-4" /></button>
|
| 149 |
+
<button onClick={() => setEditing(null)} className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"><X className="w-4 h-4" /></button>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div className="grid grid-cols-2 gap-3">
|
| 154 |
+
<div>
|
| 155 |
+
<label className="text-[10px] text-text-muted font-medium">Auth Type</label>
|
| 156 |
+
<select value={editForm.auth_type || 'api_key'} onChange={(e) => setEditForm(prev => ({ ...prev, auth_type: e.target.value }))}
|
| 157 |
+
className="w-full mt-1 px-3 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent-cyan">
|
| 158 |
+
<option value="api_key">API Key</option>
|
| 159 |
+
<option value="oauth2">OAuth 2.0</option>
|
| 160 |
+
<option value="bearer">Bearer Token</option>
|
| 161 |
+
<option value="basic">Basic Auth</option>
|
| 162 |
+
</select>
|
| 163 |
+
</div>
|
| 164 |
+
{editForm.auth_type === 'oauth2' && (
|
| 165 |
+
<>
|
| 166 |
+
<div>
|
| 167 |
+
<label className="text-[10px] text-text-muted font-medium">Authorize URL</label>
|
| 168 |
+
<input value={editForm.oauth_authorize_url || ''} onChange={(e) => setEditForm(prev => ({ ...prev, oauth_authorize_url: e.target.value }))}
|
| 169 |
+
className="w-full mt-1 px-3 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent-cyan font-mono" />
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<label className="text-[10px] text-text-muted font-medium">Token URL</label>
|
| 173 |
+
<input value={editForm.oauth_token_url || ''} onChange={(e) => setEditForm(prev => ({ ...prev, oauth_token_url: e.target.value }))}
|
| 174 |
+
className="w-full mt-1 px-3 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent-cyan font-mono" />
|
| 175 |
+
</div>
|
| 176 |
+
</>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<div>
|
| 181 |
+
<div className="flex items-center justify-between mb-2">
|
| 182 |
+
<span className="text-[10px] text-text-muted font-medium">Fields</span>
|
| 183 |
+
<button onClick={addField} className="text-[10px] text-accent-cyan hover:text-accent-cyanLight flex items-center gap-0.5">
|
| 184 |
+
<Plus className="w-3 h-3" /> Add Field
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
<div className="space-y-2">
|
| 188 |
+
{editForm.fields?.map((field, idx) => (
|
| 189 |
+
<div key={idx} className="flex items-center gap-2 bg-bg-tertiary border border-border rounded-lg p-3">
|
| 190 |
+
<input value={field.name} onChange={(e) => updateField(idx, 'name', e.target.value)} placeholder="name"
|
| 191 |
+
className="w-24 px-2 py-1 bg-bg-secondary border border-border rounded text-[10px] font-mono text-text-primary focus:outline-none focus:border-accent-cyan" />
|
| 192 |
+
<input value={field.label} onChange={(e) => updateField(idx, 'label', e.target.value)} placeholder="Label"
|
| 193 |
+
className="w-28 px-2 py-1 bg-bg-secondary border border-border rounded text-[10px] text-text-primary focus:outline-none focus:border-accent-cyan" />
|
| 194 |
+
<select value={field.type} onChange={(e) => updateField(idx, 'type', e.target.value)}
|
| 195 |
+
className="w-20 px-2 py-1 bg-bg-secondary border border-border rounded text-[10px] text-text-primary focus:outline-none focus:border-accent-cyan">
|
| 196 |
+
<option value="text">text</option>
|
| 197 |
+
<option value="password">password</option>
|
| 198 |
+
<option value="url">url</option>
|
| 199 |
+
</select>
|
| 200 |
+
<label className="flex items-center gap-1 text-[10px] text-text-muted cursor-pointer">
|
| 201 |
+
<input type="checkbox" checked={field.required} onChange={(e) => updateField(idx, 'required', e.target.checked)}
|
| 202 |
+
className="rounded border-border bg-bg-secondary text-accent-cyan focus:ring-accent-cyan" />
|
| 203 |
+
Required
|
| 204 |
+
</label>
|
| 205 |
+
<input value={field.description} onChange={(e) => updateField(idx, 'description', e.target.value)} placeholder="Description"
|
| 206 |
+
className="flex-1 px-2 py-1 bg-bg-secondary border border-border rounded text-[10px] text-text-primary focus:outline-none focus:border-accent-cyan" />
|
| 207 |
+
<button onClick={() => removeField(idx)} className="p-1 text-text-muted hover:text-accent-red">
|
| 208 |
+
<X className="w-3 h-3" />
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
))}
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
) : (
|
| 216 |
+
<div className="flex items-center justify-between px-4 py-3 bg-bg-secondary border border-border rounded-xl hover:border-border-focus transition-colors group">
|
| 217 |
+
<div className="flex items-center gap-3">
|
| 218 |
+
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden bg-bg-tertiary border border-border">
|
| 219 |
+
<img src={`https://logos.composio.dev/api/${c.integration_id}`} alt={c.integration_name} className="w-5 h-5" onError={(e) => (e.currentTarget.style.display = 'none')} />
|
| 220 |
+
</div>
|
| 221 |
+
<div>
|
| 222 |
+
<p className="text-sm font-medium">{c.integration_name}</p>
|
| 223 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 224 |
+
<span className="text-[10px] text-text-muted font-mono">{c.integration_id}</span>
|
| 225 |
+
<span className="text-text-dim">·</span>
|
| 226 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
| 227 |
+
c.auth_type === 'oauth2' ? 'bg-accent-cyan/10 text-accent-cyanLight' : 'bg-accent-green/10 text-accent-green'
|
| 228 |
+
}`}>{c.auth_type}</span>
|
| 229 |
+
<span className="text-[10px] text-text-muted">{c.fields.length} fields</span>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
<button onClick={() => startEdit(c)}
|
| 234 |
+
className="p-1.5 text-text-muted opacity-0 group-hover:opacity-100 hover:text-accent-cyan hover:bg-accent-cyan/10 rounded-lg transition-all">
|
| 235 |
+
<Edit3 className="w-3.5 h-3.5" />
|
| 236 |
+
</button>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
))}
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
</div>
|
| 244 |
</div>
|
| 245 |
</div>
|
| 246 |
</main>
|
| 247 |
</div>
|
| 248 |
)
|
| 249 |
+
}
|
frontend/app/tools/page.tsx
CHANGED
|
@@ -2,63 +2,186 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
-
import { api, type Tool } from '@/lib/api'
|
| 6 |
-
import { Search, Play } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function ToolsPage() {
|
| 9 |
const [tools, setTools] = useState<Tool[]>([])
|
| 10 |
const [loading, setLoading] = useState(true)
|
| 11 |
const [search, setSearch] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
useEffect(() => {
|
| 14 |
-
|
| 15 |
-
.
|
| 16 |
-
|
| 17 |
}, [])
|
| 18 |
|
| 19 |
-
const
|
| 20 |
-
|
| 21 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
return (
|
| 24 |
<div className="flex min-h-screen">
|
| 25 |
<Sidebar />
|
| 26 |
<main className="flex-1 flex flex-col">
|
| 27 |
-
<header className="h-14 bg-bg-secondary border-b border-border flex items-center px-6
|
| 28 |
-
<
|
| 29 |
-
|
| 30 |
-
<
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
</div>
|
| 33 |
</header>
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
<div
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
</main>
|
| 62 |
</div>
|
| 63 |
)
|
| 64 |
-
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
import { Sidebar } from '@/components/sidebar'
|
| 5 |
+
import { api, type Tool, type ToolExecution } from '@/lib/api'
|
| 6 |
+
import { Search, Play, Terminal, ChevronDown, ChevronRight, Clock, CheckCircle, XCircle } from 'lucide-react'
|
| 7 |
|
| 8 |
export default function ToolsPage() {
|
| 9 |
const [tools, setTools] = useState<Tool[]>([])
|
| 10 |
const [loading, setLoading] = useState(true)
|
| 11 |
const [search, setSearch] = useState('')
|
| 12 |
+
const [selectedTool, setSelectedTool] = useState<Tool | null>(null)
|
| 13 |
+
const [executing, setExecuting] = useState(false)
|
| 14 |
+
const [executionResult, setExecutionResult] = useState<ToolExecution | null>(null)
|
| 15 |
+
const [params, setParams] = useState<string>('{}')
|
| 16 |
+
const [expandedTool, setExpandedTool] = useState<string | null>(null)
|
| 17 |
+
const [integrationFilter, setIntegrationFilter] = useState<string>('all')
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
+
Promise.all([
|
| 21 |
+
api.get<Tool[]>('/api/tools').catch(() => []),
|
| 22 |
+
]).then(([t]) => { setTools(t); setLoading(false) })
|
| 23 |
}, [])
|
| 24 |
|
| 25 |
+
const integrations = [...new Set(tools.map(t => t.integration_id))].sort()
|
| 26 |
+
|
| 27 |
+
const filtered = tools.filter((t) => {
|
| 28 |
+
const matchSearch = !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase())
|
| 29 |
+
const matchInt = integrationFilter === 'all' || t.integration_id === integrationFilter
|
| 30 |
+
return matchSearch && matchInt
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
const handleExecute = async (tool: Tool) => {
|
| 34 |
+
setExecuting(true)
|
| 35 |
+
setExecutionResult(null)
|
| 36 |
+
try {
|
| 37 |
+
let parsedParams = {}
|
| 38 |
+
try { parsedParams = JSON.parse(params) } catch { /* use empty */ }
|
| 39 |
+
const result = await api.post<ToolExecution>(`/api/tools/${tool.id}/execute`, {
|
| 40 |
+
params: parsedParams,
|
| 41 |
+
dry_run: false,
|
| 42 |
+
})
|
| 43 |
+
setExecutionResult(result)
|
| 44 |
+
} catch (err: unknown) {
|
| 45 |
+
setExecutionResult({
|
| 46 |
+
execution_id: 'error',
|
| 47 |
+
status: 'error',
|
| 48 |
+
result: null,
|
| 49 |
+
latency_ms: 0,
|
| 50 |
+
executed_at: new Date().toISOString(),
|
| 51 |
+
error: err instanceof Error ? err.message : 'Execution failed',
|
| 52 |
+
})
|
| 53 |
+
} finally { setExecuting(false) }
|
| 54 |
+
}
|
| 55 |
|
| 56 |
return (
|
| 57 |
<div className="flex min-h-screen">
|
| 58 |
<Sidebar />
|
| 59 |
<main className="flex-1 flex flex-col">
|
| 60 |
+
<header className="h-14 bg-bg-secondary/80 backdrop-blur-xl border-b border-border flex items-center justify-between px-6 sticky top-0 z-10">
|
| 61 |
+
<div className="flex items-center gap-3">
|
| 62 |
+
<h1 className="text-base font-semibold">Tools</h1>
|
| 63 |
+
{!loading && <span className="text-xs text-text-muted bg-bg-tertiary border border-border px-2 py-0.5 rounded-md">{tools.length} total</span>}
|
| 64 |
+
</div>
|
| 65 |
+
<div className="flex items-center gap-3">
|
| 66 |
+
<select value={integrationFilter} onChange={(e) => setIntegrationFilter(e.target.value)}
|
| 67 |
+
className="px-3 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent-cyan"
|
| 68 |
+
>
|
| 69 |
+
<option value="all">All Integrations</option>
|
| 70 |
+
{integrations.map(int => <option key={int} value={int}>{int}</option>)}
|
| 71 |
+
</select>
|
| 72 |
+
<div className="relative">
|
| 73 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-dim" />
|
| 74 |
+
<input className="pl-8 pr-4 py-1.5 bg-bg-tertiary border border-border rounded-lg text-xs text-text-primary placeholder:text-text-dim focus:outline-none focus:border-accent-cyan w-56" placeholder="Search tools..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
| 75 |
+
</div>
|
| 76 |
</div>
|
| 77 |
</header>
|
| 78 |
+
|
| 79 |
+
<div className="flex-1 flex overflow-hidden">
|
| 80 |
+
{/* Tool List */}
|
| 81 |
+
<div className="w-1/2 border-r border-border overflow-y-auto p-4">
|
| 82 |
+
{loading ? (
|
| 83 |
+
<div className="space-y-2">
|
| 84 |
+
{Array.from({ length: 8 }).map((_, i) => (
|
| 85 |
+
<div key={i} className="bg-bg-tertiary border border-border rounded-xl p-4 animate-pulse">
|
| 86 |
+
<div className="flex items-center gap-3"><div className="w-8 h-8 bg-bg-hover rounded-lg" /><div className="flex-1"><div className="h-3 bg-bg-hover rounded w-32 mb-1" /><div className="h-2 bg-bg-hover rounded w-full" /></div></div>
|
| 87 |
+
</div>
|
| 88 |
+
))}
|
| 89 |
+
</div>
|
| 90 |
+
) : filtered.length === 0 ? (
|
| 91 |
+
<div className="flex flex-col items-center justify-center py-16">
|
| 92 |
+
<Terminal className="w-10 h-10 text-text-dim mb-3" />
|
| 93 |
+
<p className="text-text-muted text-sm">No tools found</p>
|
| 94 |
+
</div>
|
| 95 |
+
) : (
|
| 96 |
+
<div className="space-y-1.5">
|
| 97 |
+
{filtered.map((t) => (
|
| 98 |
+
<div key={t.id}>
|
| 99 |
+
<button
|
| 100 |
+
onClick={() => setExpandedTool(expandedTool === t.id ? null : t.id)}
|
| 101 |
+
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all ${
|
| 102 |
+
expandedTool === t.id ? 'bg-accent-cyan/5 border border-accent-cyan/20' : 'bg-bg-tertiary border border-border hover:border-border-focus'
|
| 103 |
+
}`}
|
| 104 |
+
>
|
| 105 |
+
<div className="w-8 h-8 bg-bg-secondary border border-border rounded-lg flex items-center justify-center shrink-0">
|
| 106 |
+
<Play className="w-3.5 h-3.5 text-text-muted" />
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex-1 min-w-0">
|
| 109 |
+
<p className="text-sm font-medium truncate">{t.name}</p>
|
| 110 |
+
<p className="text-[11px] text-text-muted truncate">{t.description}</p>
|
| 111 |
+
</div>
|
| 112 |
+
<span className="text-[10px] bg-bg-secondary border border-border px-1.5 py-0.5 rounded text-text-muted shrink-0">{t.integration_id}</span>
|
| 113 |
+
{expandedTool === t.id ? <ChevronDown className="w-3.5 h-3.5 text-text-muted shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-text-muted shrink-0" />}
|
| 114 |
+
</button>
|
| 115 |
+
{expandedTool === t.id && (
|
| 116 |
+
<div className="ml-12 mt-1.5 p-4 bg-bg-secondary border border-border rounded-xl space-y-3 animate-fade-in mb-1.5">
|
| 117 |
+
<div>
|
| 118 |
+
<label className="text-[10px] text-text-muted font-medium uppercase tracking-wider">Parameters (JSON)</label>
|
| 119 |
+
<textarea
|
| 120 |
+
value={params}
|
| 121 |
+
onChange={(e) => setParams(e.target.value)}
|
| 122 |
+
className="mt-1.5 w-full h-24 px-3 py-2 bg-bg-tertiary border border-border rounded-lg text-xs font-mono text-text-primary focus:outline-none focus:border-accent-cyan resize-none"
|
| 123 |
+
placeholder='{"key": "value"}'
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
<button
|
| 127 |
+
onClick={() => handleExecute(t)}
|
| 128 |
+
disabled={executing}
|
| 129 |
+
className="w-full py-2 bg-accent-cyan hover:bg-accent-cyanLight text-bg-primary text-xs font-semibold rounded-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
| 130 |
+
>
|
| 131 |
+
{executing ? <><span className="w-3 h-3 border-2 border-bg-primary border-t-transparent rounded-full animate-spin" /> Executing...</> : <><Play className="w-3.5 h-3.5" /> Execute {t.name}</>}
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
)}
|
| 135 |
</div>
|
| 136 |
+
))}
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* Result Panel */}
|
| 142 |
+
<div className="w-1/2 overflow-y-auto p-4">
|
| 143 |
+
{!executionResult && !executing && (
|
| 144 |
+
<div className="flex flex-col items-center justify-center h-full text-text-dim">
|
| 145 |
+
<Terminal className="w-12 h-12 mb-4" />
|
| 146 |
+
<p className="text-sm">Select a tool and execute it</p>
|
| 147 |
+
<p className="text-xs mt-1">Results will appear here</p>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
{executing && (
|
| 151 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 152 |
+
<div className="w-8 h-8 border-2 border-accent-cyan border-t-transparent rounded-full animate-spin mb-3" />
|
| 153 |
+
<p className="text-sm text-text-muted">Executing...</p>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
{executionResult && !executing && (
|
| 157 |
+
<div className="space-y-3 animate-fade-in">
|
| 158 |
+
<div className="flex items-center gap-3 mb-4">
|
| 159 |
+
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
| 160 |
+
executionResult.status === 'success' ? 'bg-accent-green/10' : 'bg-accent-red/10'
|
| 161 |
+
}`}>
|
| 162 |
+
{executionResult.status === 'success' ? <CheckCircle className="w-4 h-4 text-accent-green" /> : <XCircle className="w-4 h-4 text-accent-red" />}
|
| 163 |
+
</div>
|
| 164 |
+
<div>
|
| 165 |
+
<p className="text-sm font-semibold capitalize">{executionResult.status}</p>
|
| 166 |
+
<p className="text-[10px] text-text-muted flex items-center gap-1">
|
| 167 |
+
<Clock className="w-3 h-3" /> {executionResult.latency_ms}ms · {new Date(executionResult.executed_at).toLocaleTimeString()}
|
| 168 |
+
</p>
|
| 169 |
</div>
|
| 170 |
</div>
|
| 171 |
+
<div className="bg-bg-secondary border border-border rounded-xl overflow-hidden">
|
| 172 |
+
<div className="px-4 py-2.5 border-b border-border bg-bg-tertiary flex items-center gap-2">
|
| 173 |
+
<Terminal className="w-3.5 h-3.5 text-accent-cyan" />
|
| 174 |
+
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">Output</span>
|
| 175 |
+
</div>
|
| 176 |
+
<pre className="p-4 text-xs font-mono text-text-primary overflow-x-auto max-h-96 overflow-y-auto whitespace-pre-wrap">
|
| 177 |
+
{JSON.stringify(executionResult.result || executionResult.error, null, 2)}
|
| 178 |
+
</pre>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
)}
|
| 182 |
+
</div>
|
| 183 |
</div>
|
| 184 |
</main>
|
| 185 |
</div>
|
| 186 |
)
|
| 187 |
+
}
|
frontend/components/sidebar.tsx
CHANGED
|
@@ -2,87 +2,115 @@
|
|
| 2 |
|
| 3 |
import Link from 'next/link'
|
| 4 |
import { usePathname } from 'next/navigation'
|
| 5 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
-
const
|
| 9 |
{ href: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
| 10 |
{ href: '/apps', icon: Plug, label: 'Apps' },
|
|
|
|
| 11 |
{ href: '/tools', icon: Wrench, label: 'Tools' },
|
|
|
|
| 12 |
]
|
| 13 |
|
| 14 |
-
const
|
| 15 |
{ href: '/api-keys', icon: Key, label: 'API Keys' },
|
| 16 |
{ href: '/analytics', icon: BarChart3, label: 'Analytics' },
|
| 17 |
{ href: '/settings', icon: Settings, label: 'Settings' },
|
| 18 |
]
|
| 19 |
|
| 20 |
-
export function Sidebar({ integrationCount = 0 }: { integrationCount?: number }) {
|
| 21 |
const pathname = usePathname()
|
| 22 |
|
| 23 |
return (
|
| 24 |
-
<aside className="w-60 bg-bg-secondary border-r border-border flex flex-col shrink-0">
|
| 25 |
<div className="px-5 py-4 border-b border-border">
|
| 26 |
-
<
|
| 27 |
-
<div className="w-8 h-8 bg-accent-cyan rounded-lg flex items-center justify-center font-bold text-bg-primary text-sm">
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
|
| 32 |
-
<nav className="flex-1 px-3 py-4 space-y-1">
|
| 33 |
-
<div className="px-3 mb-2 text-[
|
| 34 |
-
{
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
<span className="
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
<div className="
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 72 |
</nav>
|
| 73 |
|
| 74 |
<div className="px-3 py-4 border-t border-border">
|
| 75 |
-
<div className="flex items-center justify-between">
|
| 76 |
-
<div className="flex items-center gap-2 min-w-0">
|
| 77 |
-
<div className="w-7 h-7 bg-
|
| 78 |
{typeof window !== 'undefined' ? (localStorage.getItem('email')?.[0]?.toUpperCase() || 'U') : 'U'}
|
| 79 |
</div>
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
</div>
|
| 84 |
<button onClick={() => { localStorage.removeItem('token'); localStorage.removeItem('email'); window.location.href = '/login' }}
|
| 85 |
className="p-1.5 text-text-muted hover:text-accent-red hover:bg-accent-red/10 rounded-lg transition-colors"
|
|
|
|
| 86 |
>
|
| 87 |
<LogOut className="w-3.5 h-3.5" />
|
| 88 |
</button>
|
|
@@ -90,4 +118,4 @@ export function Sidebar({ integrationCount = 0 }: { integrationCount?: number })
|
|
| 90 |
</div>
|
| 91 |
</aside>
|
| 92 |
)
|
| 93 |
-
}
|
|
|
|
| 2 |
|
| 3 |
import Link from 'next/link'
|
| 4 |
import { usePathname } from 'next/navigation'
|
| 5 |
+
import {
|
| 6 |
+
LayoutDashboard, Plug, Wrench, Link2, Key, BarChart3, Settings,
|
| 7 |
+
LogOut, ExternalLink, Play, ChevronRight
|
| 8 |
+
} from 'lucide-react'
|
| 9 |
import { cn } from '@/lib/utils'
|
| 10 |
|
| 11 |
+
const mainNav = [
|
| 12 |
{ href: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
| 13 |
{ href: '/apps', icon: Plug, label: 'Apps' },
|
| 14 |
+
{ href: '/connections', icon: Link2, label: 'Connections' },
|
| 15 |
{ href: '/tools', icon: Wrench, label: 'Tools' },
|
| 16 |
+
{ href: '/playground', icon: Play, label: 'Playground' },
|
| 17 |
]
|
| 18 |
|
| 19 |
+
const configNav = [
|
| 20 |
{ href: '/api-keys', icon: Key, label: 'API Keys' },
|
| 21 |
{ href: '/analytics', icon: BarChart3, label: 'Analytics' },
|
| 22 |
{ href: '/settings', icon: Settings, label: 'Settings' },
|
| 23 |
]
|
| 24 |
|
| 25 |
+
export function Sidebar({ integrationCount = 0, connectionCount = 0 }: { integrationCount?: number; connectionCount?: number }) {
|
| 26 |
const pathname = usePathname()
|
| 27 |
|
| 28 |
return (
|
| 29 |
+
<aside className="w-60 bg-bg-secondary border-r border-border flex flex-col shrink-0 h-screen sticky top-0">
|
| 30 |
<div className="px-5 py-4 border-b border-border">
|
| 31 |
+
<Link href="/" className="flex items-center gap-3 group">
|
| 32 |
+
<div className="w-8 h-8 bg-gradient-to-br from-accent-cyan to-accent-blue rounded-lg flex items-center justify-center font-bold text-bg-primary text-sm group-hover:scale-105 transition-transform">
|
| 33 |
+
C
|
| 34 |
+
</div>
|
| 35 |
+
<div>
|
| 36 |
+
<div className="text-base font-semibold tracking-tight">Compost</div>
|
| 37 |
+
<div className="text-[10px] text-text-dim font-medium">Platform v1.0</div>
|
| 38 |
+
</div>
|
| 39 |
+
</Link>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
+
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
| 43 |
+
<div className="px-3 mb-2 text-[10px] font-semibold uppercase tracking-widest text-text-dim">Navigation</div>
|
| 44 |
+
{mainNav.map((item) => {
|
| 45 |
+
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href))
|
| 46 |
+
return (
|
| 47 |
+
<Link key={item.href} href={item.href}
|
| 48 |
+
className={cn(
|
| 49 |
+
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-150 group',
|
| 50 |
+
isActive
|
| 51 |
+
? 'bg-accent-cyan/10 text-accent-cyanLight font-medium'
|
| 52 |
+
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
| 53 |
+
)}
|
| 54 |
+
>
|
| 55 |
+
<item.icon className={cn('w-4 h-4 shrink-0', isActive ? 'text-accent-cyanLight' : 'text-text-muted group-hover:text-text-primary')} />
|
| 56 |
+
<span className="truncate">{item.label}</span>
|
| 57 |
+
{item.href === '/apps' && integrationCount > 0 && (
|
| 58 |
+
<span className="ml-auto text-[10px] bg-bg-tertiary border border-border text-text-muted px-1.5 py-0.5 rounded-md font-medium">{integrationCount}</span>
|
| 59 |
+
)}
|
| 60 |
+
{item.href === '/connections' && connectionCount > 0 && (
|
| 61 |
+
<span className="ml-auto text-[10px] bg-accent-green/10 border border-accent-green/20 text-accent-green px-1.5 py-0.5 rounded-md font-medium">{connectionCount}</span>
|
| 62 |
+
)}
|
| 63 |
+
</Link>
|
| 64 |
+
)
|
| 65 |
+
})}
|
| 66 |
|
| 67 |
+
<div className="pt-6 pb-1">
|
| 68 |
+
<div className="px-3 mb-2 text-[10px] font-semibold uppercase tracking-widest text-text-dim">Configuration</div>
|
| 69 |
+
{configNav.map((item) => {
|
| 70 |
+
const isActive = pathname === item.href
|
| 71 |
+
return (
|
| 72 |
+
<Link key={item.href} href={item.href}
|
| 73 |
+
className={cn(
|
| 74 |
+
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-150 group',
|
| 75 |
+
isActive
|
| 76 |
+
? 'bg-accent-cyan/10 text-accent-cyanLight font-medium'
|
| 77 |
+
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
| 78 |
+
)}
|
| 79 |
+
>
|
| 80 |
+
<item.icon className={cn('w-4 h-4 shrink-0', isActive ? 'text-accent-cyanLight' : 'text-text-muted group-hover:text-text-primary')} />
|
| 81 |
+
<span className="truncate">{item.label}</span>
|
| 82 |
+
</Link>
|
| 83 |
+
)
|
| 84 |
+
})}
|
| 85 |
+
</div>
|
| 86 |
|
| 87 |
+
<div className="pt-6">
|
| 88 |
+
<a href="/api/docs" target="_blank" rel="noopener noreferrer"
|
| 89 |
+
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-text-secondary hover:bg-bg-hover hover:text-text-primary transition-colors group"
|
| 90 |
+
>
|
| 91 |
+
<ExternalLink className="w-4 h-4 shrink-0 text-text-muted group-hover:text-text-primary" />
|
| 92 |
+
<span>API Docs</span>
|
| 93 |
+
<ChevronRight className="w-3 h-3 ml-auto text-text-dim" />
|
| 94 |
+
</a>
|
| 95 |
+
</div>
|
| 96 |
</nav>
|
| 97 |
|
| 98 |
<div className="px-3 py-4 border-t border-border">
|
| 99 |
+
<div className="flex items-center justify-between px-1">
|
| 100 |
+
<div className="flex items-center gap-2.5 min-w-0">
|
| 101 |
+
<div className="w-7 h-7 bg-gradient-to-br from-accent-cyan/20 to-accent-blue/20 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 border border-border">
|
| 102 |
{typeof window !== 'undefined' ? (localStorage.getItem('email')?.[0]?.toUpperCase() || 'U') : 'U'}
|
| 103 |
</div>
|
| 104 |
+
<div className="min-w-0">
|
| 105 |
+
<div className="text-xs text-text-secondary truncate">
|
| 106 |
+
{typeof window !== 'undefined' ? localStorage.getItem('email') || 'User' : 'User'}
|
| 107 |
+
</div>
|
| 108 |
+
<div className="text-[10px] text-text-dim">Online</div>
|
| 109 |
+
</div>
|
| 110 |
</div>
|
| 111 |
<button onClick={() => { localStorage.removeItem('token'); localStorage.removeItem('email'); window.location.href = '/login' }}
|
| 112 |
className="p-1.5 text-text-muted hover:text-accent-red hover:bg-accent-red/10 rounded-lg transition-colors"
|
| 113 |
+
title="Sign out"
|
| 114 |
>
|
| 115 |
<LogOut className="w-3.5 h-3.5" />
|
| 116 |
</button>
|
|
|
|
| 118 |
</div>
|
| 119 |
</aside>
|
| 120 |
)
|
| 121 |
+
}
|
frontend/lib/api.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Integration {
|
|
| 5 |
logo_url: string
|
| 6 |
category: string
|
| 7 |
auth_type: string
|
|
|
|
| 8 |
tool_count: number
|
| 9 |
trigger_count: number
|
| 10 |
is_active: boolean
|
|
@@ -17,10 +18,21 @@ export interface Tool {
|
|
| 17 |
description: string
|
| 18 |
input_schema: Record<string, unknown>
|
| 19 |
output_schema?: Record<string, unknown>
|
| 20 |
-
category: string
|
| 21 |
is_active: boolean
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export interface Stats {
|
| 25 |
total_executions: number
|
| 26 |
successful: number
|
|
@@ -29,9 +41,44 @@ export interface Stats {
|
|
| 29 |
avg_latency_ms: number
|
| 30 |
}
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
| 36 |
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
| 37 |
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
@@ -40,16 +87,23 @@ async function fetchApi(path: string, opts: RequestInit = {}) {
|
|
| 40 |
if (res.status === 401) {
|
| 41 |
if (typeof window !== 'undefined') {
|
| 42 |
localStorage.removeItem('token')
|
|
|
|
| 43 |
window.location.href = '/login'
|
| 44 |
}
|
| 45 |
throw new Error('Unauthorized')
|
| 46 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
return res.json()
|
| 48 |
}
|
| 49 |
|
| 50 |
export const api = {
|
| 51 |
-
get: <T>(path: string) =>
|
| 52 |
post: <T>(path: string, body?: unknown) =>
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
}
|
|
|
|
|
|
|
|
|
| 5 |
logo_url: string
|
| 6 |
category: string
|
| 7 |
auth_type: string
|
| 8 |
+
oauth_scopes?: string[] | null
|
| 9 |
tool_count: number
|
| 10 |
trigger_count: number
|
| 11 |
is_active: boolean
|
|
|
|
| 18 |
description: string
|
| 19 |
input_schema: Record<string, unknown>
|
| 20 |
output_schema?: Record<string, unknown>
|
| 21 |
+
category: string | null
|
| 22 |
is_active: boolean
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export interface Connection {
|
| 26 |
+
id: string
|
| 27 |
+
integration_id: string
|
| 28 |
+
integration_name: string
|
| 29 |
+
account_label: string | null
|
| 30 |
+
status: string
|
| 31 |
+
connected_at: string
|
| 32 |
+
last_used: string | null
|
| 33 |
+
metadata: Record<string, unknown> | null
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
export interface Stats {
|
| 37 |
total_executions: number
|
| 38 |
successful: number
|
|
|
|
| 41 |
avg_latency_ms: number
|
| 42 |
}
|
| 43 |
|
| 44 |
+
export interface AuthField {
|
| 45 |
+
name: string
|
| 46 |
+
label: string
|
| 47 |
+
type: string
|
| 48 |
+
required: boolean
|
| 49 |
+
description: string
|
| 50 |
+
default: string
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface AuthConfig {
|
| 54 |
+
integration_id: string
|
| 55 |
+
auth_type: string
|
| 56 |
+
fields: AuthField[]
|
| 57 |
+
oauth_authorize_url: string | null
|
| 58 |
+
oauth_token_url: string | null
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export interface ToolExecution {
|
| 62 |
+
execution_id: string
|
| 63 |
+
status: string
|
| 64 |
+
result: Record<string, unknown> | null
|
| 65 |
+
latency_ms: number
|
| 66 |
+
executed_at: string
|
| 67 |
+
error: string | null
|
| 68 |
+
}
|
| 69 |
|
| 70 |
+
export interface APIKey {
|
| 71 |
+
id: string
|
| 72 |
+
name: string
|
| 73 |
+
key_preview: string
|
| 74 |
+
created_at: string
|
| 75 |
+
last_used: string | null
|
| 76 |
+
is_active: boolean
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const API_BASE = ''
|
| 80 |
+
|
| 81 |
+
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
| 82 |
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
|
| 83 |
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
| 84 |
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
|
|
| 87 |
if (res.status === 401) {
|
| 88 |
if (typeof window !== 'undefined') {
|
| 89 |
localStorage.removeItem('token')
|
| 90 |
+
localStorage.removeItem('email')
|
| 91 |
window.location.href = '/login'
|
| 92 |
}
|
| 93 |
throw new Error('Unauthorized')
|
| 94 |
}
|
| 95 |
+
if (!res.ok) {
|
| 96 |
+
const data = await res.json().catch(() => ({ detail: 'Request failed' }))
|
| 97 |
+
throw new Error(data.detail || `HTTP ${res.status}`)
|
| 98 |
+
}
|
| 99 |
return res.json()
|
| 100 |
}
|
| 101 |
|
| 102 |
export const api = {
|
| 103 |
+
get: <T>(path: string) => request<T>(path),
|
| 104 |
post: <T>(path: string, body?: unknown) =>
|
| 105 |
+
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
| 106 |
+
put: <T>(path: string, body?: unknown) =>
|
| 107 |
+
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
| 108 |
+
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
| 109 |
+
}
|
frontend/tailwind.config.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Config } from 'tailwindcss'
|
|
| 2 |
|
| 3 |
const config: Config = {
|
| 4 |
content: [
|
| 5 |
-
'./
|
|
|
|
| 6 |
],
|
| 7 |
theme: {
|
| 8 |
extend: {
|
|
|
|
| 2 |
|
| 3 |
const config: Config = {
|
| 4 |
content: [
|
| 5 |
+
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
| 6 |
+
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
| 7 |
],
|
| 8 |
theme: {
|
| 9 |
extend: {
|