From 590310ca955ff76c0c1f67a210ad8ca38a52c368 Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 29 Oct 2026 14:07:51 +0000 Subject: [PATCH] fix: instance detail crash + add play/stop/redeploy/remove actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/aos/instances/[id]/route.ts | 64 ++++++++++++++++++- src/components/aos/views/instances.tsx | 84 ++++++++++++------------- src/hooks/use-fetch.ts | 2 +- src/lib/dokploy.ts | 8 +++ 4 files changed, 114 insertions(+), 44 deletions(-) diff --git a/src/app/api/aos/instances/[id]/route.ts b/src/app/api/aos/instances/[id]/route.ts index cdb9e44..c8f51f9 100644 --- a/src/app/api/aos/instances/[id]/route.ts +++ b/src/app/api/aos/instances/[id]/route.ts @@ -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' diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx index 4b73597..06afbf8 100644 --- a/src/components/aos/views/instances.tsx +++ b/src/components/aos/views/instances.tsx @@ -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 ( -
+
{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 +7 d', + 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', - })} -
- ) - } - - if (status === 'active') { - return ( -
- {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 ( -
+
{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 ( -
+
{btn({ onClick: onRetry, - label: 'Provisioning wiederholen', + label: 'Retry', icon: RotateCcw, loading: busyAction === 'retry', })} + {btn({ + onClick: onDelete, + label: 'Remove', + icon: Trash2, + variant: 'destructive', + })}
) } - // provisioning / deleting / deleted / requested → no actions - return ( -

- Für diesen Status sind keine Aktionen verfügbar. -

- ) + return null } // --------------------------------------------------------------------------- diff --git a/src/hooks/use-fetch.ts b/src/hooks/use-fetch.ts index 772980a..79d658e 100755 --- a/src/hooks/use-fetch.ts +++ b/src/hooks/use-fetch.ts @@ -21,7 +21,7 @@ export function useFetch(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 diff --git a/src/lib/dokploy.ts b/src/lib/dokploy.ts index 95d61b0..14f1a3f 100644 --- a/src/lib/dokploy.ts +++ b/src/lib/dokploy.ts @@ -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 | null>('/application.deploy', { applicationId, })