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
|
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'
|
||||||
|
|||||||
@@ -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 +7d',
|
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user