feat: Initial tenant Control Panel

Privacy-first settings dashboard for LetsBe Cloud tenants.
- Next.js 15 (App Router) with Keycloak SSO via NextAuth.js v5
- Tool status grid with health indicators
- Settings pages (email, domain, server info)
- Post-provisioning setup checklist
- Orchestrator API proxy for server management
- Docker deployment ready

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-07 08:03:17 +01:00
commit 5ba6a3434a
46 changed files with 2890 additions and 0 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Authentication
NEXTAUTH_URL=https://panel.example.com
NEXTAUTH_SECRET=generate-a-secret-here
# Keycloak SSO
KEYCLOAK_CLIENT_ID=control-panel
KEYCLOAK_CLIENT_SECRET=your-client-secret
KEYCLOAK_ISSUER=https://auth.example.com/realms/letsbe
# Orchestrator API (same server)
ORCHESTRATOR_URL=http://orchestrator:8100
# Tenant domain (set during provisioning)
TENANT_DOMAIN=example.com

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permissions for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3001
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

22
docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
services:
control-panel:
build: .
container_name: letsbe-control-panel
restart: unless-stopped
ports:
- "3001:3001"
environment:
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-control-panel}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
- ORCHESTRATOR_URL=${ORCHESTRATOR_URL:-http://orchestrator:8100}
- TENANT_DOMAIN=${TENANT_DOMAIN}
- SERVER_IP=${SERVER_IP}
networks:
- letsbe
networks:
letsbe:
external: true

46
next.config.ts Normal file
View File

@ -0,0 +1,46 @@
import type { NextConfig } from 'next'
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
]
const nextConfig: NextConfig = {
output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
export default nextConfig

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "letsbe-control-panel",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "15.1.8",
"next-auth": "5.0.0-beta.30",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.8",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

9
postcss.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
module.exports = config

View File

@ -0,0 +1,2 @@
import { handlers } from '@/auth'
export const { GET, POST } = handlers

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100'
async function proxyRequest(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { path } = await params
const targetPath = path.join('/')
const url = new URL(`/api/v1/${targetPath}`, ORCHESTRATOR_URL)
// Forward query parameters
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Forward the access token if available
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`
}
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
// Forward body for non-GET requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
try {
const response = await fetch(url.toString(), fetchOptions)
const data = await response.text()
return new NextResponse(data, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to connect to orchestrator' },
{ status: 502 }
)
}
}
export const GET = proxyRequest
export const POST = proxyRequest
export const PUT = proxyRequest
export const PATCH = proxyRequest
export const DELETE = proxyRequest

26
src/app/error.tsx Normal file
View File

@ -0,0 +1,26 @@
'use client'
import { Button } from '@/components/ui/button'
import { AlertCircle, RotateCcw } from 'lucide-react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-2xl font-bold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground max-w-md">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
<Button onClick={reset} className="mt-6">
<RotateCcw className="mr-2 h-4 w-4" />
Try again
</Button>
</div>
)
}

59
src/app/globals.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

45
src/app/layout.tsx Normal file
View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { auth } from '@/auth'
import { Sidebar } from '@/components/dashboard/sidebar'
import { TooltipProvider } from '@/components/ui/tooltip'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'LetsBe Control Panel',
description: 'Manage your LetsBe server and tools',
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
return (
<html lang="en">
<body className={inter.className}>
<TooltipProvider>
{session?.user ? (
<div className="min-h-screen">
<Sidebar
userName={session.user.name}
userEmail={session.user.email}
/>
<main className="lg:pl-64">
<div className="pt-14 lg:pt-0">
<div className="p-6 lg:p-8">{children}</div>
</div>
</main>
</div>
) : (
children
)}
</TooltipProvider>
</body>
</html>
)
}

26
src/app/loading.tsx Normal file
View File

@ -0,0 +1,26 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function Loading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-80" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
</div>
)
}

39
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,39 @@
import { signIn } from '@/auth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Shield } from 'lucide-react'
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<span className="text-xl font-bold">LB</span>
</div>
<CardTitle className="text-2xl">LetsBe Control Panel</CardTitle>
<CardDescription>
Sign in to manage your server and tools
</CardDescription>
</CardHeader>
<CardContent>
<form
action={async () => {
'use server'
await signIn('keycloak', { redirectTo: '/' })
}}
>
<Button type="submit" className="w-full" size="lg">
<Shield className="mr-2 h-4 w-4" />
Sign in with Keycloak SSO
</Button>
</form>
<p className="mt-4 text-center text-xs text-muted-foreground">
Your identity is managed through Keycloak on this server.
Contact your administrator if you need access.
</p>
</CardContent>
</Card>
</div>
)
}

18
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { ArrowLeft } from 'lucide-react'
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<p className="mt-4 text-lg text-muted-foreground">Page not found</p>
<Button asChild className="mt-6">
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
)
}

187
src/app/page.tsx Normal file
View File

@ -0,0 +1,187 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import {
Cpu,
HardDrive,
MemoryStick,
Clock,
ExternalLink,
Activity,
CheckCircle2,
XCircle,
ArrowRight,
} from 'lucide-react'
import Link from 'next/link'
import { getHealth } from '@/lib/orchestrator'
import { AVAILABLE_TOOLS } from '@/lib/tools'
import { ToolStatusGrid } from '@/components/dashboard/tool-status-grid'
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
function getUsageColor(percent: number): string {
if (percent < 60) return 'text-green-600'
if (percent < 80) return 'text-yellow-600'
return 'text-red-600'
}
export default async function DashboardPage() {
let health = null
let healthError = null
try {
health = await getHealth()
} catch (e) {
healthError = e instanceof Error ? e.message : 'Failed to fetch server health'
}
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Monitor your server health and manage your tools
</p>
</div>
{/* Server Health */}
<div>
<h2 className="text-lg font-semibold mb-4">Server Health</h2>
{healthError ? (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-muted-foreground">
<XCircle className="h-5 w-5 text-destructive" />
<div>
<p className="font-medium">Unable to connect to server</p>
<p className="text-sm">{healthError}</p>
</div>
</div>
</CardContent>
</Card>
) : health ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.cpu_percent)}`}>
{health.cpu_percent.toFixed(1)}%
</div>
<Progress value={health.cpu_percent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.memory_percent)}`}>
{health.memory_percent.toFixed(1)}%
</div>
<Progress value={health.memory_percent} className="mt-2 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(health.disk_percent)}`}>
{health.disk_percent.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground mt-1">
{health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB
</p>
<Progress value={health.disk_percent} className="mt-1 h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatUptime(health.uptime_seconds)}
</div>
<div className="flex items-center gap-1.5 mt-2">
<Badge variant="success" className="text-xs">
<Activity className="mr-1 h-3 w-3" />
Online
</Badge>
<span className="text-xs text-muted-foreground">
{health.agent_count} agent{health.agent_count !== 1 ? 's' : ''}
</span>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
{/* Tool Status Grid */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Deployed Tools</h2>
<Button variant="outline" size="sm" asChild>
<Link href="/tools">
View all
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>
<ToolStatusGrid tools={AVAILABLE_TOOLS} />
</div>
{/* Quick Actions */}
<div>
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Card className="hover:border-primary/50 transition-colors">
<Link href="/setup" className="block">
<CardHeader className="pb-2">
<CardTitle className="text-base">Setup Checklist</CardTitle>
<CardDescription>
Complete post-provisioning steps
</CardDescription>
</CardHeader>
</Link>
</Card>
<Card className="hover:border-primary/50 transition-colors">
<Link href="/settings/email" className="block">
<CardHeader className="pb-2">
<CardTitle className="text-base">Configure Email</CardTitle>
<CardDescription>
Set up SMTP for outbound emails
</CardDescription>
</CardHeader>
</Link>
</Card>
<Card className="hover:border-primary/50 transition-colors">
<Link href="/settings/domain" className="block">
<CardHeader className="pb-2">
<CardTitle className="text-base">Domain Settings</CardTitle>
<CardDescription>
View DNS records and domain info
</CardDescription>
</CardHeader>
</Link>
</Card>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,130 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Globe, CheckCircle2, XCircle, AlertCircle } from 'lucide-react'
import { AVAILABLE_TOOLS } from '@/lib/tools'
const domain = process.env.TENANT_DOMAIN || 'example.com'
function DnsStatusIcon({ status }: { status: string }) {
switch (status) {
case 'active':
return <CheckCircle2 className="h-4 w-4 text-green-600" />
case 'pending':
return <AlertCircle className="h-4 w-4 text-yellow-500" />
default:
return <XCircle className="h-4 w-4 text-red-500" />
}
}
function DnsStatusBadge({ status }: { status: string }) {
switch (status) {
case 'active':
return <Badge variant="success">Active</Badge>
case 'pending':
return <Badge variant="warning">Pending</Badge>
default:
return <Badge variant="destructive">Error</Badge>
}
}
export default function DomainSettingsPage() {
// Generate subdomain list from available tools
const subdomains = [
{ name: 'panel', fullDomain: `panel.${domain}`, purpose: 'Control Panel', status: 'active' as const },
...AVAILABLE_TOOLS.map((tool) => ({
name: tool.subdomain,
fullDomain: `${tool.subdomain}.${domain}`,
purpose: tool.name,
status: 'active' as const,
})),
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Domain & DNS</h1>
<p className="text-muted-foreground">
View your domain configuration and DNS record status
</p>
</div>
{/* Domain Overview */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Domain Information
</CardTitle>
<CardDescription>Your primary domain and configuration</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Primary Domain</p>
<p className="text-lg font-semibold">{domain}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Wildcard DNS</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="success">Configured</Badge>
<span className="text-sm text-muted-foreground">*.{domain}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Subdomain Records */}
<Card>
<CardHeader>
<CardTitle>Subdomain Records</CardTitle>
<CardDescription>
All subdomains pointing to your server ({subdomains.length} total)
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{subdomains.map((sub) => (
<div
key={sub.name}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<DnsStatusIcon status={sub.status} />
<div>
<p className="text-sm font-medium">{sub.fullDomain}</p>
<p className="text-xs text-muted-foreground">{sub.purpose}</p>
</div>
</div>
<DnsStatusBadge status={sub.status} />
</div>
))}
</div>
</CardContent>
</Card>
{/* DNS Instructions */}
<Card>
<CardHeader>
<CardTitle>DNS Configuration</CardTitle>
<CardDescription>Required DNS records for your domain</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4 font-mono text-sm space-y-1">
<p>; A record for root domain</p>
<p>{domain}. IN A YOUR_SERVER_IP</p>
<p></p>
<p>; Wildcard A record for all subdomains</p>
<p>*.{domain}. IN A YOUR_SERVER_IP</p>
<p></p>
<p>; MX record for email (if using Poste.io)</p>
<p>{domain}. IN MX 10 mail.{domain}.</p>
<p></p>
<p>; SPF record</p>
<p>{domain}. IN TXT &quot;v=spf1 a mx ip4:YOUR_SERVER_IP ~all&quot;</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Save, Mail, AlertCircle, CheckCircle2 } from 'lucide-react'
export default function EmailSettingsPage() {
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [config, setConfig] = useState({
host: '',
port: '587',
username: '',
password: '',
fromEmail: '',
fromName: '',
})
const handleSave = async () => {
setSaving(true)
setError(null)
setSaved(false)
try {
const res = await fetch('/api/proxy/playbooks/poste-smtp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
smtp_host: config.host,
smtp_port: parseInt(config.port),
smtp_user: config.username,
smtp_pass: config.password,
from_email: config.fromEmail,
from_name: config.fromName,
}),
})
if (!res.ok) {
throw new Error('Failed to save SMTP configuration')
}
setSaved(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Email Configuration</h1>
<p className="text-muted-foreground">
Configure SMTP settings for outbound email from your tools
</p>
</div>
{saved && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Saved</AlertTitle>
<AlertDescription>
SMTP configuration has been updated. Email will be reconfigured shortly.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
SMTP Settings
</CardTitle>
<CardDescription>
These settings are used by Poste.io and other tools to send outbound email.
Configure your noreply address and relay settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="host">SMTP Host</Label>
<Input
id="host"
placeholder="smtp.gmail.com"
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="port">SMTP Port</Label>
<Input
id="port"
type="number"
placeholder="587"
value={config.port}
onChange={(e) => setConfig({ ...config, port: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="noreply@yourdomain.com"
value={config.username}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="App password or SMTP password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="fromEmail">From Email</Label>
<Input
id="fromEmail"
type="email"
placeholder="noreply@yourdomain.com"
value={config.fromEmail}
onChange={(e) => setConfig({ ...config, fromEmail: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fromName">From Name</Label>
<Input
id="fromName"
placeholder="Your Company"
value={config.fromName}
onChange={(e) => setConfig({ ...config, fromName: e.target.value })}
/>
</div>
</div>
<Button onClick={handleSave} disabled={saving} className="mt-2">
<Save className="mr-2 h-4 w-4" />
{saving ? 'Saving...' : 'Save Configuration'}
</Button>
</CardContent>
</Card>
</div>
)
}

66
src/app/settings/page.tsx Normal file
View File

@ -0,0 +1,66 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Mail, Globe, Server, ArrowRight } from 'lucide-react'
import Link from 'next/link'
const settingsSections = [
{
title: 'Email Configuration',
description: 'Configure SMTP settings for outbound email notifications from your tools',
icon: Mail,
href: '/settings/email',
},
{
title: 'Domain & DNS',
description: 'View your domain configuration and DNS record status for all subdomains',
icon: Globe,
href: '/settings/domain',
},
{
title: 'Server Information',
description: 'View SSH credentials, server IP, and system information',
icon: Server,
href: '/settings/server',
},
]
export default function SettingsPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Manage your server configuration and preferences
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsSections.map((section) => (
<Card key={section.href} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<section.icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-base">{section.title}</CardTitle>
</div>
</div>
<CardDescription className="mt-2">
{section.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" asChild className="w-full">
<Link href={section.href}>
Configure
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,196 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Server,
Cpu,
HardDrive,
MemoryStick,
Clock,
Network,
Terminal,
AlertCircle,
CheckCircle2,
} from 'lucide-react'
import { getHealth } from '@/lib/orchestrator'
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days} days, ${hours} hours, ${minutes} minutes`
if (hours > 0) return `${hours} hours, ${minutes} minutes`
return `${minutes} minutes`
}
export default async function ServerSettingsPage() {
let health = null
let healthError = null
try {
health = await getHealth()
} catch (e) {
healthError = e instanceof Error ? e.message : 'Failed to connect'
}
const serverIp = process.env.SERVER_IP || 'Not configured'
const domain = process.env.TENANT_DOMAIN || 'example.com'
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Server Information</h1>
<p className="text-muted-foreground">
View server details, resource usage, and connection information
</p>
</div>
{healthError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Error</AlertTitle>
<AlertDescription>{healthError}</AlertDescription>
</Alert>
)}
{/* Server Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Details
</CardTitle>
<CardDescription>Connection and identification information</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Server IP</p>
<p className="text-sm font-mono font-medium">{serverIp}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Domain</p>
<p className="text-sm font-mono font-medium">{domain}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge variant={health ? 'success' : 'destructive'}>
{health ? 'Online' : 'Unreachable'}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="text-sm font-medium">
{health ? formatUptime(health.uptime_seconds) : 'N/A'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Resource Usage */}
{health && (
<Card>
<CardHeader>
<CardTitle>Resource Usage</CardTitle>
<CardDescription>Current server resource utilization</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">CPU</span>
</div>
<span className="text-sm font-medium">{health.cpu_percent.toFixed(1)}%</span>
</div>
<Progress value={health.cpu_percent} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Memory</span>
</div>
<span className="text-sm font-medium">{health.memory_percent.toFixed(1)}%</span>
</div>
<Progress value={health.memory_percent} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Disk</span>
</div>
<span className="text-sm font-medium">
{health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB ({health.disk_percent.toFixed(1)}%)
</span>
</div>
<Progress value={health.disk_percent} className="h-2" />
</div>
</CardContent>
</Card>
)}
{/* SSH Access */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
SSH Access
</CardTitle>
<CardDescription>
Connect to your server via SSH for advanced administration
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4 font-mono text-sm">
<p className="text-muted-foreground"># Connect via SSH</p>
<p>ssh root@{serverIp}</p>
<p className="mt-2 text-muted-foreground"># Application directory</p>
<p>cd /opt/letsbe/</p>
</div>
<p className="mt-3 text-xs text-muted-foreground">
SSH credentials were provided during server provisioning.
Contact your administrator if you need access.
</p>
</CardContent>
</Card>
{/* Orchestrator Info */}
{health && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5" />
Orchestrator
</CardTitle>
<CardDescription>Automation agent status</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="flex items-center gap-1.5 mt-0.5">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Connected</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Active Agents</p>
<p className="text-sm font-medium">{health.agent_count}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Tasks</p>
<p className="text-sm font-medium">{health.task_count}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}

199
src/app/setup/page.tsx Normal file
View File

@ -0,0 +1,199 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
CheckCircle2,
Circle,
ExternalLink,
ArrowRight,
Shield,
Mail,
Key,
Users,
Globe,
Database,
Bell,
} from 'lucide-react'
import Link from 'next/link'
interface SetupStep {
id: string
title: string
description: string
icon: React.ComponentType<{ className?: string }>
href?: string
externalHref?: string
category: 'security' | 'communication' | 'tools'
}
const setupSteps: SetupStep[] = [
{
id: 'change-keycloak-password',
title: 'Change Keycloak Admin Password',
description: 'Update the default admin password for your Keycloak identity server',
icon: Shield,
externalHref: '/realms/master/account',
category: 'security',
},
{
id: 'change-portainer-password',
title: 'Change Portainer Password',
description: 'Update the default admin password for the container management dashboard',
icon: Key,
category: 'security',
},
{
id: 'configure-email',
title: 'Configure Outbound Email (SMTP)',
description: 'Set up your noreply email address for system notifications',
icon: Mail,
href: '/settings/email',
category: 'communication',
},
{
id: 'setup-sso',
title: 'Configure SSO for Tools',
description: 'Link your deployed tools to Keycloak for single sign-on access',
icon: Users,
category: 'security',
},
{
id: 'verify-dns',
title: 'Verify DNS Configuration',
description: 'Confirm all subdomains are resolving correctly to your server',
icon: Globe,
href: '/settings/domain',
category: 'communication',
},
{
id: 'nextcloud-setup',
title: 'Complete Nextcloud Setup',
description: 'Set admin password and configure your file storage',
icon: Database,
category: 'tools',
},
{
id: 'setup-monitoring',
title: 'Set Up Uptime Monitoring',
description: 'Configure Uptime Kuma to monitor your tools and get alerts',
icon: Bell,
category: 'tools',
},
]
export default function SetupPage() {
const [completed, setCompleted] = useState<Set<string>>(new Set())
const toggleStep = (id: string) => {
setCompleted((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const completedCount = completed.size
const totalSteps = setupSteps.length
const progressPercent = Math.round((completedCount / totalSteps) * 100)
const categories = {
security: { label: 'Security', steps: setupSteps.filter((s) => s.category === 'security') },
communication: { label: 'Communication', steps: setupSteps.filter((s) => s.category === 'communication') },
tools: { label: 'Tools', steps: setupSteps.filter((s) => s.category === 'tools') },
}
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Setup Checklist</h1>
<p className="text-muted-foreground">
Complete these steps to finish setting up your server
</p>
</div>
{/* Progress Overview */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
{completedCount} of {totalSteps} steps completed
</span>
<Badge variant={progressPercent === 100 ? 'success' : 'secondary'}>
{progressPercent}%
</Badge>
</div>
<Progress value={progressPercent} className="h-3" />
</CardContent>
</Card>
{/* Steps by Category */}
{Object.entries(categories).map(([key, { label, steps }]) => (
<div key={key}>
<h2 className="text-lg font-semibold mb-3">{label}</h2>
<div className="space-y-3">
{steps.map((step) => {
const isCompleted = completed.has(step.id)
return (
<Card
key={step.id}
className={isCompleted ? 'opacity-60' : ''}
>
<CardContent className="flex items-center gap-4 py-4">
<button
onClick={() => toggleStep(step.id)}
className="shrink-0"
>
{isCompleted ? (
<CheckCircle2 className="h-6 w-6 text-green-600" />
) : (
<Circle className="h-6 w-6 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium ${
isCompleted ? 'line-through' : ''
}`}
>
{step.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{step.description}
</p>
</div>
{step.href && (
<Button variant="ghost" size="sm" asChild>
<Link href={step.href}>
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
)}
{step.externalHref && (
<Button variant="ghost" size="sm" asChild>
<a
href={step.externalHref}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
))}
</div>
)
}

70
src/app/tools/page.tsx Normal file
View File

@ -0,0 +1,70 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ExternalLink } from 'lucide-react'
import { AVAILABLE_TOOLS } from '@/lib/tools'
const categoryLabels: Record<string, string> = {
communication: 'Communication',
productivity: 'Productivity',
development: 'Development',
monitoring: 'Monitoring',
storage: 'Storage',
marketing: 'Marketing',
security: 'Security',
other: 'Other',
}
export default function ToolsPage() {
const grouped = AVAILABLE_TOOLS.reduce(
(acc, tool) => {
const cat = tool.category
if (!acc[cat]) acc[cat] = []
acc[cat].push(tool)
return acc
},
{} as Record<string, typeof AVAILABLE_TOOLS>
)
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tools</h1>
<p className="text-muted-foreground">
Access all your deployed tools. Click &quot;Open&quot; to go to each tool&apos;s web interface.
</p>
</div>
{Object.entries(grouped).map(([category, tools]) => (
<div key={category}>
<h2 className="text-lg font-semibold mb-3">
{categoryLabels[category] || category}
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
<Card key={tool.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{tool.name}</CardTitle>
<Badge variant="secondary" className="text-xs">
{tool.subdomain}
</Badge>
</div>
<CardDescription>{tool.description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" asChild className="w-full">
<a href={tool.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open {tool.name}
</a>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)
}

45
src/auth.ts Normal file
View File

@ -0,0 +1,45 @@
import NextAuth from 'next-auth'
import Keycloak from 'next-auth/providers/keycloak'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth }) {
return !!auth?.user
},
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token
token.idToken = account.id_token
token.expiresAt = account.expires_at
token.refreshToken = account.refresh_token
}
if (profile) {
token.name = profile.name
token.email = profile.email
}
return token
},
async session({ session, token }) {
if (token.accessToken) {
session.accessToken = token.accessToken as string
}
return session
},
},
})
declare module 'next-auth' {
interface Session {
accessToken?: string
}
}

View File

@ -0,0 +1,187 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Grid3X3,
ClipboardCheck,
Settings,
Mail,
Globe,
Server,
LogOut,
Menu,
X,
} from 'lucide-react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Tools', href: '/tools', icon: Grid3X3 },
{ name: 'Setup', href: '/setup', icon: ClipboardCheck },
{
name: 'Settings',
href: '/settings',
icon: Settings,
children: [
{ name: 'Overview', href: '/settings', icon: Settings },
{ name: 'Email', href: '/settings/email', icon: Mail },
{ name: 'Domain', href: '/settings/domain', icon: Globe },
{ name: 'Server', href: '/settings/server', icon: Server },
],
},
]
interface SidebarProps {
userName?: string | null
userEmail?: string | null
}
export function Sidebar({ userName, userEmail }: SidebarProps) {
const pathname = usePathname()
const [mobileOpen, setMobileOpen] = useState(false)
const isActive = (href: string) => {
if (href === '/') return pathname === '/'
return pathname.startsWith(href)
}
const navContent = (
<>
<div className="flex h-16 items-center gap-2 border-b px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
LB
</div>
<div>
<p className="text-sm font-semibold">LetsBe</p>
<p className="text-xs text-muted-foreground">Control Panel</p>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
if (item.children) {
const parentActive = isActive(item.href)
return (
<div key={item.name}>
<Link
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
parentActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
{parentActive && (
<div className="ml-7 mt-1 space-y-1">
{item.children.map((child) => (
<Link
key={child.href}
href={child.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-1.5 text-sm transition-colors',
pathname === child.href
? 'text-primary font-medium'
: 'text-muted-foreground hover:text-accent-foreground'
)}
>
<child.icon className="h-3.5 w-3.5" />
{child.name}
</Link>
))}
</div>
)}
</div>
)
}
return (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.href)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{userName?.charAt(0)?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{userName || 'User'}</p>
<p className="text-xs text-muted-foreground truncate">{userEmail || ''}</p>
</div>
<form action="/api/auth/signout" method="POST">
<Button variant="ghost" size="icon" type="submit" title="Sign out">
<LogOut className="h-4 w-4" />
</Button>
</form>
</div>
</div>
</>
)
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center border-b bg-background px-4 lg:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<div className="ml-3 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-xs">
LB
</div>
<span className="text-sm font-semibold">LetsBe Control Panel</span>
</div>
</div>
{/* Mobile sidebar overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 flex-col bg-background border-r transition-transform duration-300 lg:hidden flex',
mobileOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{navContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-background">
{navContent}
</aside>
</>
)
}

View File

@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
ExternalLink,
CheckCircle2,
XCircle,
AlertCircle,
Cloud,
Shield,
MessageSquare,
Mail,
Container,
Workflow,
Database,
BarChart3,
Calendar,
FileText,
Globe,
Lock,
GitBranch,
Activity,
Send,
Table,
AlertTriangle,
Paintbrush,
} from 'lucide-react'
import type { ToolInfo } from '@/types'
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Cloud,
Shield,
MessageSquare,
Mail,
Container,
Workflow,
Database,
BarChart3,
Calendar,
FileText,
Globe,
Lock,
GitBranch,
Activity,
Send,
Table,
AlertTriangle,
Paintbrush,
}
function StatusIcon({ status }: { status: string }) {
switch (status) {
case 'running':
return <CheckCircle2 className="h-4 w-4 text-green-600" />
case 'stopped':
return <XCircle className="h-4 w-4 text-red-500" />
case 'error':
return <AlertCircle className="h-4 w-4 text-yellow-500" />
default:
return <AlertCircle className="h-4 w-4 text-muted-foreground" />
}
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case 'running':
return <Badge variant="success">Running</Badge>
case 'stopped':
return <Badge variant="destructive">Stopped</Badge>
case 'error':
return <Badge variant="warning">Error</Badge>
default:
return <Badge variant="secondary">Unknown</Badge>
}
}
interface ToolStatusGridProps {
tools: Omit<ToolInfo, 'status'>[]
}
export function ToolStatusGrid({ tools }: ToolStatusGridProps) {
const [toolStatuses, setToolStatuses] = useState<Record<string, string>>({})
useEffect(() => {
// In production, this would check each tool's health via the orchestrator proxy.
// For now, default all to 'running' as we can't check without the orchestrator.
const statuses: Record<string, string> = {}
tools.forEach((tool) => {
statuses[tool.id] = 'running'
})
setToolStatuses(statuses)
}, [tools])
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => {
const Icon = iconMap[tool.icon] || Globe
const status = toolStatuses[tool.id] || 'unknown'
return (
<Card key={tool.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium leading-tight">{tool.name}</p>
<p className="text-xs text-muted-foreground">{tool.subdomain}.domain</p>
</div>
</div>
<StatusIcon status={status} />
</div>
<p className="text-xs text-muted-foreground mb-3 line-clamp-1">
{tool.description}
</p>
<div className="flex items-center justify-between">
<StatusBadge status={status} />
<Button variant="ghost" size="sm" asChild className="h-7 text-xs">
<a href={tool.url} target="_blank" rel="noopener noreferrer">
Open
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@ -0,0 +1,59 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,48 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,40 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
warning:
'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,28 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,84 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuGroup,
}

View File

@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,26 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,90 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
side?: 'top' | 'bottom' | 'left' | 'right'
}
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
}

View File

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,28 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

67
src/lib/orchestrator.ts Normal file
View File

@ -0,0 +1,67 @@
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100'
export interface OrchestratorHealth {
status: string
uptime_seconds: number
cpu_percent: number
memory_percent: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
agent_count: number
task_count: number
}
export interface Agent {
id: string
name: string
status: string
last_heartbeat: string
capabilities: string[]
}
export interface Task {
id: string
type: string
status: string
created_at: string
completed_at: string | null
result: string | null
error: string | null
}
async function orchestratorFetch(path: string, options?: RequestInit) {
const url = `${ORCHESTRATOR_URL}${path}`
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`Orchestrator API error: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function getHealth(): Promise<OrchestratorHealth> {
return orchestratorFetch('/api/v1/health')
}
export async function getAgents(): Promise<Agent[]> {
return orchestratorFetch('/api/v1/agents')
}
export async function getTasks(limit = 20): Promise<Task[]> {
return orchestratorFetch(`/api/v1/tasks?limit=${limit}`)
}
export async function triggerPlaybook(playbook: string, params?: Record<string, string>) {
return orchestratorFetch(`/api/v1/playbooks/${playbook}`, {
method: 'POST',
body: JSON.stringify(params || {}),
})
}

168
src/lib/tools.ts Normal file
View File

@ -0,0 +1,168 @@
import type { ToolInfo } from '@/types'
const domain = process.env.TENANT_DOMAIN || 'example.com'
export const AVAILABLE_TOOLS: Omit<ToolInfo, 'status'>[] = [
{
id: 'nextcloud',
name: 'Nextcloud',
description: 'File sync, sharing, and collaboration platform',
subdomain: 'cloud',
icon: 'Cloud',
category: 'productivity',
url: `https://cloud.${domain}`,
},
{
id: 'keycloak',
name: 'Keycloak',
description: 'Identity and access management (SSO)',
subdomain: 'auth',
icon: 'Shield',
category: 'security',
url: `https://auth.${domain}`,
},
{
id: 'chatwoot',
name: 'Chatwoot',
description: 'Customer engagement and live chat platform',
subdomain: 'chat',
icon: 'MessageSquare',
category: 'communication',
url: `https://chat.${domain}`,
},
{
id: 'poste',
name: 'Poste.io',
description: 'Full-featured email server',
subdomain: 'mail',
icon: 'Mail',
category: 'communication',
url: `https://mail.${domain}`,
},
{
id: 'portainer',
name: 'Portainer',
description: 'Container management dashboard',
subdomain: 'portainer',
icon: 'Container',
category: 'monitoring',
url: `https://portainer.${domain}`,
},
{
id: 'n8n',
name: 'n8n',
description: 'Workflow automation tool',
subdomain: 'n8n',
icon: 'Workflow',
category: 'productivity',
url: `https://n8n.${domain}`,
},
{
id: 'minio',
name: 'MinIO',
description: 'S3-compatible object storage',
subdomain: 'storage',
icon: 'Database',
category: 'storage',
url: `https://storage.${domain}`,
},
{
id: 'umami',
name: 'Umami',
description: 'Privacy-focused web analytics',
subdomain: 'analytics',
icon: 'BarChart3',
category: 'monitoring',
url: `https://analytics.${domain}`,
},
{
id: 'calcom',
name: 'Cal.com',
description: 'Scheduling and appointment booking',
subdomain: 'cal',
icon: 'Calendar',
category: 'productivity',
url: `https://cal.${domain}`,
},
{
id: 'ghost',
name: 'Ghost',
description: 'Publishing and newsletter platform',
subdomain: 'blog',
icon: 'FileText',
category: 'marketing',
url: `https://blog.${domain}`,
},
{
id: 'wordpress',
name: 'WordPress',
description: 'Content management system',
subdomain: 'www',
icon: 'Globe',
category: 'marketing',
url: `https://www.${domain}`,
},
{
id: 'vaultwarden',
name: 'Vaultwarden',
description: 'Password manager (Bitwarden compatible)',
subdomain: 'vault',
icon: 'Lock',
category: 'security',
url: `https://vault.${domain}`,
},
{
id: 'gitea',
name: 'Gitea',
description: 'Self-hosted Git service',
subdomain: 'git',
icon: 'GitBranch',
category: 'development',
url: `https://git.${domain}`,
},
{
id: 'uptime-kuma',
name: 'Uptime Kuma',
description: 'Uptime monitoring tool',
subdomain: 'status',
icon: 'Activity',
category: 'monitoring',
url: `https://status.${domain}`,
},
{
id: 'listmonk',
name: 'Listmonk',
description: 'Newsletter and mailing list manager',
subdomain: 'newsletter',
icon: 'Send',
category: 'marketing',
url: `https://newsletter.${domain}`,
},
{
id: 'nocodb',
name: 'NocoDB',
description: 'Open-source Airtable alternative',
subdomain: 'db',
icon: 'Table',
category: 'productivity',
url: `https://db.${domain}`,
},
{
id: 'glitchtip',
name: 'GlitchTip',
description: 'Error tracking and monitoring',
subdomain: 'errors',
icon: 'AlertTriangle',
category: 'monitoring',
url: `https://errors.${domain}`,
},
{
id: 'penpot',
name: 'Penpot',
description: 'Design and prototyping platform',
subdomain: 'design',
icon: 'Paintbrush',
category: 'productivity',
url: `https://design.${domain}`,
},
]

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

7
src/middleware.ts Normal file
View File

@ -0,0 +1,7 @@
export { auth as middleware } from '@/auth'
export const config = {
matcher: [
'/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)',
],
}

45
src/types/index.ts Normal file
View File

@ -0,0 +1,45 @@
export interface ToolInfo {
id: string
name: string
description: string
subdomain: string
icon: string
category: 'communication' | 'productivity' | 'development' | 'monitoring' | 'storage' | 'marketing' | 'security' | 'other'
status: 'running' | 'stopped' | 'error' | 'unknown'
url: string
}
export interface SetupStep {
id: string
title: string
description: string
completed: boolean
href?: string
action?: string
}
export interface ServerInfo {
hostname: string
ip: string
os: string
uptime: string
cpuUsage: number
memoryUsage: number
diskUsage: number
diskUsedGb: number
diskTotalGb: number
}
export interface SmtpConfig {
host: string
port: number
username: string
password: string
fromEmail: string
fromName: string
}
export interface DomainInfo {
domain: string
subdomains: { name: string; target: string; status: 'active' | 'pending' | 'error' }[]
}

57
tailwind.config.ts Normal file
View File

@ -0,0 +1,57 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
}
export default config

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"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,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}