Push the rest

This commit is contained in:
2026-05-11 10:58:46 +02:00
parent adb5c1a439
commit 0031caf16c
94 changed files with 11777 additions and 3474 deletions

View File

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

View File

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

View File

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

View File

@@ -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
View 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");

View File

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

View 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;
}

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

View File

@@ -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
View 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",
);

View File

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

View File

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

View 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| &gt; 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>
);
}

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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