'use client' import { useEffect, useMemo, useState } from 'react' import { Plus, Server, FlaskConical, Clock, AlertTriangle, Timer, Loader2, ExternalLink, Ban, Play, Pause, Square, RotateCcw, CalendarPlus, Trash2, ChevronRight, CheckCircle2, XCircle, CircleDashed, Network, RefreshCw, } from 'lucide-react' import { useFetch } from '@/hooks/use-fetch' import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import SlideCommit from '@/components/SlideCommit' import { PageHeader } from '../primitives' import { formatDate, formatRelative, daysUntil } from '@/lib/format' import { cn } from '@/lib/utils' import { toast } from 'sonner' // --------------------------------------------------------------------------- // Types — mirror the Prisma Instance shape returned by the API // --------------------------------------------------------------------------- type InstanceStatus = | 'requested' | 'provisioning' | 'demo_active' | 'active' | 'grace' | 'deleting' | 'deleted' | 'failed' interface Instance { id: string licenseId: string | null companyId: string product: string status: InstanceStatus | string plan: string subdomain: string serverId: string demoExpiresAt: string | null graceEndsAt: string | null deletedAt: string | null customDomain: string | null domainVerificationToken: string | null domainVerifiedAt: string | null dokployAppId: string | null dokployDbId: string | null dokployDbName: string | null dokployDbUser: string | null dokployDbPassword: string | null dnsRecordId: string | null adminLoginToken: string | null adminLoginExpiresAt: string | null metadata: string | null lastError: string | null lastErrorAt: string | null createdAt: string updatedAt: string company: { id: string; name: string; email: string | null } license: { id: string key: string product: string plan: string billingCycle: string netPriceCents: number status: string cancelledAt: string | null } | null _count: { events: number; jobs: number } } interface InstanceEvent { id: string instanceId: string type: string step: string | null result: 'ok' | 'error' | 'pending' | string details: string | null createdAt: string } interface ProvisioningJob { id: string instanceId: string kind: string step: string attempts: number maxAttempts: number lastError: string | null status: string nextRunAt: string createdAt: string updatedAt: string } interface InstanceDetail extends Instance { events: InstanceEvent[] jobs: ProvisioningJob[] } interface InstanceStats { total: number provisioning: number demoActive: number active: number grace: number deleting: number deleted: number failed: number expiringSoon: number } interface InstancesData { instances: Instance[] stats: InstanceStats } // Minimal company + license shapes needed for the "Neue Instanz" dialog. interface CompanyLite { id: string name: string email?: string | null } interface LicenseLite { id: string key: string product: string plan: string status: string company: { id: string; name: string } } interface LicensesData { licenses: LicenseLite[] } interface CrmData { companies: CompanyLite[] } // --------------------------------------------------------------------------- // Status badge config — border-foreground/40 for active/demo, /30 for warnings // --------------------------------------------------------------------------- interface StatusMeta { label: string spinner?: boolean borderClass: string } const STATUS_META: Record = { requested: { label: 'Angefragt', borderClass: 'border-foreground/30' }, provisioning: { label: 'Provisioning…', spinner: true, borderClass: 'border-foreground/30' }, demo_active: { label: 'Demo', borderClass: 'border-foreground/40' }, active: { label: 'Aktiv', borderClass: 'border-foreground/40' }, grace: { label: 'Grace', borderClass: 'border-foreground/30' }, deleting: { label: 'Wird gelöscht…', spinner: true, borderClass: 'border-foreground/30' }, deleted: { label: 'Gelöscht', borderClass: 'border-foreground/20' }, failed: { label: 'Fehlgeschlagen', borderClass: 'border-foreground/30' }, } function StatusBadge({ status, lastError }: { status: string; lastError?: string | null }) { const meta = STATUS_META[status] ?? { label: status, borderClass: 'border-foreground/30' } const badge = ( {meta.spinner && } {meta.label} ) if (status === 'failed' && lastError) { return ( {badge} {lastError} ) } return badge } // --------------------------------------------------------------------------- // Plan label // --------------------------------------------------------------------------- const PLAN_LABELS: Record = { starter: 'Starter', pro: 'Pro', enterprise: 'Enterprise', } // --------------------------------------------------------------------------- // Main view // --------------------------------------------------------------------------- export function InstancesView() { const { data, loading, refetch } = useFetch('/api/aos/instances') const [createOpen, setCreateOpen] = useState(false) const [detailId, setDetailId] = useState(null) if (loading || !data) { return (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) } const { instances, stats } = data return (
setCreateOpen(true)}> Neue Instanz } /> {/* Stats row — 5 cards */}
0} />
{/* Table */} {instances.length === 0 ? (

Keine Instanzen

Erstelle die erste mit „Neue Instanz“.

) : (
Subdomain Produkt / Plan Kunde Status Demo bis Aktionen {instances.map((inst) => ( setDetailId(inst.id)} /> ))}
)} {/* Detail Sheet */} !o && setDetailId(null)} onMutated={refetch} /> {/* Create dialog */} i.status !== 'deleted' && i.licenseId) .map((i) => i.licenseId as string) ) } onCreated={refetch} />
) } // --------------------------------------------------------------------------- // Stat card // --------------------------------------------------------------------------- function StatCard({ icon: Icon, label, value, sub, emphasis, }: { icon: typeof Server label: string value: number sub?: string emphasis?: boolean }) { return (

