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:
commit
5ba6a3434a
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { handlers } from '@/auth'
|
||||
export const { GET, POST } = handlers
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "v=spf1 a mx ip4:YOUR_SERVER_IP ~all"</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "Open" to go to each tool'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 || {}),
|
||||
})
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export { auth as middleware } from '@/auth'
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
|
|
@ -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' }[]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue