dashboard implementation

This commit is contained in:
Ashwin Kumar 2026-04-10 01:17:00 +02:00
parent 4103dd6468
commit 1c94f97d11
13 changed files with 298 additions and 45 deletions

18
.dockerignore Normal file
View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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');

View file

@ -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}`;

View file

@ -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()}`);

View file

@ -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([]);

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