{label}

{value}

{sub &&

{sub}

}
) } // --------------------------------------------------------------------------- // Instance row // --------------------------------------------------------------------------- function InstanceRow({ instance, onOpen, }: { instance: Instance onOpen: () => void }) { const fullDomain = `${instance.subdomain}.dfmpanel.eu` const demoActive = instance.status === 'demo_active' && instance.demoExpiresAt const demoExpired = instance.demoExpiresAt && new Date(instance.demoExpiresAt) < new Date() return (
{instance.product} {PLAN_LABELS[instance.plan] ?? instance.plan}
{instance.company?.name ?? '—'} {instance.company?.email && ( {instance.company.email} )}
{instance.demoExpiresAt ? (
{formatDate(instance.demoExpiresAt)} {demoActive ? formatRelative(instance.demoExpiresAt) : 'beendet'}
) : ( — )}
) } // --------------------------------------------------------------------------- // Instance detail sheet (right side) // --------------------------------------------------------------------------- function InstanceDetailSheet({ instanceId, onOpenChange, onMutated, }: { instanceId: string | null onOpenChange: (o: boolean) => void onMutated: () => void }) { const open = !!instanceId const { data, loading, refetch } = useFetch( instanceId ? `/api/aos/instances/${instanceId}` : null ) const [busyAction, setBusyAction] = useState(null) const [confirmingDelete, setConfirmingDelete] = useState(false) // Reset transient sheet state when closing useEffect(() => { if (!open) { setBusyAction(null) setConfirmingDelete(false) } }, [open]) const handleAction = async ( action: 'suspend' | 'reactivate' | 'extend' | 'retry' | 'redeploy' | 'stop' | 'start', label: string, extendDays?: number ) => { if (!instanceId) return setBusyAction(action) const t = toast.loading(`${label}…`) try { const body: Record = { action } if (extendDays !== undefined) body.extendDays = extendDays const r = await fetch(`/api/aos/instances/${instanceId}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) const res = await r.json() if (!r.ok) throw new Error(res.error || 'action failed') toast.success(`${label} erfolgreich`, { id: t }) refetch() onMutated() } catch (err) { toast.error(err instanceof Error ? err.message : `${label} fehlgeschlagen`, { id: t }) } finally { setBusyAction(null) } } const handleDelete = async () => { if (!instanceId) return setBusyAction('delete') const t = toast.loading('Instanz wird gelöscht…') try { const r = await fetch(`/api/aos/instances/${instanceId}`, { method: 'DELETE' }) const res = await r.json() if (!r.ok) throw new Error(res.error || 'delete failed') const steps = Array.isArray(res.steps) ? res.steps : [] toast.success(`Instanz gelöscht — ${steps.length} Schritte ausgeführt`, { id: t, duration: 6000, }) onOpenChange(false) onMutated() } catch (err) { toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t }) } finally { setBusyAction(null) } } // Parse metadata for Dokploy resource IDs const meta = useMemo(() => { if (!data?.metadata) return {} try { return JSON.parse(data.metadata) as Record } catch { return {} } }, [data?.metadata]) const instance = data const status = instance?.status ?? '' const fullDomain = instance ? `${instance.subdomain}.dfmpanel.eu` : '' return ( {instance ? instance.subdomain : 'Instanz'} {fullDomain}
{loading || !instance ? (
) : (
{/* Top: status + IDs */}
{instance.plan && ( {PLAN_LABELS[instance.plan] ?? instance.plan} )} {instance.product}
{instance.lastError && (

Fehler: {instance.lastError}

)}
{/* Instance facts */}

Instanz

{instance.id}} /> {fullDomain} } /> {instance.customDomain && ( {instance.customDomain} {instance.domainVerifiedAt ? ( ) : ( )} } /> )}
{/* License info */} {instance.license && (

Lizenz

{maskLicenseKey(instance.license.key)} } /> {instance.license.status} } />
)} {/* Dokploy resource IDs */}

