From 35bf51302c4361da91a235fee09a410f3c8f4034 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 11 May 2026 03:24:27 +0000 Subject: [PATCH] feat(admin-web): adopt trading web deployment model with docker-compose - Add admin-web to docker-compose.yml following trading pattern - Update admin-web Dockerfile with multi-stage build and metadata - Add build metadata (commit SHA, branch, timestamp, author, message) - Add hotcopy deployment script for quick updates - Add unauthorized page and rate limiting library - Add runtime utilities and auto-refresh hook Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dashboards/admin-web/Dockerfile | 65 ++++++++----- .../admin-web/src/app/unauthorized/page.tsx | 30 ++++++ dashboards/admin-web/src/lib/rate-limit.ts | 92 +++++++++++++++++++ dashboards/admin-web/src/lib/runtime.ts | 5 + .../admin-web/src/lib/use-auto-refresh.ts | 54 +++++++++++ docker-compose.yml | 27 ++++++ scripts/deploy-admin-hotcopy.sh | 29 ++++++ 7 files changed, 277 insertions(+), 25 deletions(-) create mode 100644 dashboards/admin-web/src/app/unauthorized/page.tsx create mode 100644 dashboards/admin-web/src/lib/rate-limit.ts create mode 100644 dashboards/admin-web/src/lib/runtime.ts create mode 100644 dashboards/admin-web/src/lib/use-auto-refresh.ts create mode 100755 scripts/deploy-admin-hotcopy.sh diff --git a/dashboards/admin-web/Dockerfile b/dashboards/admin-web/Dockerfile index a571f45b..8f641230 100644 --- a/dashboards/admin-web/Dockerfile +++ b/dashboards/admin-web/Dockerfile @@ -1,43 +1,58 @@ -FROM node:22-alpine AS builder +# Build context: learning_ai_common_plat/ (monorepo root) +# --- Stage 1: Build --- +FROM node:20-alpine AS builder + +RUN corepack enable && corepack prepare pnpm@10.6.5 --activate + WORKDIR /app -ARG HTTP_PROXY="" -ARG HTTPS_PROXY="" -ARG NO_PROXY="localhost,127.0.0.1" -ENV HTTP_PROXY=${HTTP_PROXY} -ENV HTTPS_PROXY=${HTTPS_PROXY} -ENV NO_PROXY=${NO_PROXY} -ENV NODE_TLS_REJECT_UNAUTHORIZED=0 -ENV NPM_CONFIG_STRICT_SSL=false -ENV HUSKY=0 - -RUN npm config set strict-ssl false \ - && npm install -g pnpm@10.6.5 - -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +# Gitea npm registry for @bytelyst/* packages +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY packages/ packages/ COPY dashboards/admin-web/package.json dashboards/admin-web/ -RUN pnpm install --ignore-scripts +RUN --mount=type=secret,id=gitea_npm_token \ + TOKEN=$(cat /run/secrets/gitea_npm_token) && \ + printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\n' "$TOKEN" > .npmrc && \ + pnpm install --ignore-scripts --legacy-peer-deps COPY dashboards/admin-web/ dashboards/admin-web/ -ENV COSMOS_ENDPOINT=https://placeholder.documents.azure.com:443/ -ENV COSMOS_KEY=placeholder== -ENV COSMOS_DATABASE=lysnrai -ENV JWT_SECRET=build-time-placeholder -ENV NEXT_TELEMETRY_DISABLED=1 +# Build-time env vars (baked into the static bundle) +ARG NEXT_PUBLIC_PRODUCT_ID=admin +ARG NEXT_PUBLIC_PLATFORM_URL=https://api.bytelyst.com/platform/api -RUN pnpm -r --filter @bytelyst/admin-web... build +# Build metadata for @bytelyst/devops (web bundle) +ARG BYTELYST_COMMIT_SHA=unknown +ARG BYTELYST_COMMIT_SHA_FULL=unknown +ARG BYTELYST_BRANCH=unknown +ARG BYTELYST_BUILT_AT=unknown +ARG BYTELYST_COMMIT_AUTHOR=unknown +ARG BYTELYST_COMMIT_MESSAGE=unknown +ARG BYTELYST_DOCKER_IMAGE=admin-web:latest + +ENV NEXT_PUBLIC_PRODUCT_ID=${NEXT_PUBLIC_PRODUCT_ID} \ + NEXT_PUBLIC_PLATFORM_URL=${NEXT_PUBLIC_PLATFORM_URL} \ + NEXT_PUBLIC_BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \ + NEXT_PUBLIC_BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \ + NEXT_PUBLIC_BYTELYST_BRANCH=${BYTELYST_BRANCH} \ + NEXT_PUBLIC_BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \ + NEXT_PUBLIC_BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \ + NEXT_PUBLIC_BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \ + NEXT_PUBLIC_BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE} \ + NEXT_TELEMETRY_DISABLED=1 + +RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm -r --filter @bytelyst/admin-web... build RUN pnpm --filter @bytelyst/admin-web deploy --legacy --ignore-scripts /app/deploy -FROM node:22-alpine +# --- Stage 2: Serve --- +FROM node:20-alpine AS runner + WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3001 ENV HOSTNAME=0.0.0.0 -ENV HUSKY=0 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -50,4 +65,4 @@ USER nextjs EXPOSE 3001 -CMD ["node", "dashboards/admin-web/server.js"] +CMD ["node", "server.js"] diff --git a/dashboards/admin-web/src/app/unauthorized/page.tsx b/dashboards/admin-web/src/app/unauthorized/page.tsx new file mode 100644 index 00000000..085764ed --- /dev/null +++ b/dashboards/admin-web/src/app/unauthorized/page.tsx @@ -0,0 +1,30 @@ +import { ShieldX } from 'lucide-react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function UnauthorizedPage() { + return ( +
+ + +
+ +
+ Access Denied +
+ +

