Migrate frontend from Next.js to Vite + React Router

Full technology change: Next.js App Router replaced with Vite +
react-router-dom v6. All 21 pages and 5 landing components converted
(next/link -> Link, next/navigation -> react-router-dom hooks, next/font
-> @fontsource/inter, process.env -> import.meta.env). Dropped the unused
Stripe API routes, which can't run in a client-only Vite build anyway and
were never wired to any page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-07-01 17:10:03 +05:30
parent 61d04a662f
commit 5b4834e6a5
38 changed files with 6768 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=https://odoo-mcp.thedomainnest.com
VITE_APP_URL=https://your-frontend-domain.com

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ dist/
*.local
.DS_Store
*.log
*.tsbuildinfo

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OdooMCP Cloud — Secure MCP Server for Odoo</title>
<meta name="description" content="Connect your Odoo ERP to Claude, Codex, and AI agents with a secure hosted MCP server." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3229
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "odoomcp-cloud-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource/inter": "^5.0.0",
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"react": "^18",
"react-dom": "^18",
"react-router-dom": "^6.26.0",
"recharts": "^3.9.1"
},
"devDependencies": {
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"vite": "^5.4.0",
"vite-tsconfig-paths": "^5.0.0"
}
}

6
postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

52
src/App.tsx Normal file
View File

@ -0,0 +1,52 @@
import { Routes, Route } from 'react-router-dom';
import Landing from './pages/Landing';
import Login from './pages/Login';
import Signup from './pages/Signup';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import DashboardLayout from './pages/dashboard/DashboardLayout';
import DashboardHome from './pages/dashboard/DashboardHome';
import Connections from './pages/dashboard/Connections';
import Billing from './pages/dashboard/Billing';
import Endpoints from './pages/dashboard/Endpoints';
import Tokens from './pages/dashboard/Tokens';
import Usage from './pages/dashboard/Usage';
import Logs from './pages/dashboard/Logs';
import AdminLogin from './pages/admin/AdminLogin';
import AdminLayout from './pages/admin/AdminLayout';
import AdminOverview from './pages/admin/AdminOverview';
import AdminAnalytics from './pages/admin/AdminAnalytics';
import AdminUsers from './pages/admin/AdminUsers';
import AdminUserDetail from './pages/admin/AdminUserDetail';
export default function App() {
return (
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="connections" element={<Connections />} />
<Route path="billing" element={<Billing />} />
<Route path="endpoints" element={<Endpoints />} />
<Route path="tokens" element={<Tokens />} />
<Route path="usage" element={<Usage />} />
<Route path="logs" element={<Logs />} />
</Route>
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminOverview />} />
<Route path="analytics" element={<AdminAnalytics />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
</Route>
</Routes>
);
}

View File