Dokploy-Resourcen

{instance.dokployAppId ?? '—'}} /> {instance.dokployDbId ?? '—'}} /> {(meta.dokployDomainId as string) ?? '—'} } /> {instance.dnsRecordId ?? '—'}} />
{/* Codebase Graph (Graphify) */}

Codebase-Graph (Graphify)

{(() => { const graphEvents = instance.events.filter( (e) => e.type.startsWith('graphify.') ) const buildQueued = graphEvents.some( (e) => e.type === 'graphify.build.queued' ) const buildDone = graphEvents.some( (e) => e.type === 'graphify.build.ok' ) const buildFailed = graphEvents.some( (e) => e.type === 'graphify.build.error' ) if (buildDone) { return (
Graph erstellt — Support-AI kann Codebasis analysieren.
) } if (buildFailed) { return (
Graph-Erstellung fehlgeschlagen. Siehe Ereignisverlauf.
) } if (buildQueued) { return (
Graph wird im Hintergrund erstellt…
) } return (
Kein Graphify-Graph vorhanden.
) })()}

Der Codebase-Graph ermöglicht der Support-AI, relevante Code-Stellen zu finden, wenn ein Ticket erstellt wird.

{/* Status timeline (events) */}

Ereignisverlauf

{instance.events.length === 0 ? (

Keine Ereignisse protokolliert.

) : (
    {instance.events.map((ev) => ( ))}
)}
{/* Actions */}

Aktionen

