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:
1 parent
89adb63f5e
commit
590310ca95
4 files changed
+113
-43
No files matched your search
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
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 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ExternalLink,
|
||||
Ban,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
RotateCcw,
|
||||
CalendarPlus,
|
||||
Trash2,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
XCircle,
|
||||
CircleDashed,
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card } from '@/components/ui/card'
|
||||
@@ -554,7 +557,7 @@ function InstanceDetailSheet({
|
||||
}, [open])
|
||||
|
||||
const handleAction = async (
|
||||
action: 'suspend' | 'reactivate' | 'extend' | 'retry',
|
||||
action: 'suspend' | 'reactivate' | 'extend' | 'retry' | 'redeploy' | 'stop' | 'start',
|
||||
label: string,
|
||||
extendDays?: number
|
||||
) => {
|
||||
@@ -904,9 +907,12 @@ function InstanceDetailSheet({
|
||||
status={status}
|
||||
busyAction={busyAction}
|
||||
onSuspend={() => handleAction('suspend', 'Suspendieren')}
|
||||
onReactivate={() => handleAction('reactivate', 'Reaktivieren')}
|
||||
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) && (
|
||||
@@ -1042,6 +1048,9 @@ function ActionsRow({
|
||||
onReactivate,
|
||||
onExtend,
|
||||
onRetry,
|
||||
onRedeploy,
|
||||
onStop,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
status: string
|
||||
@@ -1050,6 +1059,9 @@ function ActionsRow({
|
||||
onReactivate: () => void
|
||||
onExtend: () => void
|
||||
onRetry: () => void
|
||||
onRedeploy: () => void
|
||||
onStop: () => void
|
||||
onStart: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const busy = busyAction !== null
|
||||
@@ -1079,43 +1091,30 @@ function ActionsRow({
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'demo_active') {
|
||||
if (status === 'demo_active' || status === 'active') {
|
||||
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({
|
||||
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: onSuspend,
|
||||
label: 'Suspendieren',
|
||||
icon: Ban,
|
||||
loading: busyAction === 'suspend',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Löschen',
|
||||
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',
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
@@ -1125,16 +1124,16 @@ function ActionsRow({
|
||||
|
||||
if (status === 'grace') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{btn({
|
||||
onClick: onReactivate,
|
||||
label: 'Reaktivieren',
|
||||
label: 'Start',
|
||||
icon: Play,
|
||||
loading: busyAction === 'reactivate',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Sofort löschen',
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
@@ -1144,23 +1143,24 @@ function ActionsRow({
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{btn({
|
||||
onClick: onRetry,
|
||||
label: 'Provisioning wiederholen',
|
||||
label: 'Retry',
|
||||
icon: RotateCcw,
|
||||
loading: busyAction === 'retry',
|
||||
})}
|
||||
{btn({
|
||||
onClick: onDelete,
|
||||
label: 'Remove',
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// provisioning / deleting / deleted / requested → no actions
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für diesen Status sind keine Aktionen verfügbar.
|
||||
</p>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useFetch<T>(url: string | null) {
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(url, { signal: controller.signal })
|
||||
fetch(url, { signal: controller.signal, credentials: 'same-origin' })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
return (await r.json()) as T
|
||||
|
||||
@@ -238,6 +238,14 @@ export function createDokployClient(config: DokployConfig) {
|
||||
}
|
||||
|
||||
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', {
|
||||
applicationId,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user