diff --git a/deployment/docker-compose.stack.yml b/deployment/docker-compose.stack.yml index 83f44ef71..dd6042e55 100644 --- a/deployment/docker-compose.stack.yml +++ b/deployment/docker-compose.stack.yml @@ -749,8 +749,8 @@ services: # DBAL Frontend - Daemon overview + query console dbal-frontend: build: - context: ../frontends/dbal - dockerfile: Dockerfile + context: .. + dockerfile: frontends/dbal/Dockerfile args: DBAL_DAEMON_URL: http://dbal:8080 container_name: metabuilder-dbal-frontend diff --git a/frontends/cli/Dockerfile b/frontends/cli/Dockerfile new file mode 100644 index 000000000..38edcf183 --- /dev/null +++ b/frontends/cli/Dockerfile @@ -0,0 +1,31 @@ +# Build the metabuilder-cli C++ binary +# Context: monorepo root (..) +# Requires: metabuilder/base-conan-deps:latest base image + +ARG BASE_REGISTRY=metabuilder +FROM ${BASE_REGISTRY}/base-conan-deps:latest AS builder + +# Copy CLI source +COPY frontends/cli/ /app/cli/ + +# Install Conan deps and build +WORKDIR /app/cli +RUN conan install . \ + --output-folder=build \ + --build=missing \ + -s build_type=Release \ + -c tools.system.package_manager:mode=install \ + && cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \ + && cmake --build build --config Release + +# Minimal runtime image +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/cli/build/metabuilder-cli /usr/local/bin/metabuilder-cli + +ENTRYPOINT ["metabuilder-cli"] diff --git a/frontends/dbal/Dockerfile b/frontends/dbal/Dockerfile index 008c297ca..c0d87dfd7 100644 --- a/frontends/dbal/Dockerfile +++ b/frontends/dbal/Dockerfile @@ -1,33 +1,65 @@ -FROM node:24-alpine AS base +# Stage 1: Build the C++ CLI binary +ARG BASE_REGISTRY=metabuilder +FROM ${BASE_REGISTRY}/base-conan-deps:latest AS cli-builder -FROM base AS deps +COPY frontends/cli/ /app/cli/ +WORKDIR /app/cli +RUN conan install . \ + --output-folder=build \ + --build=missing \ + -s build_type=Release \ + -c tools.system.package_manager:mode=install \ + && cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \ + && cmake --build build --config Release + +# Stage 2: Build the Next.js frontend +FROM node:24-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app -COPY package.json ./ +COPY frontends/dbal/package.json ./ RUN npm install --legacy-peer-deps -FROM base AS builder +FROM node:24-alpine AS nextjs-builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY frontends/dbal/ . ARG DBAL_DAEMON_URL=http://dbal:8080 ENV DBAL_DAEMON_URL=$DBAL_DAEMON_URL RUN mkdir -p public && npm run build -FROM base AS runner +# Stage 3: Runtime — Ubuntu 24.04 (glibc 2.38 matches conan-deps build) +FROM node:24-bookworm-slim AS node-bin +FROM ubuntu:24.04 AS runner WORKDIR /app + +# Copy Node.js from official image +COPY --from=node-bin /usr/local/bin/node /usr/local/bin/node +COPY --from=node-bin /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# Copy CLI binary +COPY --from=cli-builder /app/cli/build/metabuilder-cli /usr/local/bin/metabuilder-cli + +# Copy Next.js standalone output +COPY --from=nextjs-builder /app/public ./public +RUN mkdir .next +COPY --from=nextjs-builder /app/.next/standalone ./ +COPY --from=nextjs-builder /app/.next/static ./.next/static + ENV NODE_ENV=production -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -COPY --from=builder /app/public ./public -RUN mkdir .next && chown nextjs:nodejs .next -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -USER nextjs +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV METABUILDER_BASE_URL=http://dbal:8080 + EXPOSE 3000 -ENV PORT=3000 HOSTNAME="0.0.0.0" HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/dbal || exit 1 diff --git a/frontends/dbal/app/api/cli/route.ts b/frontends/dbal/app/api/cli/route.ts new file mode 100644 index 000000000..48e57b3cf --- /dev/null +++ b/frontends/dbal/app/api/cli/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server' +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) + +const CLI_PATH = '/usr/local/bin/metabuilder-cli' +const DBAL_URL = process.env.METABUILDER_BASE_URL ?? process.env.DBAL_DAEMON_URL ?? 'http://dbal:8080' +const MAX_TIMEOUT = 15000 + +export async function POST(request: NextRequest) { + const { command, token } = await request.json() + + if (!command || typeof command !== 'string') { + return NextResponse.json({ error: 'Missing command' }, { status: 400 }) + } + + // Parse the command string into args + // "dbal list Snippet" → ["dbal", "list", "Snippet"] + const args = command.trim().split(/\s+/) + + // Remove leading "dbal" or "metabuilder-cli" if present — it's the binary itself + if (args[0] === 'dbal' || args[0] === 'metabuilder-cli') { + args.shift() + } + + // Prepend "dbal" subcommand if the first arg is a known DBAL command + const dbalCommands = ['ping', 'list', 'read', 'create', 'update', 'delete', 'execute', 'rest', 'schema'] + const authCommands = ['session', 'login'] + const userCommands = ['list', 'get'] + + // Route to the right subcommand + let finalArgs: string[] + if (dbalCommands.includes(args[0])) { + finalArgs = ['dbal', ...args] + } else if (args[0] === 'auth') { + finalArgs = args + } else if (args[0] === 'user' || args[0] === 'tenant') { + finalArgs = args + } else if (args[0] === 'package') { + finalArgs = args + } else { + // Pass through as-is + finalArgs = args + } + + try { + const env = { + ...process.env, + METABUILDER_BASE_URL: DBAL_URL, + ...(token ? { DBAL_ADMIN_TOKEN: token } : {}), + } + + const { stdout, stderr } = await execFileAsync(CLI_PATH, finalArgs, { + timeout: MAX_TIMEOUT, + maxBuffer: 1024 * 1024, + env: env as NodeJS.ProcessEnv, + }) + + // Try to parse stdout as JSON + let data: unknown + try { + data = JSON.parse(stdout) + } catch { + data = stdout || stderr || '(no output)' + } + + return NextResponse.json({ + status: 200, + statusText: 'OK', + data, + command: `metabuilder-cli ${finalArgs.join(' ')}`, + stderr: stderr || undefined, + timestamp: new Date().toISOString(), + }) + } catch (err: unknown) { + const error = err as { code?: string; stdout?: string; stderr?: string; message?: string } + + let data: unknown + if (error.stdout) { + try { data = JSON.parse(error.stdout) } catch { data = error.stdout } + } + + return NextResponse.json({ + status: error.code === 'ETIMEDOUT' ? 408 : 500, + statusText: error.code === 'ETIMEDOUT' ? 'Timeout' : 'CLI Error', + data: data ?? { error: error.stderr || error.message || 'Unknown error' }, + command: `metabuilder-cli ${finalArgs.join(' ')}`, + timestamp: new Date().toISOString(), + }) + } +} diff --git a/frontends/dbal/src/QueryConsole.tsx b/frontends/dbal/src/QueryConsole.tsx index fdf8122f1..5a4bf0b20 100644 --- a/frontends/dbal/src/QueryConsole.tsx +++ b/frontends/dbal/src/QueryConsole.tsx @@ -38,13 +38,15 @@ const MAX_HISTORY = 20 const DEFAULT_TOKEN = '069e6487a710300381cd52120eab95d56d7f53beee21479cbeba9128217cbea9' const CLI_EXAMPLES = [ + 'dbal ping', 'dbal list Snippet', - 'dbal read Snippet ', - 'dbal create Snippet title="Hello" content="world"', - 'dbal delete Snippet ', 'dbal rest pastebin pastebin Snippet', 'dbal rest pastebin pastebin User', - 'dbal ping', + 'dbal create Snippet title="Hello" content="world"', + 'auth session', + 'tenant list', + 'user list', + 'package list', ] function parseCli(input: string): { method: HttpMethod; path: string; body?: Record } | null { @@ -267,18 +269,43 @@ export function QueryConsole() { executeQuery(method, path, parsedBody) }, [method, body, buildPath, executeQuery]) - const executeCli = useCallback(() => { - const parsed = parseCli(cliInput) - if (!parsed) { - setResponse({ - status: 0, statusText: 'Parse Error', - data: { error: `Unknown command. Try: ${CLI_EXAMPLES[0]}` }, - url: '', timestamp: new Date().toISOString(), + const executeCli = useCallback(async () => { + if (!cliInput.trim()) return + setLoading(true) + setResponse(null) + + try { + const basePath = process.env.__NEXT_ROUTER_BASEPATH || '/dbal' + const res = await fetch(`${basePath}/api/cli`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: cliInput, token }), }) - return + const data: QueryResponse & { command?: string } = await res.json() + setResponse(data) + + const entry: QueryHistoryEntry = { + id: crypto.randomUUID(), + method: 'GET', + path: data.command || cliInput, + status: data.status, + statusText: data.statusText, + timestamp: data.timestamp, + cli: cliInput, + } + const updated = [entry, ...history].slice(0, MAX_HISTORY) + setHistory(updated) + saveHistory(updated) + } catch (err) { + setResponse({ + status: 0, statusText: 'Fetch Error', + data: { error: err instanceof Error ? err.message : 'Unknown error' }, + url: cliInput, timestamp: new Date().toISOString(), + }) + } finally { + setLoading(false) } - executeQuery(parsed.method, parsed.path, parsed.body, cliInput) - }, [cliInput, executeQuery]) + }, [cliInput, token, history]) const restoreFromHistory = useCallback((entry: QueryHistoryEntry) => { if (entry.cli) {