+ You don't have permission to access this page. Please contact your administrator if + you believe this is an error. +

+ + + +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/lib/rate-limit.ts b/dashboards/admin-web/src/lib/rate-limit.ts new file mode 100644 index 00000000..4eb1a93f --- /dev/null +++ b/dashboards/admin-web/src/lib/rate-limit.ts @@ -0,0 +1,92 @@ +/** + * Simple in-memory rate limiter for Next.js API routes. + * For production, consider using Redis-backed rate limiting. + */ + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +const rateLimitStore = new Map(); + +export interface RateLimitOptions { + windowMs: number; // Time window in milliseconds + maxRequests: number; // Max requests per window + keyGenerator?: (request: Request) => string; // Custom key generator +} + +export interface RateLimitResult { + success: boolean; + limit: number; + remaining: number; + resetTime: number; +} + +export function createRateLimiter(options: RateLimitOptions) { + const { windowMs, maxRequests, keyGenerator } = options; + + return { + check(request: Request): RateLimitResult { + const key = keyGenerator ? keyGenerator(request) : getDefaultKey(request); + const now = Date.now(); + + // Clean up expired entries + for (const [k, v] of rateLimitStore.entries()) { + if (v.resetTime < now) { + rateLimitStore.delete(k); + } + } + + // Get or create entry + let entry = rateLimitStore.get(key); + if (!entry || entry.resetTime < now) { + entry = { count: 0, resetTime: now + windowMs }; + rateLimitStore.set(key, entry); + } + + // Check limit + const remaining = Math.max(0, maxRequests - entry.count); + const success = entry.count < maxRequests; + + if (success) { + entry.count++; + } + + return { + success, + limit: maxRequests, + remaining, + resetTime: entry.resetTime, + }; + }, + + reset(key: string): void { + rateLimitStore.delete(key); + }, + }; +} + +function getDefaultKey(request: Request): string { + // Use IP address as default key + const forwarded = request.headers.get('x-forwarded-for'); + const realIp = request.headers.get('x-real-ip'); + const ip = forwarded?.split(',')[0]?.trim() || realIp || 'unknown'; + return `ratelimit:${ip}`; +} + +// Pre-configured rate limiters +export const authRateLimiter = createRateLimiter({ + windowMs: 15 * 60 * 1000, // 15 minutes + maxRequests: 5, // 5 login attempts per 15 minutes +}); + +export const generalRateLimiter = createRateLimiter({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 100, // 100 requests per minute +}); + +export const strictRateLimiter = createRateLimiter({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 20, // 20 requests per minute +}); diff --git a/dashboards/admin-web/src/lib/runtime.ts b/dashboards/admin-web/src/lib/runtime.ts new file mode 100644 index 00000000..6c26c84f --- /dev/null +++ b/dashboards/admin-web/src/lib/runtime.ts @@ -0,0 +1,5 @@ +// Admin web runtime configuration - matches trading web pattern +export const adminRuntime = { + productId: process.env.NEXT_PUBLIC_PRODUCT_ID || 'bytelyst-admin', + platformApiUrl: process.env.NEXT_PUBLIC_PLATFORM_URL || 'https://api.bytelyst.com/platform/api', +}; diff --git a/dashboards/admin-web/src/lib/use-auto-refresh.ts b/dashboards/admin-web/src/lib/use-auto-refresh.ts new file mode 100644 index 00000000..9499f0a0 --- /dev/null +++ b/dashboards/admin-web/src/lib/use-auto-refresh.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useCallback } from 'react'; + +interface UseAutoRefreshOptions { + callback: () => void | Promise; + intervalMs: number; + enabled?: boolean; + immediate?: boolean; +} + +/** + * Custom hook for auto-refreshing data at regular intervals. + * Cleans up automatically on unmount. + */ +export function useAutoRefresh({ + callback, + intervalMs, + enabled = true, + immediate = true, +}: UseAutoRefreshOptions) { + const intervalRef = useRef(null); + const isMountedRef = useRef(true); + + const executeCallback = useCallback(async () => { + if (isMountedRef.current && enabled) { + await callback(); + } + }, [callback, enabled]); + + useEffect(() => { + isMountedRef.current = true; + + if (immediate) { + executeCallback(); + } + + if (enabled) { + intervalRef.current = setInterval(executeCallback, intervalMs); + } + + return () => { + isMountedRef.current = false; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [callback, intervalMs, enabled, immediate, executeCallback]); + + const refresh = useCallback(() => { + executeCallback(); + }, [executeCallback]); + + return { refresh }; +} diff --git a/docker-compose.yml b/docker-compose.yml index 6aa4bd86..f0c4f497 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -249,6 +249,33 @@ services: timeout: 5s retries: 10 + # ── Admin Web (Next.js Platform Admin Console) ───────────────── + admin-web: + build: + context: . + dockerfile: dashboards/admin-web/Dockerfile + args: + BYTELYST_PACKAGE_SOURCE: ${BYTELYST_PACKAGE_SOURCE:-vendor} + NEXT_PUBLIC_PRODUCT_ID: ${NEXT_PUBLIC_PRODUCT_ID:-admin} + NEXT_PUBLIC_PLATFORM_URL: http://platform-service:4003 + container_name: admin-web + ports: + - '3001:3001' + networks: + - default + restart: unless-stopped + depends_on: + platform-service: + condition: service_healthy + environment: + - NODE_ENV=production + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:3001'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + # ── Volumes ─────────────────────────────────────────────────────── volumes: azurite-data: diff --git a/scripts/deploy-admin-hotcopy.sh b/scripts/deploy-admin-hotcopy.sh new file mode 100755 index 00000000..10b7abf7 --- /dev/null +++ b/scripts/deploy-admin-hotcopy.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +echo "Building admin web artifacts..." +cd /opt/bytelyst/learning_ai_common_plat && pnpm -r --filter @bytelyst/admin-web... build +pnpm --filter @bytelyst/admin-web deploy --legacy --ignore-scripts /tmp/admin-deploy + +echo "Copying frontend assets into admin-web..." +docker cp /tmp/admin-deploy/.next/standalone admin-web:/app/ +docker cp /tmp/admin-deploy/.next/static admin-web:/app/.next/static +docker cp /tmp/admin-deploy/public admin-web:/app/public + +echo "Restarting admin-web..." +docker restart admin-web >/dev/null + +echo "Waiting for web readiness..." +for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -s http://127.0.0.1:3001 > /dev/null; then + break + fi + sleep 2 +done + +echo "Admin Web hotcopy deployment complete" +echo "Web status:" +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3001 + +# Cleanup +rm -rf /tmp/admin-deploy \ No newline at end of file