Files
AOS/src/components/aos/views/instances.tsx
T
Z User 590310ca95 fix: instance detail crash + add play/stop/redeploy/remove actions
Fixes:
1. useFetch: add credentials:'same-origin' — cookie wasn't being sent
   with API calls, causing 401 → React error boundary → 'Etwas ist schiefgelaufen'
2. Instance detail Sheet now loads correctly (cookie sent with request)

New instance actions (UI + API):
- Redeploy: triggers Dokploy application.redeploy (rebuilds from latest commit)
- Stop: triggers Dokploy application.stop (pauses container)
- Start: triggers Dokploy application.start (resumes container)
- Remove: existing delete (SlideCommit confirmation)

Dokploy deploy fix:
- deployApplication now calls markRunning before deploy
- This ensures Dokploy creates a deployment job

ActionsRow redesigned:
- demo_active/active: [Redeploy] [Stop] [Demo +7d] [Remove]
- grace: [Start] [Remove]
- failed: [Retry] [Remove]
- provisioning/deleting/deleted: no actions
2026-10-29 14:07:51 +00:00

1381 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, StatusMeta> = {
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 = (
<Badge variant="outline" className={cn('gap-1', meta.borderClass)}>
{meta.spinner && <Loader2 className="size-3 animate-spin" />}
{meta.label}
</Badge>
)
if (status === 'failed' && lastError) {
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-pre-wrap text-left">
{lastError}
</TooltipContent>
</Tooltip>
)
}
return badge
}
// ---------------------------------------------------------------------------
// Plan label
// ---------------------------------------------------------------------------
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
pro: 'Pro',
enterprise: 'Enterprise',
}
// ---------------------------------------------------------------------------
// Main view
// ---------------------------------------------------------------------------
export function InstancesView() {
const { data, loading, refetch } = useFetch<InstancesData>('/api/aos/instances')
const [createOpen, setCreateOpen] = useState(false)
const [detailId, setDetailId] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-48" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64" />
</div>
)
}
const { instances, stats } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Instanzen"
description="Tenant-Instanzen der AOS-Produkte auf dem Dokploy-Server. Provisioning, Demo-Phasen, Grace-Modus und Teardown zentral gesteuert."
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Neue Instanz
</Button>
}
/>
{/* Stats row — 5 cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<StatCard
icon={Server}
label="Aktive Instanzen"
value={stats.active}
/>
<StatCard
icon={FlaskConical}
label="Demo aktiv"
value={stats.demoActive}
sub="14-Tage-Testphase"
/>
<StatCard
icon={Clock}
label="In Grace"
value={stats.grace}
sub="Suspendiert, löschbar"
/>
<StatCard
icon={AlertTriangle}
label="Fehlgeschlagen"
value={stats.failed}
sub="Manuelle Prüfung"
emphasis={stats.failed > 0}
/>
<StatCard
icon={Timer}
label="Läuft ab (48h)"
value={stats.expiringSoon}
/>
</div>
{/* Table */}
{instances.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
<Server className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Keine Instanzen</p>
<p className="text-xs text-muted-foreground">
Erstelle die erste mit „Neue Instanz“.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Neue Instanz
</Button>
</div>
) : (
<div className="rounded-lg border border-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Subdomain</TableHead>
<TableHead>Produkt / Plan</TableHead>
<TableHead>Kunde</TableHead>
<TableHead>Status</TableHead>
<TableHead>Demo bis</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{instances.map((inst) => (
<InstanceRow
key={inst.id}
instance={inst}
onOpen={() => setDetailId(inst.id)}
/>
))}
</TableBody>
</Table>
</div>
)}
{/* Detail Sheet */}
<InstanceDetailSheet
instanceId={detailId}
onOpenChange={(o) => !o && setDetailId(null)}
onMutated={refetch}
/>
{/* Create dialog */}
<CreateInstanceDialog
open={createOpen}
onOpenChange={setCreateOpen}
existingLicenseIds={
new Set(
instances
.filter((i) => i.status !== 'deleted' && i.licenseId)
.map((i) => i.licenseId as string)
)
}
onCreated={refetch}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
icon: Icon,
label,
value,
sub,
emphasis,
}: {
icon: typeof Server
label: string
value: number
sub?: string
emphasis?: boolean
}) {
return (
<Card
className={cn(
'gap-0 px-4 py-3',
emphasis && 'border-foreground/30'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="truncate text-xs font-medium text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
{sub && <p className="text-[11px] text-muted-foreground">{sub}</p>}
</div>
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md border',
emphasis ? 'border-foreground/30 text-foreground' : 'border-border text-muted-foreground'
)}
>
<Icon className="size-4" />
</div>
</div>
</Card>
)
}
// ---------------------------------------------------------------------------
// 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 (
<TableRow
onClick={onOpen}
className="cursor-pointer"
data-state="selected"
>
<TableCell>
<div className="flex flex-col gap-1">
<a
href={`https://${fullDomain}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{fullDomain}
<ExternalLink className="size-3 text-muted-foreground" />
</a>
{instance.customDomain && (
<span className="text-[11px] text-muted-foreground">{instance.customDomain}</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{instance.product}</span>
<span className="text-[11px] text-muted-foreground">
{PLAN_LABELS[instance.plan] ?? instance.plan}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm">{instance.company?.name ?? '—'}</span>
{instance.company?.email && (
<span className="text-[11px] text-muted-foreground">{instance.company.email}</span>
)}
</div>
</TableCell>
<TableCell>
<StatusBadge status={instance.status} lastError={instance.lastError} />
</TableCell>
<TableCell>
{instance.demoExpiresAt ? (
<div className="flex flex-col gap-0.5">
<span className="text-sm">{formatDate(instance.demoExpiresAt)}</span>
<span
className={cn(
'text-[11px]',
demoExpired ? 'text-muted-foreground' : 'text-muted-foreground'
)}
>
{demoActive ? formatRelative(instance.demoExpiresAt) : 'beendet'}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
>
Details
<ChevronRight className="size-3.5" />
</Button>
</TableCell>
</TableRow>
)
}
// ---------------------------------------------------------------------------
// 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<InstanceDetail>(
instanceId ? `/api/aos/instances/${instanceId}` : null
)
const [busyAction, setBusyAction] = useState<string | null>(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<string, unknown> = { 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<string, unknown>
} catch {
return {}
}
}, [data?.metadata])
const instance = data
const status = instance?.status ?? ''
const fullDomain = instance ? `${instance.subdomain}.dfmpanel.eu` : ''
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full gap-0 p-0 sm:max-w-xl md:max-w-2xl"
>
<SheetHeader className="border-b border-border px-6 py-4">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
{instance ? instance.subdomain : 'Instanz'}
</SheetTitle>
<SheetDescription>
{fullDomain}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading || !instance ? (
<div className="space-y-4">
<Skeleton className="h-20" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
) : (
<div className="space-y-6">
{/* Top: status + IDs */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<StatusBadge status={status} lastError={instance.lastError} />
{instance.plan && (
<Badge variant="outline" className="border-foreground/20">
{PLAN_LABELS[instance.plan] ?? instance.plan}
</Badge>
)}
<Badge variant="outline" className="border-foreground/20">
{instance.product}
</Badge>
</div>
{instance.lastError && (
<p className="rounded-md border border-foreground/30 bg-muted/40 px-3 py-2 text-xs text-foreground">
<span className="font-medium">Fehler:</span> {instance.lastError}
</p>
)}
</div>
{/* Instance facts */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Instanz
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<DetailRow label="ID" value={<code className="text-xs">{instance.id}</code>} />
<DetailRow
label="URL"
value={
<a
href={`https://${fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:underline"
>
{fullDomain}
<ExternalLink className="size-3 text-muted-foreground" />
</a>
}
/>
<DetailRow label="Produkt" value={instance.product} />
<DetailRow label="Plan" value={PLAN_LABELS[instance.plan] ?? instance.plan} />
<DetailRow
label="Demo bis"
value={
instance.demoExpiresAt
? `${formatDate(instance.demoExpiresAt)} (${daysUntil(instance.demoExpiresAt)} d)`
: '—'
}
/>
<DetailRow
label="Grace bis"
value={instance.graceEndsAt ? formatDate(instance.graceEndsAt) : '—'}
/>
<DetailRow label="Erstellt" value={formatDate(instance.createdAt)} />
<DetailRow
label="Gelöscht"
value={instance.deletedAt ? formatDate(instance.deletedAt) : '—'}
/>
{instance.customDomain && (
<DetailRow
label="Custom Domain"
value={
<span className="flex items-center gap-1.5">
{instance.customDomain}
{instance.domainVerifiedAt ? (
<CheckCircle2 className="size-3 text-foreground" />
) : (
<CircleDashed className="size-3 text-muted-foreground" />
)}
</span>
}
/>
)}
</dl>
</section>
{/* License info */}
{instance.license && (
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Lizenz
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<DetailRow
label="Key"
value={
<code className="text-xs">
{maskLicenseKey(instance.license.key)}
</code>
}
/>
<DetailRow label="Produkt" value={instance.license.product} />
<DetailRow
label="Abrechnung"
value={
instance.license.billingCycle === 'yearly' ? 'Jährlich' : 'Monatlich'
}
/>
<DetailRow
label="Nettopreis"
value={`${(instance.license.netPriceCents / 100).toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} €`}
/>
<DetailRow
label="Lizenzstatus"
value={
<Badge variant="outline" className="border-foreground/20">
{instance.license.status}
</Badge>
}
/>
<DetailRow
label="Gekündigt"
value={
instance.license.cancelledAt
? formatDate(instance.license.cancelledAt)
: '—'
}
/>
</dl>
</section>
)}
{/* Dokploy resource IDs */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Dokploy-Resourcen
</h3>
<dl className="grid grid-cols-1 gap-y-2 text-sm">
<DetailRow
label="App ID"
value={<code className="text-xs">{instance.dokployAppId ?? '—'}</code>}
/>
<DetailRow
label="DB ID"
value={<code className="text-xs">{instance.dokployDbId ?? '—'}</code>}
/>
<DetailRow
label="Domain ID"
value={
<code className="text-xs">
{(meta.dokployDomainId as string) ?? '—'}
</code>
}
/>
<DetailRow
label="DNS Record ID"
value={<code className="text-xs">{instance.dnsRecordId ?? '—'}</code>}
/>
</dl>
</section>
{/* Codebase Graph (Graphify) */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Codebase-Graph (Graphify)
</h3>
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs">
{(() => {
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 (
<div className="flex items-center gap-2">
<CheckCircle2 className="size-3.5 text-foreground" />
<span>Graph erstellt — Support-AI kann Codebasis analysieren.</span>
</div>
)
}
if (buildFailed) {
return (
<div className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-muted-foreground" />
<span>Graph-Erstellung fehlgeschlagen. Siehe Ereignisverlauf.</span>
</div>
)
}
if (buildQueued) {
return (
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
<span>Graph wird im Hintergrund erstellt…</span>
</div>
)
}
return (
<div className="flex items-center gap-2">
<CircleDashed className="size-3.5 text-muted-foreground" />
<span>Kein Graphify-Graph vorhanden.</span>
</div>
)
})()}
<p className="mt-2 text-[10px] text-muted-foreground">
Der Codebase-Graph ermöglicht der Support-AI, relevante
Code-Stellen zu finden, wenn ein Ticket erstellt wird.
</p>
</div>
</section>
{/* Status timeline (events) */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Ereignisverlauf
</h3>
{instance.events.length === 0 ? (
<p className="text-xs text-muted-foreground">Keine Ereignisse protokolliert.</p>
) : (
<ol className="space-y-2">
{instance.events.map((ev) => (
<EventRow key={ev.id} event={ev} />
))}
</ol>
)}
</section>
{/* Actions */}
<section className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Aktionen
</h3>
{confirmingDelete ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
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).
</p>
<SlideCommit
label="Instanz wirklich löschen →"
onCommit={handleDelete}
loading={busyAction === 'delete'}
/>
<Button
variant="ghost"
size="sm"
className="w-full"
disabled={busyAction !== null}
onClick={() => setConfirmingDelete(false)}
>
Abbrechen
</Button>
</div>
) : (
<>
<ActionsRow
status={status}
busyAction={busyAction}
onSuspend={() => 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) && (
<Button
variant="outline"
size="sm"
className="w-full gap-1.5"
disabled={busyAction !== null}
onClick={async () => {
setBusyAction('graph-build')
const t = toast.loading('Graphify-Graph wird erstellt…')
try {
const r = await fetch(`/api/aos/instances/${instance.id}/graph-build`, { method: 'POST' })
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'build failed')
if (res.status === 'queued') {
toast.info('Graphify nicht konfiguriert — Build-Kommando generiert.', {
id: t,
description: 'Worker muss den Build ausführen.',
duration: 6000,
})
} else if (res.status === 'command-ready') {
toast.success('Graphify-Service erreichbar. Build-Kommando bereit.', {
id: t,
description: 'Worker führt den Build aus.',
duration: 6000,
})
} else {
toast.success('Graph-Build gestartet', { id: t })
}
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Build fehlgeschlagen', { id: t })
} finally {
setBusyAction(null)
}
}}
>
<Network className="size-3.5" />
Graph neu erstellen
</Button>
)}
</>
)}
</section>
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
)
}
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 (
<li className="rounded-md border border-border bg-background/60 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn('gap-1', tone)}>
<Icon className="size-3" />
{event.result}
</Badge>
<span className="text-sm font-medium">{event.type}</span>
{event.step && (
<span className="text-[11px] text-muted-foreground">· {event.step}</span>
)}
<span className="ml-auto text-[11px] text-muted-foreground" title={event.createdAt}>
{formatRelative(event.createdAt)}
</span>
</div>
{pretty && (
<Collapsible open={open} onOpenChange={setOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-[11px]">
{open ? 'Details verbergen' : 'Details anzeigen'}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 max-h-48 overflow-auto rounded-md border border-border bg-muted/40 p-2 text-[11px] leading-relaxed">
{pretty}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</li>
)
}
// ---------------------------------------------------------------------------
// 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 (
<Button
variant={props.variant ?? 'outline'}
size="sm"
disabled={busy}
onClick={props.onClick}
className="justify-start"
>
{props.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className="size-4" />
)}
{props.label}
</Button>
)
}
if (status === 'demo_active' || status === 'active') {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{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',
})}
</div>
)
}
if (status === 'grace') {
return (
<div className="grid grid-cols-2 gap-2">
{btn({
onClick: onReactivate,
label: 'Start',
icon: Play,
loading: busyAction === 'reactivate',
})}
{btn({
onClick: onDelete,
label: 'Remove',
icon: Trash2,
variant: 'destructive',
})}
</div>
)
}
if (status === 'failed') {
return (
<div className="grid grid-cols-2 gap-2">
{btn({
onClick: onRetry,
label: 'Retry',
icon: RotateCcw,
loading: busyAction === 'retry',
})}
{btn({
onClick: onDelete,
label: 'Remove',
icon: Trash2,
variant: 'destructive',
})}
</div>
)
}
return null
}
// ---------------------------------------------------------------------------
// Create Instance Dialog
// ---------------------------------------------------------------------------
function CreateInstanceDialog({
open,
onOpenChange,
existingLicenseIds,
onCreated,
}: {
open: boolean
onOpenChange: (o: boolean) => void
existingLicenseIds: Set<string>
onCreated: () => void
}) {
const { data: crmData } = useFetch<CrmData>('/api/aos/crm')
const { data: licensesData } = useFetch<LicensesData>('/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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Neue Instanz</DialogTitle>
<DialogDescription>
Stellt eine neue Tenant-Instanz bereit. Provisioning via Dokploy + Hetzner DNS
(~1–3 Min). Die Instanz läuft unter &lt;subdomain&gt;.dfmpanel.eu.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{/* Kunde */}
<div className="space-y-1.5">
<Label className="text-xs">Kunde</Label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Lizenz */}
<div className="space-y-1.5">
<Label className="text-xs">Lizenz</Label>
<Select value={licenseId} onValueChange={setLicenseId} disabled={!companyId}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
!companyId
? 'Zuerst Firma wählen'
: availableLicenses.length === 0
? 'Keine Lizenzen verfügbar'
: 'Lizenz wählen'
}
/>
</SelectTrigger>
<SelectContent>
{availableLicenses.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.key} · {l.product} ({PLAN_LABELS[l.plan] ?? l.plan})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Nur Lizenzen ohne bestehende Instanz. Gekündigte Lizenzen sind nicht verfügbar.
</p>
</div>
{/* Produkt + Plan */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Produkt</Label>
<Input value={product} onChange={(e) => setProduct(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Plan</Label>
<Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="starter">Starter</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Git */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Git Repo</Label>
<Input value={gitRepo} onChange={(e) => setGitRepo(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Git Branch</Label>
<Input value={gitBranch} onChange={(e) => setGitBranch(e.target.value)} />
</div>
</div>
{/* Demo Switch */}
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="space-y-0.5">
<Label className="text-xs">Demo starten</Label>
<p className="text-[11px] text-muted-foreground">
14 Tage kostenlos. Bei Abbruch wird die Instanz automatisch gelöscht.
</p>
</div>
<Switch checked={isDemo} onCheckedChange={setIsDemo} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
Abbrechen
</Button>
<Button onClick={submit} disabled={saving || !licenseId}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Provisionieren
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}