Files
AOS/src/app/api/aos/instances/[id]/route.ts
T
Z User 32a8b01845 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
2026-10-29 16:29:28 +00:00

365 lines
15 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { createDokployClientFromEnv } from '@/lib/dokploy'
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const patchSchema = z.object({
action: z.enum(['suspend', 'reactivate', 'extend', 'retry', 'redeploy', 'stop', 'start']),
extendDays: z.number().int().min(1).max(90).optional(),
})
// GET /api/aos/instances/[id] — instance detail with events + provisioning log
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const instance = await db.instance.findUnique({
where: { id },
include: {
company: { select: { id: true, name: true, email: true } },
license: { select: { id: true, key: true, product: true, plan: true, billingCycle: true, netPriceCents: true, status: true, cancelledAt: true } },
events: { orderBy: { createdAt: 'desc' }, take: 50 },
jobs: { orderBy: { createdAt: 'desc' }, take: 10 },
},
})
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
return NextResponse.json({ instance })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// PATCH /api/aos/instances/[id] — suspend / reactivate / extend demo / retry provisioning
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = patchSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { action, extendDays } = parsed.data
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
const dokploy = createDokployClientFromEnv()
if (action === 'suspend') {
if (!['demo_active', 'active'].includes(instance.status)) {
return NextResponse.json({ error: `cannot suspend instance in status ${instance.status}` }, { status: 400 })
}
const prevStatus = instance.status
// Stop the Dokploy application
if (dokploy && instance.dokployAppId) {
try {
await dokploy.stopApplication(instance.dokployAppId)
} catch (e) {
// Log but don't fail — the instance record is the source of truth
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'suspend.dokploy.error',
step: 'dokploy',
result: 'error',
details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }),
},
})
}
}
await db.instance.update({
where: { id },
data: {
status: 'grace',
graceEndsAt: new Date(Date.now() + 7 * 86400000), // 7-day grace
},
})
await db.instanceEvent.create({
data: { instanceId: id, type: 'suspend', step: 'done', result: 'ok', details: JSON.stringify({ prevStatus }) },
})
return NextResponse.json({ ok: true, status: 'grace' })
}
if (action === 'reactivate') {
if (instance.status !== 'grace') {
return NextResponse.json({ error: 'can only reactivate from grace' }, { status: 400 })
}
if (dokploy && instance.dokployAppId) {
try {
await dokploy.startApplication(instance.dokployAppId)
} catch (e) {
await db.instanceEvent.create({
data: { instanceId: id, type: 'reactivate.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
})
}
}
// If the license is still in trial, keep demo_active; otherwise active
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: 'reactivate', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) },
})
return NextResponse.json({ ok: true, status: newStatus })
}
if (action === 'extend') {
if (instance.status !== 'demo_active') {
return NextResponse.json({ error: 'can only extend demo_active instances' }, { status: 400 })
}
const days = extendDays ?? 7
const newExpiry = new Date(Date.now() + days * 86400000)
await db.instance.update({
where: { id },
data: { demoExpiresAt: newExpiry },
})
await db.instanceEvent.create({
data: { instanceId: id, type: 'demo.extend', step: 'done', result: 'ok', details: JSON.stringify({ extendedBy: days, newExpiry }) },
})
return NextResponse.json({ ok: true, demoExpiresAt: newExpiry })
}
if (action === 'retry') {
// Reset to provisioning — the reconciler or manual call will re-provision
if (instance.status !== 'failed') {
return NextResponse.json({ error: 'can only retry failed instances' }, { status: 400 })
}
await db.instance.update({
where: { id },
data: { status: 'requested', lastError: null, lastErrorAt: null },
})
await db.instanceEvent.create({
data: { instanceId: id, type: 'provision.retry', step: 'init', result: 'pending' },
})
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'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
// Removes: Dokploy domain → database → application + volumes → DNS record.
// Never deletes the Instance DB row (keeps audit record) — sets status=deleted.
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (instance.status === 'deleted') {
return NextResponse.json({ ok: true, message: 'already deleted', steps: [] })
}
await db.instance.update({ where: { id }, data: { status: 'deleting' } })
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.start', step: 'init', result: 'pending' },
})
const steps: string[] = []
const dokploy = createDokployClientFromEnv()
const dns = createHetznerDnsClientFromEnv()
// Parse metadata for Dokploy domain ID
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {}
let dokployFailed = false
let dnsFailed = false
// 1. Remove Dokploy domain + database + application (idempotent)
if (dokploy) {
try {
const result = await dokploy.teardownInstance({
dokployAppId: instance.dokployAppId ?? undefined,
dokployDomainId: meta.dokployDomainId,
})
steps.push(...result.steps)
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
})
} catch (e) {
dokployFailed = true
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
})
}
} else {
steps.push('dokploy.skipped (not configured)')
}
// 2. Remove DNS A record (if per-instance, not wildcard).
// IMPORTANT: Dokploy silme başarısız olsa bile DNS'i silmeye çalış —
// orphaned DNS record subdomain takeover riski taşır. Ama DNS silme
// hata verirse dnsRecordId'yi KORU, instance'ı failed yap ki reconciler
// sonra tekrar deneyebilsin.
if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
try {
await dns.deleteRecord(instance.dnsRecordId)
steps.push('dns.removed')
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
})
} catch (e) {
dnsFailed = true
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.dns.error', step: 'dns', result: 'error', details: JSON.stringify({ recordId: instance.dnsRecordId, error: e instanceof Error ? e.message : 'unknown' }) },
})
}
}
// 3. Decide final state.
// - Both succeeded → 'deleted' (clear all IDs + secrets)
// - Either failed → 'failed' (keep IDs for reconciler retry)
const now = new Date()
if (dokployFailed || dnsFailed) {
await db.instance.update({
where: { id },
data: {
status: 'failed',
lastError: `deletion partial: ${steps.join(', ')}`,
lastErrorAt: now,
// Keep dokployAppId + dnsRecordId so reconciler can retry.
},
})
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.partial', step: 'done', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) },
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'instance.delete.partial',
entity: 'Instance',
entityId: id,
after: JSON.stringify({ subdomain: instance.subdomain, steps, dokployFailed, dnsFailed, message: 'reconciler will retry' }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: false,
status: 'failed',
steps,
message: 'Löschung teilweise fehlgeschlagen — Reconciler wird erneut versuchen. Dokploy + Hetzner A record birlikte silinmedi; orphaned resource riski için instance failed durumda.',
}, { status: 500 })
}
// 4. Both succeeded → mark deleted (keep row for audit — GoBD)
await db.instance.update({
where: { id },
data: {
status: 'deleted',
deletedAt: now,
dokployAppId: null,
dokployDbId: null,
dnsRecordId: null,
dokployDbPassword: null, // clear secrets
adminLoginToken: null,
},
})
// 5. Unlink from license (keep license, just remove instance ref)
if (instance.licenseId) {
await db.license.update({
where: { id: instance.licenseId },
data: { instance: { disconnect: true } },
})
}
await db.instanceEvent.create({
data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) },
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'instance.delete',
entity: 'Instance',
entityId: id,
after: JSON.stringify({ subdomain: instance.subdomain, steps }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}