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:
parent
b6e996714d
commit
35bf51302c
@ -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"]
|
||||||
|
|||||||
30
dashboards/admin-web/src/app/unauthorized/page.tsx
Normal file
30
dashboards/admin-web/src/app/unauthorized/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
dashboards/admin-web/src/lib/rate-limit.ts
Normal file
92
dashboards/admin-web/src/lib/rate-limit.ts
Normal 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
|
||||||
|
});
|
||||||
5
dashboards/admin-web/src/lib/runtime.ts
Normal file
5
dashboards/admin-web/src/lib/runtime.ts
Normal 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',
|
||||||
|
};
|
||||||
54
dashboards/admin-web/src/lib/use-auto-refresh.ts
Normal file
54
dashboards/admin-web/src/lib/use-auto-refresh.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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
29
scripts/deploy-admin-hotcopy.sh
Executable 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
|
||||||
Loading…
Reference in New Issue
Block a user