@ -0,0 +1,56 @@
import { Shield, Zap, Code2, Key, Database, Settings2 } from 'lucide-react';
const FEATURES = [
{
icon: Shield,
title: 'Secure & Hosted',
desc: 'We host and maintain the MCP server infrastructure.',
},
{
icon: Zap,
title: 'Claude Ready',
desc: 'Codex, Cursor and other clients supported.',
},
{
icon: Code2,
title: 'Developer Friendly',
desc: 'Isolated tokens, rotation, usage limits and logs.',
},
{
icon: Key,
title: 'Secure Tokens',
desc: 'Isolated tokens, rotation, usage limits and logs.',
},
{
icon: Database,
title: 'Odoo Modules',
desc: 'CRM, Sales, Inventory, Accounting and more.',
},
{
icon: Settings2,
title: 'Admin Controls',
desc: 'Granular permissions and full activity logs.',
},
];
export default function Features() {
return (
<section id="features" className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-6">
{FEATURES.map(({ icon: Icon, title, desc }) => (
<div key={title} className="bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex flex-col gap-3">
<div className="w-10 h-10 bg-brand-50 rounded-lg flex items-center justify-center">
<Icon size={20} className="text-brand-600" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{title}</p>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,95 @@
import { Link } from 'react-router-dom';
import { ArrowRight, Shield } from 'lucide-react';
const AI_TOOLS = [
{ name: 'Claude', color: 'bg-orange-100 text-orange-700' },
{ name: 'Codex', color: 'bg-green-100 text-green-700' },
{ name: 'Cursor', color: 'bg-blue-100 text-blue-700' },
{ name: 'AI Agents', color: 'bg-purple-100 text-purple-700' },
];
export default function Hero() {
return (
<section className="pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row items-center gap-16">
{/* Left: copy */}
<div className="flex-1 max-w-xl">
<div className="inline-flex items-center gap-2 bg-brand-50 text-brand-700 text-xs font-medium px-3 py-1.5 rounded-full mb-6">
<Shield size={12} />
Secure MCP Server for Odoo
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-4">
Connect your Odoo ERP to{' '}
<span className="text-gradient">Claude, Codex, and AI agents</span>
</h1>
<p className="text-lg text-gray-600 mb-8">
A secure hosted MCP server for Odoo. Subscribe, connect your Odoo
instance, and instantly use your ERP data inside your favorite AI tools.
</p>
<div className="flex flex-wrap gap-3 mb-8">
<Link to="/signup"
className="inline-flex items-center gap-2 gradient-brand text-white px-6 py-3 rounded-xl font-semibold hover:opacity-90 transition-opacity">
Start Free Trial <ArrowRight size={16} />
</Link>
<Link to="#how-it-works"
className="inline-flex items-center gap-2 border border-gray-200 text-gray-700 px-6 py-3 rounded-xl font-semibold hover:bg-gray-50 transition-colors">
View Setup Guide
</Link>
</div>
<p className="text-xs text-gray-500">No credit card required · 7-day free trial · Cancel anytime</p>
</div>
{/* Right: diagram */}
<div className="flex-1 flex justify-center lg:justify-end">
<div className="bg-white border border-gray-100 rounded-2xl shadow-xl p-8 w-full max-w-md">
{/* Odoo ERP → OdooMCP → AI Tools */}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 bg-purple-50 rounded-xl flex items-center justify-center">
<span className="text-2xl">🗄</span>
</div>
<span className="text-xs font-medium text-gray-600">Odoo ERP</span>
</div>
<div className="flex-1 border-t-2 border-dashed border-brand-200 relative">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-brand-400 rounded-full" />
</div>
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 gradient-brand rounded-xl flex items-center justify-center shadow-lg">
<span className="text-white text-xs font-bold">MCP</span>
</div>
<span className="text-xs font-medium text-brand-700">OdooMCP Cloud</span>
</div>
<div className="flex-1 border-t-2 border-dashed border-brand-200 relative">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-brand-400 rounded-full" />
</div>
<div className="flex flex-col gap-2">
{AI_TOOLS.map((t) => (
<div key={t.name}
className={`text-xs font-medium px-2.5 py-1 rounded-lg ${t.color}`}>
{t.name}
</div>
))}
</div>
</div>
{/* MCP endpoint badge */}
<div className="mt-8 bg-gray-50 rounded-xl p-4">
<p className="text-xs text-gray-500 mb-1">Your MCP Endpoint</p>
<p className="text-xs font-mono text-brand-600 break-all">
https://odoo-mcp.thedomainnest.com/mcp/sse
</p>
<div className="flex gap-4 mt-3 text-xs text-gray-600">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full" /> Active
</span>
<span>Tools: 24 enabled</span>
<span>Claude: Connected</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,33 @@
const STEPS = [
{ n: 1, title: 'Create Account', desc: 'Sign up and choose the best plan for your needs.' },
{ n: 2, title: 'Connect Odoo', desc: 'Add your Odoo instance and select modules.' },
{ n: 3, title: 'Get MCP Endpoint', desc: 'We generate your secure MCP URL and token.' },
{ n: 4, title: 'Connect AI Tools', desc: 'Use our guides for Claude, Codex, Cursor and more.' },
{ n: 5, title: 'Ask Your ERP', desc: 'Query your Odoo data using natural language.' },
];
export default function HowItWorks() {
return (
<section id="how-it-works" className="py-20">
<div className="max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">How it works</h2>
<div className="relative flex flex-col sm:flex-row gap-8">
{/* connecting line */}
<div className="hidden sm:block absolute top-8 left-[10%] right-[10%] h-0.5 bg-brand-100" />
{STEPS.map((s, i) => (
<div key={s.n} className="flex-1 flex flex-col items-center text-center relative">
<div className="w-16 h-16 rounded-full bg-brand-600 text-white flex items-center justify-center font-bold text-lg mb-4 shadow-lg z-10">
{s.n}
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">{s.title}</h3>
<p className="text-xs text-gray-500 leading-relaxed">{s.desc}</p>
{i < STEPS.length - 1 && (
<div className="hidden sm:block absolute top-8 right-0 translate-x-1/2 text-brand-300 text-xl z-20"></div>
)}
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,58 @@
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { Menu, X } from 'lucide-react';
export default function Navbar() {
const [open, setOpen] = useState(false);
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-sm">O</span>
</div>
<span className="font-semibold text-gray-900 text-sm">OdooMCP Cloud</span>
</Link>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-6 text-sm text-gray-600">
{['Features', 'Use Cases', 'Pricing', 'Docs', 'Security', 'Contact'].map((item) => (
<Link key={item} to={`#${item.toLowerCase().replace(' ', '-')}`}
className="hover:text-gray-900 transition-colors">{item}</Link>
))}
</div>
<div className="hidden md:flex items-center gap-3">
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900 px-3 py-1.5">
Log in
</Link>
<Link to="/signup"
className="text-sm bg-brand-600 text-white px-4 py-1.5 rounded-lg hover:bg-brand-700 transition-colors font-medium">
Start Free Trial
</Link>
</div>
{/* Mobile toggle */}
<button className="md:hidden p-2" onClick={() => setOpen(!open)}>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</div>
{open && (
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-4 space-y-3">
{['Features', 'Use Cases', 'Pricing', 'Docs', 'Security', 'Contact'].map((item) => (
<Link key={item} to={`#${item.toLowerCase()}`}
className="block text-sm text-gray-700 py-1" onClick={() => setOpen(false)}>{item}</Link>
))}
<hr className="border-gray-100" />
<Link to="/login" className="block text-sm text-gray-700 py-1">Log in</Link>
<Link to="/signup" className="block w-full text-center bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium">
Start Free Trial
</Link>
</div>
)}
</nav>
);
}

View File

@ -0,0 +1,148 @@
import { Link } from 'react-router-dom';
import { Check } from 'lucide-react';
import { clsx } from 'clsx';
const PLANS = [
{
name: 'Starter',
price: 19,
desc: 'For small Odoo users',
popular: false,
features: [
'1 Odoo connection',
'1 MCP endpoint',
'Read-only tools',
'Token rotation',
'Claude/Codex setup guide',
'1,000 MCP requests/month',
'Email support',
],
cta: 'Start Free Trial',
href: '/signup?plan=starter',
},
{
name: 'Pro',
price: 49,
desc: 'For growing businesses',
popular: true,
features: [
'3 Odoo connections',
'Read / Write tools',
'Token rotation',
'Access logs',
'10,000 MCP requests/month',
'Priority support',
],
cta: 'Start Free Trial',
href: '/signup?plan=pro',
},
{
name: 'Agency',
price: 149,
desc: 'For agencies & consultants',
popular: false,
features: [
'25 client workspaces',
'White-label endpoint',
'Team members',
'Usage dashboard',
'Custom tool permissions',
'50,000 MCP requests/month',
],
cta: 'Start Free Trial',
href: '/signup?plan=agency',
},
{
name: 'Enterprise',
price: null,
desc: 'For large organisations',
popular: false,
features: [
'Dedicated MCP server',
'Private deployment',
'SSO / OAuth',
'Advanced audit logs',
'Custom Odoo modules',
'SLA & dedicated support',
],
cta: 'Contact Sales',
href: '/contact',
},
];
export default function Pricing() {
return (
<section id="pricing" className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-3">Simple, transparent pricing</h2>
<p className="text-gray-600">Choose the plan that fits your business. All plans include 7-day free trial.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{PLANS.map((plan) => (
<div
key={plan.name}
className={clsx(
'relative rounded-2xl p-6 flex flex-col',
plan.popular
? 'gradient-brand text-white shadow-xl scale-105'
: 'bg-white border border-gray-200 shadow-sm',
)}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-yellow-400 text-yellow-900 text-xs font-bold px-3 py-1 rounded-full">
Most Popular
</div>
)}
<div className="mb-6">
<h3 className={clsx('font-bold text-lg mb-1', plan.popular ? 'text-white' : 'text-gray-900')}>
{plan.name}
</h3>
<p className={clsx('text-xs mb-4', plan.popular ? 'text-purple-200' : 'text-gray-500')}>
{plan.desc}
</p>
<div className="flex items-end gap-1">
{plan.price ? (
<>
<span className={clsx('text-4xl font-bold', plan.popular ? 'text-white' : 'text-gray-900')}>
${plan.price}
</span>
<span className={clsx('text-sm mb-1', plan.popular ? 'text-purple-200' : 'text-gray-500')}>
/month
</span>
</>
) : (
<span className={clsx('text-4xl font-bold', plan.popular ? 'text-white' : 'text-gray-900')}>
Custom
</span>
)}
</div>
</div>
<ul className="flex-1 space-y-2.5 mb-6">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-xs">
<Check size={13} className={clsx('mt-0.5 shrink-0', plan.popular ? 'text-purple-200' : 'text-brand-500')} />
<span className={plan.popular ? 'text-purple-100' : 'text-gray-700'}>{f}</span>
</li>
))}
</ul>
<Link
to={plan.href}
className={clsx(
'block text-center text-sm font-semibold px-4 py-2.5 rounded-xl transition-all',
plan.popular
? 'bg-white text-brand-700 hover:bg-purple-50'
: 'gradient-brand text-white hover:opacity-90',
)}
>
{plan.cta}
</Link>
</div>
))}
</div>
</div>
</section>
);
}

21
src/index.css Normal file
View File

@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900 antialiased;
}
}
@layer utilities {
.gradient-brand {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
}
.text-gradient {
background: linear-gradient(135deg, #7c3aed, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}

363
src/lib/api.ts Normal file
View File

@ -0,0 +1,363 @@
const BASE = import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com';
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...opts?.headers },
...opts,
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail ?? 'Request failed');
return data as T;
}
export interface SignupPayload {
email: string;
password: string;
odoo_url?: string;
odoo_database?: string;
odoo_username?: string;
odoo_credential?: string;
odoo_is_api_key?: boolean;
}
export interface SignupResult {
user_id: number;
email: string;
api_key: string;
mcp_connection: { url: string; header: string };
}
export interface MeResult {
user_id: number;
email: string;
api_key_prefix: string;
created_at: string;
odoo_instances: Array<{
instance_name: string;
url: string;
database: string;
username: string;
type: string;
}>;
}
export interface CredentialPayload {
instance_name: string;
url: string;
database: string;
username: string;
credential: string;
is_api_key: boolean;
}
export interface ApiKeyInfo {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
revoked_at: string | null;
is_active: boolean;
}
export interface AnalyticsSummary {
total_calls: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
total_duration_ms: number;
unique_tools: number;
unique_instances: number;
}
export interface ToolStat {
tool_name: string;
calls: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
total_duration_ms: number;
last_used_at: string;
}
export interface InstanceStat {
instance_name: string;
calls: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
total_duration_ms: number;
}
export interface TimeseriesBucket {
period: string;
calls: number;
avg_duration_ms: number;
error_count: number;
}
export interface ToolDetail {
tool_name: string;
summary: {
calls: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
total_duration_ms: number;
};
timeseries: TimeseriesBucket[];
}
export interface LogEntry {
id: number;
tool_name: string;
instance_name: string;
started_at: string;
duration_ms: number;
success: boolean;
error_message: string | null;
}
export interface LogFilters {
tool_name?: string;
instance_name?: string;
success?: boolean;
}
function authHeader(apiKey: string) {
return { Authorization: `Bearer ${apiKey}` };
}
function qs(params: Record<string, string | number | boolean | undefined>): string {
const parts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
return parts.length ? `?${parts.join('&')}` : '';
}
export const api = {
signup: (body: SignupPayload) =>
req<SignupResult>('/api/signup', { method: 'POST', body: JSON.stringify(body) }),
login: (email: string, password: string) =>
req<{ user_id: number; email: string; api_key_prefix: string }>('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
me: (apiKey: string) =>
req<MeResult>('/api/me', { headers: authHeader(apiKey) }),
regenKey: (apiKey: string) =>
req<{ api_key: string; mcp_connection: { url: string; header: string } }>('/api/api-key/regenerate', {
method: 'POST',
headers: authHeader(apiKey),
}),
addCredential: (apiKey: string, body: CredentialPayload) =>
req('/api/credentials', {
method: 'POST',
headers: authHeader(apiKey),
body: JSON.stringify(body),
}),
listCredentials: (apiKey: string) =>
req<{ instances: MeResult['odoo_instances'] }>('/api/credentials', {
headers: authHeader(apiKey),
}),
deleteCredential: (apiKey: string, name: string) =>
req(`/api/credentials/${name}`, { method: 'DELETE', headers: authHeader(apiKey) }),
forgotPassword: (email: string) =>
req<{ message: string }>('/api/password/forgot', {
method: 'POST',
body: JSON.stringify({ email }),
}),
resetPassword: (token: string, new_password: string) =>
req<{ message: string }>('/api/password/reset', {
method: 'POST',
body: JSON.stringify({ token, new_password }),
}),
listApiKeys: (apiKey: string) =>
req<{ keys: ApiKeyInfo[]; total: number }>('/api/keys', { headers: authHeader(apiKey) }),
createApiKey: (apiKey: string, name: string) =>
req<{ id: number; name: string; api_key: string; mcp_connection: { url: string; header: string } }>(
'/api/keys',
{ method: 'POST', headers: authHeader(apiKey), body: JSON.stringify({ name }) },
),
revokeApiKey: (apiKey: string, id: number) =>
req<{ success: boolean }>(`/api/keys/${id}`, { method: 'DELETE', headers: authHeader(apiKey) }),
analyticsSummary: (apiKey: string, since?: string, until?: string) =>
req<AnalyticsSummary>(`/api/analytics/summary${qs({ since, until })}`, {
headers: authHeader(apiKey),
}),
analyticsByTool: (apiKey: string, since?: string, until?: string) =>
req<{ tools: ToolStat[] }>(`/api/analytics/by-tool${qs({ since, until })}`, {
headers: authHeader(apiKey),
}),
analyticsByInstance: (apiKey: string, since?: string, until?: string) =>
req<{ instances: InstanceStat[] }>(`/api/analytics/by-instance${qs({ since, until })}`, {
headers: authHeader(apiKey),
}),
analyticsTimeseries: (apiKey: string, since?: string, until?: string, bucket: 'hour' | 'day' | 'week' = 'day') =>
req<{ buckets: TimeseriesBucket[] }>(`/api/analytics/timeseries${qs({ since, until, bucket })}`, {
headers: authHeader(apiKey),
}),
analyticsToolDetail: (apiKey: string, toolName: string, since?: string, until?: string) =>
req<ToolDetail>(`/api/analytics/tools/${encodeURIComponent(toolName)}${qs({ since, until })}`, {
headers: authHeader(apiKey),
}),
listLogs: (apiKey: string, page = 1, pageSize = 25, filters?: LogFilters) =>
req<{ items: LogEntry[]; page: number; page_size: number; total: number }>(
`/api/logs${qs({ page, page_size: pageSize, ...filters })}`,
{ headers: authHeader(apiKey) },
),
};
// Session helpers (localStorage)
export const session = {
save: (apiKey: string, email: string) => {
if (typeof window === 'undefined') return;
localStorage.setItem('odoomcp_key', apiKey);
localStorage.setItem('odoomcp_email', email);
},
getKey: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('odoomcp_key');
},
getEmail: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('odoomcp_email');
},
clear: () => {
if (typeof window === 'undefined') return;
localStorage.removeItem('odoomcp_key');
localStorage.removeItem('odoomcp_email');
},
};
// ─── Admin Panel ────────────────────────────────────────────────────────────
export interface AdminUserSummary {
id: number;
email: string;
created_at: string;
is_active: boolean;
credential_count: number;
active_key_count: number;
total_calls: number;
last_active_at: string | null;
}
export interface AdminUserDetail {
id: number;
email: string;
is_active: boolean;
created_at: string;
odoo_instances: MeResult['odoo_instances'];
api_keys: ApiKeyInfo[];
usage_summary: AnalyticsSummary;
}
export interface AdminSystemSummary {
total_users: number;
active_users: number;
disabled_users: number;
total_calls: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
active_callers: number;
}
function adminAuthHeader(token: string) {
return { Authorization: `Bearer ${token}` };
}
export const adminApi = {
login: (email: string, password: string) =>
req<{ admin_token: string; message: string }>('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
logout: (token: string) =>
req<{ success: boolean }>('/api/admin/logout', { method: 'POST', headers: adminAuthHeader(token) }),
listUsers: (token: string, page = 1, pageSize = 25, since?: string, until?: string) =>
req<{ items: AdminUserSummary[]; page: number; page_size: number; total: number }>(
`/api/admin/users${qs({ page, page_size: pageSize, since, until })}`,
{ headers: adminAuthHeader(token) },
),
getUser: (token: string, userId: number) =>
req<AdminUserDetail>(`/api/admin/users/${userId}`, { headers: adminAuthHeader(token) }),
setUserStatus: (token: string, userId: number, isActive: boolean) =>
req<{ success: boolean }>(`/api/admin/users/${userId}/status`, {
method: 'PATCH',
headers: adminAuthHeader(token),
body: JSON.stringify({ is_active: isActive }),
}),
deleteUser: (token: string, userId: number) =>
req<{ success: boolean }>(`/api/admin/users/${userId}`, {
method: 'DELETE',
headers: adminAuthHeader(token),
}),
revokeUserKey: (token: string, userId: number, keyId: number) =>
req<{ success: boolean }>(`/api/admin/users/${userId}/keys/${keyId}/revoke`, {
method: 'POST',
headers: adminAuthHeader(token),
}),
analyticsSummary: (token: string, since?: string, until?: string) =>
req<AdminSystemSummary>(`/api/admin/analytics/summary${qs({ since, until })}`, {
headers: adminAuthHeader(token),
}),
analyticsByTool: (token: string, since?: string, until?: string) =>
req<{ tools: ToolStat[] }>(`/api/admin/analytics/by-tool${qs({ since, until })}`, {
headers: adminAuthHeader(token),
}),
analyticsTimeseries: (token: string, since?: string, until?: string, bucket: 'hour' | 'day' | 'week' = 'day') =>
req<{ buckets: TimeseriesBucket[] }>(`/api/admin/analytics/timeseries${qs({ since, until, bucket })}`, {
headers: adminAuthHeader(token),
}),
};
// Admin session helpers — deliberately separate localStorage keys from the
// regular user `session` above so the two auth states never collide.
export const adminSession = {
save: (token: string, email: string) => {
if (typeof window === 'undefined') return;
localStorage.setItem('odoomcp_admin_token', token);
localStorage.setItem('odoomcp_admin_email', email);
},
getToken: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('odoomcp_admin_token');
},
getEmail: (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('odoomcp_admin_email');
},
clear: () => {
if (typeof window === 'undefined') return;
localStorage.removeItem('odoomcp_admin_token');
localStorage.removeItem('odoomcp_admin_email');
},
};

14
src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import '@fontsource/inter';
import './index.css';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@ -0,0 +1,91 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.forgotPassword(email);
setSent(true);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-sm">O</span>
</div>
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link
</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
{sent ? (
<div className="text-center space-y-4">
<div className="bg-green-50 border border-green-100 text-green-700 text-sm rounded-lg px-4 py-3">
If that email is registered, a reset link has been sent. Check your inbox.
</div>
<Link to="/login" className="text-sm text-brand-600 hover:underline font-medium">
Back to login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="you@company.com"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
>
{loading ? 'Sending…' : 'Send reset link'}
</button>
</form>
)}
</div>
<p className="text-center text-xs text-gray-500 mt-6">
Remembered your password?{' '}
<Link to="/login" className="text-brand-600 hover:underline font-medium">
Log in
</Link>
</p>
</div>
</div>
);
}

67
src/pages/Landing.tsx Normal file
View File

@ -0,0 +1,67 @@
import Navbar from '@/components/landing/Navbar';
import Hero from '@/components/landing/Hero';
import Features from '@/components/landing/Features';
import HowItWorks from '@/components/landing/HowItWorks';
import Pricing from '@/components/landing/Pricing';
const TRUST_LOGOS = ['odoo', 'ACSONE', 'VENTURE', 'CYBROSYS', 'Camptocamp', 'Shuva', 'PRAGMATIC'];
export default function Landing() {
return (
<>
<Navbar />
<main>
<Hero />
<Features />
<HowItWorks />
<Pricing />
{/* Trust logos */}
<section className="py-12 border-t border-gray-100">
<div className="max-w-7xl mx-auto px-4 text-center">
<p className="text-xs text-gray-400 mb-6 uppercase tracking-widest">
Trusted by Odoo professionals teams worldwide
</p>
<div className="flex flex-wrap items-center justify-center gap-8">
{TRUST_LOGOS.map((name) => (
<span key={name} className="text-sm font-semibold text-gray-400 hover:text-gray-600 transition-colors">
{name}
</span>
))}
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-12">
<div className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row justify-between gap-6 text-xs">
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded gradient-brand" />
<span className="text-white font-semibold text-sm">OdooMCP Cloud</span>
</div>
<p>Secure MCP Server for Odoo ERP</p>
</div>
<div className="flex gap-12">
<div className="space-y-2">
<p className="text-white font-medium">Product</p>
{['Features', 'Pricing', 'Security', 'Docs'].map((l) => (
<p key={l}><a href="#" className="hover:text-white transition-colors">{l}</a></p>
))}
</div>
<div className="space-y-2">
<p className="text-white font-medium">Company</p>
{['About', 'Blog', 'Contact', 'Privacy'].map((l) => (
<p key={l}><a href="#" className="hover:text-white transition-colors">{l}</a></p>
))}
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 mt-8 pt-6 border-t border-gray-800 text-xs text-center">
© {new Date().getFullYear()} OdooMCP Cloud. All rights reserved.
</div>
</footer>
</main>
</>
);
}

102
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,102 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, session } from '@/lib/api';
export default function Login() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const savedKey = session.getKey();
if (!savedKey) {
setError('No API key found. Please use your API key directly or re-generate it from a previous signup.');
setLoading(false);
return;
}
// Verify credentials via /api/login then confirm key works
await api.login(form.email, form.password);
session.save(savedKey, form.email);
navigate('/dashboard');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-sm">O</span>
</div>
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
<p className="text-sm text-gray-500 mt-1">Log in to your dashboard</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="you@company.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-gray-700">Password</label>
<Link to="/forgot-password" className="text-xs text-brand-600 hover:underline">
Forgot password?
</Link>
</div>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
>
{loading ? 'Logging in…' : 'Log in'}
</button>
</form>
</div>
<p className="text-center text-xs text-gray-500 mt-6">
Don&apos;t have an account?{' '}
<Link to="/signup" className="text-brand-600 hover:underline font-medium">
Start free trial
</Link>
</p>
</div>
</div>
);
}

