Push the rest
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# OTel BI Frontend — build-time variables only
|
||||
# Copy to .env.local for local dev.
|
||||
#
|
||||
# OIDC configuration is NOT set here. The frontend fetches it at runtime
|
||||
# from GET /api/config on the gateway, which reads it from the gateway's
|
||||
# own environment variables. Nothing OIDC-related is baked into the bundle.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# URL the browser uses to reach the API gateway
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# OpenTelemetry collector endpoint (Grafana Alloy OTLP/HTTP)
|
||||
VITE_OTEL_COLLECTOR_ENDPOINT=http://localhost:4318
|
||||
# K8s + Alloy example:
|
||||
# VITE_OTEL_COLLECTOR_ENDPOINT=http://alloy.monitoring.svc.cluster.local:4318
|
||||
|
||||
VITE_OTEL_SERVICE_NAME=otel-bi-frontend
|
||||
VITE_OTEL_SERVICE_NAMESPACE=final-thesis
|
||||
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_AUTHORITY=https://<your-idp-domain>/realms/<your-realm>
|
||||
VITE_OIDC_CLIENT_ID=otel-bi-frontend
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:5173
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM rockylinux/rockylinux:10 AS build
|
||||
|
||||
RUN dnf install -y nodejs npm && dnf clean all
|
||||
|
||||
ARG VITE_API_BASE_URL=http://localhost:8000
|
||||
ARG VITE_OTEL_COLLECTOR_ENDPOINT=http://localhost:4318
|
||||
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||
VITE_OTEL_COLLECTOR_ENDPOINT=$VITE_OTEL_COLLECTOR_ENDPOINT
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM rockylinux/rockylinux:10 AS final
|
||||
|
||||
RUN dnf install -y nginx && dnf clean all
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — all routes served by index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json;
|
||||
}
|
||||
666
frontend/package-lock.json
generated
666
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "otel-bi-frontend",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-zone-peer-dep": "^2.2.0",
|
||||
@@ -24,13 +25,16 @@
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^3.2.1",
|
||||
"zone.js": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.2",
|
||||
"vite": "^7.1.4"
|
||||
}
|
||||
@@ -1564,6 +1568,278 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.8.1",
|
||||
"@emnapi/runtime": "^1.8.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"tailwindcss": "4.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.91.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
||||
@@ -1872,6 +2148,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2023,6 +2312,16 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.321",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||
@@ -2030,6 +2329,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
@@ -2141,6 +2454,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
@@ -2175,6 +2495,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2217,6 +2547,267 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -2233,6 +2824,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
@@ -2417,6 +3018,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
||||
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
@@ -2542,6 +3181,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2552,6 +3197,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"@opentelemetry/context-zone-peer-dep": "^2.2.0",
|
||||
"@opentelemetry/core": "^2.2.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.58.0",
|
||||
"@opentelemetry/instrumentation": "^0.213.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.58.0",
|
||||
"@opentelemetry/instrumentation-fetch": "^0.213.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-xml-http-request": "^0.213.0",
|
||||
@@ -27,13 +27,16 @@
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^3.2.1",
|
||||
"zone.js": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.2",
|
||||
"vite": "^7.1.4"
|
||||
}
|
||||
|
||||
@@ -1,363 +1,145 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition, useDeferredValue } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { getDashboard } from "./api/client";
|
||||
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
|
||||
import { useAuth } from "./auth/AuthContext";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
import SalesDashboard from "./pages/aw/SalesDashboard";
|
||||
import RepScores from "./pages/aw/RepScores";
|
||||
import ProductDemand from "./pages/aw/ProductDemand";
|
||||
import AnomalyDetection from "./pages/aw/AnomalyDetection";
|
||||
import StockDashboard from "./pages/wwi/StockDashboard";
|
||||
import SupplierScores from "./pages/wwi/SupplierScores";
|
||||
import WhatIf from "./pages/wwi/WhatIf";
|
||||
import BusinessEvents from "./pages/wwi/BusinessEvents";
|
||||
import OperationsPage from "./pages/ops/OperationsPage";
|
||||
import AuditPage from "./pages/ops/AuditPage";
|
||||
import ExportsPage from "./pages/ops/ExportsPage";
|
||||
|
||||
const tracer = trace.getTracer("bi-frontend-ui");
|
||||
|
||||
function formatCompactDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
function NavItem({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) => `nav-link${isActive ? " nav-active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTooltipMoney(
|
||||
value: string | number | readonly (string | number)[] | undefined,
|
||||
): string {
|
||||
const raw = Array.isArray(value) ? Number(value[0]) : Number(value);
|
||||
return money.format(Number.isFinite(raw) ? raw : 0);
|
||||
}
|
||||
|
||||
function formatTooltipNumber(
|
||||
value: string | number | readonly (string | number)[] | undefined,
|
||||
): string {
|
||||
const raw = Array.isArray(value) ? Number(value[0]) : Number(value);
|
||||
return Number.isFinite(raw) ? raw.toFixed(2) : "0.00";
|
||||
function CenteredShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-center p-4 text-[#d6e7ff]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const auth = useAuth();
|
||||
const dashboardQuery = useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: getDashboard,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 120_000,
|
||||
enabled: auth.authenticated || !auth.enabled,
|
||||
});
|
||||
|
||||
const deferredRankings = useDeferredValue(
|
||||
dashboardQuery.data?.rankings ?? [],
|
||||
);
|
||||
|
||||
const chartHistory =
|
||||
dashboardQuery.data?.history.slice(-120).map((point) => ({
|
||||
date: point.date,
|
||||
actual: point.revenue,
|
||||
forecast: null as number | null,
|
||||
lower: null as number | null,
|
||||
upper: null as number | null,
|
||||
})) ?? [];
|
||||
const chartForecast =
|
||||
dashboardQuery.data?.forecasts.slice(0, 45).map((point) => ({
|
||||
date: point.date,
|
||||
actual: null as number | null,
|
||||
forecast: point.predicted_revenue,
|
||||
lower: point.lower_bound,
|
||||
upper: point.upper_bound,
|
||||
})) ?? [];
|
||||
const trendData = [...chartHistory, ...chartForecast];
|
||||
|
||||
const refreshData = () => {
|
||||
tracer.startActiveSpan("frontend.refresh_click", async (span) => {
|
||||
try {
|
||||
startTransition(() => {
|
||||
void dashboardQuery.refetch();
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: "Failed to refresh dashboard data.",
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="loading-shell">Initializing OIDC session...</div>;
|
||||
return <CenteredShell>Initializing OIDC session…</CenteredShell>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Authentication setup error.
|
||||
<br />
|
||||
{auth.error}
|
||||
</div>
|
||||
);
|
||||
return <CenteredShell>Authentication error: {auth.error}</CenteredShell>;
|
||||
}
|
||||
|
||||
if (auth.enabled && !auth.authenticated) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Authentication required.
|
||||
<br />
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={() => void auth.login()}
|
||||
type="button"
|
||||
>
|
||||
Sign In with OIDC
|
||||
</button>
|
||||
</div>
|
||||
<CenteredShell>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="m-0 text-lg">Authentication required.</p>
|
||||
<button className="btn-primary" onClick={() => void auth.login()} type="button">
|
||||
Sign In with OIDC
|
||||
</button>
|
||||
</div>
|
||||
</CenteredShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboardQuery.isLoading) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Loading telemetry-enabled BI dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboardQuery.error || !dashboardQuery.data) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Dashboard could not load.
|
||||
<br />
|
||||
{(dashboardQuery.error as Error | undefined)?.message ??
|
||||
"No response from backend."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { kpis, recommendations, telemetry } = dashboardQuery.data;
|
||||
const topScore = deferredRankings[0]?.score ?? 0;
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="radial-glow" />
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">Business Intelligence Command Center</p>
|
||||
<h1>Warehouse Forecasting and Ranking Dashboard</h1>
|
||||
<p className="subtitle">
|
||||
Data sources: <strong>WorldWideImporters</strong> +{" "}
|
||||
<strong>AdventureWorks2022DWH</strong> (read-only) with
|
||||
OpenTelemetry traces from browser to SQL.
|
||||
</p>
|
||||
<p className="trace-id">
|
||||
Last backend trace:{" "}
|
||||
<code>{telemetry.backendTraceId ?? "missing-trace-id-header"}</code>
|
||||
</p>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<nav className="
|
||||
w-[220px] max-[980px]:w-[180px]
|
||||
shrink-0
|
||||
bg-[rgba(8,16,28,0.92)]
|
||||
border-r border-[rgba(186,212,255,0.22)]
|
||||
flex flex-col
|
||||
py-5 px-3
|
||||
sticky top-0 h-screen overflow-y-auto
|
||||
max-sm:w-full max-sm:h-auto max-sm:static
|
||||
max-sm:flex-row max-sm:flex-wrap max-sm:gap-2 max-sm:p-3
|
||||
">
|
||||
<div className="flex items-center gap-2 font-bold text-[0.95rem] tracking-tight mb-6 px-[0.4rem] max-sm:mb-0">
|
||||
<span className="text-[#57d4ff] text-[0.7rem]">●</span>
|
||||
<span>OTel BI Platform</span>
|
||||
</div>
|
||||
<div className="auth-actions">
|
||||
<p className="subtitle">
|
||||
User: <strong>{auth.subject ?? "unknown"}</strong>
|
||||
</p>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={refreshData}
|
||||
type="button"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{auth.enabled ? (
|
||||
<button
|
||||
className="logout-button"
|
||||
onClick={() => void auth.logout()}
|
||||
type="button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
AdventureWorks DW
|
||||
</div>
|
||||
<NavItem to="/aw/sales" label="Sales & Forecast" />
|
||||
<NavItem to="/aw/reps" label="Rep Scores" />
|
||||
<NavItem to="/aw/products" label="Product Demand" />
|
||||
<NavItem to="/aw/anomalies" label="Anomaly Detection" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid">
|
||||
<article className="kpi-card">
|
||||
<p>Total Revenue</p>
|
||||
<h2>{money.format(kpis.total_revenue)}</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Gross Margin</p>
|
||||
<h2>{kpis.gross_margin_pct.toFixed(2)}%</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Avg Order Value</p>
|
||||
<h2>{money.format(kpis.avg_order_value)}</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Total Quantity</p>
|
||||
<h2>
|
||||
{kpis.total_quantity.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</h2>
|
||||
</article>
|
||||
</section>
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
WideWorldImporters DW
|
||||
</div>
|
||||
<NavItem to="/wwi/stock" label="Stock & Reorder" />
|
||||
<NavItem to="/wwi/suppliers" label="Supplier Scores" />
|
||||
<NavItem to="/wwi/whatif" label="What-if Scenarios" />
|
||||
<NavItem to="/wwi/events" label="Business Events" />
|
||||
</div>
|
||||
|
||||
<section className="panel-grid">
|
||||
<article className="panel wide">
|
||||
<div className="panel-title-row">
|
||||
<h3>Revenue Trend + Forecast</h3>
|
||||
<span>{trendData.length} points</span>
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
Platform
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 4"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatCompactDate}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => money.format(value)}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(label) =>
|
||||
new Date(label).toLocaleDateString("en-US")
|
||||
}
|
||||
formatter={formatTooltipMoney}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="upper"
|
||||
stroke="none"
|
||||
fill="rgba(90, 201, 255, 0.1)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lower"
|
||||
stroke="none"
|
||||
fill="rgba(15, 20, 31, 0.9)"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actual"
|
||||
stroke="#f9de70"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="forecast"
|
||||
stroke="#57d4ff"
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray="8 5"
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
<NavItem to="/ops/jobs" label="Operations" />
|
||||
<NavItem to="/ops/audit" label="Audit Log" />
|
||||
<NavItem to="/ops/exports" label="Export History" />
|
||||
</div>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-title-row">
|
||||
<h3>Top Product Score</h3>
|
||||
<span>Weighted ranking index</span>
|
||||
</div>
|
||||
<div className="score-wrap">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart
|
||||
data={[
|
||||
{ label: "baseline", value: 0 },
|
||||
{ label: "current", value: topScore },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
/>
|
||||
<XAxis dataKey="label" stroke="rgba(255,255,255,0.65)" />
|
||||
<YAxis stroke="rgba(255,255,255,0.65)" />
|
||||
<Tooltip formatter={formatTooltipNumber} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#8ef2c7"
|
||||
fill="rgba(142, 242, 199, 0.28)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="score-caption">
|
||||
Current leader score <strong>{topScore.toFixed(2)}</strong> / 100
|
||||
<div className="mt-auto pt-4 border-t border-[rgba(186,212,255,0.22)] max-sm:mt-0 max-sm:pt-0 max-sm:border-t-0">
|
||||
{auth.subject && (
|
||||
<p className="text-[0.78rem] text-[rgba(233,244,255,0.7)] m-0 mb-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{auth.subject}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
{auth.enabled && (
|
||||
<button className="btn-ghost" onClick={() => void auth.logout()} type="button">
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<article className="panel wide">
|
||||
<div className="panel-title-row">
|
||||
<h3>Product Rankings</h3>
|
||||
<span>Top {deferredRankings.length}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Product</th>
|
||||
<th>Category</th>
|
||||
<th>Revenue</th>
|
||||
<th>Margin</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deferredRankings.map((item) => (
|
||||
<tr key={`${item.rank}-${item.product_id}`}>
|
||||
<td>{item.rank}</td>
|
||||
<td>{item.product_name}</td>
|
||||
<td>{item.category}</td>
|
||||
<td>{money.format(item.revenue)}</td>
|
||||
<td>{item.margin_pct.toFixed(2)}%</td>
|
||||
<td>{item.score.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/aw/sales" replace />} />
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-title-row">
|
||||
<h3>Recommendations</h3>
|
||||
<span>Action queue</span>
|
||||
</div>
|
||||
<ul className="recommendations-list">
|
||||
{recommendations.map((item, index) => (
|
||||
<li key={`${item.title}-${index}`}>
|
||||
<span className={`priority ${item.priority}`}>
|
||||
{item.priority}
|
||||
</span>
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.summary}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<Route path="/aw/sales" element={<SalesDashboard />} />
|
||||
<Route path="/aw/reps" element={<RepScores />} />
|
||||
<Route path="/aw/products" element={<ProductDemand />} />
|
||||
<Route path="/aw/anomalies" element={<AnomalyDetection />} />
|
||||
|
||||
<Route path="/wwi/stock" element={<StockDashboard />} />
|
||||
<Route path="/wwi/suppliers" element={<SupplierScores />} />
|
||||
<Route path="/wwi/whatif" element={<WhatIf />} />
|
||||
<Route path="/wwi/events" element={<BusinessEvents />} />
|
||||
|
||||
<Route path="/ops/jobs" element={<OperationsPage />} />
|
||||
<Route path="/ops/audit" element={<AuditPage />} />
|
||||
<Route path="/ops/exports" element={<ExportsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/aw/sales" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
frontend/src/api/aw.ts
Normal file
70
frontend/src/api/aw.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { currentAccessToken } from "../auth/oidc";
|
||||
import type {
|
||||
AWKpi,
|
||||
AWHistoryPoint,
|
||||
AWForecastPoint,
|
||||
AWRepScore,
|
||||
AWProductDemand,
|
||||
AWAnomalyPoint,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
const tracer = trace.getTracer("aw-frontend-api");
|
||||
|
||||
async function get<T>(path: string, spanName: string): Promise<T> {
|
||||
return tracer.startActiveSpan(spanName, async (span) => {
|
||||
try {
|
||||
const token = currentAccessToken();
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${body}`);
|
||||
}
|
||||
span.setAttribute("http.status_code", resp.status);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getAWKpis = () =>
|
||||
get<AWKpi>("/api/aw/sales/kpis", "frontend.aw.sales_kpis");
|
||||
|
||||
export const getAWSalesHistory = (daysBack = 365) =>
|
||||
get<AWHistoryPoint[]>(
|
||||
`/api/aw/sales/history?days_back=${daysBack}`,
|
||||
"frontend.aw.sales_history",
|
||||
);
|
||||
|
||||
export const getAWSalesForecast = (horizonDays = 30) =>
|
||||
get<AWForecastPoint[]>(
|
||||
`/api/aw/sales/forecast?horizon_days=${horizonDays}`,
|
||||
"frontend.aw.sales_forecast",
|
||||
);
|
||||
|
||||
export const getAWRepScores = (topN = 10) =>
|
||||
get<AWRepScore[]>(
|
||||
`/api/aw/reps/scores?top_n=${topN}`,
|
||||
"frontend.aw.rep_scores",
|
||||
);
|
||||
|
||||
export const getAWProductDemand = (topN = 20) =>
|
||||
get<AWProductDemand[]>(
|
||||
`/api/aw/products/demand?top_n=${topN}`,
|
||||
"frontend.aw.product_demand",
|
||||
);
|
||||
|
||||
export const getAWAnomalies = () =>
|
||||
get<AWAnomalyPoint[]>("/api/aw/anomalies", "frontend.aw.anomalies");
|
||||
@@ -1,53 +0,0 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
|
||||
import { currentAccessToken } from "../auth/oidc";
|
||||
import type { DashboardPayload, DashboardResponse } from "./types";
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
const tracer = trace.getTracer("bi-frontend-api");
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getDashboard(): Promise<DashboardPayload> {
|
||||
return tracer.startActiveSpan("frontend.api.dashboard", async (span) => {
|
||||
try {
|
||||
const token = currentAccessToken();
|
||||
const response = await fetch(`${API_BASE_URL}/api/dashboard`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
const data = await parseJson<DashboardResponse>(response);
|
||||
const backendTraceId = response.headers.get("x-trace-id");
|
||||
const backendSpanId = response.headers.get("x-span-id");
|
||||
span.setAttribute("dashboard.kpis", Object.keys(data.kpis).length);
|
||||
span.setAttribute("backend.trace_id_present", backendTraceId !== null);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return {
|
||||
...data,
|
||||
telemetry: {
|
||||
backendTraceId,
|
||||
backendSpanId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: "dashboard request failed",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
22
frontend/src/api/config.ts
Normal file
22
frontend/src/api/config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type AppConfig = {
|
||||
oidc_enabled: boolean;
|
||||
oidc_authority: string;
|
||||
oidc_client_id: string;
|
||||
oidc_scope: string;
|
||||
};
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
let _config: AppConfig | null = null;
|
||||
|
||||
export async function fetchAppConfig(): Promise<AppConfig> {
|
||||
const resp = await fetch(`${API_BASE}/api/config`);
|
||||
if (!resp.ok) throw new Error(`Failed to fetch app config: ${resp.status}`);
|
||||
_config = (await resp.json()) as AppConfig;
|
||||
return _config;
|
||||
}
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
if (!_config) throw new Error("App config not initialised. fetchAppConfig() must complete before rendering.");
|
||||
return _config;
|
||||
}
|
||||
91
frontend/src/api/gateway.ts
Normal file
91
frontend/src/api/gateway.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { currentAccessToken } from "../auth/oidc";
|
||||
import type { JobExecution, AuditEntry, ExportRecord } from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
const tracer = trace.getTracer("gateway-frontend-api");
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = currentAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function get<T>(path: string, spanName: string): Promise<T> {
|
||||
return tracer.startActiveSpan(spanName, async (span) => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Accept: "application/json", ...authHeaders() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${body}`);
|
||||
}
|
||||
span.setAttribute("http.status_code", resp.status);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function post<T>(path: string, spanName: string, body: unknown = {}): Promise<T> {
|
||||
return tracer.startActiveSpan(spanName, async (span) => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
span.setAttribute("http.status_code", resp.status);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getAWJobs = (limit = 50) =>
|
||||
get<JobExecution[]>(`/api/jobs/aw?limit=${limit}`, "frontend.jobs.aw");
|
||||
|
||||
export const getWWIJobs = (limit = 50) =>
|
||||
get<JobExecution[]>(`/api/jobs/wwi?limit=${limit}`, "frontend.jobs.wwi");
|
||||
|
||||
export const triggerAWJob = (jobName: string) =>
|
||||
post<{ triggered: boolean; job_name: string }>(
|
||||
`/api/jobs/aw/${jobName}/trigger`,
|
||||
"frontend.jobs.aw.trigger",
|
||||
);
|
||||
|
||||
export const triggerWWIJob = (jobName: string) =>
|
||||
post<{ triggered: boolean; job_name: string }>(
|
||||
`/api/jobs/wwi/${jobName}/trigger`,
|
||||
"frontend.jobs.wwi.trigger",
|
||||
);
|
||||
|
||||
export const getAuditLog = (limit = 100, domain?: string) => {
|
||||
const qs = domain ? `?limit=${limit}&domain=${domain}` : `?limit=${limit}`;
|
||||
return get<AuditEntry[]>(`/api/audit${qs}`, "frontend.audit");
|
||||
};
|
||||
|
||||
export const getExportHistory = (limit = 100, domain?: string) => {
|
||||
const qs = domain ? `?limit=${limit}&domain=${domain}` : `?limit=${limit}`;
|
||||
return get<ExportRecord[]>(`/api/exports${qs}`, "frontend.exports");
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export type KPI = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdventureWorks domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AWKpi = {
|
||||
total_revenue: number;
|
||||
gross_margin_pct: number;
|
||||
total_quantity: number;
|
||||
@@ -6,47 +10,172 @@ export type KPI = {
|
||||
records_in_window: number;
|
||||
};
|
||||
|
||||
export type HistoryPoint = {
|
||||
export type AWHistoryPoint = {
|
||||
date: string;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type ForecastPoint = {
|
||||
export type AWForecastPoint = {
|
||||
date: string;
|
||||
predicted_revenue: number;
|
||||
lower_bound: number;
|
||||
upper_bound: number;
|
||||
};
|
||||
|
||||
export type RankingItem = {
|
||||
export type AWRepScore = {
|
||||
rank: number;
|
||||
employee_key: number;
|
||||
rep_name: string;
|
||||
rep_title: string;
|
||||
territory: string;
|
||||
revenue: number;
|
||||
orders: number;
|
||||
avg_deal_size: number;
|
||||
margin_pct: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type AWProductDemand = {
|
||||
rank: number;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
category: string;
|
||||
revenue: number;
|
||||
quantity: number;
|
||||
orders: number;
|
||||
margin_pct: number;
|
||||
demand_score: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WideWorldImporters domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WWIKpi = {
|
||||
total_revenue: number;
|
||||
gross_margin_pct: number;
|
||||
total_quantity: number;
|
||||
avg_order_value: number;
|
||||
records_in_window: number;
|
||||
};
|
||||
|
||||
export type WWIReorderRecommendation = {
|
||||
stock_item_key: number;
|
||||
stock_item_name: string;
|
||||
unit_price: number;
|
||||
current_stock: number;
|
||||
avg_daily_demand: number;
|
||||
days_until_stockout: number | null;
|
||||
recommended_reorder_qty: number;
|
||||
urgency: "HIGH" | "MEDIUM" | "LOW";
|
||||
};
|
||||
|
||||
export type WWISupplierScore = {
|
||||
rank: number;
|
||||
supplier_key: number;
|
||||
supplier_name: string;
|
||||
category: string;
|
||||
total_orders: number;
|
||||
fill_rate_pct: number;
|
||||
finalization_rate_pct: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type Recommendation = {
|
||||
title: string;
|
||||
priority: string;
|
||||
summary: string;
|
||||
export type WWIBusinessEvent = {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
event_type: "LOW_STOCK" | "ORDER_DROP" | "SUPPLIER_RISK";
|
||||
severity: "HIGH" | "MEDIUM" | "LOW";
|
||||
entity_key: string;
|
||||
entity_name: string;
|
||||
message: string;
|
||||
trace_id: string | null;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DashboardResponse = {
|
||||
kpis: KPI;
|
||||
history: HistoryPoint[];
|
||||
forecasts: ForecastPoint[];
|
||||
rankings: RankingItem[];
|
||||
recommendations: Recommendation[];
|
||||
export type WWIWhatIfResult = {
|
||||
stock_item_key: number;
|
||||
stock_item_name: string;
|
||||
demand_multiplier: number;
|
||||
current_stock: number;
|
||||
base_avg_daily_demand: number;
|
||||
adjusted_daily_demand: number;
|
||||
projected_days_until_stockout: number | null;
|
||||
projected_stockout_date: string | null;
|
||||
recommended_order_qty: number;
|
||||
estimated_reorder_cost: number;
|
||||
};
|
||||
|
||||
export type DashboardPayload = DashboardResponse & {
|
||||
telemetry: {
|
||||
backendTraceId: string | null;
|
||||
backendSpanId: string | null;
|
||||
};
|
||||
export type WWIScenario = {
|
||||
id: string;
|
||||
created_at: string;
|
||||
stock_item_key: number;
|
||||
stock_item_name: string;
|
||||
demand_multiplier: number;
|
||||
projected_days_until_stockout: number | null;
|
||||
recommended_order_qty: number;
|
||||
result: WWIWhatIfResult;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AWAnomalyPoint = {
|
||||
date: string;
|
||||
revenue: number;
|
||||
rolling_mean: number;
|
||||
lower_band: number;
|
||||
upper_band: number;
|
||||
is_anomaly: boolean;
|
||||
z_score: number;
|
||||
direction: "high" | "low" | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform / ops types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type JobExecution = {
|
||||
id: string;
|
||||
job_name: string;
|
||||
domain: string;
|
||||
status: "running" | "success" | "failure";
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
duration_ms: number | null;
|
||||
records_processed: number | null;
|
||||
error_message: string | null;
|
||||
trace_id: string | null;
|
||||
};
|
||||
|
||||
export type AuditEntry = {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
action: string;
|
||||
status: string;
|
||||
actor_type: string;
|
||||
actor_id: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
entity_type: string;
|
||||
trace_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ExportRecord = {
|
||||
id: string;
|
||||
exported_at: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source_view: string;
|
||||
format: "xlsx" | "pdf";
|
||||
filters_applied: Record<string, unknown>;
|
||||
row_count: number;
|
||||
file_size_bytes: number;
|
||||
actor_id: string;
|
||||
trace_id: string | null;
|
||||
};
|
||||
|
||||
|
||||
107
frontend/src/api/wwi.ts
Normal file
107
frontend/src/api/wwi.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { currentAccessToken } from "../auth/oidc";
|
||||
import type {
|
||||
WWIKpi,
|
||||
WWIReorderRecommendation,
|
||||
WWISupplierScore,
|
||||
WWIBusinessEvent,
|
||||
WWIWhatIfResult,
|
||||
WWIScenario,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
const tracer = trace.getTracer("wwi-frontend-api");
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = currentAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function get<T>(path: string, spanName: string): Promise<T> {
|
||||
return tracer.startActiveSpan(spanName, async (span) => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Accept: "application/json", ...authHeaders() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${body}`);
|
||||
}
|
||||
span.setAttribute("http.status_code", resp.status);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, spanName: string): Promise<T> {
|
||||
return tracer.startActiveSpan(spanName, async (span) => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
span.setAttribute("http.status_code", resp.status);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getWWIKpis = () =>
|
||||
get<WWIKpi>("/api/wwi/sales/kpis", "frontend.wwi.sales_kpis");
|
||||
|
||||
export const getWWIReorderRecommendations = () =>
|
||||
get<WWIReorderRecommendation[]>(
|
||||
"/api/wwi/stock/recommendations",
|
||||
"frontend.wwi.reorder_recommendations",
|
||||
);
|
||||
|
||||
export const getWWISupplierScores = (topN = 10) =>
|
||||
get<WWISupplierScore[]>(
|
||||
`/api/wwi/suppliers/scores?top_n=${topN}`,
|
||||
"frontend.wwi.supplier_scores",
|
||||
);
|
||||
|
||||
export const getWWIBusinessEvents = (limit = 100) =>
|
||||
get<WWIBusinessEvent[]>(
|
||||
`/api/wwi/events?limit=${limit}`,
|
||||
"frontend.wwi.business_events",
|
||||
);
|
||||
|
||||
export const createWWIScenario = (
|
||||
stockItemKey: number,
|
||||
demandMultiplier: number,
|
||||
) =>
|
||||
post<WWIWhatIfResult>(
|
||||
"/api/wwi/scenarios",
|
||||
{ stock_item_key: stockItemKey, demand_multiplier: demandMultiplier },
|
||||
"frontend.wwi.create_scenario",
|
||||
);
|
||||
|
||||
export const getWWIScenarios = (limit = 20) =>
|
||||
get<WWIScenario[]>(
|
||||
`/api/wwi/scenarios?limit=${limit}`,
|
||||
"frontend.wwi.list_scenarios",
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UserManager, type User, WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
import { getAppConfig } from "../api/config";
|
||||
|
||||
type OIDCConfig = {
|
||||
enabled: boolean;
|
||||
authority: string;
|
||||
@@ -12,17 +14,14 @@ type OIDCConfig = {
|
||||
let cachedUser: User | null = null;
|
||||
|
||||
function config(): OIDCConfig {
|
||||
const enabled = (import.meta.env.VITE_OIDC_ENABLED ?? "true") !== "false";
|
||||
const appConfig = getAppConfig();
|
||||
return {
|
||||
enabled,
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "",
|
||||
clientId: import.meta.env.VITE_OIDC_CLIENT_ID ?? "",
|
||||
redirectUri:
|
||||
import.meta.env.VITE_OIDC_REDIRECT_URI ?? window.location.origin,
|
||||
postLogoutRedirectUri:
|
||||
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ??
|
||||
window.location.origin,
|
||||
scope: import.meta.env.VITE_OIDC_SCOPE ?? "openid profile email",
|
||||
enabled: appConfig.oidc_enabled,
|
||||
authority: appConfig.oidc_authority,
|
||||
clientId: appConfig.oidc_client_id,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
scope: appConfig.oidc_scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,8 +32,8 @@ export function isOIDCEnabled(): boolean {
|
||||
export function oidcConfigError(): string | null {
|
||||
const cfg = config();
|
||||
if (!cfg.enabled) return null;
|
||||
if (!cfg.authority) return "VITE_OIDC_AUTHORITY is not set.";
|
||||
if (!cfg.clientId) return "VITE_OIDC_CLIENT_ID is not set.";
|
||||
if (!cfg.authority) return "OIDC authority is not configured.";
|
||||
if (!cfg.clientId) return "OIDC client ID is not configured.";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import "./styles.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import App from "./App";
|
||||
import { fetchAppConfig } from "./api/config";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { setupTelemetry } from "./telemetry";
|
||||
|
||||
@@ -20,12 +22,16 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
fetchAppConfig().then(() => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
252
frontend/src/pages/aw/AnomalyDetection.tsx
Normal file
252
frontend/src/pages/aw/AnomalyDetection.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getAWAnomalies } from "../../api/aw";
|
||||
import type { AWAnomalyPoint } from "../../api/types";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("aw-anomaly-detection");
|
||||
|
||||
function directionBadge(d: AWAnomalyPoint["direction"]) {
|
||||
if (d === "high") return <span className="badge badge-high">high</span>;
|
||||
if (d === "low") return <span className="badge badge-low">low</span>;
|
||||
return <span className="badge">—</span>;
|
||||
}
|
||||
|
||||
export default function AnomalyDetection() {
|
||||
const query = useQuery({
|
||||
queryKey: ["aw", "anomalies"],
|
||||
queryFn: getAWAnomalies,
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.aw.anomalies.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => { void query.refetch(); });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-[rgba(233,244,255,0.5)]">
|
||||
Loading anomaly data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-red-400">
|
||||
Error: {String(query.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const series = query.data ?? [];
|
||||
const anomalies = series.filter((p) => p.is_anomaly);
|
||||
|
||||
// Build chart data: scatter points only for anomalies
|
||||
const chartData = series.map((p) => ({
|
||||
...p,
|
||||
anomalyRevenue: p.is_anomaly ? p.revenue : null,
|
||||
}));
|
||||
|
||||
const domainMin = Math.min(...series.map((p) => p.lower_band)) * 0.95;
|
||||
const domainMax = Math.max(...series.map((p) => p.upper_band)) * 1.05;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">
|
||||
Revenue Anomaly Detection
|
||||
</h1>
|
||||
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
|
||||
Rolling 30-day z-score on daily revenue — anomalies at |z| > 2.0
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={refresh} disabled={query.isFetching}>
|
||||
{query.isFetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary KPIs */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-[rgba(233,244,255,0.92)]">{series.length}</div>
|
||||
<div className="text-sm text-[rgba(233,244,255,0.5)] mt-1">Data points</div>
|
||||
</div>
|
||||
<div className="card text-center">
|
||||
<div className="text-3xl font-bold text-red-400">{anomalies.length}</div>
|
||||
<div className="text-sm text-[rgba(233,244,255,0.5)] mt-1">Anomalies detected</div>
|
||||
</div>
|
||||
<div className="card text-center col-span-2 sm:col-span-1">
|
||||
<div className="text-3xl font-bold text-[rgba(233,244,255,0.92)]">
|
||||
{series.length > 0
|
||||
? ((anomalies.length / series.length) * 100).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="text-sm text-[rgba(233,244,255,0.5)] mt-1">Anomaly rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-4">
|
||||
Daily Revenue with Confidence Band
|
||||
</h2>
|
||||
{series.length === 0 ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm text-center py-10">
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(233,244,255,0.08)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: "rgba(233,244,255,0.4)", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
interval={Math.floor(series.length / 8)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "rgba(233,244,255,0.4)", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
|
||||
domain={[domainMin, domainMax]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(8,16,28,0.95)",
|
||||
border: "1px solid rgba(233,244,255,0.12)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "rgba(233,244,255,0.7)" }}
|
||||
formatter={(value, name) => {
|
||||
if (name === "band") return null;
|
||||
return [money.format(Number(value)), String(name)];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: "rgba(233,244,255,0.5)", fontSize: 12 }}
|
||||
/>
|
||||
{/* Confidence band as area */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="upper_band"
|
||||
stroke="none"
|
||||
fill="rgba(87,212,255,0.10)"
|
||||
name="band"
|
||||
legendType="none"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lower_band"
|
||||
stroke="none"
|
||||
fill="rgba(8,16,28,1)"
|
||||
name="band"
|
||||
legendType="none"
|
||||
/>
|
||||
{/* Rolling mean */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rolling_mean"
|
||||
stroke="rgba(87,212,255,0.7)"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
name="Rolling mean"
|
||||
/>
|
||||
{/* Actual revenue */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="rgba(233,244,255,0.55)"
|
||||
dot={false}
|
||||
strokeWidth={1}
|
||||
name="Revenue"
|
||||
/>
|
||||
{/* Anomaly scatter */}
|
||||
<Scatter
|
||||
dataKey="anomalyRevenue"
|
||||
fill="#ff5050"
|
||||
name="Anomaly"
|
||||
shape="circle"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Anomaly table */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-4">
|
||||
Anomaly Events ({anomalies.length})
|
||||
</h2>
|
||||
{anomalies.length === 0 ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm text-center py-8">
|
||||
No anomalies detected in the current window.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Revenue</th>
|
||||
<th>Rolling Mean</th>
|
||||
<th>Z-Score</th>
|
||||
<th>Direction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anomalies.map((p) => (
|
||||
<tr key={p.date}>
|
||||
<td className="font-mono text-sm">{p.date}</td>
|
||||
<td>{money.format(p.revenue)}</td>
|
||||
<td className="text-[rgba(233,244,255,0.5)]">
|
||||
{money.format(p.rolling_mean)}
|
||||
</td>
|
||||
<td
|
||||
className={
|
||||
Math.abs(p.z_score) > 3
|
||||
? "text-red-400 font-semibold"
|
||||
: "text-amber-400"
|
||||
}
|
||||
>
|
||||
{p.z_score.toFixed(2)}
|
||||
</td>
|
||||
<td>{directionBadge(p.direction)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/aw/ProductDemand.tsx
Normal file
146
frontend/src/pages/aw/ProductDemand.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getAWProductDemand } from "../../api/aw";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("aw-product-demand");
|
||||
|
||||
function scoreBadgeClass(score: number) {
|
||||
return `badge ${score >= 70 ? "score-high" : score >= 40 ? "score-medium" : "score-low"}`;
|
||||
}
|
||||
|
||||
export default function ProductDemand() {
|
||||
const query = useQuery({
|
||||
queryKey: ["aw", "product-demand"],
|
||||
queryFn: () => getAWProductDemand(20),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.aw.product_demand.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => { void query.refetch(); });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const products = query.data ?? [];
|
||||
const chartData = products.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
AdventureWorks — Product Demand Scores
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
40% revenue velocity + 35% order frequency + 25% margin — from FactInternetSales
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading product demand…</div>
|
||||
)}
|
||||
{query.error && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl mb-3 text-sm">
|
||||
Failed to load: {(query.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 && (
|
||||
<div className="flex flex-col gap-[0.9rem]">
|
||||
{/* Bar chart */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<h3 className="m-0 font-semibold text-base">Top 10 Demand Score</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="product_name"
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
tick={{ fontSize: 10, angle: -35, textAnchor: "end" }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} stroke="rgba(255,255,255,0.65)" />
|
||||
<Tooltip formatter={(v) => [`${Number(v).toFixed(1)}`, "Demand Score"]} />
|
||||
<Bar dataKey="demand_score" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((_, i) => (
|
||||
<Cell key={i} fill={i < 3 ? "#f9de70" : i < 7 ? "#57d4ff" : "#8ef2c7"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Rankings table */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-2">
|
||||
<h3 className="m-0 font-semibold text-base">Product Demand Rankings</h3>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-sm">Top {products.length}</span>
|
||||
</div>
|
||||
<div className="mt-2 max-h-[350px] overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Product</th>
|
||||
<th>Category</th>
|
||||
<th>Revenue</th>
|
||||
<th>Orders</th>
|
||||
<th>Quantity</th>
|
||||
<th>Margin%</th>
|
||||
<th>Demand Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={p.product_id}>
|
||||
<td>{p.rank}</td>
|
||||
<td>{p.product_name}</td>
|
||||
<td>{p.category}</td>
|
||||
<td>{money.format(p.revenue)}</td>
|
||||
<td>{p.orders.toLocaleString()}</td>
|
||||
<td>{p.quantity.toLocaleString("en-US", { maximumFractionDigits: 0 })}</td>
|
||||
<td>{p.margin_pct.toFixed(1)}%</td>
|
||||
<td>
|
||||
<span className={scoreBadgeClass(p.demand_score)}>
|
||||
{p.demand_score.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/pages/aw/RepScores.tsx
Normal file
151
frontend/src/pages/aw/RepScores.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getAWRepScores } from "../../api/aw";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("aw-rep-scores");
|
||||
|
||||
const SCORE_COLORS = [
|
||||
"#f9de70", "#f4c84a", "#edba2c", "#e5ac12", "#da9e00",
|
||||
"#57d4ff", "#3ec8f5", "#27bcea", "#14b0dd", "#04a4d0",
|
||||
];
|
||||
|
||||
function scoreBadgeClass(score: number) {
|
||||
return `badge ${score >= 70 ? "score-high" : score >= 40 ? "score-medium" : "score-low"}`;
|
||||
}
|
||||
|
||||
export default function RepScores() {
|
||||
const query = useQuery({
|
||||
queryKey: ["aw", "rep-scores"],
|
||||
queryFn: () => getAWRepScores(15),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.aw.rep_scores.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => { void query.refetch(); });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const reps = query.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
AdventureWorks — Sales Rep Performance
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
Scored from FactResellerSales + DimEmployee (50% revenue, 30% order volume, 20% avg deal size)
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading rep scores…</div>
|
||||
)}
|
||||
{query.error && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl mb-3 text-sm">
|
||||
Failed to load: {(query.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reps.length > 0 && (
|
||||
<div className="flex flex-col gap-[0.9rem]">
|
||||
{/* Bar chart */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<h3 className="m-0 font-semibold text-base">Score by Rep</h3>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-sm">Top {reps.length}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={reps} layout="vertical" margin={{ left: 120 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" horizontal={false} />
|
||||
<XAxis type="number" domain={[0, 100]} stroke="rgba(255,255,255,0.65)" />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="rep_name"
|
||||
width={110}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip formatter={(v) => [`${Number(v).toFixed(1)}`, "Score"]} />
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{reps.map((_, i) => (
|
||||
<Cell key={i} fill={SCORE_COLORS[i % SCORE_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Table */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-2">
|
||||
<h3 className="m-0 font-semibold text-base">Rep Scoreboard</h3>
|
||||
</div>
|
||||
<div className="mt-2 max-h-[350px] overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Rep</th>
|
||||
<th>Title</th>
|
||||
<th>Territory</th>
|
||||
<th>Revenue</th>
|
||||
<th>Orders</th>
|
||||
<th>Avg Deal</th>
|
||||
<th>Margin%</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reps.map((r) => (
|
||||
<tr key={r.employee_key}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.rep_name}</td>
|
||||
<td>{r.rep_title}</td>
|
||||
<td>{r.territory}</td>
|
||||
<td>{money.format(r.revenue)}</td>
|
||||
<td>{r.orders.toLocaleString()}</td>
|
||||
<td>{money.format(r.avg_deal_size)}</td>
|
||||
<td>{r.margin_pct.toFixed(1)}%</td>
|
||||
<td>
|
||||
<span className={scoreBadgeClass(r.score)}>{r.score.toFixed(1)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
frontend/src/pages/aw/SalesDashboard.tsx
Normal file
166
frontend/src/pages/aw/SalesDashboard.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getAWKpis, getAWSalesHistory, getAWSalesForecast } from "../../api/aw";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("aw-sales-dashboard");
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function KpiCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<article className="card">
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] text-[0.82rem] uppercase tracking-[0.08em]">
|
||||
{label}
|
||||
</p>
|
||||
<h2 className="m-0 mt-2 text-[clamp(1.1rem,1.7vw,1.6rem)] font-bold">{value}</h2>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SalesDashboard() {
|
||||
const kpiQuery = useQuery({
|
||||
queryKey: ["aw", "kpis"],
|
||||
queryFn: getAWKpis,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["aw", "sales-history"],
|
||||
queryFn: () => getAWSalesHistory(365),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const forecastQuery = useQuery({
|
||||
queryKey: ["aw", "sales-forecast"],
|
||||
queryFn: () => getAWSalesForecast(45),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const trendData = [
|
||||
...(historyQuery.data?.slice(-120).map((p) => ({
|
||||
date: p.date,
|
||||
actual: p.revenue,
|
||||
forecast: null as number | null,
|
||||
lower: null as number | null,
|
||||
upper: null as number | null,
|
||||
})) ?? []),
|
||||
...(forecastQuery.data?.slice(0, 45).map((p) => ({
|
||||
date: p.date,
|
||||
actual: null as number | null,
|
||||
forecast: p.predicted_revenue,
|
||||
lower: p.lower_bound,
|
||||
upper: p.upper_bound,
|
||||
})) ?? []),
|
||||
];
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.aw.sales_dashboard.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => {
|
||||
void kpiQuery.refetch();
|
||||
void historyQuery.refetch();
|
||||
void forecastQuery.refetch();
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const kpis = kpiQuery.data;
|
||||
const loading = kpiQuery.isLoading || historyQuery.isLoading || forecastQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
{/* Page header */}
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
AdventureWorks — Sales Overview
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
Revenue, margin, and forecast derived from FactInternetSales + FactResellerSales
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading sales data…</div>
|
||||
)}
|
||||
|
||||
{/* KPI row */}
|
||||
{kpis && (
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[0.9rem] mb-[0.9rem]">
|
||||
<KpiCard label="Total Revenue (180d)" value={money.format(kpis.total_revenue)} />
|
||||
<KpiCard label="Gross Margin" value={`${kpis.gross_margin_pct.toFixed(2)}%`} />
|
||||
<KpiCard label="Avg Order Value" value={money.format(kpis.avg_order_value)} />
|
||||
<KpiCard
|
||||
label="Total Quantity"
|
||||
value={kpis.total_quantity.toLocaleString("en-US", { maximumFractionDigits: 0 })}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Trend chart */}
|
||||
{trendData.length > 0 && (
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<h3 className="m-0 font-semibold text-base">Revenue Trend + 45-day Forecast</h3>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-sm shrink-0">
|
||||
{trendData.length} points
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ComposedChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="4 4" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="date" tickFormatter={formatDate} stroke="rgba(255,255,255,0.65)" />
|
||||
<YAxis tickFormatter={(v) => money.format(v)} stroke="rgba(255,255,255,0.65)" />
|
||||
<Tooltip
|
||||
labelFormatter={(l) => new Date(l).toLocaleDateString("en-US")}
|
||||
formatter={(v) => money.format(Number(v))}
|
||||
/>
|
||||
<Area type="monotone" dataKey="upper" stroke="none" fill="rgba(90,201,255,0.10)" />
|
||||
<Area type="monotone" dataKey="lower" stroke="none" fill="rgba(15,20,31,0.90)" />
|
||||
<Line type="monotone" dataKey="actual" stroke="#f9de70" strokeWidth={2.5} dot={false} name="Actual" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="forecast"
|
||||
stroke="#57d4ff"
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray="8 5"
|
||||
dot={false}
|
||||
name="Forecast"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/ops/AuditPage.tsx
Normal file
108
frontend/src/pages/ops/AuditPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { getAuditLog } from "../../api/gateway";
|
||||
|
||||
const DOMAIN_OPTIONS = ["", "aw", "wwi", "platform"];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["audit", domain],
|
||||
queryFn: () => getAuditLog(200, domain || undefined),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">Audit Log</h1>
|
||||
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
|
||||
All system actions recorded across services
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
className="form-input text-sm"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
>
|
||||
<option value="">All domains</option>
|
||||
{DOMAIN_OPTIONS.filter(Boolean).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{query.isLoading ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">Loading…</div>
|
||||
) : query.isError ? (
|
||||
<div className="text-red-400 text-sm py-8 text-center">
|
||||
Failed to load audit log.
|
||||
</div>
|
||||
) : (query.data?.length ?? 0) === 0 ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">
|
||||
No entries found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Action</th>
|
||||
<th>Domain</th>
|
||||
<th>Service</th>
|
||||
<th>Entity</th>
|
||||
<th>Actor</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.data!.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)] whitespace-nowrap">
|
||||
{new Date(r.occurred_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="font-mono text-xs">{r.action}</td>
|
||||
<td className="text-xs">{r.domain}</td>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)]">{r.service}</td>
|
||||
<td className="text-xs">{r.entity_type}</td>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)] max-w-[120px] truncate">
|
||||
{r.actor_id}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
r.status === "success"
|
||||
? "badge badge-low"
|
||||
: r.status === "failure"
|
||||
? "badge badge-high"
|
||||
: "badge"
|
||||
}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/ops/ExportsPage.tsx
Normal file
108
frontend/src/pages/ops/ExportsPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { getExportHistory } from "../../api/gateway";
|
||||
|
||||
const DOMAIN_OPTIONS = ["", "aw", "wwi"];
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function ExportsPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["exports", domain],
|
||||
queryFn: () => getExportHistory(200, domain || undefined),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">
|
||||
Export History
|
||||
</h1>
|
||||
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
|
||||
All data exports generated across domains
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
className="form-input text-sm"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
>
|
||||
<option value="">All domains</option>
|
||||
{DOMAIN_OPTIONS.filter(Boolean).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{query.isLoading ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">Loading…</div>
|
||||
) : query.isError ? (
|
||||
<div className="text-red-400 text-sm py-8 text-center">
|
||||
Failed to load export history.
|
||||
</div>
|
||||
) : (query.data?.length ?? 0) === 0 ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">
|
||||
No exports recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Domain</th>
|
||||
<th>View</th>
|
||||
<th>Format</th>
|
||||
<th>Rows</th>
|
||||
<th>Size</th>
|
||||
<th>Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{query.data!.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)] whitespace-nowrap">
|
||||
{new Date(r.exported_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-xs">{r.domain.toUpperCase()}</td>
|
||||
<td className="text-xs font-mono">{r.source_view}</td>
|
||||
<td>
|
||||
<span className="badge">{r.format.toUpperCase()}</span>
|
||||
</td>
|
||||
<td className="text-xs">{r.row_count.toLocaleString()}</td>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)]">
|
||||
{formatBytes(r.file_size_bytes)}
|
||||
</td>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)] max-w-[120px] truncate">
|
||||
{r.actor_id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/src/pages/ops/OperationsPage.tsx
Normal file
201
frontend/src/pages/ops/OperationsPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { getAWJobs, getWWIJobs, triggerAWJob, triggerWWIJob } from "../../api/gateway";
|
||||
import type { JobExecution } from "../../api/types";
|
||||
|
||||
const AW_JOBS = [
|
||||
{ id: "forecast", label: "Daily Forecast" },
|
||||
{ id: "scores", label: "Rep & Product Scores" },
|
||||
{ id: "data_quality", label: "Data Quality" },
|
||||
{ id: "anomaly_detection", label: "Anomaly Detection" },
|
||||
];
|
||||
|
||||
const WWI_JOBS = [
|
||||
{ id: "reorder", label: "Reorder Recommendations" },
|
||||
{ id: "supplier_scores", label: "Supplier Scores" },
|
||||
{ id: "events", label: "Business Events" },
|
||||
{ id: "data_quality", label: "Data Quality" },
|
||||
];
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === "success") return "badge badge-low";
|
||||
if (status === "failure") return "badge badge-high";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
function JobTable({ rows }: { rows: JobExecution[] }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm text-center py-8">
|
||||
No job history yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Records</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="font-mono text-xs">{r.job_name}</td>
|
||||
<td><span className={statusClass(r.status)}>{r.status}</span></td>
|
||||
<td className="text-xs text-[rgba(233,244,255,0.5)]">
|
||||
{new Date(r.started_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{r.duration_ms != null ? `${r.duration_ms.toLocaleString()} ms` : "—"}
|
||||
</td>
|
||||
<td className="text-xs">{r.records_processed ?? "—"}</td>
|
||||
<td className="text-xs text-red-400 max-w-[200px] truncate">
|
||||
{r.error_message ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerButton({
|
||||
jobId,
|
||||
label,
|
||||
domain,
|
||||
onTrigger,
|
||||
}: {
|
||||
jobId: string;
|
||||
label: string;
|
||||
domain: "aw" | "wwi";
|
||||
onTrigger: (id: string) => Promise<unknown>;
|
||||
}) {
|
||||
const [triggered, setTriggered] = useState(false);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => onTrigger(jobId),
|
||||
onSuccess: () => setTriggered(true),
|
||||
});
|
||||
return (
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || triggered}
|
||||
title={`Trigger ${domain.toUpperCase()} ${label}`}
|
||||
>
|
||||
{mutation.isPending ? "Triggering…" : triggered ? "Triggered" : `▶ ${label}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const awJobs = useQuery({
|
||||
queryKey: ["jobs", "aw"],
|
||||
queryFn: () => getAWJobs(50),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const wwiJobs = useQuery({
|
||||
queryKey: ["jobs", "wwi"],
|
||||
queryFn: () => getWWIJobs(50),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["jobs"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">Operations</h1>
|
||||
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
|
||||
Scheduled job history and manual triggers
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={refresh}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Manual triggers */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
|
||||
Manual Job Triggers
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-[rgba(233,244,255,0.4)] uppercase tracking-wider mb-2">
|
||||
AdventureWorks
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AW_JOBS.map((j) => (
|
||||
<TriggerButton
|
||||
key={j.id}
|
||||
jobId={j.id}
|
||||
label={j.label}
|
||||
domain="aw"
|
||||
onTrigger={triggerAWJob}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[rgba(233,244,255,0.4)] uppercase tracking-wider mb-2">
|
||||
WideWorldImporters
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WWI_JOBS.map((j) => (
|
||||
<TriggerButton
|
||||
key={j.id}
|
||||
jobId={j.id}
|
||||
label={j.label}
|
||||
domain="wwi"
|
||||
onTrigger={triggerWWIJob}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AW job history */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
|
||||
AdventureWorks — Job History
|
||||
</h2>
|
||||
{awJobs.isLoading ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-4">Loading…</div>
|
||||
) : awJobs.isError ? (
|
||||
<div className="text-red-400 text-sm py-4">Failed to load job history.</div>
|
||||
) : (
|
||||
<JobTable rows={awJobs.data ?? []} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WWI job history */}
|
||||
<div className="card">
|
||||
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
|
||||
WideWorldImporters — Job History
|
||||
</h2>
|
||||
{wwiJobs.isLoading ? (
|
||||
<div className="text-[rgba(233,244,255,0.4)] text-sm py-4">Loading…</div>
|
||||
) : wwiJobs.isError ? (
|
||||
<div className="text-red-400 text-sm py-4">Failed to load job history.</div>
|
||||
) : (
|
||||
<JobTable rows={wwiJobs.data ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend/src/pages/wwi/BusinessEvents.tsx
Normal file
123
frontend/src/pages/wwi/BusinessEvents.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import { getWWIBusinessEvents } from "../../api/wwi";
|
||||
import type { WWIBusinessEvent } from "../../api/types";
|
||||
|
||||
const tracer = trace.getTracer("wwi-business-events");
|
||||
|
||||
function SeverityBadge({ severity }: { severity: WWIBusinessEvent["severity"] }) {
|
||||
const cls =
|
||||
severity === "HIGH" ? "badge-high" : severity === "MEDIUM" ? "badge-medium" : "badge-low";
|
||||
return <span className={`badge ${cls}`}>{severity}</span>;
|
||||
}
|
||||
|
||||
function EventTypeBadge({ type }: { type: WWIBusinessEvent["event_type"] }) {
|
||||
const colors: Record<string, string> = {
|
||||
LOW_STOCK: "#f9de70",
|
||||
ORDER_DROP: "#57d4ff",
|
||||
SUPPLIER_RISK: "#ff9d7a",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ color: colors[type] ?? "#fff", background: "rgba(255,255,255,0.07)" }}
|
||||
>
|
||||
{type.replace("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function eventBorderClass(severity: WWIBusinessEvent["severity"]) {
|
||||
return severity === "HIGH"
|
||||
? "event-border-high"
|
||||
: severity === "MEDIUM"
|
||||
? "event-border-medium"
|
||||
: "event-border-low";
|
||||
}
|
||||
|
||||
export default function BusinessEvents() {
|
||||
const query = useQuery({
|
||||
queryKey: ["wwi", "business-events"],
|
||||
queryFn: () => getWWIBusinessEvents(100),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.wwi.business_events.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => { void query.refetch(); });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const events = query.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
WideWorldImporters — Business Events
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
Auto-generated alerts from stock checks — LOW_STOCK events deduplicated per 24h window
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading business events…</div>
|
||||
)}
|
||||
{query.error && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl mb-3 text-sm">
|
||||
Failed to load: {(query.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length === 0 && !query.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-8 text-center text-sm">
|
||||
No business events recorded yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-3">
|
||||
<h3 className="m-0 font-semibold text-base">Event Log</h3>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-sm">{events.length} events</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`bg-[rgba(10,18,30,0.7)] border border-[rgba(186,212,255,0.22)] rounded-xl px-4 py-3 ${eventBorderClass(event.severity)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<EventTypeBadge type={event.event_type} />
|
||||
<SeverityBadge severity={event.severity} />
|
||||
<span className="font-semibold text-sm">{event.entity_name}</span>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-[0.78rem] ml-auto">
|
||||
{new Date(event.occurred_at).toLocaleString("en-US")}
|
||||
</span>
|
||||
{event.trace_id && (
|
||||
<code className="text-[0.72rem] text-[#8ef2c7] opacity-70">
|
||||
{event.trace_id.slice(0, 12)}…
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[rgba(220,235,255,0.85)]">{event.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
frontend/src/pages/wwi/StockDashboard.tsx
Normal file
145
frontend/src/pages/wwi/StockDashboard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import { getWWIKpis, getWWIReorderRecommendations } from "../../api/wwi";
|
||||
import type { WWIReorderRecommendation } from "../../api/types";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("wwi-stock-dashboard");
|
||||
|
||||
function urgencyClass(urgency: WWIReorderRecommendation["urgency"]) {
|
||||
return `badge ${urgency === "HIGH" ? "badge-high" : urgency === "MEDIUM" ? "badge-medium" : "badge-low"}`;
|
||||
}
|
||||
|
||||
export default function StockDashboard() {
|
||||
const kpiQuery = useQuery({
|
||||
queryKey: ["wwi", "kpis"],
|
||||
queryFn: getWWIKpis,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
const reorderQuery = useQuery({
|
||||
queryKey: ["wwi", "reorder-recommendations"],
|
||||
queryFn: getWWIReorderRecommendations,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.wwi.stock_dashboard.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => {
|
||||
void kpiQuery.refetch();
|
||||
void reorderQuery.refetch();
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const kpis = kpiQuery.data;
|
||||
const items = reorderQuery.data ?? [];
|
||||
const highCount = items.filter((i) => i.urgency === "HIGH").length;
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
WideWorldImporters — Stock & Reorder
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
Items requiring reorder — derived from Fact.Movement + Fact.Sale demand velocity
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
{kpis && (
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[0.9rem] mb-[0.9rem]">
|
||||
<article className="card">
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] text-[0.82rem] uppercase tracking-[0.08em]">Total Revenue (180d)</p>
|
||||
<h2 className="m-0 mt-2 text-[clamp(1.1rem,1.7vw,1.6rem)] font-bold">{money.format(kpis.total_revenue)}</h2>
|
||||
</article>
|
||||
<article className="card">
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] text-[0.82rem] uppercase tracking-[0.08em]">Gross Margin</p>
|
||||
<h2 className="m-0 mt-2 text-[clamp(1.1rem,1.7vw,1.6rem)] font-bold">{kpis.gross_margin_pct.toFixed(2)}%</h2>
|
||||
</article>
|
||||
<article className="card">
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] text-[0.82rem] uppercase tracking-[0.08em]">Items Needing Reorder</p>
|
||||
<h2 className="m-0 mt-2 text-[clamp(1.1rem,1.7vw,1.6rem)] font-bold">{items.length}</h2>
|
||||
</article>
|
||||
<article className={`card ${highCount > 0 ? "border-[rgba(255,100,100,0.45)]!" : ""}`}>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] text-[0.82rem] uppercase tracking-[0.08em]">Critical (HIGH urgency)</p>
|
||||
<h2 className="m-0 mt-2 text-[clamp(1.1rem,1.7vw,1.6rem)] font-bold">{highCount}</h2>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{reorderQuery.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading reorder data…</div>
|
||||
)}
|
||||
{reorderQuery.error && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl mb-3 text-sm">
|
||||
Failed to load: {(reorderQuery.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reorder table */}
|
||||
{items.length > 0 && (
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-2">
|
||||
<h3 className="m-0 font-semibold text-base">Reorder Recommendations</h3>
|
||||
<span className="text-[rgba(233,244,255,0.7)] text-sm">{items.length} items</span>
|
||||
</div>
|
||||
<div className="mt-2 max-h-[350px] overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Urgency</th>
|
||||
<th>Stock Item</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Avg Daily Demand</th>
|
||||
<th>Days Until Stockout</th>
|
||||
<th>Recommended Reorder Qty</th>
|
||||
<th>Unit Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.stock_item_key}>
|
||||
<td><span className={urgencyClass(item.urgency)}>{item.urgency}</span></td>
|
||||
<td>{item.stock_item_name}</td>
|
||||
<td>{item.current_stock.toLocaleString("en-US", { maximumFractionDigits: 1 })}</td>
|
||||
<td>{item.avg_daily_demand.toFixed(2)}</td>
|
||||
<td>
|
||||
{item.days_until_stockout !== null
|
||||
? `${item.days_until_stockout.toFixed(1)}d`
|
||||
: "Overdue"}
|
||||
</td>
|
||||
<td>{item.recommended_reorder_qty.toLocaleString()}</td>
|
||||
<td>{money.format(item.unit_price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{!reorderQuery.isLoading && items.length === 0 && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-8 text-center text-sm">
|
||||
No items currently require reorder — all stock levels are healthy.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/pages/wwi/SupplierScores.tsx
Normal file
139
frontend/src/pages/wwi/SupplierScores.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getWWISupplierScores } from "../../api/wwi";
|
||||
|
||||
const tracer = trace.getTracer("wwi-supplier-scores");
|
||||
|
||||
function scoreBadgeClass(score: number) {
|
||||
return `badge ${score >= 80 ? "score-high" : score >= 60 ? "score-medium" : "score-low"}`;
|
||||
}
|
||||
|
||||
export default function SupplierScores() {
|
||||
const query = useQuery({
|
||||
queryKey: ["wwi", "supplier-scores"],
|
||||
queryFn: () => getWWISupplierScores(15),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
tracer.startActiveSpan("frontend.wwi.supplier_scores.refresh", (span) => {
|
||||
try {
|
||||
startTransition(() => { void query.refetch(); });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const suppliers = query.data ?? [];
|
||||
const chartData = suppliers.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4 max-sm:flex-col">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
WideWorldImporters — Supplier Reliability
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
60% fill rate + 40% finalization rate — from Fact.Purchase + Dimension.Supplier
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-secondary shrink-0" onClick={refresh} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading && (
|
||||
<div className="text-[rgba(233,244,255,0.7)] py-4 text-sm">Loading supplier scores…</div>
|
||||
)}
|
||||
{query.error && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl mb-3 text-sm">
|
||||
Failed to load: {(query.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suppliers.length > 0 && (
|
||||
<div className="flex flex-col gap-[0.9rem]">
|
||||
{/* Bar chart */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<h3 className="m-0 font-semibold text-base">Reliability Score by Supplier</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 150 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" horizontal={false} />
|
||||
<XAxis type="number" domain={[0, 100]} stroke="rgba(255,255,255,0.65)" />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="supplier_name"
|
||||
width={140}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip formatter={(v) => [`${Number(v).toFixed(1)}`, "Score"]} />
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((s, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={s.score >= 80 ? "#8ef2c7" : s.score >= 60 ? "#f9de70" : "#ff7a7a"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Scoreboard table */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-2">
|
||||
<h3 className="m-0 font-semibold text-base">Supplier Scoreboard</h3>
|
||||
</div>
|
||||
<div className="mt-2 max-h-[350px] overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Supplier</th>
|
||||
<th>Category</th>
|
||||
<th>Total Orders</th>
|
||||
<th>Fill Rate</th>
|
||||
<th>Finalization Rate</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suppliers.map((s) => (
|
||||
<tr key={s.supplier_key}>
|
||||
<td>{s.rank}</td>
|
||||
<td>{s.supplier_name}</td>
|
||||
<td>{s.category}</td>
|
||||
<td>{s.total_orders.toLocaleString()}</td>
|
||||
<td>{s.fill_rate_pct.toFixed(1)}%</td>
|
||||
<td>{s.finalization_rate_pct.toFixed(1)}%</td>
|
||||
<td>
|
||||
<span className={scoreBadgeClass(s.score)}>{s.score.toFixed(1)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
frontend/src/pages/wwi/WhatIf.tsx
Normal file
207
frontend/src/pages/wwi/WhatIf.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { createWWIScenario, getWWIScenarios } from "../../api/wwi";
|
||||
import type { WWIWhatIfResult } from "../../api/types";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const tracer = trace.getTracer("wwi-whatif");
|
||||
|
||||
export default function WhatIf() {
|
||||
const [stockItemKey, setStockItemKey] = useState<string>("");
|
||||
const [multiplier, setMultiplier] = useState<number>(1.0);
|
||||
const [result, setResult] = useState<WWIWhatIfResult | null>(null);
|
||||
|
||||
const scenariosQuery = useQuery({
|
||||
queryKey: ["wwi", "scenarios"],
|
||||
queryFn: () => getWWIScenarios(10),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ key, mult }: { key: number; mult: number }) => {
|
||||
return tracer.startActiveSpan("frontend.wwi.create_scenario", async (span) => {
|
||||
try {
|
||||
const res = await createWWIScenario(key, mult);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return res;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
void scenariosQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const key = parseInt(stockItemKey, 10);
|
||||
if (isNaN(key) || key < 1) return;
|
||||
mutation.mutate({ key, mult: multiplier });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1100px]">
|
||||
<div className="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="m-0 mb-1 text-2xl font-bold tracking-tight">
|
||||
WideWorldImporters — What-if Scenarios
|
||||
</h2>
|
||||
<p className="m-0 text-[rgba(233,244,255,0.7)] max-w-[74ch] text-sm">
|
||||
Simulate demand changes: adjust demand multiplier to project stockout date and reorder requirements
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form + result side-by-side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-[0.9rem] mb-[0.9rem]">
|
||||
{/* Simulation form */}
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-3">
|
||||
<h3 className="m-0 font-semibold text-base">Run Simulation</h3>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<label className="flex flex-col gap-1 text-sm text-[rgba(233,244,255,0.7)]">
|
||||
Stock Item Key
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={stockItemKey}
|
||||
onChange={(e) => setStockItemKey(e.target.value)}
|
||||
placeholder="e.g. 112"
|
||||
required
|
||||
/>
|
||||
<span className="text-[rgba(186,212,255,0.45)] text-[0.75rem]">
|
||||
Integer key from Dimension.[Stock Item]
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm text-[rgba(233,244,255,0.7)]">
|
||||
Demand Multiplier: <strong className="text-[#f3f7ff]">{multiplier.toFixed(2)}×</strong>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full"
|
||||
min={0.1}
|
||||
max={5.0}
|
||||
step={0.1}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(parseFloat(e.target.value))}
|
||||
/>
|
||||
<div className="flex justify-between text-[0.72rem] text-[rgba(186,212,255,0.45)]">
|
||||
<span>0.1× (very low)</span>
|
||||
<span>1.0× (baseline)</span>
|
||||
<span>5.0× (surge)</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={mutation.isPending || !stockItemKey}
|
||||
>
|
||||
{mutation.isPending ? "Simulating…" : "Run Simulation"}
|
||||
</button>
|
||||
|
||||
{mutation.isError && (
|
||||
<div className="text-[#ffb6b6] py-3 px-4 bg-[rgba(255,80,80,0.10)] border border-[rgba(255,100,100,0.25)] rounded-xl text-sm">
|
||||
{(mutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-3">
|
||||
<h3 className="m-0 font-semibold text-base">Simulation Result</h3>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Item</dt>
|
||||
<dd className="m-0 font-medium">{result.stock_item_name}</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Current Stock</dt>
|
||||
<dd className="m-0 font-medium">{result.current_stock.toLocaleString()} units</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Baseline Daily Demand</dt>
|
||||
<dd className="m-0 font-medium">{result.base_avg_daily_demand.toFixed(2)} units/day</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Adjusted Daily Demand ({result.demand_multiplier}×)</dt>
|
||||
<dd className="m-0 font-medium">{result.adjusted_daily_demand.toFixed(2)} units/day</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Days Until Stockout</dt>
|
||||
<dd className="m-0 font-medium">
|
||||
{result.projected_days_until_stockout !== null
|
||||
? (
|
||||
<span className={result.projected_days_until_stockout < 7 ? "text-[#ff9d7a] font-semibold" : ""}>
|
||||
{result.projected_days_until_stockout.toFixed(1)} days
|
||||
</span>
|
||||
)
|
||||
: "No stockout (demand = 0)"}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Projected Stockout Date</dt>
|
||||
<dd className="m-0 font-medium">{result.projected_stockout_date ?? "—"}</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Recommended Order Qty</dt>
|
||||
<dd className="m-0 font-medium">{result.recommended_order_qty.toLocaleString()} units</dd>
|
||||
|
||||
<dt className="text-[rgba(233,244,255,0.7)] whitespace-nowrap">Estimated Reorder Cost</dt>
|
||||
<dd className="m-0 font-medium">{money.format(result.estimated_reorder_cost)}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent scenarios table */}
|
||||
{(scenariosQuery.data?.length ?? 0) > 0 && (
|
||||
<article className="card">
|
||||
<div className="flex justify-between items-baseline gap-4 mb-2">
|
||||
<h3 className="m-0 font-semibold text-base">Recent Scenarios</h3>
|
||||
</div>
|
||||
<div className="mt-2 max-h-[350px] overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Stock Item</th>
|
||||
<th>Multiplier</th>
|
||||
<th>Days Until Stockout</th>
|
||||
<th>Recommended Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scenariosQuery.data?.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{new Date(s.created_at).toLocaleString("en-US")}</td>
|
||||
<td>{s.stock_item_name}</td>
|
||||
<td>{s.demand_multiplier.toFixed(2)}×</td>
|
||||
<td>
|
||||
{s.projected_days_until_stockout !== null
|
||||
? `${s.projected_days_until_stockout.toFixed(1)}d`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{s.recommended_order_qty.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +1,141 @@
|
||||
:root {
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Base — global resets and body gradient
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #f3f7ff;
|
||||
background: #0a1019;
|
||||
}
|
||||
|
||||
--bg-primary: #0a1019;
|
||||
--bg-secondary: #101d2e;
|
||||
--bg-panel: rgba(16, 28, 44, 0.72);
|
||||
--border: rgba(186, 212, 255, 0.22);
|
||||
--accent-a: #f9de70;
|
||||
--accent-b: #57d4ff;
|
||||
--accent-c: #8ef2c7;
|
||||
--text-muted: rgba(233, 244, 255, 0.7);
|
||||
--shadow: 0 20px 55px rgba(3, 8, 16, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 0% 0%,
|
||||
rgba(122, 82, 242, 0.2),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 100% 10%,
|
||||
rgba(87, 212, 255, 0.18),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(150deg, var(--bg-primary), var(--bg-secondary));
|
||||
}
|
||||
radial-gradient(circle at 0% 0%, rgba(122, 82, 242, 0.2), transparent 30%),
|
||||
radial-gradient(circle at 100% 10%, rgba(87, 212, 255, 0.18), transparent 30%),
|
||||
linear-gradient(150deg, #0a1019, #101d2e);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1200px, 100% - 2rem);
|
||||
margin: 1.5rem auto 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.radial-glow {
|
||||
position: fixed;
|
||||
width: 48vw;
|
||||
height: 48vw;
|
||||
max-width: 540px;
|
||||
max-height: 540px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(87, 212, 255, 0.16),
|
||||
transparent 65%
|
||||
);
|
||||
top: -12rem;
|
||||
right: -10rem;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dashboard-header,
|
||||
.kpi-grid,
|
||||
.panel-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
margin: 0.2rem 0 0.5rem;
|
||||
font-size: clamp(1.6rem, 2.2vw, 2.4rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.trace-id code {
|
||||
color: var(--accent-c);
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background: linear-gradient(125deg, var(--accent-b), #7be8ff);
|
||||
color: #04111b;
|
||||
border: 0;
|
||||
font-weight: 700;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(248, 159, 159, 0.2);
|
||||
color: #ffd6d6;
|
||||
border: 1px solid rgba(255, 184, 184, 0.45);
|
||||
font-weight: 700;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 0.95rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.kpi-card p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.kpi-card h2 {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: clamp(1.1rem, 1.7vw, 1.6rem);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.panel.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.panel-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-title-row h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-title-row span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chart-wrap,
|
||||
.score-wrap {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.score-caption {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 0.7rem;
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.45rem;
|
||||
border-bottom: 1px solid rgba(216, 232, 255, 0.09);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
th {
|
||||
color: rgba(233, 244, 255, 0.7);
|
||||
font-size: 0.77rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations-list {
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
/* ---------------------------------------------------------------------------
|
||||
Components — complex visual classes kept out of JSX
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.recommendations-list li {
|
||||
border: 1px solid rgba(190, 210, 245, 0.14);
|
||||
background: rgba(12, 20, 31, 0.7);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
@layer components {
|
||||
/* Glassmorphism panel / card */
|
||||
.card {
|
||||
background: rgba(16, 28, 44, 0.72);
|
||||
border: 1px solid rgba(186, 212, 255, 0.22);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 20px 55px rgba(3, 8, 16, 0.45);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.recommendations-list h4 {
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
}
|
||||
|
||||
.recommendations-list p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.priority {
|
||||
/* Badges — apply .badge plus one modifier */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.priority.high {
|
||||
background: rgba(255, 112, 112, 0.17);
|
||||
color: #ffb6b6;
|
||||
}
|
||||
|
||||
.priority.medium {
|
||||
background: rgba(255, 205, 112, 0.18);
|
||||
color: #ffe3ae;
|
||||
}
|
||||
|
||||
.priority.low {
|
||||
background: rgba(142, 242, 199, 0.18);
|
||||
color: #b9ffd8;
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
color: #d6e7ff;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-high { background: rgba(255, 80, 80, 0.18); color: #ffb6b6; }
|
||||
.badge-medium { background: rgba(255, 200, 80, 0.18); color: #ffe0ae; }
|
||||
.badge-low { background: rgba(100, 220, 160, 0.18); color: #b2ffd3; }
|
||||
.score-high { background: rgba(142, 242, 199, 0.18); color: #8ef2c7; }
|
||||
.score-medium { background: rgba(249, 222, 112, 0.18); color: #f9de70; }
|
||||
.score-low { background: rgba(255, 120, 120, 0.18); color: #ffb6b6; }
|
||||
|
||||
/* Event card left-border severity colours */
|
||||
.event-border-high { border-left: 3px solid #ff7a7a; }
|
||||
.event-border-medium { border-left: 3px solid #f9de70; }
|
||||
.event-border-low { border-left: 3px solid #8ef2c7; }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: linear-gradient(125deg, #57d4ff, #7be8ff);
|
||||
color: #04111b;
|
||||
border: 0;
|
||||
font-weight: 700;
|
||||
padding: 0.72rem 1.2rem;
|
||||
border-radius: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(87, 212, 255, 0.12);
|
||||
color: #57d4ff;
|
||||
border: 1px solid rgba(87, 212, 255, 0.35);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: rgba(255, 200, 200, 0.75);
|
||||
border: 1px solid rgba(255, 160, 160, 0.3);
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Sidebar navigation links */
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgba(233, 244, 255, 0.75);
|
||||
text-decoration: none;
|
||||
font-size: 0.88rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
.nav-link:hover { background: rgba(87, 212, 255, 0.10); color: #fff; }
|
||||
.nav-active { background: rgba(87, 212, 255, 0.16); color: #57d4ff !important; font-weight: 600; }
|
||||
|
||||
/* Dark-themed form input */
|
||||
.form-input {
|
||||
background: rgba(10, 20, 35, 0.7);
|
||||
border: 1px solid rgba(186, 212, 255, 0.22);
|
||||
color: #f3f7ff;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user