dashboard implementation
This commit is contained in:
parent
4103dd6468
commit
1c94f97d11
13 changed files with 298 additions and 45 deletions
18
.dockerignore
Normal file
18
.dockerignore
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
.output
|
||||
output
|
||||
storybook-static
|
||||
playwright-report
|
||||
test-results
|
||||
admin.log
|
||||
admin.pid
|
||||
.playwright-cli
|
||||
.vinxi
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
|
|
@ -21,8 +21,8 @@ WORKDIR /app
|
|||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
ENV PORT=3000
|
||||
ENV PORT=9202
|
||||
ENV HOST=0.0.0.0
|
||||
EXPOSE 3000
|
||||
EXPOSE 9102
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -8,3 +8,32 @@ Port admin modules one by one with strict API/permission parity.
|
|||
See `docs/MIGRATION_MASTER_PLAN.md`.
|
||||
|
||||
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
|
||||
|
||||
## Local Docker test (low RAM, no port conflict)
|
||||
|
||||
This repo ships an Alpine runtime image and a `docker-compose.yml` profile mapped to port `9102`.
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
curl -I http://localhost:9202
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Run additional isolated instances (`9103`, `9104`, ...):
|
||||
|
||||
```bash
|
||||
docker run -d --name nxtgauge-admin-solid-9103 -p 9103:9202 nxtgauge-admin-solid:local
|
||||
docker run -d --name nxtgauge-admin-solid-9104 -p 9104:9202 nxtgauge-admin-solid:local
|
||||
```
|
||||
|
|
|
|||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: nxtgauge-admin-solid:local
|
||||
container_name: nxtgauge-admin-solid
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 9102
|
||||
NODE_OPTIONS: --max-old-space-size=256
|
||||
GATEWAY_URL: http://host.docker.internal:9100
|
||||
ports:
|
||||
- "9202:9102"
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
cpus: 0.75
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:9102/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
- Frontend admin app: `/Users/ashwin/workspace/nxtgauge-admin-solid`
|
||||
- Rust backend workspace: `/Users/ashwin/workspace/nxtgauge-backend-rust`
|
||||
- Required ports:
|
||||
- Admin app: `http://localhost:3000`
|
||||
- Admin app: `http://localhost:9202`
|
||||
- Storybook: `http://localhost:6006`
|
||||
- Preconditions:
|
||||
- `npm install` completed in admin repo.
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -5,11 +5,11 @@
|
|||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"start:3000": "HOST=0.0.0.0 PORT=3000 node .output/server/index.mjs",
|
||||
"admin:restart:3000": "bash ./scripts/admin-3000-service.sh restart",
|
||||
"admin:stop:3000": "bash ./scripts/admin-3000-service.sh stop",
|
||||
"admin:status:3000": "bash ./scripts/admin-3000-service.sh status",
|
||||
"admin:start:3000": "bash ./scripts/admin-3000-service.sh start",
|
||||
"start:9202": "HOST=0.0.0.0 PORT=9202 node .output/server/index.mjs",
|
||||
"admin:restart:9202": "bash ./scripts/admin-3000-service.sh restart",
|
||||
"admin:stop:9202": "bash ./scripts/admin-3000-service.sh stop",
|
||||
"admin:status:9202": "bash ./scripts/admin-3000-service.sh status",
|
||||
"admin:start:9202": "bash ./scripts/admin-3000-service.sh start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="/Users/ashwin/workspace/nxtgauge-admin-solid"
|
||||
PID_FILE="/tmp/nxtgauge-admin-3000.pid"
|
||||
APP_LOG="/tmp/nxtgauge-admin-3000.log"
|
||||
APP_URL="http://127.0.0.1:3000/admin/external-dashboard-management"
|
||||
PID_FILE="/tmp/nxtgauge-admin-9202.pid"
|
||||
APP_LOG="/tmp/nxtgauge-admin-9202.log"
|
||||
APP_URL="http://127.0.0.1:9202/admin/external-dashboard-management"
|
||||
|
||||
kill_listeners() {
|
||||
lsof -tiTCP:3000 -sTCP:LISTEN | xargs -r kill -9 || true
|
||||
lsof -tiTCP:9202 -sTCP:LISTEN | xargs -r kill -9 || true
|
||||
}
|
||||
|
||||
start() {
|
||||
|
|
@ -15,7 +15,7 @@ start() {
|
|||
old_pid="$(cat "$PID_FILE" || true)"
|
||||
if [[ -n "${old_pid:-}" ]] && kill -0 "$old_pid" 2>/dev/null; then
|
||||
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
|
||||
echo "admin-3000 already running (pid $old_pid)"
|
||||
echo "admin-9202 already running (pid $old_pid)"
|
||||
echo "url: $APP_URL"
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -24,25 +24,25 @@ start() {
|
|||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
pkill -f "admin-3000-daemon.sh" || true
|
||||
pkill -f "admin-9202-daemon.sh" || true
|
||||
pkill -f ".output/server/index.mjs" || true
|
||||
kill_listeners
|
||||
|
||||
nohup /bin/zsh -lc "cd '$ROOT_DIR' && HOST=0.0.0.0 PORT=3000 node .output/server/index.mjs" >>"$APP_LOG" 2>&1 &
|
||||
nohup /bin/zsh -lc "cd '$ROOT_DIR' && HOST=0.0.0.0 PORT=9202 node .output/server/index.mjs" >>"$APP_LOG" 2>&1 &
|
||||
new_pid=$!
|
||||
echo "$new_pid" > "$PID_FILE"
|
||||
|
||||
for _ in {1..30}; do
|
||||
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
|
||||
echo "admin-3000: running (pid $new_pid)"
|
||||
echo "admin-9202: running (pid $new_pid)"
|
||||
echo "url: $APP_URL"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "admin-3000 failed to become healthy"
|
||||
tail -n 80 /tmp/nxtgauge-admin-3000.log || true
|
||||
echo "admin-9202 failed to become healthy"
|
||||
tail -n 80 /tmp/nxtgauge-admin-9202.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
|
@ -55,9 +55,9 @@ stop() {
|
|||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
pkill -f "admin-3000-daemon.sh|.output/server/index.mjs" || true
|
||||
pkill -f "admin-9202-daemon.sh|.output/server/index.mjs" || true
|
||||
kill_listeners
|
||||
echo "admin-3000: stopped"
|
||||
echo "admin-9202: stopped"
|
||||
}
|
||||
|
||||
status() {
|
||||
|
|
@ -70,14 +70,14 @@ status() {
|
|||
fi
|
||||
fi
|
||||
|
||||
if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "admin-3000: running"
|
||||
if lsof -nP -iTCP:9202 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "admin-9202: running"
|
||||
echo "process: $app_state${app_pid:+ (pid $app_pid)}"
|
||||
lsof -nP -iTCP:3000 -sTCP:LISTEN
|
||||
lsof -nP -iTCP:9202 -sTCP:LISTEN
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "admin-3000: stopped"
|
||||
echo "admin-9202: stopped"
|
||||
echo "process: $app_state${app_pid:+ (pid $app_pid)}"
|
||||
exit 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="/Users/ashwin/workspace/nxtgauge-admin-solid"
|
||||
PID_FILE="/tmp/nxtgauge-admin-3000.pid"
|
||||
LOG_FILE="/tmp/nxtgauge-admin-3000.log"
|
||||
APP_URL="http://127.0.0.1:3000/admin/external-dashboard-management"
|
||||
PID_FILE="/tmp/nxtgauge-admin-9202.pid"
|
||||
LOG_FILE="/tmp/nxtgauge-admin-9202.log"
|
||||
APP_URL="http://127.0.0.1:9202/admin/external-dashboard-management"
|
||||
|
||||
stop_server() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
|
|
@ -15,16 +15,16 @@ stop_server() {
|
|||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
lsof -tiTCP:3000 -sTCP:LISTEN | xargs -r kill -9 || true
|
||||
lsof -tiTCP:9202 -sTCP:LISTEN | xargs -r kill -9 || true
|
||||
}
|
||||
|
||||
status_server() {
|
||||
if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "admin-3000: running"
|
||||
lsof -nP -iTCP:3000 -sTCP:LISTEN
|
||||
if lsof -nP -iTCP:9202 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "admin-9202: running"
|
||||
lsof -nP -iTCP:9202 -sTCP:LISTEN
|
||||
exit 0
|
||||
fi
|
||||
echo "admin-3000: stopped"
|
||||
echo "admin-9202: stopped"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
|
@ -36,20 +36,20 @@ start_server() {
|
|||
npm run build
|
||||
fi
|
||||
|
||||
nohup env HOST=127.0.0.1 PORT=3000 node .output/server/index.mjs >"$LOG_FILE" 2>&1 &
|
||||
nohup env HOST=127.0.0.1 PORT=9202 node .output/server/index.mjs >"$LOG_FILE" 2>&1 &
|
||||
new_pid=$!
|
||||
echo "$new_pid" > "$PID_FILE"
|
||||
|
||||
for _ in {1..20}; do
|
||||
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
|
||||
echo "admin-3000: running (pid $new_pid)"
|
||||
echo "admin-9202: running (pid $new_pid)"
|
||||
echo "url: $APP_URL"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "admin-3000: failed to start"
|
||||
echo "admin-9202: failed to start"
|
||||
echo "Last log lines:"
|
||||
tail -n 40 "$LOG_FILE" || true
|
||||
exit 1
|
||||
|
|
@ -63,7 +63,7 @@ case "$action" in
|
|||
;;
|
||||
stop)
|
||||
stop_server
|
||||
echo "admin-3000: stopped"
|
||||
echo "admin-9202: stopped"
|
||||
;;
|
||||
status)
|
||||
status_server
|
||||
|
|
|
|||
|
|
@ -497,6 +497,27 @@ const PORTFOLIO_SPECS: Record<string, PortfolioSpec> = {
|
|||
{ name: 'Royal', price: '₹1,200/plate', items: ['Custom menu', 'Chef station', 'Premium décor', 'Full-day service'] },
|
||||
],
|
||||
},
|
||||
JOB_SEEKER: {
|
||||
roleLabel: 'Job Seeker',
|
||||
tabs: ['about', 'education', 'work experience', 'skills & certifications', 'projects', 'faqs'],
|
||||
specialties: ['Frontend Development', 'Backend APIs', 'QA Testing', 'Data Analysis', 'UI Design', 'Customer Support'],
|
||||
statsLabels: ['Applications Sent', 'Years Exp', 'Profiles Viewed', 'Interviews', 'Open To Work'],
|
||||
equipmentLabel: 'Resume Assets',
|
||||
galleryTabLabel: 'projects',
|
||||
experienceTabLabel: 'work experience',
|
||||
serviceTabLabel: 'skills & certifications',
|
||||
faqItems: [
|
||||
{ q: 'What kind of roles are you targeting?', a: 'I am targeting full-time and contract roles aligned with my core skills and prior project experience.' },
|
||||
{ q: 'Are you open to relocation or remote?', a: 'Yes. I am open to remote roles and relocation for the right opportunity.' },
|
||||
{ q: 'How soon can you join?', a: 'I can join immediately or within the standard notice period discussed during hiring.' },
|
||||
{ q: 'Can you share sample projects?', a: 'Yes. Portfolio projects and case studies are available in the Projects tab.' },
|
||||
],
|
||||
packages: [
|
||||
{ name: 'Career Snapshot', price: 'Profile Section', items: ['Summary headline', 'Preferred role', 'Current location', 'Availability'] },
|
||||
{ name: 'Experience Overview', price: 'Profile Section', items: ['Work history', 'Key achievements', 'Project highlights', 'Tools used'] },
|
||||
{ name: 'Showcase Pack', price: 'Profile Section', items: ['Project links', 'Skill matrix', 'Certifications', 'Resume download'] },
|
||||
],
|
||||
},
|
||||
PROFESSIONAL: {
|
||||
roleLabel: 'Professional',
|
||||
tabs: ['about', 'services & pricing', 'portfolio', 'experience', 'testimonials', 'faqs'],
|
||||
|
|
@ -565,7 +586,7 @@ const PORTFOLIO_TESTIMONIALS = [
|
|||
function customerViewFor(sidebar: string, roleKey: string): CustomerView {
|
||||
const key = String(sidebar || '').toLowerCase().trim();
|
||||
const role = normalizeRoleKey(roleKey);
|
||||
const isProfessionalRole = role !== 'COMPANY' && role !== 'JOB_SEEKER' && role !== 'CUSTOMER';
|
||||
const isProfessionalRole = role !== 'COMPANY' && role !== 'CUSTOMER';
|
||||
if (key === 'my dashboard') return { title: 'Service Seeker Dashboard Overview', subtitle: 'Manage your requirements and track professional responses in real-time.', tabs: ['overview', 'recent requirements', 'quick actions'], cta: 'Post New Requirement' };
|
||||
if (key === 'leads') return { title: 'Leads', subtitle: 'Browse marketplace requirements and request contact access for current opportunities.', tabs: [], cta: 'Buy Credits' };
|
||||
if (key === 'jobs') {
|
||||
|
|
@ -1031,7 +1052,7 @@ export default function DashboardDesignPreview(props: {
|
|||
}) {
|
||||
const isProfessionalRoleKey = (roleKey: string) => {
|
||||
const role = normalizeRoleKey(roleKey);
|
||||
return role !== 'COMPANY' && role !== 'JOB_SEEKER' && role !== 'CUSTOMER';
|
||||
return role !== 'COMPANY' && role !== 'CUSTOMER';
|
||||
};
|
||||
const normalizeTabKey = (value: string) => String(value || '').trim().toLowerCase();
|
||||
const isCustomerExternalMode = createMemo(() => props.mode === 'customer_external');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Server-side helper: all backend calls go through the Rust gateway
|
||||
const GATEWAY_URL = (process.env.GATEWAY_URL || 'http://localhost:8000').replace(/\/+$/, '');
|
||||
const GATEWAY_URL = (process.env.GATEWAY_URL || 'http://localhost:9100').replace(/\/+$/, '');
|
||||
|
||||
export function gatewayUrl(path: string): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { createMemo } from 'solid-js';
|
|||
import ApprovalManagementPage from './approval';
|
||||
import VerificationManagementPage from './verification';
|
||||
import UsersManagementPage from './users';
|
||||
import ExternalDashboardManagementPage from './external-dashboard-management';
|
||||
import InternalDashboardManagementPage from './internal-dashboard-management';
|
||||
|
||||
function toTitle(value: string): string {
|
||||
return value
|
||||
|
|
@ -12,7 +14,7 @@ function toTitle(value: string): string {
|
|||
.join(' ');
|
||||
}
|
||||
|
||||
const LEGACY_ADMIN_ORIGIN = import.meta.env.VITE_LEGACY_ADMIN_ORIGIN || 'http://localhost:3001';
|
||||
const LEGACY_ADMIN_ORIGIN = import.meta.env.VITE_LEGACY_ADMIN_ORIGIN || 'http://localhost:9201';
|
||||
|
||||
function resolveLegacyPath(modulePath: string): string {
|
||||
switch (modulePath) {
|
||||
|
|
@ -50,6 +52,14 @@ export default function LegacyModuleShellPage() {
|
|||
return <UsersManagementPage />;
|
||||
}
|
||||
|
||||
if (modulePath === 'external-dashboard-management' || modulePath === 'onboarding-management') {
|
||||
return <ExternalDashboardManagementPage />;
|
||||
}
|
||||
|
||||
if (modulePath === 'internal-dashboard-management') {
|
||||
return <InternalDashboardManagementPage />;
|
||||
}
|
||||
|
||||
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
|
||||
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
|
||||
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const ROLE_BASED_SIDEBAR: Record<'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CU
|
|||
JOB_SEEKER: [
|
||||
'My Dashboard',
|
||||
'My Profile',
|
||||
'My Portfolio',
|
||||
'Jobs',
|
||||
'My Applications',
|
||||
'Saved Jobs',
|
||||
|
|
@ -112,7 +113,7 @@ function hasSidebarItem(items: string[], value: string): boolean {
|
|||
function applyPortfolioRule(items: string[], persona: 'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CUSTOMER' | null): string[] {
|
||||
if (!items.length) return items;
|
||||
|
||||
if (persona === 'PROFESSIONAL') {
|
||||
if (persona === 'PROFESSIONAL' || persona === 'JOB_SEEKER') {
|
||||
if (hasSidebarItem(items, PORTFOLIO_SIDEBAR_ITEM)) return items;
|
||||
const insertAfter = items.findIndex((item) => normalizeToken(item) === 'my profile');
|
||||
if (insertAfter >= 0) {
|
||||
|
|
@ -392,8 +393,19 @@ export default function ExternalDashboardManagementPage() {
|
|||
if (id) roleKeyById.set(id, key);
|
||||
});
|
||||
|
||||
setRows(dashRows
|
||||
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
|
||||
const externalRoleKeySet = new Set(
|
||||
roleRows
|
||||
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL')
|
||||
.map((r: any) => String(r?.key || '').toUpperCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const externalDashRows = dashRows
|
||||
.filter((item: any) => {
|
||||
const audience = String(item?.audience || '').toUpperCase();
|
||||
if (audience === 'EXTERNAL') return true;
|
||||
const rowRoleKey = String(item?.role_key || item?.config_json?.role_key || '').toUpperCase();
|
||||
return rowRoleKey ? externalRoleKeySet.has(rowRoleKey) : false;
|
||||
})
|
||||
.map(normalizeDashboard)
|
||||
.map((row: ExternalDashboard) => {
|
||||
const resolvedRoleKey = String(row.roleKey || roleKeyById.get(row.roleId) || '').toUpperCase();
|
||||
|
|
@ -403,8 +415,35 @@ export default function ExternalDashboardManagementPage() {
|
|||
roleKey: resolvedRoleKey || row.roleKey,
|
||||
sidebarItems: mergeSidebarForPersona(row.sidebarItems, persona),
|
||||
};
|
||||
})
|
||||
.sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt)));
|
||||
});
|
||||
|
||||
// Fallback: if runtime rows are empty but external roles exist, synthesize draft rows so UI isn't blank.
|
||||
const fallbackRows = externalDashRows.length
|
||||
? externalDashRows
|
||||
: roleRows
|
||||
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL')
|
||||
.map((r: any) => {
|
||||
const roleKey = String(r?.key || '').toUpperCase();
|
||||
const roleId = String(r?.id || '');
|
||||
const roleName = String(r?.name || normalizeRoleNameFromKey(roleKey));
|
||||
const persona = personaFromKey(roleKey);
|
||||
return {
|
||||
id: `draft-${roleId || roleKey}`,
|
||||
roleId,
|
||||
roleKey,
|
||||
name: `${roleName} Dashboard`,
|
||||
code: `EXTERNAL-${roleKey}`,
|
||||
widgets: [],
|
||||
tabs: [],
|
||||
sidebarItems: mergeSidebarForPersona([], persona),
|
||||
fields: [],
|
||||
previewPath: rolePreviewPath(roleKey),
|
||||
status: 'DRAFT' as const,
|
||||
updatedAt: '',
|
||||
};
|
||||
});
|
||||
|
||||
setRows(fallbackRows.sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt)));
|
||||
} catch (e: any) {
|
||||
setRows([]);
|
||||
setRoles([]);
|
||||
|
|
|
|||
113
src/routes/api/admin/[...path].ts
Normal file
113
src/routes/api/admin/[...path].ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
|
||||
|
||||
/**
|
||||
* Admin API proxy endpoint.
|
||||
* Allows frontend code to call `/api/admin/*` while forwarding to Rust gateway `/api/admin/*`.
|
||||
*/
|
||||
export async function GET({ request, params }: { request: Request; params: any }) {
|
||||
return proxyRequest('GET', request, params);
|
||||
}
|
||||
|
||||
export async function POST({ request, params }: { request: Request; params: any }) {
|
||||
return proxyRequest('POST', request, params);
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }: { request: Request; params: any }) {
|
||||
return proxyRequest('PUT', request, params);
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }: { request: Request; params: any }) {
|
||||
return proxyRequest('DELETE', request, params);
|
||||
}
|
||||
|
||||
export async function PATCH({ request, params }: { request: Request; params: any }) {
|
||||
return proxyRequest('PATCH', request, params);
|
||||
}
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
options: { retries?: number; retryDelayMs?: number } = {},
|
||||
): Promise<Response> {
|
||||
const retries = Math.max(0, options.retries ?? 2);
|
||||
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 250);
|
||||
|
||||
let lastResponse: Response | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (!RETRYABLE_STATUSES.has(response.status) || attempt === retries) {
|
||||
return response;
|
||||
}
|
||||
lastResponse = response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === retries) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(retryDelayMs * (attempt + 1));
|
||||
}
|
||||
|
||||
if (lastResponse) return lastResponse;
|
||||
if (lastError) throw lastError;
|
||||
throw new Error('Gateway request failed');
|
||||
}
|
||||
|
||||
async function proxyRequest(method: string, request: Request, params: any) {
|
||||
try {
|
||||
const pathParts = Array.isArray(params?.path)
|
||||
? params.path
|
||||
: (params?.path ? [params.path] : []);
|
||||
const rawPath = pathParts.map((p: unknown) => String(p || '').trim()).filter(Boolean).join('/');
|
||||
const path = `/api/admin${rawPath ? `/${rawPath}` : ''}`;
|
||||
const url = new URL(request.url);
|
||||
const queryString = url.search ? url.search : '';
|
||||
|
||||
let body: string | undefined;
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
body = await request.text();
|
||||
}
|
||||
|
||||
const upstreamUrl = gatewayUrl(path + queryString);
|
||||
const upstreamInit: RequestInit = {
|
||||
method,
|
||||
headers: withAuthHeaders(request, {
|
||||
'Content-Type': request.headers.get('Content-Type') || 'application/json',
|
||||
}),
|
||||
body,
|
||||
cache: 'no-store',
|
||||
};
|
||||
|
||||
const response = (method === 'GET')
|
||||
? await fetchWithRetry(upstreamUrl, upstreamInit, { retries: 2, retryDelayMs: 250 })
|
||||
: await fetch(upstreamUrl, upstreamInit);
|
||||
const responseHeaders = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!['server', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
return new Response(responseBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: error?.message || 'Upstream gateway unavailable' }),
|
||||
{ status: 502, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue