diff --git a/src/app/api/aos/graphify-health/route.ts b/src/app/api/aos/graphify-health/route.ts new file mode 100644 index 0000000..9c3987a --- /dev/null +++ b/src/app/api/aos/graphify-health/route.ts @@ -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', + }) +} diff --git a/src/app/api/aos/instances/[id]/graph-build/route.ts b/src/app/api/aos/instances/[id]/graph-build/route.ts new file mode 100644 index 0000000..f96c9cd --- /dev/null +++ b/src/app/api/aos/instances/[id]/graph-build/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/instances/[id]/graph-status/route.ts b/src/app/api/aos/instances/[id]/graph-status/route.ts new file mode 100644 index 0000000..aea9ac0 --- /dev/null +++ b/src/app/api/aos/instances/[id]/graph-status/route.ts @@ -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 }) + } +} diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx index 1617d08..4b73597 100644 --- a/src/components/aos/views/instances.tsx +++ b/src/components/aos/views/instances.tsx @@ -19,6 +19,7 @@ import { CheckCircle2, XCircle, CircleDashed, + Network, } from 'lucide-react' import { useFetch } from '@/hooks/use-fetch' import { Card } from '@/components/ui/card' @@ -898,15 +899,57 @@ function InstanceDetailSheet({ ) : ( - handleAction('suspend', 'Suspendieren')} - onReactivate={() => handleAction('reactivate', 'Reaktivieren')} - onExtend={() => handleAction('extend', 'Demo verlängern', 7)} - onRetry={() => handleAction('retry', 'Provisioning wiederholen')} - onDelete={() => setConfirmingDelete(true)} - /> + <> + handleAction('suspend', 'Suspendieren')} + onReactivate={() => handleAction('reactivate', 'Reaktivieren')} + onExtend={() => handleAction('extend', 'Demo verlängern', 7)} + onRetry={() => handleAction('retry', 'Provisioning wiederholen')} + onDelete={() => setConfirmingDelete(true)} + /> + {['demo_active', 'active'].includes(status) && ( + + )} + )}