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>
This commit is contained in:
root 2026-05-11 03:24:27 +00:00
parent b6e996714d
commit 35bf51302c
7 changed files with 277 additions and 25 deletions

View File

@ -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 WORKDIR /app
ARG HTTP_PROXY="" # Gitea npm registry for @bytelyst/* packages
ARG HTTPS_PROXY="" COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
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 ./
COPY packages/ packages/ COPY packages/ packages/
COPY dashboards/admin-web/package.json dashboards/admin-web/ 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/ COPY dashboards/admin-web/ dashboards/admin-web/
ENV COSMOS_ENDPOINT=https://placeholder.documents.azure.com:443/ # Build-time env vars (baked into the static bundle)
ENV COSMOS_KEY=placeholder== ARG NEXT_PUBLIC_PRODUCT_ID=admin
ENV COSMOS_DATABASE=lysnrai ARG NEXT_PUBLIC_PLATFORM_URL=https://api.bytelyst.com/platform/api
ENV JWT_SECRET=build-time-placeholder
ENV NEXT_TELEMETRY_DISABLED=1
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 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 WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3001 ENV PORT=3001
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV HUSKY=0
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@ -50,4 +65,4 @@ USER nextjs
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dashboards/admin-web/server.js"] CMD ["node", "server.js"]

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center p-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-lg">Access Denied</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-sm text-muted-foreground">
You don&apos;t have permission to access this page. Please contact your administrator if
you believe this is an error.
</p>
<Link href="/">
<Button variant="outline" className="w-full">
Return to Dashboard
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string, RateLimitEntry>();
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
});

View File

@ -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',
};

View File

@ -0,0 +1,54 @@
import { useEffect, useRef, useCallback } from 'react';
interface UseAutoRefreshOptions {
callback: () => void | Promise<void>;
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<NodeJS.Timeout | null>(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 };
}

View File

@ -249,6 +249,33 @@ services:
timeout: 5s timeout: 5s
retries: 10 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 ───────────────────────────────────────────────────────
volumes: volumes:
azurite-data: azurite-data:

29
scripts/deploy-admin-hotcopy.sh Executable file
View File

@ -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