2026-01-17 12:33:11 +01:00
|
|
|
'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
|
2026-01-17 12:45:54 +01:00
|
|
|
let newTools = prev.filter((t) => t !== tool)
|
2026-01-17 12:33:11 +01:00
|
|
|
// 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
|
2026-01-17 12:42:29 +01:00
|
|
|
const newTools = [...prev, tool]
|
2026-01-17 12:33:11 +01:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|