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
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
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