{confirmingDelete ? (

Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge und Volumes werden entfernt (die Datenbank-Zeile bleibt für das Audit-Log erhalten).

) : ( <> handleAction('suspend', 'Suspendieren')} onReactivate={() => handleAction('reactivate', 'Starten')} onExtend={() => handleAction('extend', 'Demo verlängern', 7)} onRetry={() => handleAction('retry', 'Provisioning wiederholen')} onRedeploy={() => handleAction('redeploy', 'Redeploy')} onStop={() => handleAction('stop', 'Stop')} onStart={() => handleAction('start', 'Start')} onDelete={() => setConfirmingDelete(true)} /> {['demo_active', 'active'].includes(status) && ( )} )}
)}
) } function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { return (
{label}
{value}
) } function maskLicenseKey(key: string): string { if (!key) return '—' if (key.length <= 12) return key return `${key.slice(0, 8)}…${key.slice(-4)}` } // --------------------------------------------------------------------------- // Event row — collapsible JSON details // --------------------------------------------------------------------------- function EventRow({ event }: { event: InstanceEvent }) { const [open, setOpen] = useState(false) const result = event.result const tone = result === 'ok' ? 'border-foreground/40 text-foreground' : result === 'error' ? 'border-foreground/30 bg-muted text-foreground' : 'border-foreground/30 text-muted-foreground' const Icon = result === 'ok' ? CheckCircle2 : result === 'error' ? XCircle : CircleDashed let pretty = event.details ?? '' try { if (pretty) pretty = JSON.stringify(JSON.parse(pretty), null, 2) } catch { // not JSON, keep raw } return (
  • {event.result} {event.type} {event.step && ( · {event.step} )} {formatRelative(event.createdAt)}
    {pretty && (
                  {pretty}
                
    )}
  • ) } // --------------------------------------------------------------------------- // Actions row — depends on status // --------------------------------------------------------------------------- function ActionsRow({ status, busyAction, onSuspend, onReactivate, onExtend, onRetry, onRedeploy, onStop, onStart, onDelete, }: { status: string busyAction: string | null onSuspend: () => void onReactivate: () => void onExtend: () => void onRetry: () => void onRedeploy: () => void onStop: () => void onStart: () => void onDelete: () => void }) { const busy = busyAction !== null const btn = (props: { onClick: () => void label: string icon: typeof Ban variant?: 'default' | 'outline' | 'destructive' | 'ghost' loading?: boolean }) => { const Icon = props.icon return ( ) } if (status === 'demo_active' || status === 'active') { return (
    {btn({ onClick: onRedeploy, label: 'Redeploy', icon: RefreshCw, loading: busyAction === 'redeploy', })} {btn({ onClick: onStop, label: 'Stop', icon: Square, loading: busyAction === 'stop', })} {status === 'demo_active' && btn({ onClick: onExtend, label: 'Demo +7d', icon: CalendarPlus, loading: busyAction === 'extend', })} {btn({ onClick: onDelete, label: 'Remove', icon: Trash2, variant: 'destructive', })}
    ) } if (status === 'grace') { return (
    {btn({ onClick: onReactivate, label: 'Start', icon: Play, loading: busyAction === 'reactivate', })} {btn({ onClick: onDelete, label: 'Remove', icon: Trash2, variant: 'destructive', })}
    ) } if (status === 'failed') { return (
    {btn({ onClick: onRetry, label: 'Retry', icon: RotateCcw, loading: busyAction === 'retry', })} {btn({ onClick: onDelete, label: 'Remove', icon: Trash2, variant: 'destructive', })}
    ) } return null } // --------------------------------------------------------------------------- // Create Instance Dialog // --------------------------------------------------------------------------- function CreateInstanceDialog({ open, onOpenChange, existingLicenseIds, onCreated, }: { open: boolean onOpenChange: (o: boolean) => void existingLicenseIds: Set onCreated: () => void }) { const { data: crmData } = useFetch('/api/aos/crm') const { data: licensesData } = useFetch('/api/aos/licenses') const [companyId, setCompanyId] = useState('') const [licenseId, setLicenseId] = useState('') const [product, setProduct] = useState('restov1') const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') const [gitRepo, setGitRepo] = useState('https://github.com/cetinmustafa83/restov1') const [gitBranch, setGitBranch] = useState('main') const [isDemo, setIsDemo] = useState(true) const [saving, setSaving] = useState(false) const companies = useMemo( () => (crmData?.companies ?? []) .slice() .sort((a, b) => a.name.localeCompare(b.name)), [crmData?.companies] ) // Filter licenses: same company, no existing live instance. const availableLicenses = useMemo(() => { const all = licensesData?.licenses ?? [] return all .filter((l) => l.company?.id === companyId) .filter((l) => !existingLicenseIds.has(l.id)) .filter((l) => l.status !== 'cancelled') }, [licensesData?.licenses, companyId, existingLicenseIds]) // Pick first company when opening useEffect(() => { if (open && companies[0] && !companyId) setCompanyId(companies[0].id) }, [open, companies, companyId]) // Auto-select the first available license when company changes useEffect(() => { if (availableLicenses[0] && !availableLicenses.find((l) => l.id === licenseId)) { setLicenseId(availableLicenses[0].id) } if (availableLicenses.length === 0) setLicenseId('') }, [availableLicenses, licenseId]) // Reset form when closing useEffect(() => { if (open) return setCompanyId('') setLicenseId('') setProduct('restov1') setPlan('pro') setGitRepo('https://github.com/cetinmustafa83/restov1') setGitBranch('main') setIsDemo(true) }, [open]) const submit = async () => { if (!companyId) { toast.error('Bitte Firma auswählen'); return } if (!licenseId) { toast.error('Bitte Lizenz auswählen'); return } if (!product.trim()) { toast.error('Produkt eingeben'); return } setSaving(true) const t = toast.loading('Instanz wird provisioniert (1–3 Min)…') try { const r = await fetch('/api/aos/instances', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ companyId, licenseId, product: product.trim(), plan, gitRepo: gitRepo.trim(), gitBranch: gitBranch.trim(), isDemo, }), }) const res = await r.json() if (!r.ok) throw new Error(res.error || 'create failed') toast.success(`Instanz wird provisioniert — ${res.url ?? res.subdomain}`, { id: t, duration: 8000, }) onOpenChange(false) onCreated() } catch (err) { toast.error(err instanceof Error ? err.message : 'Provisioning fehlgeschlagen', { id: t }) } finally { setSaving(false) } } return ( Neue Instanz Stellt eine neue Tenant-Instanz bereit. Provisioning via Dokploy + Hetzner DNS (~1–3 Min). Die Instanz läuft unter <subdomain>.dfmpanel.eu.
    {/* Kunde */}
    {/* Lizenz */}

    Nur Lizenzen ohne bestehende Instanz. Gekündigte Lizenzen sind nicht verfügbar.

    {/* Produkt + Plan */}
    setProduct(e.target.value)} />
    {/* Git */}
    setGitRepo(e.target.value)} />
    setGitBranch(e.target.value)} />
    {/* Demo Switch */}

    14 Tage kostenlos. Bei Abbruch wird die Instanz automatisch gelöscht.

    ) }