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
1381 lines
45 KiB
TypeScript
1381 lines
45 KiB
TypeScript
'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 <subdomain>.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>
|
||
)
|
||
}
|