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
This commit is contained in:
Z User committed 2026-10-29 14:07:51 +00:00
1 parent 89adb63f5e
commit 590310ca95
4 files changed
+114 -44

No files matched your search

+63 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
export const maxDuration = 300 export const maxDuration = 300
const patchSchema = z.object({ const patchSchema = z.object({
action: z.enum(['suspend', 'reactivate', 'extend', 'retry']), action: z.enum(['suspend', 'reactivate', 'extend', 'retry', 'redeploy', 'stop', 'start']),
extendDays: z.number().int().min(1).max(90).optional(), extendDays: z.number().int().min(1).max(90).optional(),
}) })
@@ -143,6 +143,68 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' }) return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' })
} }
if (action === 'redeploy') {
if (!['demo_active', 'active', 'failed'].includes(instance.status)) {
return NextResponse.json({ error: `cannot redeploy instance in status ${instance.status}` }, { status: 400 })
}
if (dokploy && instance.dokployAppId) {
try {
await dokploy.redeployApplication(instance.dokployAppId)
} catch (e) {
await db.instanceEvent.create({
data: { instanceId: id, type: 'redeploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
})
}
}
await db.instanceEvent.create({
data: { instanceId: id, type: 'redeploy', step: 'done', result: 'ok' },
})
return NextResponse.json({ ok: true, status: 'redeploying' })
}
if (action === 'stop') {
if (!['demo_active', 'active'].includes(instance.status)) {
return NextResponse.json({ error: `cannot stop instance in status ${instance.status}` }, { status: 400 })
}
if (dokploy && instance.dokployAppId) {
try {
await dokploy.stopApplication(instance.dokployAppId)
} catch (e) {
await db.instanceEvent.create({
data: { instanceId: id, type: 'stop.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
})
}
}
await db.instanceEvent.create({
data: { instanceId: id, type: 'stop', step: 'done', result: 'ok' },
})
return NextResponse.json({ ok: true, status: 'stopped' })
}
if (action === 'start') {
if (!['grace', 'failed'].includes(instance.status)) {
return NextResponse.json({ error: `cannot start instance in status ${instance.status}` }, { status: 400 })
}
if (dokploy && instance.dokployAppId) {
try {
await dokploy.startApplication(instance.dokployAppId)
} catch (e) {
await db.instanceEvent.create({
data: { instanceId: id, type: 'start.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
})
}
}
const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active'
await db.instance.update({
where: { id },
data: { status: newStatus, graceEndsAt: null },
})
await db.instanceEvent.create({
data: { instanceId: id, type: 'start', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) },
})
return NextResponse.json({ ok: true, status: newStatus })
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 }) return NextResponse.json({ error: 'unknown action' }, { status: 400 })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error' const msg = err instanceof Error ? err.message : 'internal server error'
+42 -42
View File
@@ -12,6 +12,8 @@ import {
ExternalLink, ExternalLink,
Ban, Ban,
Play, Play,
Pause,
Square,
RotateCcw, RotateCcw,
CalendarPlus, CalendarPlus,
Trash2, Trash2,
@@ -20,6 +22,7 @@ import {
XCircle, XCircle,
CircleDashed, CircleDashed,
Network, Network,
RefreshCw,
} from 'lucide-react' } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch' import { useFetch } from '@/hooks/use-fetch'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@@ -554,7 +557,7 @@ function InstanceDetailSheet({
}, [open]) }, [open])
const handleAction = async ( const handleAction = async (
action: 'suspend' | 'reactivate' | 'extend' | 'retry', action: 'suspend' | 'reactivate' | 'extend' | 'retry' | 'redeploy' | 'stop' | 'start',
label: string, label: string,
extendDays?: number extendDays?: number
) => { ) => {
@@ -904,9 +907,12 @@ function InstanceDetailSheet({
status={status} status={status}
busyAction={busyAction} busyAction={busyAction}
onSuspend={() => handleAction('suspend', 'Suspendieren')} onSuspend={() => handleAction('suspend', 'Suspendieren')}
onReactivate={() => handleAction('reactivate', 'Reaktivieren')} onReactivate={() => handleAction('reactivate', 'Starten')}
onExtend={() => handleAction('extend', 'Demo verlängern', 7)} onExtend={() => handleAction('extend', 'Demo verlängern', 7)}
onRetry={() => handleAction('retry', 'Provisioning wiederholen')} onRetry={() => handleAction('retry', 'Provisioning wiederholen')}
onRedeploy={() => handleAction('redeploy', 'Redeploy')}
onStop={() => handleAction('stop', 'Stop')}
onStart={() => handleAction('start', 'Start')}
onDelete={() => setConfirmingDelete(true)} onDelete={() => setConfirmingDelete(true)}
/> />
{['demo_active', 'active'].includes(status) && ( {['demo_active', 'active'].includes(status) && (
@@ -1042,6 +1048,9 @@ function ActionsRow({
onReactivate, onReactivate,
onExtend, onExtend,
onRetry, onRetry,
onRedeploy,
onStop,
onStart,
onDelete, onDelete,
}: { }: {
status: string status: string
@@ -1050,6 +1059,9 @@ function ActionsRow({
onReactivate: () => void onReactivate: () => void
onExtend: () => void onExtend: () => void
onRetry: () => void onRetry: () => void
onRedeploy: () => void
onStop: () => void
onStart: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const busy = busyAction !== null const busy = busyAction !== null
@@ -1079,43 +1091,30 @@ function ActionsRow({
) )
} }
if (status === 'demo_active') { if (status === 'demo_active' || status === 'active') {
return ( return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{btn({ {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, onClick: onExtend,
label: 'Demo +7 d', label: 'Demo +7d',
icon: CalendarPlus, icon: CalendarPlus,
loading: busyAction === 'extend', loading: busyAction === 'extend',
})} })}
{btn({
onClick: onSuspend,
label: 'Suspendieren',
icon: Ban,
loading: busyAction === 'suspend',
})}
{btn({ {btn({
onClick: onDelete, onClick: onDelete,
label: 'Löschen', label: 'Remove',
icon: Trash2,
variant: 'destructive',
})}
</div>
)
}
if (status === 'active') {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{btn({
onClick: onSuspend,
label: 'Suspendieren',
icon: Ban,
loading: busyAction === 'suspend',
})}
{btn({
onClick: onDelete,
label: 'Löschen',
icon: Trash2, icon: Trash2,
variant: 'destructive', variant: 'destructive',
})} })}
@@ -1125,16 +1124,16 @@ function ActionsRow({
if (status === 'grace') { if (status === 'grace') {
return ( return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-2 gap-2">
{btn({ {btn({
onClick: onReactivate, onClick: onReactivate,
label: 'Reaktivieren', label: 'Start',
icon: Play, icon: Play,
loading: busyAction === 'reactivate', loading: busyAction === 'reactivate',
})} })}
{btn({ {btn({
onClick: onDelete, onClick: onDelete,
label: 'Sofort löschen', label: 'Remove',
icon: Trash2, icon: Trash2,
variant: 'destructive', variant: 'destructive',
})} })}
@@ -1144,23 +1143,24 @@ function ActionsRow({
if (status === 'failed') { if (status === 'failed') {
return ( return (
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-2 gap-2">
{btn({ {btn({
onClick: onRetry, onClick: onRetry,
label: 'Provisioning wiederholen', label: 'Retry',
icon: RotateCcw, icon: RotateCcw,
loading: busyAction === 'retry', loading: busyAction === 'retry',
})} })}
{btn({
onClick: onDelete,
label: 'Remove',
icon: Trash2,
variant: 'destructive',
})}
</div> </div>
) )
} }
// provisioning / deleting / deleted / requested → no actions return null
return (
<p className="text-xs text-muted-foreground">
Für diesen Status sind keine Aktionen verfügbar.
</p>
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -21,7 +21,7 @@ export function useFetch<T>(url: string | null) {
const controller = new AbortController() const controller = new AbortController()
setLoading(true) setLoading(true)
setError(null) setError(null)
fetch(url, { signal: controller.signal }) fetch(url, { signal: controller.signal, credentials: 'same-origin' })
.then(async (r) => { .then(async (r) => {
if (!r.ok) throw new Error('HTTP ' + r.status) if (!r.ok) throw new Error('HTTP ' + r.status)
return (await r.json()) as T return (await r.json()) as T
+8
View File
@@ -238,6 +238,14 @@ export function createDokployClient(config: DokployConfig) {
} }
async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> { async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> {
// First mark the app as running (Dokploy requires this before deploy)
try {
await call('/application.markRunning', { applicationId })
} catch {
// Non-fatal — markRunning may not be needed for all Dokploy versions
}
// Then trigger the deploy
const res = await call<Record<string, unknown> | null>('/application.deploy', { const res = await call<Record<string, unknown> | null>('/application.deploy', {
applicationId, applicationId,
}) })