126
src/pages/ResetPassword.tsx Normal file
View File

@ -0,0 +1,126 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '@/lib/api';
export default function ResetPassword() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!token) {
setError('Missing or invalid reset link. Request a new one.');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
if (password !== confirm) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
await api.resetPassword(token, password);
setDone(true);
setTimeout(() => navigate('/login'), 2500);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Reset failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-sm">O</span>
</div>
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Set a new password</h1>
<p className="text-sm text-gray-500 mt-1">
Choose a new password for your account
</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
{done ? (
<div className="text-center space-y-4">
<div className="bg-green-50 border border-green-100 text-green-700 text-sm rounded-lg px-4 py-3">
Password reset. All existing API keys were revoked. Redirecting to login
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{!token && (
<div className="bg-yellow-50 border border-yellow-100 text-yellow-800 text-xs rounded-lg px-3 py-2.5">
No reset token found in the link. Please use the link from your email, or{' '}
<Link to="/forgot-password" className="underline font-medium">request a new one</Link>.
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">New password</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Confirm password</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
>
{loading ? 'Resetting…' : 'Reset password'}
</button>
</form>
)}
</div>
<p className="text-center text-xs text-gray-500 mt-6">
<Link to="/login" className="text-brand-600 hover:underline font-medium">
Back to login
</Link>
</p>
</div>
</div>
);
}

166
src/pages/Signup.tsx Normal file
View File

@ -0,0 +1,166 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api, session } from '@/lib/api';
import { Copy, Check, AlertCircle } from 'lucide-react';
export default function Signup() {
const navigate = useNavigate();
const [params] = useSearchParams();
const planFromUrl = params.get('plan') ?? 'starter';
const [step, setStep] = useState<'account' | 'key'>('account');
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
const [apiKey, setApiKey] = useState('');
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSignup(e: React.FormEvent) {
e.preventDefault();
if (form.password !== form.confirmPassword) {
setError('Passwords do not match');
return;
}
setError('');
setLoading(true);
try {
const result = await api.signup({ email: form.email, password: form.password });
setApiKey(result.api_key);
session.save(result.api_key, result.email);
setStep('key');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Signup failed');
} finally {
setLoading(false);
}
}
function copyKey() {
navigator.clipboard.writeText(apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
{step === 'key' ? (
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<Check size={20} className="text-green-600" />
</div>
<div>
<h2 className="font-bold text-gray-900">Account created!</h2>
<p className="text-xs text-gray-500">Save your API key shown once only</p>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-2 mb-3">
<AlertCircle size={14} className="text-yellow-700 mt-0.5 shrink-0" />
<p className="text-xs text-yellow-700 font-medium">
This is your API key. It will NOT be shown again. Copy and store it securely.
</p>
</div>
<div className="flex items-center gap-2 bg-white rounded-lg border border-yellow-200 px-3 py-2.5">
<code className="flex-1 text-xs text-gray-800 break-all font-mono">{apiKey}</code>
<button onClick={copyKey} className="shrink-0 text-brand-600 hover:text-brand-700">
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-4 mb-6 text-xs text-gray-700">
<p className="font-medium mb-2">Your MCP Server URL:</p>
<code className="text-brand-600 break-all">
{import.meta.env.VITE_API_URL}/mcp/sse
</code>
<p className="font-medium mt-3 mb-1">Bearer Token:</p>
<code className="text-gray-600 break-all">{apiKey}</code>
</div>
<button
onClick={() => navigate('/dashboard')}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity"
>
Go to Dashboard
</button>
</div>
</div>
) : (
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-sm">O</span>
</div>
<span className="font-semibold text-gray-900">OdooMCP Cloud</span>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Start your free trial</h1>
<p className="text-sm text-gray-500 mt-1">
Plan: <span className="capitalize font-medium text-brand-600">{planFromUrl}</span> · 7 days free
</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
<form onSubmit={handleSignup} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Email</label>
<input
type="email" required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="you@company.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Password</label>
<input
type="password" required minLength={8}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="Min 8 characters"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Confirm Password</label>
<input
type="password" required
value={form.confirmPassword}
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-100 text-red-700 text-xs rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button
type="submit" disabled={loading}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
>
{loading ? 'Creating account…' : 'Create Account'}
</button>
<p className="text-center text-xs text-gray-400">
No credit card required · Cancel anytime
</p>
</form>
</div>
<p className="text-center text-xs text-gray-500 mt-6">
Already have an account?{' '}
<Link to="/login" className="text-brand-600 hover:underline font-medium">Log in</Link>
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,86 @@
import { useState, useEffect, useCallback } from 'react';
import { adminApi, adminSession, ToolStat } from '@/lib/api';
const RANGES = [
{ label: '7 days', days: 7 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
];
function isoDaysAgo(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString();
}
export default function AdminAnalytics() {
const [rangeDays, setRangeDays] = useState(30);
const [byTool, setByTool] = useState<ToolStat[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const token = adminSession.getToken();
if (!token) return;
setLoading(true);
adminApi
.analyticsByTool(token, isoDaysAgo(rangeDays))
.then((r) => setByTool(r.tools))
.catch(console.error)
.finally(() => setLoading(false));
}, [rangeDays]);
useEffect(load, [load]);
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">System Analytics</h1>
<p className="text-sm text-gray-500 mt-0.5">Every tool called, aggregated across all users.</p>
</div>
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setRangeDays(r.days)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
{r.label}
</button>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{byTool.length === 0 && !loading ? (
<div className="p-12 text-center text-sm text-gray-400">No tool calls in this range yet.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-6 py-3 font-medium">Tool</th>
<th className="px-6 py-3 font-medium">Total Calls</th>
<th className="px-6 py-3 font-medium">Errors</th>
<th className="px-6 py-3 font-medium">Avg Duration</th>
<th className="px-6 py-3 font-medium">Last Used</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{byTool.map((t) => (
<tr key={t.tool_name}>
<td className="px-6 py-3 font-mono text-xs text-gray-900">{t.tool_name}</td>
<td className="px-6 py-3">{t.calls}</td>
<td className="px-6 py-3 text-red-600">{t.error_count}</td>
<td className="px-6 py-3 text-gray-600">{t.avg_duration_ms.toFixed(1)} ms</td>
<td className="px-6 py-3 text-xs text-gray-400">{t.last_used_at}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link, Outlet } from 'react-router-dom';
import { adminApi, adminSession } from '@/lib/api';
import {
ShieldCheck, LayoutDashboard, Users, BarChart2, LogOut, Menu,
} from 'lucide-react';
import { clsx } from 'clsx';
const NAV = [
{ label: 'Overview', href: '/admin', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'System Analytics', href: '/admin/analytics', icon: BarChart2 },
];
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [email, setEmail] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
const token = adminSession.getToken();
const em = adminSession.getEmail();
if (!token) { navigate('/admin/login', { replace: true }); return; }
if (em) setEmail(em);
}, [navigate]);
async function logout() {
const token = adminSession.getToken();
if (token) {
try { await adminApi.logout(token); } catch { /* token may already be expired server-side */ }
}
adminSession.clear();
navigate('/admin/login');
}
const Sidebar = (
<aside className="flex flex-col h-full bg-gray-950 text-gray-300 w-64 shrink-0">
<div className="px-5 py-4 border-b border-gray-800 flex items-center gap-2">
<div className="w-7 h-7 rounded bg-gray-800 flex items-center justify-center">
<ShieldCheck size={14} className="text-brand-500" />
</div>
<span className="text-white font-semibold text-sm">Admin Panel</span>
</div>
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-0.5">
{NAV.map(({ label, href, icon: Icon }) => {
const active = pathname === href || (href !== '/admin' && pathname.startsWith(href));
return (
<Link
key={href}
to={href}
onClick={() => setSidebarOpen(false)}
className={clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
active ? 'bg-brand-600 text-white' : 'hover:bg-gray-800 hover:text-white',
)}
>
<Icon size={16} />
{label}
</Link>
);
})}
</nav>
<div className="px-3 pb-4 border-t border-gray-800 pt-3 space-y-0.5">
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors"
>
<LogOut size={16} /> Logout
</button>
<div className="px-3 pt-2">
<p className="text-xs text-gray-500 truncate">{email}</p>
</div>
</div>
</aside>
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50">
<div className="hidden lg:flex flex-col h-full">{Sidebar}</div>
{sidebarOpen && (
<div className="lg:hidden fixed inset-0 z-50 flex">
<div className="flex flex-col h-full">{Sidebar}</div>
<div className="flex-1 bg-black/40" onClick={() => setSidebarOpen(false)} />
</div>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100">
<button onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
<span className="font-semibold text-sm text-gray-900">Admin Panel</span>
</div>
<main className="flex-1 overflow-y-auto p-6"><Outlet /></main>
</div>
</div>
);
}

View File

@ -0,0 +1,81 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, adminSession } from '@/lib/api';
import { ShieldCheck } from 'lucide-react';
export default function AdminLogin() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await adminApi.login(form.email, form.password);
adminSession.save(result.admin_token, form.email);
navigate('/admin');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-gray-800 mb-4">
<ShieldCheck size={22} className="text-brand-500" />
</div>
<h1 className="text-2xl font-bold text-white">Admin Panel</h1>
<p className="text-sm text-gray-400 mt-1">Operator access only</p>
</div>
<div className="bg-gray-900 rounded-2xl border border-gray-800 p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">Admin Email</label>
<input
type="text"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="admin@yourcompany.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">Password</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-950 border border-red-900 text-red-400 text-xs rounded-lg px-3 py-2.5">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full gradient-brand text-white py-2.5 rounded-xl text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { useState, useEffect, useCallback } from 'react';
import { adminApi, adminSession, AdminSystemSummary, ToolStat, TimeseriesBucket } from '@/lib/api';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { Users, Activity, XCircle, Clock } from 'lucide-react';
const RANGES = [
{ label: '7 days', days: 7 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
];
function isoDaysAgo(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString();
}
export default function AdminOverview() {
const [rangeDays, setRangeDays] = useState(30);
const [summary, setSummary] = useState<AdminSystemSummary | null>(null);
const [byTool, setByTool] = useState<ToolStat[]>([]);
const [timeseries, setTimeseries] = useState<TimeseriesBucket[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const token = adminSession.getToken();
if (!token) return;
setLoading(true);
const since = isoDaysAgo(rangeDays);
Promise.all([
adminApi.analyticsSummary(token, since),
adminApi.analyticsByTool(token, since),
adminApi.analyticsTimeseries(token, since, undefined, rangeDays > 30 ? 'week' : 'day'),
])
.then(([s, t, ts]) => {
setSummary(s);
setByTool(t.tools);
setTimeseries(ts.buckets);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [rangeDays]);
useEffect(load, [load]);
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">System Overview</h1>
<p className="text-sm text-gray-500 mt-0.5">Activity across every registered user.</p>
</div>
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setRangeDays(r.days)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
{r.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard icon={Users} label="Total Users" value={summary?.total_users ?? '—'} color="blue" />
<StatCard icon={Users} label="Active / Disabled" value={summary ? `${summary.active_users} / ${summary.disabled_users}` : '—'} color="purple" />
<StatCard icon={Activity} label="Total Calls" value={summary?.total_calls ?? '—'} color="green" />
<StatCard icon={XCircle} label="Errors" value={summary?.error_count ?? '—'} color="red" />
</div>
<div className="grid grid-cols-3 gap-4">
<StatCard icon={Clock} label="Avg Duration" value={summary ? `${summary.avg_duration_ms.toFixed(1)} ms` : '—'} color="purple" />
<StatCard icon={Activity} label="Active Callers" value={summary?.active_callers ?? '—'} color="blue" />
<StatCard icon={Activity} label="Success Rate" value={summary && summary.total_calls > 0 ? `${((summary.success_count / summary.total_calls) * 100).toFixed(1)}%` : '—'} color="green" />
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls Over Time (All Users)</h2>
{timeseries.length === 0 && !loading ? (
<EmptyChart />
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeseries}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="period" tick={{ fontSize: 11 }} stroke="#9ca3af" />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
<Tooltip />
<Line type="monotone" dataKey="calls" stroke="#7c3aed" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Most Used Tools (System-Wide)</h2>
{byTool.length === 0 && !loading ? (
<EmptyChart />
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={byTool.slice(0, 10)}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="tool_name" tick={{ fontSize: 10 }} stroke="#9ca3af" interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
<Tooltip />
<Bar dataKey="calls" fill="#7c3aed" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
function StatCard({ icon: Icon, label, value, color }: {
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
}) {
const colors = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
purple: 'bg-purple-50 text-purple-600',
};
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
<Icon size={16} />
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
);
}
function EmptyChart() {
return (
<div className="h-[240px] flex items-center justify-center text-sm text-gray-400">
No data in this range yet.
</div>
);
}

View File

@ -0,0 +1,187 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { adminApi, adminSession, AdminUserDetail as AdminUserDetailType } from '@/lib/api';
import { ArrowLeft, Trash2, Activity, CheckCircle2, XCircle, Clock } from 'lucide-react';
export default function AdminUserDetailPage() {
const params = useParams<{ id: string }>();
const navigate = useNavigate();
const userId = Number(params.id);
const [user, setUser] = useState<AdminUserDetailType | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const token = adminSession.getToken();
if (!token) return;
setLoading(true);
adminApi
.getUser(token, userId)
.then(setUser)
.catch(console.error)
.finally(() => setLoading(false));
}, [userId]);
useEffect(load, [load]);
async function toggleActive() {
if (!user) return;
const token = adminSession.getToken();
if (!token) return;
await adminApi.setUserStatus(token, user.id, !user.is_active);
load();
}
async function handleDelete() {
if (!user) return;
if (!confirm(`Permanently delete "${user.email}"? This cannot be undone.`)) return;
const token = adminSession.getToken();
if (!token) return;
await adminApi.deleteUser(token, user.id);
navigate('/admin/users');
}
async function revokeKey(keyId: number, name: string) {
if (!user) return;
if (!confirm(`Force-revoke "${name}"? Any client using this key will immediately lose access.`)) return;
const token = adminSession.getToken();
if (!token) return;
await adminApi.revokeUserKey(token, user.id, keyId);
load();
}
if (loading && !user) {
return <div className="max-w-4xl text-sm text-gray-400">Loading</div>;
}
if (!user) {
return <div className="max-w-4xl text-sm text-gray-400">User not found.</div>;
}
return (
<div className="max-w-4xl space-y-6">
<Link to="/admin/users" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={14} /> Back to users
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">{user.email}</h1>
<p className="text-sm text-gray-500 mt-0.5">
Joined {user.created_at} &middot;{' '}
<span className={user.is_active ? 'text-green-700' : 'text-gray-500'}>
{user.is_active ? 'Active' : 'Disabled'}
</span>
</p>
</div>
<div className="flex gap-2">
<button
onClick={toggleActive}
className="border border-gray-200 text-gray-700 px-4 py-2 rounded-xl text-sm font-medium hover:bg-gray-50"
>
{user.is_active ? 'Disable Account' : 'Enable Account'}
</button>
<button
onClick={handleDelete}
className="flex items-center gap-2 bg-red-50 text-red-600 px-4 py-2 rounded-xl text-sm font-medium hover:bg-red-100"
>
<Trash2 size={14} /> Delete Account
</button>
</div>
</div>
{/* Usage summary */}
<div className="grid grid-cols-4 gap-4">
<StatCard icon={Activity} label="Total Calls" value={user.usage_summary.total_calls} color="blue" />
<StatCard icon={CheckCircle2} label="Successful" value={user.usage_summary.success_count} color="green" />
<StatCard icon={XCircle} label="Errors" value={user.usage_summary.error_count} color="red" />
<StatCard icon={Clock} label="Avg Duration" value={`${user.usage_summary.avg_duration_ms.toFixed(1)} ms`} color="purple" />
</div>
{/* Odoo instances */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Odoo Instances</h2>
{user.odoo_instances.length === 0 ? (
<p className="text-sm text-gray-400">No Odoo instances connected.</p>
) : (
<div className="space-y-3">
{user.odoo_instances.map((inst) => (
<div key={inst.instance_name} className="flex items-center justify-between text-sm border-b border-gray-50 pb-2">
<span className="font-medium text-gray-900">{inst.instance_name}</span>
<span className="text-xs text-gray-500">{inst.url} &middot; {inst.database} &middot; {inst.type}</span>
</div>
))}
</div>
)}
</div>
{/* API keys */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-900">API Keys</h2>
</div>
{user.api_keys.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No API keys.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-6 py-3 font-medium">Name</th>
<th className="px-6 py-3 font-medium">Key</th>
<th className="px-6 py-3 font-medium">Last Used</th>
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{user.api_keys.map((k) => (
<tr key={k.id}>
<td className="px-6 py-3 font-medium text-gray-900">{k.name}</td>
<td className="px-6 py-3 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
<td className="px-6 py-3 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
<td className="px-6 py-3">
{k.is_active ? (
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded-full">Active</span>
) : (
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">Revoked</span>
)}
</td>
<td className="px-6 py-3 text-right">
{k.is_active && (
<button
onClick={() => revokeKey(k.id, k.name)}
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
title="Force revoke"
>
<Trash2 size={15} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
function StatCard({ icon: Icon, label, value, color }: {
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
}) {
const colors = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
purple: 'bg-purple-50 text-purple-600',
};
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
<Icon size={16} />
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { adminApi, adminSession, AdminUserSummary } from '@/lib/api';
import { ChevronLeft, ChevronRight, Trash2, Users as UsersIcon } from 'lucide-react';
const PAGE_SIZE = 25;
export default function AdminUsers() {
const [items, setItems] = useState<AdminUserSummary[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const token = adminSession.getToken();
if (!token) return;
setLoading(true);
adminApi
.listUsers(token, page, PAGE_SIZE)
.then((r) => {
setItems(r.items);
setTotal(r.total);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [page]);
useEffect(load, [load]);
async function toggleActive(user: AdminUserSummary) {
const token = adminSession.getToken();
if (!token) return;
try {
await adminApi.setUserStatus(token, user.id, !user.is_active);
load();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update status');
}
}
async function handleDelete(user: AdminUserSummary) {
if (!confirm(`Permanently delete "${user.email}"? This removes their Odoo connections, API keys, and usage history. This cannot be undone.`)) return;
const token = adminSession.getToken();
if (!token) return;
try {
await adminApi.deleteUser(token, user.id);
load();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete user');
}
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="max-w-6xl space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-900">Users</h1>
<p className="text-sm text-gray-500 mt-0.5">Every registered account on this server.</p>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{items.length === 0 && !loading ? (
<div className="p-12 text-center">
<UsersIcon size={32} className="mx-auto text-gray-300 mb-3" />
<p className="text-sm font-medium text-gray-600">No users yet</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-6 py-3 font-medium">Email</th>
<th className="px-6 py-3 font-medium">Joined</th>
<th className="px-6 py-3 font-medium">Instances</th>
<th className="px-6 py-3 font-medium">Active Keys</th>
<th className="px-6 py-3 font-medium">Total Calls</th>
<th className="px-6 py-3 font-medium">Last Active</th>
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{items.map((u) => (
<tr key={u.id}>
<td className="px-6 py-3">
<Link to={`/admin/users/${u.id}`} className="font-medium text-gray-900 hover:text-brand-600 hover:underline">
{u.email}
</Link>
</td>
<td className="px-6 py-3 text-xs text-gray-500">{u.created_at}</td>
<td className="px-6 py-3">{u.credential_count}</td>
<td className="px-6 py-3">{u.active_key_count}</td>
<td className="px-6 py-3">{u.total_calls}</td>
<td className="px-6 py-3 text-xs text-gray-400">{u.last_active_at ?? 'Never'}</td>
<td className="px-6 py-3">
<button
onClick={() => toggleActive(u)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
u.is_active
? 'text-green-700 bg-green-50 hover:bg-green-100'
: 'text-gray-500 bg-gray-100 hover:bg-gray-200'
}`}
>
{u.is_active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="px-6 py-3 text-right">
<button
onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
title="Delete user"
>
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between text-sm text-gray-500">
<span>Page {page} of {totalPages} ({total} total)</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,97 @@
import { Check } from 'lucide-react';
const PLANS = [
{ name: 'Starter', price: 19, current: false, features: ['1 connection', '1 MCP endpoint', 'Read-only', '1,000 req/mo'] },
{ name: 'Pro', price: 49, current: true, features: ['3 connections', 'Read/Write', 'Access logs', '10,000 req/mo'] },
{ name: 'Agency', price: 149, current: false, features: ['25 workspaces', 'White-label', 'Team members', '50,000 req/mo'] },
];
export default function Billing() {
return (
<div className="max-w-4xl space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-900">Billing</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage your subscription and payment details.</p>
</div>
{/* Current plan */}
<div className="bg-white rounded-xl border border-brand-200 shadow-sm p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 mb-1">Current Plan</p>
<p className="text-lg font-bold text-gray-900">Pro Plan</p>
<p className="text-sm text-gray-500">$49/month · Renews July 29, 2026</p>
</div>
<span className="bg-green-100 text-green-700 text-xs font-medium px-3 py-1.5 rounded-full">Active</span>
</div>
<div className="mt-4 flex gap-3">
<button className="text-sm border border-gray-200 text-gray-600 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors">
Manage Payment Method
</button>
<button className="text-sm text-red-600 border border-red-200 px-4 py-2 rounded-lg hover:bg-red-50 transition-colors">
Cancel Subscription
</button>
</div>
</div>
{/* Plan comparison */}
<div>
<h2 className="text-sm font-semibold text-gray-900 mb-4">Change Plan</h2>
<div className="grid grid-cols-3 gap-4">
{PLANS.map((plan) => (
<div key={plan.name}
className={`rounded-xl border p-5 ${plan.current ? 'border-brand-400 bg-brand-50' : 'border-gray-200 bg-white'}`}>
<p className="font-semibold text-sm text-gray-900">{plan.name}</p>
<p className="text-2xl font-bold text-gray-900 mt-1 mb-3">${plan.price}<span className="text-sm font-normal text-gray-400">/mo</span></p>
<ul className="space-y-1.5 mb-4">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-1.5 text-xs text-gray-600">
<Check size={11} className="text-brand-500" /> {f}
</li>
))}
</ul>
{plan.current ? (
<div className="text-center text-xs text-brand-600 font-medium py-2">Current Plan</div>
) : (
<button className="w-full gradient-brand text-white py-2 rounded-lg text-xs font-medium hover:opacity-90 transition-opacity">
Switch to {plan.name}
</button>
)}
</div>
))}
</div>
</div>
{/* Invoice history */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Invoice History</h2>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left pb-2 font-medium">Date</th>
<th className="text-left pb-2 font-medium">Description</th>
<th className="text-left pb-2 font-medium">Amount</th>
<th className="text-left pb-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{[
{ date: 'Jun 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
{ date: 'May 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
{ date: 'Apr 29, 2026', desc: 'Pro Plan — Monthly', amount: '$49.00', status: 'Paid' },
].map((row, i) => (
<tr key={i}>
<td className="py-2.5 text-gray-500">{row.date}</td>
<td className="py-2.5 text-gray-700">{row.desc}</td>
<td className="py-2.5 text-gray-900 font-medium">{row.amount}</td>
<td className="py-2.5">
<span className="bg-green-50 text-green-600 px-2 py-0.5 rounded-full">{row.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { api, session } from '@/lib/api';
import { Plus, Trash2, TestTube2, Globe, CheckCircle } from 'lucide-react';
interface Instance {
instance_name: string;
url: string;
database: string;
username: string;
type: string;
}
export default function Connections() {
const [instances, setInstances] = useState<Instance[]>([]);
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
instance_name: 'default',
url: '',
database: '',
username: '',
credential: '',
is_api_key: true,
});
function load() {
const key = session.getKey();
if (!key) return;
api.listCredentials(key).then((r) => setInstances(r.instances)).catch(console.error);
}
useEffect(load, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const key = session.getKey();
if (!key) return;
setLoading(true);
try {
await api.addCredential(key, form);
setShowForm(false);
setForm({ instance_name: 'default', url: '', database: '', username: '', credential: '', is_api_key: true });
load();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to add connection');
} finally {
setLoading(false);
}
}
async function handleDelete(name: string) {
if (!confirm(`Remove "${name}"?`)) return;
const key = session.getKey();
if (!key) return;
await api.deleteCredential(key, name);
load();
}
return (
<div className="max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">Odoo Connections</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage your Odoo ERP instances.</p>
</div>
<button onClick={() => setShowForm(true)}
className="flex items-center gap-2 gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity">
<Plus size={16} /> Add Connection
</button>
</div>
{/* Existing connections */}
{instances.length === 0 && !showForm && (
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
<Globe size={32} className="mx-auto text-gray-300 mb-3" />
<p className="text-sm font-medium text-gray-600 mb-1">No connections yet</p>
<p className="text-xs text-gray-400 mb-4">Add your Odoo instance to get started.</p>
<button onClick={() => setShowForm(true)}
className="gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium">
Add Connection
</button>
</div>
)}
{instances.map((inst) => (
<div key={inst.instance_name} className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
<CheckCircle size={18} className="text-green-600" />
</div>
<div>
<p className="font-semibold text-sm text-gray-900">{inst.instance_name}</p>
<p className="text-xs text-gray-500">{inst.url}</p>
</div>
</div>
<button onClick={() => handleDelete(inst.instance_name)}
className="text-gray-400 hover:text-red-500 p-1 transition-colors">
<Trash2 size={15} />
</button>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-xs">
<div><p className="text-gray-400 mb-0.5">Database</p><p className="text-gray-700 font-mono">{inst.database}</p></div>
<div><p className="text-gray-400 mb-0.5">Username</p><p className="text-gray-700">{inst.username}</p></div>
<div><p className="text-gray-400 mb-0.5">Auth Type</p><p className="text-gray-700 capitalize">{inst.type}</p></div>
</div>
</div>
))}
{/* Add connection form panel */}
{showForm && (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-5">Add Odoo Connection</h2>
<form onSubmit={handleAdd} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field label="Connection Name" value={form.instance_name}
onChange={(v) => setForm({ ...form, instance_name: v })} placeholder="e.g. production" />
<Field label="Odoo URL" value={form.url}
onChange={(v) => setForm({ ...form, url: v })} placeholder="https://mycompany.odoo.com" />
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Database Name" value={form.database}
onChange={(v) => setForm({ ...form, database: v })} placeholder="mydb" />
<Field label="Odoo Username" value={form.username}
onChange={(v) => setForm({ ...form, username: v })} placeholder="admin@company.com" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Authentication Method</label>
<select value={form.is_api_key ? 'apikey' : 'password'}
onChange={(e) => setForm({ ...form, is_api_key: e.target.value === 'apikey' })}
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500">
<option value="apikey">API Key (Recommended)</option>
<option value="password">Password</option>
</select>
</div>
<Field
label={form.is_api_key ? 'Odoo API Key' : 'Password'}
value={form.credential}
onChange={(v) => setForm({ ...form, credential: v })}
placeholder={form.is_api_key ? 'Paste your Odoo API key' : 'Odoo password'}
type="password"
/>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={loading}
className="gradient-brand text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 disabled:opacity-60 flex items-center gap-2">
{loading ? 'Saving…' : <><TestTube2 size={14} /> Test & Save</>}
</button>
<button type="button" onClick={() => setShowForm(false)}
className="border border-gray-200 text-gray-600 px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50">
Cancel
</button>
</div>
</form>
</div>
)}
</div>
);
}
function Field({ label, value, onChange, placeholder, type = 'text' }: {
label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string;
}) {
return (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">{label}</label>
<input
type={type} value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} required
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
);
}

View File

@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { api, session, type MeResult } from '@/lib/api';
import { Cpu, Globe, BarChart2, Copy, RotateCcw, ExternalLink, Activity } from 'lucide-react';
import { clsx } from 'clsx';
const MCP_URL = `${import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com'}/mcp/sse`;
const RECENT_ACTIVITY = [
{ time: '2 mins ago', event: 'Connection sync', status: 'Success', detail: 'Odoo connection is healthy' },
{ time: '5 mins ago', event: 'MCP request', status: 'Success', detail: 'search_invoices' },
{ time: '12 mins ago', event: 'Token rotated', status: 'Success', detail: 'New token generated' },
{ time: '1 hour ago', event: 'Connection sync', status: 'Success', detail: 'Odoo connection is healthy' },
];
export default function DashboardHome() {
const [me, setMe] = useState<MeResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const key = session.getKey();
if (!key) return;
api.me(key).then(setMe).catch(console.error).finally(() => setLoading(false));
}, []);
const instance = me?.odoo_instances?.[0];
const requestsUsed = 2431;
const requestsLimit = 10000;
return (
<div className="max-w-6xl space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-0.5">Welcome back{me ? `, ${me.email}` : ''}.</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Subscription"
value="Pro Plan"
sub="Renews Jul 29, 2026"
icon={<span className="text-xl">💎</span>}
color="bg-purple-50"
/>
<StatCard
label="MCP Status"
value={<span className="flex items-center gap-1.5"><span className="w-2 h-2 bg-green-400 rounded-full" />Active</span>}
sub="All systems operational"
icon={<Cpu size={18} className="text-blue-500" />}
color="bg-blue-50"
/>
<StatCard
label="Odoo Connection"
value={instance ? 'Connected' : 'Not connected'}
sub={instance ? 'Healthy' : 'Add a connection'}
icon={<Globe size={18} className="text-green-500" />}
color="bg-green-50"
/>
<StatCard
label="Requests This Month"
value={`${requestsUsed.toLocaleString()} / ${requestsLimit.toLocaleString()}`}
sub={
<div className="mt-1">
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full"
style={{ width: `${(requestsUsed / requestsLimit) * 100}%` }}
/>
</div>
<span className="text-xs text-gray-400">{Math.round((requestsUsed / requestsLimit) * 100)}% used</span>
</div>
}
icon={<BarChart2 size={18} className="text-orange-500" />}
color="bg-orange-50"
/>
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* Odoo Connection Health */}
<div className="lg:col-span-1 bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Odoo Connection Health</h2>
{instance ? (
<div className="space-y-3 text-xs">
<Row label="Odoo URL" value={instance.url} />
<Row label="Database" value={instance.database} />
<Row label="Last Sync" value="2 minutes ago" />
<Row label="Status" value={<span className="text-green-600 font-medium">Healthy</span>} />
<button className="w-full text-center text-brand-600 text-xs font-medium border border-brand-200 rounded-lg py-2 hover:bg-brand-50 transition-colors mt-2">
View Connection
</button>
</div>
) : (
<p className="text-xs text-gray-500">No Odoo instance connected yet.{' '}
<a href="/dashboard/connections" className="text-brand-600 hover:underline">Add one </a>
</p>
)}
</div>
{/* Quick Actions */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div className="space-y-2">
{[
{ label: 'Copy MCP URL', icon: Copy, action: () => navigator.clipboard.writeText(MCP_URL) },
{ label: 'Rotate Token', icon: RotateCcw, action: () => {} },
{ label: 'Open Claude Setup', icon: ExternalLink, action: () => {} },
{ label: 'Open Codex Setup', icon: ExternalLink, action: () => {} },
{ label: 'View Logs', icon: Activity, action: () => {} },
].map(({ label, icon: Icon, action }) => (
<button key={label} onClick={action}
className="w-full flex items-center gap-3 text-sm text-gray-700 hover:text-brand-600 hover:bg-brand-50 px-3 py-2 rounded-lg transition-colors text-left">
<Icon size={14} className="text-gray-400" /> {label}
</button>
))}
</div>
</div>
{/* Usage Overview */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Usage Overview</h2>
<div className="flex items-end gap-1 h-24 mb-2">
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 100, 60, 85].map((h, i) => (
<div key={i} className="flex-1 bg-brand-100 rounded-sm hover:bg-brand-400 transition-colors cursor-pointer"
style={{ height: `${h}%` }} />
))}
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>Jun 1</span><span>Jun 15</span><span>Jun 29</span>
</div>
<p className="text-2xl font-bold text-gray-900 mt-3">{requestsUsed.toLocaleString()}</p>
<p className="text-xs text-gray-500">Requests this month</p>
</div>
</div>
{/* Recent Activity */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-900">Recent Activity</h2>
<a href="/dashboard/logs" className="text-xs text-brand-600 hover:underline">View all logs </a>
</div>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left pb-2 font-medium">Time</th>
<th className="text-left pb-2 font-medium">Event</th>
<th className="text-left pb-2 font-medium">Status</th>
<th className="text-left pb-2 font-medium">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{RECENT_ACTIVITY.map((row, i) => (
<tr key={i}>
<td className="py-2.5 text-gray-400">{row.time}</td>
<td className="py-2.5 text-gray-700">{row.event}</td>
<td className="py-2.5">
<span className="inline-flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />{row.status}
</span>
</td>
<td className="py-2.5 text-gray-500">{row.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function StatCard({ label, value, sub, icon, color }: {
label: string; value: React.ReactNode; sub: React.ReactNode; icon: React.ReactNode; color: string;
}) {
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<div className="flex items-start justify-between mb-3">
<p className="text-xs text-gray-500">{label}</p>
<div className={clsx('w-8 h-8 rounded-lg flex items-center justify-center', color)}>{icon}</div>
</div>
<p className="text-sm font-semibold text-gray-900">{value}</p>
<div className="text-xs text-gray-400 mt-1">{sub}</div>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-2">
<span className="text-gray-500 shrink-0">{label}</span>
<span className="text-gray-900 text-right truncate">{value}</span>
</div>
);
}

View File

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link, Outlet } from 'react-router-dom';
import { session } from '@/lib/api';
import {
LayoutDashboard, Globe, Cpu, BookOpen, Shield, BarChart2,
FileText, CreditCard, Key, Users, Settings, HelpCircle, LogOut, Menu, X,
} from 'lucide-react';
import { clsx } from 'clsx';
const NAV = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Odoo Connections', href: '/dashboard/connections', icon: Globe },
{ label: 'MCP Endpoints', href: '/dashboard/endpoints', icon: Cpu },
{ label: 'AI Setup Guides', href: '/dashboard/guides', icon: BookOpen },
{ label: 'Tools & Permissions', href: '/dashboard/tools', icon: Shield },
{ label: 'Usage', href: '/dashboard/usage', icon: BarChart2 },
{ label: 'Logs', href: '/dashboard/logs', icon: FileText },
{ label: 'Billing', href: '/dashboard/billing', icon: CreditCard },
{ label: 'API Tokens', href: '/dashboard/tokens', icon: Key },
{ label: 'Team', href: '/dashboard/team', icon: Users },
{ label: 'Settings', href: '/dashboard/settings', icon: Settings },
];
export default function DashboardLayout() {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [email, setEmail] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
const key = session.getKey();
const em = session.getEmail();
if (!key) { navigate('/login', { replace: true }); return; }
if (em) setEmail(em);
}, [navigate]);
function logout() {
session.clear();
navigate('/');
}
const Sidebar = (
<aside className="flex flex-col h-full bg-gray-900 text-gray-300 w-64 shrink-0">
{/* Logo */}
<div className="px-5 py-4 border-b border-gray-800">
<Link to="/" className="flex items-center gap-2">
<div className="w-7 h-7 rounded gradient-brand flex items-center justify-center">
<span className="text-white font-bold text-xs">O</span>
</div>
<span className="text-white font-semibold text-sm">OdooMCP Cloud</span>
</Link>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-0.5">
{NAV.map(({ label, href, icon: Icon }) => {
const active = pathname === href || (href !== '/dashboard' && pathname.startsWith(href));
return (
<Link
key={href}
to={href}
onClick={() => setSidebarOpen(false)}
className={clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
active
? 'bg-brand-600 text-white'
: 'hover:bg-gray-800 hover:text-white',
)}
>
<Icon size={16} />
{label}
</Link>
);
})}
</nav>
{/* Bottom */}
<div className="px-3 pb-4 border-t border-gray-800 pt-3 space-y-0.5">
<Link to="/dashboard/support"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors">
<HelpCircle size={16} /> Support
</Link>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm hover:bg-gray-800 hover:text-white transition-colors">
<LogOut size={16} /> Logout
</button>
<div className="px-3 pt-2">
<p className="text-xs text-gray-500 truncate">{email}</p>
</div>
</div>
</aside>
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50">
{/* Desktop sidebar */}
<div className="hidden lg:flex flex-col h-full">
{Sidebar}
</div>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="lg:hidden fixed inset-0 z-50 flex">
<div className="flex flex-col h-full">{Sidebar}</div>
<div className="flex-1 bg-black/40" onClick={() => setSidebarOpen(false)} />
</div>
)}
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile topbar */}
<div className="lg:hidden flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-100">
<button onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
<span className="font-semibold text-sm text-gray-900">OdooMCP Cloud</span>
</div>
<main className="flex-1 overflow-y-auto p-6"><Outlet /></main>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import { session } from '@/lib/api';
import { Copy, Check, RotateCcw } from 'lucide-react';
const MCP_URL = `${import.meta.env.VITE_API_URL ?? 'https://odoo-mcp.thedomainnest.com'}/mcp/sse`;
const AI_CLIENTS = [
{ name: 'Claude', icon: '🤖', setup: 'claude' },
{ name: 'Codex', icon: '⚡', setup: 'codex' },
{ name: 'Cursor', icon: '🖱️', setup: 'cursor' },
{ name: 'Windsurf', icon: '🏄', setup: 'windsurf' },
{ name: 'Custom MCP', icon: '🔧', setup: 'custom' },
];
export default function Endpoints() {
const [apiKey, setApiKey] = useState('');
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedToken, setCopiedToken] = useState(false);
const [activeSetup, setActiveSetup] = useState('claude');
useEffect(() => {
const k = session.getKey();
if (k) setApiKey(k);
}, []);
function copy(text: string, setter: (v: boolean) => void) {
navigator.clipboard.writeText(text);
setter(true);
setTimeout(() => setter(false), 2000);
}
const claudeJson = JSON.stringify({
mcpServers: {
'odoo-mcp': {
type: 'sse',
url: MCP_URL,
headers: { Authorization: `Bearer ${apiKey || 'your-api-key-here'}` },
},
},
}, null, 2);
return (
<div className="max-w-4xl space-y-6">
<div>
<h1 className="text-xl font-bold text-gray-900">MCP Endpoints</h1>
<p className="text-sm text-gray-500 mt-0.5">Your secure MCP server connection details.</p>
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* Left: URL + token */}
<div className="lg:col-span-2 space-y-4">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Your MCP Server URL</h2>
<div className="flex items-center gap-2 bg-gray-50 rounded-xl border border-gray-200 px-4 py-3 mb-4">
<code className="flex-1 text-sm text-gray-800 break-all font-mono">{MCP_URL}</code>
<button onClick={() => copy(MCP_URL, setCopiedUrl)}
className="shrink-0 text-brand-600 hover:text-brand-700 p-1">
{copiedUrl ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<h2 className="text-sm font-semibold text-gray-900 mb-2">Bearer Token</h2>
<div className="flex items-center gap-2 bg-gray-50 rounded-xl border border-gray-200 px-4 py-3">
<code className="flex-1 text-sm text-gray-500 break-all font-mono">
{apiKey ? `${apiKey.slice(0, 20)}${'•'.repeat(20)}` : 'Loading…'}
</code>
<button onClick={() => copy(apiKey, setCopiedToken)}
className="shrink-0 text-brand-600 hover:text-brand-700 p-1">
{copiedToken ? <Check size={16} /> : <Copy size={16} />}
</button>
<button className="shrink-0 text-gray-400 hover:text-gray-600 p-1" title="Rotate token">
<RotateCcw size={16} />
</button>
</div>
</div>
{/* Connect With */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Connect With</h2>
<div className="flex gap-3 flex-wrap mb-6">
{AI_CLIENTS.map((c) => (
<button key={c.setup}
onClick={() => setActiveSetup(c.setup)}
className={`flex flex-col items-center gap-1.5 px-4 py-3 rounded-xl border text-xs font-medium transition-colors ${
activeSetup === c.setup
? 'border-brand-400 bg-brand-50 text-brand-700'
: 'border-gray-200 hover:border-brand-200 text-gray-600'
}`}>
<span className="text-xl">{c.icon}</span>
{c.name}
<span className="text-[10px] text-gray-400">Setup Guide</span>
</button>
))}
</div>
{activeSetup === 'claude' && (
<div>
<p className="text-xs text-gray-600 mb-3">
Add this to your Claude Code settings (<code className="bg-gray-100 px-1 py-0.5 rounded text-[11px]">~/.claude/settings.json</code>):
</p>
<div className="relative bg-gray-900 rounded-xl p-4">
<pre className="text-green-400 text-xs overflow-x-auto font-mono">{claudeJson}</pre>
<button onClick={() => copy(claudeJson, () => {})}
className="absolute top-3 right-3 text-gray-400 hover:text-white p-1">
<Copy size={13} />
</button>
</div>
<p className="text-xs text-gray-500 mt-3">
Or run: <code className="bg-gray-100 px-1 py-0.5 rounded text-[11px]">claude mcp add --transport sse odoo-mcp {MCP_URL} -H &quot;Authorization: Bearer YOUR_KEY&quot;</code>
</p>
</div>
)}
{activeSetup !== 'claude' && (
<div className="bg-gray-50 rounded-xl p-4 text-xs text-gray-500 text-center">
Setup guide for {AI_CLIENTS.find(c => c.setup === activeSetup)?.name} coming soon.
</div>
)}
</div>
</div>
{/* Right: status card */}
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Endpoint Status</h2>
<div className="space-y-3 text-xs">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-green-700 font-medium">Active</span>
</div>
<Row label="Transport" value="HTTPS Remote MCP" />
<Row label="Odoo Connection" value={<span className="text-green-600">Healthy</span>} />
<Row label="Tools Enabled" value="60+" />
<Row label="Created" value="Jun 20, 2026" />
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 text-xs text-yellow-700">
<p className="font-medium mb-1">Security Notice</p>
<p>Do not commit your token to GitHub. Use environment variables in production.</p>
</div>
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-2">
<span className="text-gray-500 shrink-0">{label}</span>
<span className="text-gray-900 text-right">{value}</span>
</div>
);
}

View File

@ -0,0 +1,148 @@
import { Fragment, useState, useEffect, useCallback } from 'react';
import { api, session, LogEntry } from '@/lib/api';
import { ChevronLeft, ChevronRight, CheckCircle2, XCircle, FileText } from 'lucide-react';
const PAGE_SIZE = 25;
export default function Logs() {
const [items, setItems] = useState<LogEntry[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [toolFilter, setToolFilter] = useState('');
const [successFilter, setSuccessFilter] = useState<'all' | 'success' | 'error'>('all');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const key = session.getKey();
if (!key) return;
setLoading(true);
api
.listLogs(key, page, PAGE_SIZE, {
tool_name: toolFilter || undefined,
success: successFilter === 'all' ? undefined : successFilter === 'success',
})
.then((r) => {
setItems(r.items);
setTotal(r.total);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [page, toolFilter, successFilter]);
useEffect(load, [load]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">Logs</h1>
<p className="text-sm text-gray-500 mt-0.5">Every MCP tool call made against your account.</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3">
<input
type="text"
value={toolFilter}
onChange={(e) => {
setPage(1);
setToolFilter(e.target.value);
}}
placeholder="Filter by tool name…"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent w-64"
/>
<select
value={successFilter}
onChange={(e) => {
setPage(1);
setSuccessFilter(e.target.value as 'all' | 'success' | 'error');
}}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="all">All statuses</option>
<option value="success">Success only</option>
<option value="error">Errors only</option>
</select>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{items.length === 0 && !loading ? (
<div className="p-12 text-center">
<FileText size={32} className="mx-auto text-gray-300 mb-3" />
<p className="text-sm font-medium text-gray-600">No log entries found</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium">Tool</th>
<th className="px-6 py-3 font-medium">Instance</th>
<th className="px-6 py-3 font-medium">Duration</th>
<th className="px-6 py-3 font-medium">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{items.map((log) => (
<Fragment key={log.id}>
<tr
className={log.error_message ? 'cursor-pointer hover:bg-gray-50' : ''}
onClick={() => log.error_message && setExpandedId(expandedId === log.id ? null : log.id)}
>
<td className="px-6 py-3">
{log.success ? (
<CheckCircle2 size={15} className="text-green-600" />
) : (
<XCircle size={15} className="text-red-500" />
)}
</td>
<td className="px-6 py-3 font-mono text-xs text-gray-900">{log.tool_name}</td>
<td className="px-6 py-3 text-gray-600">{log.instance_name}</td>
<td className="px-6 py-3 text-gray-600">{log.duration_ms} ms</td>
<td className="px-6 py-3 text-xs text-gray-400">{log.started_at}</td>
</tr>
{expandedId === log.id && log.error_message && (
<tr>
<td colSpan={5} className="px-6 py-3 bg-red-50 text-xs text-red-700 font-mono">
{log.error_message}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="p-2 border border-gray-200 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { api, session, ApiKeyInfo } from '@/lib/api';
import { Plus, Trash2, Key, Copy, Check, AlertTriangle } from 'lucide-react';
export default function Tokens() {
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
function load() {
const key = session.getKey();
if (!key) return;
api.listApiKeys(key).then((r) => setKeys(r.keys)).catch(console.error);
}
useEffect(load, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const key = session.getKey();
if (!key) return;
setLoading(true);
try {
const result = await api.createApiKey(key, name || 'Untitled key');
setNewKey(result.api_key);
setName('');
setShowForm(false);
load();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to create key');
} finally {
setLoading(false);
}
}
async function handleRevoke(id: number, keyName: string) {
if (!confirm(`Revoke "${keyName}"? Any client using this key will immediately lose access.`)) return;
const key = session.getKey();
if (!key) return;
try {
await api.revokeApiKey(key, id);
load();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to revoke key');
}
}
function copyKey() {
if (!newKey) return;
navigator.clipboard.writeText(newKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">API Tokens</h1>
<p className="text-sm text-gray-500 mt-0.5">
Create and revoke API keys used to connect MCP clients.
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity"
>
<Plus size={16} /> New Key
</button>
</div>
{newKey && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-5">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-yellow-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-yellow-900 mb-1">
Save this key now it will not be shown again
</p>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 bg-white border border-yellow-200 rounded-lg px-3 py-2 text-xs font-mono break-all">
{newKey}
</code>
<button
onClick={copyKey}
className="shrink-0 border border-yellow-300 bg-white rounded-lg p-2 hover:bg-yellow-100 transition-colors"
>
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} className="text-yellow-700" />}
</button>
</div>
<button
onClick={() => setNewKey(null)}
className="text-xs text-yellow-700 hover:underline mt-3"
>
I've saved it, dismiss
</button>
</div>
</div>
</div>
)}
{showForm && (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-5">Create API Key</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Key Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Claude Desktop, Production Server"
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={loading}
className="gradient-brand text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 disabled:opacity-60"
>
{loading ? 'Creating…' : 'Create Key'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="border border-gray-200 text-gray-600 px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
</div>
)}
{keys.length === 0 && !showForm && (
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
<Key size={32} className="mx-auto text-gray-300 mb-3" />
<p className="text-sm font-medium text-gray-600 mb-1">No API keys yet</p>
<p className="text-xs text-gray-400 mb-4">Create one to connect an MCP client.</p>
</div>
)}
{keys.length > 0 && (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-5 py-3 font-medium">Name</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Created</th>
<th className="px-5 py-3 font-medium">Last Used</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{keys.map((k) => (
<tr key={k.id}>
<td className="px-5 py-3.5 font-medium text-gray-900">{k.name}</td>
<td className="px-5 py-3.5 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
<td className="px-5 py-3.5 text-xs text-gray-500">{k.created_at}</td>
<td className="px-5 py-3.5 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
<td className="px-5 py-3.5">
{k.is_active ? (
<span className="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
Active
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
Revoked
</span>
)}
</td>
<td className="px-5 py-3.5 text-right">
{k.is_active && (
<button
onClick={() => handleRevoke(k.id, k.name)}
className="text-gray-400 hover:text-red-500 p-1 transition-colors"
title="Revoke key"
>
<Trash2 size={15} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from 'react';
import { api, session, AnalyticsSummary, ToolStat, InstanceStat, TimeseriesBucket } from '@/lib/api';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { Activity, CheckCircle2, XCircle, Clock } from 'lucide-react';
const RANGES = [
{ label: '7 days', days: 7 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
];
function isoDaysAgo(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString();
}
export default function Usage() {
const [rangeDays, setRangeDays] = useState(30);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [byTool, setByTool] = useState<ToolStat[]>([]);
const [byInstance, setByInstance] = useState<InstanceStat[]>([]);
const [timeseries, setTimeseries] = useState<TimeseriesBucket[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
const key = session.getKey();
if (!key) return;
setLoading(true);
const since = isoDaysAgo(rangeDays);
Promise.all([
api.analyticsSummary(key, since),
api.analyticsByTool(key, since),
api.analyticsByInstance(key, since),
api.analyticsTimeseries(key, since, undefined, rangeDays > 30 ? 'week' : 'day'),
])
.then(([s, t, i, ts]) => {
setSummary(s);
setByTool(t.tools);
setByInstance(i.instances);
setTimeseries(ts.buckets);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [rangeDays]);
useEffect(load, [load]);
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">Usage</h1>
<p className="text-sm text-gray-500 mt-0.5">Tool calls, durations, and activity across your MCP connections.</p>
</div>
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setRangeDays(r.days)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
rangeDays === r.days ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-4 gap-4">
<StatCard icon={Activity} label="Total Calls" value={summary?.total_calls ?? '—'} color="blue" />
<StatCard icon={CheckCircle2} label="Successful" value={summary?.success_count ?? '—'} color="green" />
<StatCard icon={XCircle} label="Errors" value={summary?.error_count ?? '—'} color="red" />
<StatCard
icon={Clock}
label="Avg Duration"
value={summary ? `${summary.avg_duration_ms.toFixed(1)} ms` : '—'}
color="purple"
/>
</div>
{/* Time-series chart */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls Over Time</h2>
{timeseries.length === 0 && !loading ? (
<EmptyChart />
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeseries}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="period" tick={{ fontSize: 11 }} stroke="#9ca3af" />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
<Tooltip />
<Line type="monotone" dataKey="calls" stroke="#7c3aed" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Per-tool bar chart + table */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">Calls by Tool</h2>
{byTool.length === 0 && !loading ? (
<EmptyChart />
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={byTool.slice(0, 8)}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="tool_name" tick={{ fontSize: 10 }} stroke="#9ca3af" interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" allowDecimals={false} />
<Tooltip />
<Bar dataKey="calls" fill="#7c3aed" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4">By Instance</h2>
{byInstance.length === 0 ? (
<EmptyChart />
) : (
<div className="space-y-3">
{byInstance.map((inst) => (
<div key={inst.instance_name} className="flex items-center justify-between text-sm border-b border-gray-50 pb-2">
<span className="font-medium text-gray-900">{inst.instance_name}</span>
<div className="flex gap-4 text-xs text-gray-500">
<span>{inst.calls} calls</span>
<span>{inst.error_count} errors</span>
<span>{inst.avg_duration_ms.toFixed(0)} ms avg</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Per-tool breakdown table */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-900">Tool Breakdown</h2>
</div>
{byTool.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No tool calls in this range yet.</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr className="text-left text-xs text-gray-500">
<th className="px-6 py-3 font-medium">Tool</th>
<th className="px-6 py-3 font-medium">Calls</th>
<th className="px-6 py-3 font-medium">Success</th>
<th className="px-6 py-3 font-medium">Errors</th>
<th className="px-6 py-3 font-medium">Avg Duration</th>
<th className="px-6 py-3 font-medium">Total Duration</th>
<th className="px-6 py-3 font-medium">Last Used</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{byTool.map((t) => (
<tr key={t.tool_name}>
<td className="px-6 py-3 font-mono text-xs text-gray-900">{t.tool_name}</td>
<td className="px-6 py-3">{t.calls}</td>
<td className="px-6 py-3 text-green-700">{t.success_count}</td>
<td className="px-6 py-3 text-red-600">{t.error_count}</td>
<td className="px-6 py-3 text-gray-600">{t.avg_duration_ms.toFixed(1)} ms</td>
<td className="px-6 py-3 text-gray-600">{(t.total_duration_ms / 1000).toFixed(2)} s</td>
<td className="px-6 py-3 text-xs text-gray-400">{t.last_used_at}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
function StatCard({ icon: Icon, label, value, color }: {
icon: typeof Activity; label: string; value: string | number; color: 'blue' | 'green' | 'red' | 'purple';
}) {
const colors = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
purple: 'bg-purple-50 text-purple-600',
};
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
<Icon size={16} />
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
);
}
function EmptyChart() {
return (
<div className="h-[240px] flex items-center justify-center text-sm text-gray-400">
No data in this range yet.
</div>
);
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

23
tailwind.config.cjs Normal file
View File

@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
brand: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
},
},
},
plugins: [],
};

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": ["vite/client"],
"paths": { "@/*": ["./src/*"] }
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: { port: 3000 },
});