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