letsbe-hub/src/components/admin/ToolsEditor.tsx

303 lines
11 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useMemo } from 'react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Loader2,
Pencil,
X,
Check,
Package,
CheckSquare,
Square,
} from 'lucide-react'
// All available tools from the stacks folder
export const ALL_TOOLS: Record<string, { name: string; description: string; category: string }> = {
// Core Infrastructure
orchestrator: { name: 'Orchestrator', description: 'LetsBe control plane API', category: 'Core' },
sysadmin: { name: 'SysAdmin Agent', description: 'Remote automation worker', category: 'Core' },
portainer: { name: 'Portainer', description: 'Container management UI', category: 'Core' },
// Communication
poste: { name: 'Poste.io', description: 'Email server with webmail', category: 'Communication' },
chatwoot: { name: 'Chatwoot', description: 'Customer engagement platform', category: 'Communication' },
listmonk: { name: 'Listmonk', description: 'Newsletter & mailing list manager', category: 'Communication' },
// File Storage & Collaboration
nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration', category: 'Files' },
minio: { name: 'MinIO', description: 'S3-compatible object storage', category: 'Files' },
documenso: { name: 'Documenso', description: 'Document signing platform', category: 'Files' },
// Identity & Security
keycloak: { name: 'Keycloak', description: 'Identity & access management', category: 'Security' },
vaultwarden: { name: 'Vaultwarden', description: 'Password manager (Bitwarden)', category: 'Security' },
// Automation & Workflows
n8n: { name: 'n8n', description: 'Workflow automation', category: 'Automation' },
activepieces: { name: 'Activepieces', description: 'No-code automation platform', category: 'Automation' },
windmill: { name: 'Windmill', description: 'Developer-first workflows', category: 'Automation' },
typebot: { name: 'Typebot', description: 'Conversational app builder', category: 'Automation' },
// Development
gitea: { name: 'Gitea', description: 'Git server with web UI', category: 'Development' },
'gitea-drone': { name: 'Drone CI', description: 'Continuous integration with Gitea', category: 'Development' },
// Databases & Analytics
nocodb: { name: 'NocoDB', description: 'Airtable alternative database UI', category: 'Data' },
redash: { name: 'Redash', description: 'Data visualization & dashboards', category: 'Data' },
umami: { name: 'Umami', description: 'Privacy-focused web analytics', category: 'Data' },
// AI & Chat
librechat: { name: 'LibreChat', description: 'ChatGPT-style AI interface', category: 'AI' },
// CMS & Content
ghost: { name: 'Ghost', description: 'Publishing platform & blog', category: 'Content' },
wordpress: { name: 'WordPress', description: 'Content management system', category: 'Content' },
squidex: { name: 'Squidex', description: 'Headless CMS', category: 'Content' },
// Business Tools
calcom: { name: 'Cal.com', description: 'Scheduling & calendar booking', category: 'Business' },
odoo: { name: 'Odoo', description: 'ERP & business apps suite', category: 'Business' },
penpot: { name: 'Penpot', description: 'Design & prototyping platform', category: 'Business' },
// Monitoring & Maintenance
glitchtip: { name: 'GlitchTip', description: 'Error tracking (Sentry alt)', category: 'Monitoring' },
'uptime-kuma': { name: 'Uptime Kuma', description: 'Uptime monitoring', category: 'Monitoring' },
'diun-watchtower': { name: 'Diun/Watchtower', description: 'Container update notifications', category: 'Monitoring' },
// Other
html: { name: 'Static HTML', description: 'Simple static website hosting', category: 'Other' },
}
// Default tools that are always recommended
export const DEFAULT_TOOLS = ['html', 'portainer', 'diun-watchtower']
// Tool dependencies: selecting a tool automatically selects its dependencies
const TOOL_DEPENDENCIES: Record<string, string[]> = {
'gitea': ['gitea-drone'], // Gitea requires Drone CI
'ghost': ['html'], // CMS tools require HTML
'wordpress': ['html'],
'squidex': ['html'],
}
// Category order for display
const CATEGORY_ORDER = [
'Core',
'Communication',
'Files',
'Security',
'Automation',
'Development',
'Data',
'AI',
'Content',
'Business',
'Monitoring',
'Other',
]
interface ToolsEditorProps {
tools: string[]
onSave: (tools: string[]) => Promise<void>
isEditable: boolean
isSaving?: boolean
}
export function ToolsEditor({ tools, onSave, isEditable, isSaving = false }: ToolsEditorProps) {
const [isEditing, setIsEditing] = useState(false)
const [selectedTools, setSelectedTools] = useState<string[]>(tools)
const [saving, setSaving] = useState(false)
// Group tools by category
const toolsByCategory = useMemo(() => {
const grouped: Record<string, string[]> = {}
for (const [key, info] of Object.entries(ALL_TOOLS)) {
if (!grouped[info.category]) {
grouped[info.category] = []
}
grouped[info.category].push(key)
}
return grouped
}, [])
const handleToggleTool = (tool: string) => {
setSelectedTools((prev) => {
if (prev.includes(tool)) {
// Removing a tool - also remove tools that depend on it
const newTools = prev.filter((t) => t !== tool)
// If removing gitea, also remove gitea-drone
if (tool === 'gitea') {
newTools = newTools.filter((t) => t !== 'gitea-drone')
}
return newTools
} else {
// Adding a tool - also add its dependencies
const newTools = [...prev, tool]
const deps = TOOL_DEPENDENCIES[tool]
if (deps) {
deps.forEach((dep) => {
if (!newTools.includes(dep)) {
newTools.push(dep)
}
})
}
return newTools
}
})
}
const handleSave = async () => {
setSaving(true)
try {
await onSave(selectedTools)
setIsEditing(false)
} catch (error) {
console.error('Failed to save tools:', error)
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setSelectedTools(tools)
setIsEditing(false)
}
// Display mode - just show selected tools
if (!isEditing) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Selected Tools
</CardTitle>
<CardDescription>Tools to be deployed on this server</CardDescription>
</div>
{isEditable && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit Tools
</Button>
)}
</CardHeader>
<CardContent>
{tools.length === 0 ? (
<p className="text-muted-foreground text-sm">No tools selected</p>
) : (
<div className="flex flex-wrap gap-2">
{tools.map((tool) => {
const toolInfo = ALL_TOOLS[tool]
return (
<span
key={tool}
className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium"
title={toolInfo?.description}
>
{toolInfo?.name || tool}
</span>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
// Edit mode - show all tools grouped by category with checkboxes
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Edit Tools
</CardTitle>
<CardDescription>
Select which tools to deploy ({selectedTools.length} selected)
</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancel} disabled={saving}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button variant="outline" size="sm" onClick={() => setSelectedTools(Object.keys(ALL_TOOLS))} disabled={saving}>
Select All
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || selectedTools.length === 0}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="max-h-[500px] overflow-y-auto space-y-6 pr-2">
{CATEGORY_ORDER.map((category) => {
const categoryTools = toolsByCategory[category]
if (!categoryTools) return null
return (
<div key={category}>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 sticky top-0 bg-white py-1">
{category}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{categoryTools.map((toolKey) => {
const tool = ALL_TOOLS[toolKey]
const isSelected = selectedTools.includes(toolKey)
return (
<button
key={toolKey}
type="button"
onClick={() => handleToggleTool(toolKey)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors text-left w-full ${
isSelected
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{isSelected ? (
<CheckSquare className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
) : (
<Square className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{tool.name}</div>
<div className="text-xs text-muted-foreground truncate">
{tool.description}
</div>
</div>
</button>
)
})}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}