From a1c76b79d083d21a06cfec4d410c4a2fdd67fc9b Mon Sep 17 00:00:00 2001 From: Mathieu Garcia Date: Wed, 1 Apr 2026 06:36:19 +0200 Subject: [PATCH 1/5] feat: Add new UI based on next.js + fumadocs --- .env.example | 25 + ui-next/.dockerignore | 46 + ui-next/.gitignore | 55 + ui-next/Dockerfile | 36 + ui-next/README.md | 36 + ui-next/app/(app)/api-interne/api-tester.tsx | 756 + ui-next/app/(app)/api-interne/page.tsx | 20 + ui-next/app/(app)/catalogs/[id]/page.tsx | 11 + ui-next/app/(app)/catalogs/page.tsx | 1288 ++ ui-next/app/(app)/dashboard/page.tsx | 5 + ui-next/app/(app)/events/page.tsx | 69 + .../app/(app)/infrastructures/[id]/page.tsx | 11 + ui-next/app/(app)/infrastructures/page.tsx | 1024 + ui-next/app/(app)/layout.tsx | 26 + ui-next/app/(app)/password/page.tsx | 5 + ui-next/app/(app)/profile/page.tsx | 232 + ui-next/app/(app)/settings/page.tsx | 248 + ui-next/app/(app)/softwares/[id]/page.tsx | 11 + ui-next/app/(app)/softwares/page.tsx | 1244 ++ ui-next/app/(app)/users/[id]/page.tsx | 11 + ui-next/app/(app)/users/page.tsx | 493 + ui-next/app/(app)/variables/[id]/page.tsx | 11 + ui-next/app/(auth)/layout.tsx | 15 + ui-next/app/(auth)/login/page.tsx | 92 + ui-next/app/api-interne/reference/route.ts | 15 + ui-next/app/api/account/route.ts | 43 + ui-next/app/api/auth/[...nextauth]/route.ts | 3 + .../app/api/catalogs/[id]/execute/route.ts | 85 + ui-next/app/api/catalogs/[id]/fork/route.ts | 80 + ui-next/app/api/catalogs/[id]/route.ts | 53 + ui-next/app/api/catalogs/route.ts | 58 + ui-next/app/api/docs/roles-variables/route.ts | 20 + ui-next/app/api/events/route.ts | 57 + ui-next/app/api/graphs/route.ts | 193 + .../api/infrastructures/[id]/execute/route.ts | 119 + .../api/infrastructures/[id]/remove/route.ts | 43 + ui-next/app/api/infrastructures/[id]/route.ts | 75 + .../infrastructures/[id]/tfstates/route.ts | 92 + ui-next/app/api/infrastructures/route.ts | 62 + ui-next/app/api/inventory/route.ts | 50 + ui-next/app/api/openapi-internal/route.ts | 376 + ui-next/app/api/ping/route.ts | 5 + ui-next/app/api/settings/export/route.ts | 82 + ui-next/app/api/settings/general/route.ts | 39 + ui-next/app/api/settings/import/route.ts | 76 + ui-next/app/api/softwares/[id]/route.ts | 274 + ui-next/app/api/softwares/route.ts | 109 + ui-next/app/api/users/[id]/route.ts | 82 + ui-next/app/api/users/password/route.ts | 31 + ui-next/app/api/users/profile/route.ts | 45 + ui-next/app/api/users/route.ts | 65 + ui-next/app/api/variables/[id]/route.ts | 225 + ui-next/app/api/variables/route.ts | 68 + ui-next/app/api/variables/secret/route.ts | 173 + ui-next/app/docs/[...slug]/page.tsx | 41 + ui-next/app/docs/layout.tsx | 6 + ui-next/app/docs/page.tsx | 14 + ui-next/app/favicon.ico | Bin 0 -> 25931 bytes ui-next/app/globals.css | 26 + ui-next/app/layout.tsx | 42 + ui-next/app/page.tsx | 161 + ui-next/auth.config.ts | 55 + .../components/dashboard/force-graph-3d.tsx | 631 + .../components/layout/collapsible-layout.tsx | 50 + ui-next/components/layout/side-menu.tsx | 269 + ui-next/components/layout/theme-toggle.tsx | 35 + ui-next/components/marketing/docs-article.tsx | 31 + .../marketing/docs-copyable-code-block.tsx | 68 + .../marketing/docs-roles-variables.tsx | 105 + ui-next/components/marketing/docs-toc-nav.tsx | 111 + .../marketing/public-collapsible-layout.tsx | 50 + ui-next/components/marketing/public-shell.tsx | 39 + .../components/marketing/public-sidebar.tsx | 167 + .../marketing/public-theme-picker.tsx | 45 + ui-next/components/providers.tsx | 29 + ui-next/components/ui/resource-shell.tsx | 53 + ui-next/content/docs/api-interne.mdx | 1 + ui-next/content/docs/basic-usage.mdx | 271 + ui-next/content/docs/catalogs.mdx | 106 + ui-next/content/docs/events.mdx | 29 + ui-next/content/docs/infrastructures.mdx | 31 + ui-next/content/docs/installation.mdx | 361 + ui-next/content/docs/intro.mdx | 83 + ui-next/content/docs/profile.mdx | 63 + ui-next/content/docs/settings.mdx | 86 + ui-next/content/docs/softwares.mdx | 142 + ui-next/content/docs/users.mdx | 107 + ui-next/drizzle.config.ts | 10 + ui-next/drizzle/0000_curious_hulk.sql | 85 + ui-next/drizzle/meta/0000_snapshot.json | 566 + ui-next/drizzle/meta/_journal.json | 13 + ui-next/eslint.config.mjs | 18 + ui-next/i18n/request.ts | 30 + ui-next/lib/api-utils.ts | 97 + ui-next/lib/auth.ts | 88 + ui-next/lib/crypto.ts | 88 + ui-next/lib/db/client.ts | 18 + ui-next/lib/db/schema.ts | 94 + ui-next/lib/docs/source.ts | 135 + ui-next/lib/infrastructure-icons.ts | 70 + ui-next/lib/inventory.ts | 131 + ui-next/lib/runner.ts | 97 + ui-next/lib/site-settings.ts | 74 + ui-next/lib/validations/catalog.ts | 70 + ui-next/lib/validations/common.ts | 4 + ui-next/lib/validations/infrastructure.ts | 40 + ui-next/lib/validations/software.ts | 59 + ui-next/lib/validations/user.ts | 64 + ui-next/lib/validations/variable.ts | 34 + ui-next/mdx-components.tsx | 9 + ui-next/mdx.d.ts | 6 + ui-next/messages/en.json | 54 + ui-next/messages/es.json | 54 + ui-next/messages/fr.json | 54 + ui-next/messages/sk.json | 54 + ui-next/next.config.ts | 13 + ui-next/package-lock.json | 16479 ++++++++++++++++ ui-next/package.json | 72 + ui-next/postcss.config.mjs | 7 + ui-next/proxy.ts | 17 + ui-next/public/file.svg | 1 + ui-next/public/globe.svg | 1 + ui-next/public/next.svg | 1 + ui-next/public/roles-variables-paas.json | 579 + ui-next/public/roles-variables-saas.json | 106 + ui-next/public/roles-variables.json | 579 + ui-next/public/vercel.svg | 1 + ui-next/public/window.svg | 1 + ui-next/react-syntax-highlighter.d.ts | 3 + ui-next/scripts/extract-roles-variables.js | 62 + ui-next/tsconfig.json | 43 + 131 files changed, 31857 insertions(+) create mode 100644 .env.example create mode 100644 ui-next/.dockerignore create mode 100644 ui-next/.gitignore create mode 100644 ui-next/Dockerfile create mode 100644 ui-next/README.md create mode 100644 ui-next/app/(app)/api-interne/api-tester.tsx create mode 100644 ui-next/app/(app)/api-interne/page.tsx create mode 100644 ui-next/app/(app)/catalogs/[id]/page.tsx create mode 100644 ui-next/app/(app)/catalogs/page.tsx create mode 100644 ui-next/app/(app)/dashboard/page.tsx create mode 100644 ui-next/app/(app)/events/page.tsx create mode 100644 ui-next/app/(app)/infrastructures/[id]/page.tsx create mode 100644 ui-next/app/(app)/infrastructures/page.tsx create mode 100644 ui-next/app/(app)/layout.tsx create mode 100644 ui-next/app/(app)/password/page.tsx create mode 100644 ui-next/app/(app)/profile/page.tsx create mode 100644 ui-next/app/(app)/settings/page.tsx create mode 100644 ui-next/app/(app)/softwares/[id]/page.tsx create mode 100644 ui-next/app/(app)/softwares/page.tsx create mode 100644 ui-next/app/(app)/users/[id]/page.tsx create mode 100644 ui-next/app/(app)/users/page.tsx create mode 100644 ui-next/app/(app)/variables/[id]/page.tsx create mode 100644 ui-next/app/(auth)/layout.tsx create mode 100644 ui-next/app/(auth)/login/page.tsx create mode 100644 ui-next/app/api-interne/reference/route.ts create mode 100644 ui-next/app/api/account/route.ts create mode 100644 ui-next/app/api/auth/[...nextauth]/route.ts create mode 100644 ui-next/app/api/catalogs/[id]/execute/route.ts create mode 100644 ui-next/app/api/catalogs/[id]/fork/route.ts create mode 100644 ui-next/app/api/catalogs/[id]/route.ts create mode 100644 ui-next/app/api/catalogs/route.ts create mode 100644 ui-next/app/api/docs/roles-variables/route.ts create mode 100644 ui-next/app/api/events/route.ts create mode 100644 ui-next/app/api/graphs/route.ts create mode 100644 ui-next/app/api/infrastructures/[id]/execute/route.ts create mode 100644 ui-next/app/api/infrastructures/[id]/remove/route.ts create mode 100644 ui-next/app/api/infrastructures/[id]/route.ts create mode 100644 ui-next/app/api/infrastructures/[id]/tfstates/route.ts create mode 100644 ui-next/app/api/infrastructures/route.ts create mode 100644 ui-next/app/api/inventory/route.ts create mode 100644 ui-next/app/api/openapi-internal/route.ts create mode 100644 ui-next/app/api/ping/route.ts create mode 100644 ui-next/app/api/settings/export/route.ts create mode 100644 ui-next/app/api/settings/general/route.ts create mode 100644 ui-next/app/api/settings/import/route.ts create mode 100644 ui-next/app/api/softwares/[id]/route.ts create mode 100644 ui-next/app/api/softwares/route.ts create mode 100644 ui-next/app/api/users/[id]/route.ts create mode 100644 ui-next/app/api/users/password/route.ts create mode 100644 ui-next/app/api/users/profile/route.ts create mode 100644 ui-next/app/api/users/route.ts create mode 100644 ui-next/app/api/variables/[id]/route.ts create mode 100644 ui-next/app/api/variables/route.ts create mode 100644 ui-next/app/api/variables/secret/route.ts create mode 100644 ui-next/app/docs/[...slug]/page.tsx create mode 100644 ui-next/app/docs/layout.tsx create mode 100644 ui-next/app/docs/page.tsx create mode 100644 ui-next/app/favicon.ico create mode 100644 ui-next/app/globals.css create mode 100644 ui-next/app/layout.tsx create mode 100644 ui-next/app/page.tsx create mode 100644 ui-next/auth.config.ts create mode 100644 ui-next/components/dashboard/force-graph-3d.tsx create mode 100644 ui-next/components/layout/collapsible-layout.tsx create mode 100644 ui-next/components/layout/side-menu.tsx create mode 100644 ui-next/components/layout/theme-toggle.tsx create mode 100644 ui-next/components/marketing/docs-article.tsx create mode 100644 ui-next/components/marketing/docs-copyable-code-block.tsx create mode 100644 ui-next/components/marketing/docs-roles-variables.tsx create mode 100644 ui-next/components/marketing/docs-toc-nav.tsx create mode 100644 ui-next/components/marketing/public-collapsible-layout.tsx create mode 100644 ui-next/components/marketing/public-shell.tsx create mode 100644 ui-next/components/marketing/public-sidebar.tsx create mode 100644 ui-next/components/marketing/public-theme-picker.tsx create mode 100644 ui-next/components/providers.tsx create mode 100644 ui-next/components/ui/resource-shell.tsx create mode 100644 ui-next/content/docs/api-interne.mdx create mode 100644 ui-next/content/docs/basic-usage.mdx create mode 100644 ui-next/content/docs/catalogs.mdx create mode 100644 ui-next/content/docs/events.mdx create mode 100644 ui-next/content/docs/infrastructures.mdx create mode 100644 ui-next/content/docs/installation.mdx create mode 100644 ui-next/content/docs/intro.mdx create mode 100644 ui-next/content/docs/profile.mdx create mode 100644 ui-next/content/docs/settings.mdx create mode 100644 ui-next/content/docs/softwares.mdx create mode 100644 ui-next/content/docs/users.mdx create mode 100644 ui-next/drizzle.config.ts create mode 100644 ui-next/drizzle/0000_curious_hulk.sql create mode 100644 ui-next/drizzle/meta/0000_snapshot.json create mode 100644 ui-next/drizzle/meta/_journal.json create mode 100644 ui-next/eslint.config.mjs create mode 100644 ui-next/i18n/request.ts create mode 100644 ui-next/lib/api-utils.ts create mode 100644 ui-next/lib/auth.ts create mode 100644 ui-next/lib/crypto.ts create mode 100644 ui-next/lib/db/client.ts create mode 100644 ui-next/lib/db/schema.ts create mode 100644 ui-next/lib/docs/source.ts create mode 100644 ui-next/lib/infrastructure-icons.ts create mode 100644 ui-next/lib/inventory.ts create mode 100644 ui-next/lib/runner.ts create mode 100644 ui-next/lib/site-settings.ts create mode 100644 ui-next/lib/validations/catalog.ts create mode 100644 ui-next/lib/validations/common.ts create mode 100644 ui-next/lib/validations/infrastructure.ts create mode 100644 ui-next/lib/validations/software.ts create mode 100644 ui-next/lib/validations/user.ts create mode 100644 ui-next/lib/validations/variable.ts create mode 100644 ui-next/mdx-components.tsx create mode 100644 ui-next/mdx.d.ts create mode 100644 ui-next/messages/en.json create mode 100644 ui-next/messages/es.json create mode 100644 ui-next/messages/fr.json create mode 100644 ui-next/messages/sk.json create mode 100644 ui-next/next.config.ts create mode 100644 ui-next/package-lock.json create mode 100644 ui-next/package.json create mode 100644 ui-next/postcss.config.mjs create mode 100644 ui-next/proxy.ts create mode 100644 ui-next/public/file.svg create mode 100644 ui-next/public/globe.svg create mode 100644 ui-next/public/next.svg create mode 100644 ui-next/public/roles-variables-paas.json create mode 100644 ui-next/public/roles-variables-saas.json create mode 100644 ui-next/public/roles-variables.json create mode 100644 ui-next/public/vercel.svg create mode 100644 ui-next/public/window.svg create mode 100644 ui-next/react-syntax-highlighter.d.ts create mode 100644 ui-next/scripts/extract-roles-variables.js create mode 100644 ui-next/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..da6f797 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Administration credentials +TEMP_ADMIN_EMAIL=admin@example.com +TEMP_ADMIN_PASSWORD=change-me-securely + +# UI Next configuration +# URL interne du réseau Docker pour accéder au service ui-next +# Format: http://ui-next:3000 (accès interne) ou http://localhost:3000 (accès local) +SIMPLE_STACK_UI_URL=http://ui-next:3000 + +# UI authentication (mapped from admin credentials) +SIMPLE_STACK_UI_USER=${TEMP_ADMIN_EMAIL} +SIMPLE_STACK_UI_PASSWORD=${TEMP_ADMIN_PASSWORD} + +# Terraform HTTP backend authentication +TF_HTTP_USERNAME=${TEMP_ADMIN_EMAIL} +TF_HTTP_PASSWORD=${TEMP_ADMIN_PASSWORD} + +# NextAuth secret for session encryption +NEXTAUTH_SECRET=your-secret-key-change-in-production + +# Optional: Custom ports +UI_NEXT_PORT=3000 + +# Optional: Ansible Java version +ANSIBLE_JAVA_VERSION=21 diff --git a/ui-next/.dockerignore b/ui-next/.dockerignore new file mode 100644 index 0000000..775b36f --- /dev/null +++ b/ui-next/.dockerignore @@ -0,0 +1,46 @@ +# dependencies +node_modules + +# build outputs +.next +out +dist +build +.turbo +.cache +coverage + +# logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env / secrets +.env +.env.* + +# vcs +.git +.gitignore + +# editor / os +.vscode +.idea +.DS_Store + +# local data / runtime +data +databases +tmp +logs + +# tests +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx + +# docs +README.md diff --git a/ui-next/.gitignore b/ui-next/.gitignore new file mode 100644 index 0000000..665a7be --- /dev/null +++ b/ui-next/.gitignore @@ -0,0 +1,55 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ +/.turbo/ + +# production +/build +/dist/ + +# misc +.DS_Store +*.pem +*.log + +# local runtime / temp +/tmp/ +/logs/ + +# local data stores +/data/ +/databases/ + +# cache +/.cache/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui-next/Dockerfile b/ui-next/Dockerfile new file mode 100644 index 0000000..c83cb94 --- /dev/null +++ b/ui-next/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:24-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:24-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Run as non-root user +RUN addgroup -S nodejs && adduser -S nextjs -G nodejs + +# Create data directory and give ownership to nextjs user +RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data + +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.ts ./next.config.ts +COPY --from=builder /app/messages ./messages + +USER nextjs +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/ui-next/README.md b/ui-next/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/ui-next/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/ui-next/app/(app)/api-interne/api-tester.tsx b/ui-next/app/(app)/api-interne/api-tester.tsx new file mode 100644 index 0000000..d7c79a6 --- /dev/null +++ b/ui-next/app/(app)/api-interne/api-tester.tsx @@ -0,0 +1,756 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { + BookCopy, + Boxes, + LayoutDashboard, + Network, + ScrollText, + Settings, + UserCog, + Users, + Variable, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +type HttpMethod = "get" | "post" | "put" | "delete" | "patch"; + +type OpenApiOperation = { + summary?: string; + tags?: string[]; + responses?: Record; + requestBody?: { + required?: boolean; + content?: Record; + }; +}; + +type OpenApiPathItem = Partial>; + +type OpenApiDoc = { + paths?: Record; +}; + +type RouteItem = { + feature: string; + path: string; + method: HttpMethod; + summary: string; + needsBody: boolean; + responseCodes: string[]; +}; + +type ApiResponse = { + status: number; + durationMs: number; + contentType: string; + bodyText: string; +}; + +const methodOrder: HttpMethod[] = ["get", "post", "put", "patch", "delete"]; + +function extractPathParams(path: string): string[] { + const matches = path.match(/\{[^}]+\}/g) ?? []; + return matches.map((raw) => raw.slice(1, -1)); +} + +function buildPathFromTemplate(path: string, pathParams: Record): string { + return path.replace(/\{([^}]+)\}/g, (_, key: string) => { + const value = pathParams[key] ?? ""; + return encodeURIComponent(value); + }); +} + +function prettyPrint(value: string): string { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } +} + +function toFeatureLabel(feature: string): string { + const label = feature.replace(/[-_]/g, " "); + return label.charAt(0).toUpperCase() + label.slice(1); +} + +function getFeatureIcon(feature: string): LucideIcon { + const featureIcons: Record = { + account: UserCog, + catalogs: BookCopy, + events: ScrollText, + graphs: Network, + infrastructures: Network, + inventory: Boxes, + settings: Settings, + softwares: Boxes, + system: LayoutDashboard, + users: Users, + variables: Variable, + }; + + return featureIcons[feature] ?? LayoutDashboard; +} + +function getMethodBadgeClass(method: HttpMethod): string { + const base = "rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide"; + const colors: Record = { + get: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", + post: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", + put: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", + patch: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300", + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", + }; + + return `${base} ${colors[method]}`; +} + +function getResponseStatusBadgeClass(status: number): string { + const base = "rounded-full px-2 py-1 font-semibold"; + + if (status >= 200 && status < 300) { + return `${base} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300`; + } + + if (status >= 300 && status < 400) { + return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300`; + } + + if (status >= 400 && status < 500) { + return `${base} bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300`; + } + + if (status >= 500) { + return `${base} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300`; + } + + return `${base} bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200`; +} + +function getRequestExample(path: string, method: HttpMethod): unknown { + const key = `${method.toUpperCase()} ${path}`; + + const examples: Record = { + "POST /api/account": { + email: "admin@example.local", + password: "Password123", + }, + "POST /api/events": { + event_type: "deploy", + status: "success", + message: "Deployment completed", + timestamp: new Date().toISOString(), + }, + "POST /api/catalogs": { + name: "my_catalog", + version: "1.0.0", + forkable: true, + }, + "PUT /api/catalogs/{id}": { + alias: "My Catalog", + description: "Catalog update description", + documentation: "https://docs.example.com/catalog", + cron: false, + crontab: "* * * * *", + }, + "POST /api/catalogs/{id}/fork": { + origin: "catalog-origin-id", + version: "1.0.0", + suffix: "custom", + alias: "My Fork", + description: "Forked catalog", + cron: false, + crontab: "* * * * *", + dockerfile_root: "FROM ubuntu:24.04", + dockerfile_nonroot: "FROM ubuntu:24.04", + }, + "PUT /api/catalogs/{id}/fork": { + origin: "catalog-origin-id", + suffix: "custom", + alias: "My Fork", + description: "Updated forked catalog", + cron: false, + crontab: "* * * * *", + dockerfile_root: "FROM ubuntu:24.04", + dockerfile_nonroot: "FROM ubuntu:24.04", + }, + "POST /api/infrastructures": { + name: "frontends-main", + description: "Main frontend infrastructure", + icon: "server", + color: "#1d4ed8", + }, + "PUT /api/infrastructures/{id}": { + name: "frontends-main", + description: "Updated infrastructure description", + icon: "server", + color: "#1d4ed8", + }, + "POST /api/infrastructures/{id}/tfstates": { + version: 4, + resources: [], + }, + "POST /api/settings/export": { + projects: ["infrastructure-id"], + }, + "POST /api/settings/import": { + projects: [ + { + infrastructure: { + name: "Imported project", + description: "Imported from backup", + icon: "server", + color: "#1d4ed8", + tfstate: {}, + }, + softwares: [], + variables: [], + }, + ], + }, + "POST /api/softwares": { + instance: "instance001.frontends.local", + software: "catalog-software-id", + size: "small", + domain: "app.example.com", + domain_alias: "", + exposition: "public", + }, + "PUT /api/softwares/{id}": { + instance: "instance001.frontends.local", + software: "catalog-software-id", + size: "medium", + domain: "app.example.com", + domain_alias: "", + exposition: "public", + }, + "POST /api/softwares/{id}": { + action: "start", + }, + "POST /api/users": { + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com", + language: "en", + password: "Password123", + token: "", + isdisabled: false, + sa: false, + }, + "PUT /api/users/{id}": { + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com", + language: "en", + password: "Password123", + token: "", + sa: false, + isdisabled: false, + isinactive: false, + notifications: true, + }, + "PUT /api/users/profile": { + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com", + language: "en", + }, + "PUT /api/users/password": { + password: "NewPassword123", + }, + "POST /api/variables": { + type: "project", + key: "my.project.example", + value: "secret-value", + }, + "PUT /api/variables/{id}": { + type: "project", + key: "my.project.example", + value: "updated-secret-value", + }, + "POST /api/variables/{id}": { + mode: "read2", + key2: "my_project_example", + }, + "POST /api/variables/secret": { + type: "project", + key: "my.project.example", + subkey: "password", + missing: "warn", + nosymbols: false, + length: 24, + }, + }; + + return examples[key] ?? {}; +} + +function getResponseExample(path: string, method: HttpMethod, statusCode: string): unknown { + const key = `${method.toUpperCase()} ${path} ${statusCode}`; + + const specificExamples: Record = { + "GET /api/ping 200": { ok: true }, + "GET /api/events 200": [{ type: "info", body: "15/03/2026 14:45:00 - Deployment completed" }], + "POST /api/events 201": { ok: true }, + "GET /api/catalogs 200": [{ id: "catalog-id", name: "my_catalog", version: "1.0.0" }], + "GET /api/infrastructures 200": [ + { id: "infra-id", name: "frontends-main", description: "Main infrastructure" }, + ], + "GET /api/users 200": [ + { id: "user-id", first_name: "John", last_name: "Doe", email: "john.doe@example.com" }, + ], + "GET /api/variables 200": [{ id: "var-id", type: "project", key: "my.project.example" }], + }; + + if (specificExamples[key]) { + return specificExamples[key]; + } + + const code = Number(statusCode); + if (code === 201) return { ok: true }; + if (code === 400) return { error: "Bad Request" }; + if (code === 401) return { error: "Unauthorized" }; + if (code === 403) return { error: "Forbidden" }; + if (code === 404) return { error: "Not found" }; + if (code >= 500) return { error: "Internal Server Error" }; + return { ok: true }; +} + +export default function ApiTester() { + const [routes, setRoutes] = useState([]); + const [selectedRouteKey, setSelectedRouteKey] = useState(""); + const [pathParams, setPathParams] = useState>({}); + const [payload, setPayload] = useState("{}"); + const [isLoadingSpec, setIsLoadingSpec] = useState(true); + const [isRunning, setIsRunning] = useState(false); + const [specError, setSpecError] = useState(""); + const [requestError, setRequestError] = useState(""); + const [selectedExampleCode, setSelectedExampleCode] = useState(""); + const [response, setResponse] = useState(null); + const [copyState, setCopyState] = useState<"idle" | "success" | "error">("idle"); + + useEffect(() => { + let cancelled = false; + + const loadSpec = async () => { + setIsLoadingSpec(true); + setSpecError(""); + try { + const res = await fetch("/api/openapi-internal", { + method: "GET", + cache: "no-store", + credentials: "same-origin", + }); + + if (!res.ok) { + throw new Error(`Impossible de charger la spec (${res.status})`); + } + + const doc = (await res.json()) as OpenApiDoc; + const nextRoutes: RouteItem[] = []; + + for (const [path, pathItem] of Object.entries(doc.paths ?? {})) { + for (const method of methodOrder) { + const operation = pathItem?.[method]; + if (!operation) continue; + + const defaultSummary = `${method.toUpperCase()} ${path}`; + const feature = (operation.tags?.[0] ?? "other").toLowerCase(); + nextRoutes.push({ + feature, + path, + method, + summary: operation.summary ?? defaultSummary, + needsBody: !!operation.requestBody, + responseCodes: Object.keys(operation.responses ?? {}).sort(), + }); + } + } + + nextRoutes.sort((a, b) => { + if (a.feature !== b.feature) { + return a.feature.localeCompare(b.feature); + } + if (a.path === b.path) { + return methodOrder.indexOf(a.method) - methodOrder.indexOf(b.method); + } + return a.path.localeCompare(b.path); + }); + + if (cancelled) return; + + setRoutes(nextRoutes); + if (nextRoutes.length > 0) { + setSelectedRouteKey(`${nextRoutes[0].method} ${nextRoutes[0].path}`); + } + } catch (error) { + if (!cancelled) { + setSpecError(error instanceof Error ? error.message : "Erreur inconnue"); + } + } finally { + if (!cancelled) { + setIsLoadingSpec(false); + } + } + }; + + loadSpec(); + + return () => { + cancelled = true; + }; + }, []); + + const selectedRoute = useMemo(() => { + return routes.find((route) => `${route.method} ${route.path}` === selectedRouteKey) ?? null; + }, [routes, selectedRouteKey]); + + const groupedRoutes = useMemo(() => { + const groups = new Map(); + for (const route of routes) { + const existing = groups.get(route.feature) ?? []; + existing.push(route); + groups.set(route.feature, existing); + } + + return [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([feature, items]) => ({ feature, items })); + }, [routes]); + + const selectedPathParamKeys = useMemo(() => { + if (!selectedRoute) return []; + return extractPathParams(selectedRoute.path); + }, [selectedRoute]); + + const selectedExamplePayload = useMemo(() => { + if (!selectedRoute) return "{}"; + return prettyPrint(JSON.stringify(getRequestExample(selectedRoute.path, selectedRoute.method))); + }, [selectedRoute]); + + const selectedResponseExample = useMemo(() => { + if (!selectedRoute || !selectedExampleCode) return "{}"; + return prettyPrint( + JSON.stringify(getResponseExample(selectedRoute.path, selectedRoute.method, selectedExampleCode)), + ); + }, [selectedRoute, selectedExampleCode]); + + useEffect(() => { + const defaults: Record = {}; + for (const key of selectedPathParamKeys) { + defaults[key] = pathParams[key] ?? ""; + } + setPathParams(defaults); + setRequestError(""); + setResponse(null); + if (selectedRoute?.responseCodes?.length) { + setSelectedExampleCode(selectedRoute.responseCodes[0]); + } else { + setSelectedExampleCode("200"); + } + setPayload(selectedExamplePayload); + }, [selectedPathParamKeys]); + + const finalUrl = useMemo(() => { + if (!selectedRoute) return ""; + return buildPathFromTemplate(selectedRoute.path, pathParams); + }, [selectedRoute, pathParams]); + + const canRun = !!selectedRoute && !isRunning; + + async function copyResponseToClipboard() { + if (!response) return; + + try { + await navigator.clipboard.writeText(response.bodyText || "(Reponse vide)"); + setCopyState("success"); + } catch { + setCopyState("error"); + } + } + + async function runRequest() { + if (!selectedRoute) return; + + setRequestError(""); + setResponse(null); + + for (const key of selectedPathParamKeys) { + if (!pathParams[key]?.trim()) { + setRequestError(`Le parametre de chemin \"${key}\" est obligatoire.`); + return; + } + } + + let parsedBody: unknown = undefined; + if (selectedRoute.needsBody) { + try { + parsedBody = payload.trim() ? JSON.parse(payload) : {}; + } catch { + setRequestError("Payload JSON invalide."); + return; + } + } + + const headers: HeadersInit = {}; + if (selectedRoute.needsBody) { + headers["Content-Type"] = "application/json"; + } + + setIsRunning(true); + + const startedAt = performance.now(); + try { + const res = await fetch(finalUrl, { + method: selectedRoute.method.toUpperCase(), + headers, + body: selectedRoute.needsBody ? JSON.stringify(parsedBody) : undefined, + credentials: "same-origin", + cache: "no-store", + }); + + const rawText = await res.text(); + const durationMs = Math.round(performance.now() - startedAt); + + setResponse({ + status: res.status, + durationMs, + contentType: res.headers.get("content-type") ?? "", + bodyText: prettyPrint(rawText), + }); + setCopyState("idle"); + } catch (error) { + setRequestError(error instanceof Error ? error.message : "Echec de la requete"); + } finally { + setIsRunning(false); + } + } + + return ( +
+ + +
+
+

API interne

+

+ Teste les endpoints directement avec ta session active. +

+
+ + +
+
+ + {!selectedRoute ? ( +
Selectionne une route pour continuer.
+ ) : ( +
+
+ {selectedPathParamKeys.length > 0 && ( +
+

+ Parametres de chemin +

+ {selectedPathParamKeys.map((key) => ( + + setPathParams((prev) => ({ + ...prev, + [key]: event.target.value, + })) + } + placeholder={key} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm outline-none ring-zinc-500 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-900" + /> + ))} +
+ )} + + {selectedRoute.method !== "get" && ( +
+ +