feat: Graphify build worker endpoints + UI trigger
- POST /api/aos/instances/[id]/graph-build: trigger or get build command - POST /api/aos/instances/[id]/graph-status: worker callback (ok/error) - GET /api/aos/graphify-health: check if Graphify service is up - Instance detail: 'Graph neu erstellen' button (visible when demo_active/active) - Graceful degradation: if GRAPHIFY_URL not set, returns build command for external worker
This commit is contained in:
1 parent
7d94fe53ad
commit
25599a5516
4 files changed
+301
-9
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createGraphifyClientFromEnv } from '@/lib/graphify'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/aos/graphify-health
|
||||||
|
// Checks if the Graphify MCP HTTP service is configured and reachable.
|
||||||
|
// Used by the instance detail UI to show whether graphify is available.
|
||||||
|
export async function GET() {
|
||||||
|
const url = process.env.GRAPHIFY_URL
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: false,
|
||||||
|
ok: false,
|
||||||
|
message: 'Graphify not configured (GRAPHIFY_URL missing)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createGraphifyClientFromEnv()
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({ configured: true, ok: false, message: 'client init failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await client.health()
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: true,
|
||||||
|
ok: health.ok,
|
||||||
|
url,
|
||||||
|
version: health.version,
|
||||||
|
message: health.ok ? 'Graphify service is up' : 'Graphify service unreachable',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { buildGraphCommand, graphPathForInstance } from '@/lib/graphify'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 600 // 10 min — graph build can take a while
|
||||||
|
|
||||||
|
// POST /api/aos/instances/[id]/graph-build
|
||||||
|
// Triggers a Graphify graph build for this instance's codebase.
|
||||||
|
// Can be called by:
|
||||||
|
// 1. The AOS UI (admin "Graph neu erstellen" button)
|
||||||
|
// 2. An external worker (cron job) that polls for queued graphs
|
||||||
|
// 3. The provisioning hook (fire-and-forget)
|
||||||
|
//
|
||||||
|
// The actual build runs via the Graphify CLI in a subprocess. In production,
|
||||||
|
// this should be delegated to a worker container that has Graphify installed.
|
||||||
|
// For dev/demo: we shell out directly (requires graphify installed on host).
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const instance = await db.instance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { license: true },
|
||||||
|
})
|
||||||
|
if (!instance) {
|
||||||
|
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (instance.status === 'deleted' || instance.status === 'deleting') {
|
||||||
|
return NextResponse.json({ error: 'cannot build graph for deleted instance' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata for git repo info
|
||||||
|
const meta = instance.metadata ? JSON.parse(instance.metadata) as { gitRepo?: string; gitBranch?: string } : {}
|
||||||
|
const gitRepo = meta.gitRepo ?? process.env.INSTANCE_GIT_REPO ?? 'https://github.com/cetinmustafa83/restov1'
|
||||||
|
const gitBranch = meta.gitBranch ?? process.env.INSTANCE_GIT_BRANCH ?? 'main'
|
||||||
|
const graphPath = graphPathForInstance(instance.id)
|
||||||
|
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: {
|
||||||
|
instanceId: id,
|
||||||
|
type: 'graphify.build.start',
|
||||||
|
step: 'graphify',
|
||||||
|
result: 'pending',
|
||||||
|
details: JSON.stringify({ gitRepo, gitBranch, graphPath }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// If Graphify service is not configured, return the command for an
|
||||||
|
// external worker to execute. This allows the build to be picked up
|
||||||
|
// by a separate worker container.
|
||||||
|
if (!process.env.GRAPHIFY_URL) {
|
||||||
|
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
status: 'queued',
|
||||||
|
message: 'Graphify service not configured — build command generated for external worker.',
|
||||||
|
graphPath,
|
||||||
|
command: cmd,
|
||||||
|
gitRepo,
|
||||||
|
gitBranch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graphify is configured — check if the MCP HTTP service is reachable
|
||||||
|
const { createGraphifyClientFromEnv } = await import('@/lib/graphify')
|
||||||
|
const client = createGraphifyClientFromEnv()
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({ error: 'Graphify client init failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await client.health()
|
||||||
|
if (!health.ok) {
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: {
|
||||||
|
instanceId: id,
|
||||||
|
type: 'graphify.build.error',
|
||||||
|
step: 'graphify',
|
||||||
|
result: 'error',
|
||||||
|
details: JSON.stringify({ error: 'Graphify service unreachable' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: false,
|
||||||
|
error: 'Graphify service unreachable',
|
||||||
|
command: buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath }),
|
||||||
|
}, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The MCP HTTP service is query-only. The actual build must be done
|
||||||
|
// via the CLI in a worker. We return the command so the caller (or a
|
||||||
|
// cron job) can execute it.
|
||||||
|
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
status: 'command-ready',
|
||||||
|
message: 'Graphify service is up. Execute the build command in a worker.',
|
||||||
|
graphPath,
|
||||||
|
command: cmd,
|
||||||
|
gitRepo,
|
||||||
|
gitBranch,
|
||||||
|
graphifyVersion: health.version,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET — returns the current graph build status for this instance
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const events = await db.instanceEvent.findMany({
|
||||||
|
where: { instanceId: id, type: { startsWith: 'graphify.' } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildStarted = events.some((e) => e.type === 'graphify.build.start' || e.type === 'graphify.build.queued')
|
||||||
|
const buildDone = events.some((e) => e.type === 'graphify.build.ok')
|
||||||
|
const buildFailed = events.some((e) => e.type === 'graphify.build.error')
|
||||||
|
|
||||||
|
let status: 'none' | 'queued' | 'building' | 'ready' | 'failed'
|
||||||
|
if (buildDone) status = 'ready'
|
||||||
|
else if (buildFailed) status = 'failed'
|
||||||
|
else if (buildStarted) status = 'building'
|
||||||
|
else status = 'none'
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status,
|
||||||
|
events: events.map((e) => ({
|
||||||
|
type: e.type,
|
||||||
|
result: e.result,
|
||||||
|
details: e.details,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// POST /api/aos/instances/[id]/graph-status
|
||||||
|
// Called by the graph build worker after it finishes (success or failure).
|
||||||
|
// Body: { status: 'ok' | 'error', stats?: {...}, error?: '...' }
|
||||||
|
//
|
||||||
|
// This is the callback endpoint that completes the graph build lifecycle:
|
||||||
|
// 1. AOS queues build (graphify.build.queued)
|
||||||
|
// 2. Worker picks up, calls POST /graph-build, gets the command
|
||||||
|
// 3. Worker executes graphify CLI
|
||||||
|
// 4. Worker calls this endpoint to report success/failure
|
||||||
|
// 5. AOS records graphify.build.ok / graphify.build.error
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const status = body.status === 'ok' ? 'ok' : 'error'
|
||||||
|
|
||||||
|
const instance = await db.instance.findUnique({ where: { id } })
|
||||||
|
if (!instance) {
|
||||||
|
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'ok') {
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: {
|
||||||
|
instanceId: id,
|
||||||
|
type: 'graphify.build.ok',
|
||||||
|
step: 'graphify',
|
||||||
|
result: 'ok',
|
||||||
|
details: JSON.stringify({
|
||||||
|
stats: body.stats ?? null,
|
||||||
|
graphPath: body.graphPath ?? null,
|
||||||
|
builtAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'graphify-worker',
|
||||||
|
action: 'graphify.build.ok',
|
||||||
|
entity: 'Instance',
|
||||||
|
entityId: id,
|
||||||
|
after: JSON.stringify({ stats: body.stats }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({ ok: true, status: 'ok' })
|
||||||
|
} else {
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: {
|
||||||
|
instanceId: id,
|
||||||
|
type: 'graphify.build.error',
|
||||||
|
step: 'graphify',
|
||||||
|
result: 'error',
|
||||||
|
details: JSON.stringify({ error: body.error ?? 'unknown build error' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.instance.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
lastError: `graphify build failed: ${body.error ?? 'unknown'}`,
|
||||||
|
lastErrorAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({ ok: false, status: 'error', error: body.error })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
CircleDashed,
|
CircleDashed,
|
||||||
|
Network,
|
||||||
} 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'
|
||||||
@@ -898,15 +899,57 @@ function InstanceDetailSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ActionsRow
|
<>
|
||||||
status={status}
|
<ActionsRow
|
||||||
busyAction={busyAction}
|
status={status}
|
||||||
onSuspend={() => handleAction('suspend', 'Suspendieren')}
|
busyAction={busyAction}
|
||||||
onReactivate={() => handleAction('reactivate', 'Reaktivieren')}
|
onSuspend={() => handleAction('suspend', 'Suspendieren')}
|
||||||
onExtend={() => handleAction('extend', 'Demo verlängern', 7)}
|
onReactivate={() => handleAction('reactivate', 'Reaktivieren')}
|
||||||
onRetry={() => handleAction('retry', 'Provisioning wiederholen')}
|
onExtend={() => handleAction('extend', 'Demo verlängern', 7)}
|
||||||
onDelete={() => setConfirmingDelete(true)}
|
onRetry={() => handleAction('retry', 'Provisioning wiederholen')}
|
||||||
/>
|
onDelete={() => setConfirmingDelete(true)}
|
||||||
|
/>
|
||||||
|
{['demo_active', 'active'].includes(status) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-1.5"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusyAction('graph-build')
|
||||||
|
const t = toast.loading('Graphify-Graph wird erstellt…')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/aos/instances/${instance.id}/graph-build`, { method: 'POST' })
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'build failed')
|
||||||
|
if (res.status === 'queued') {
|
||||||
|
toast.info('Graphify nicht konfiguriert — Build-Kommando generiert.', {
|
||||||
|
id: t,
|
||||||
|
description: 'Worker muss den Build ausführen.',
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
} else if (res.status === 'command-ready') {
|
||||||
|
toast.success('Graphify-Service erreichbar. Build-Kommando bereit.', {
|
||||||
|
id: t,
|
||||||
|
description: 'Worker führt den Build aus.',
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.success('Graph-Build gestartet', { id: t })
|
||||||
|
}
|
||||||
|
refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Build fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Network className="size-3.5" />
|
||||||
|
Graph neu erstellen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user