From 4cc048660927faa7cbdabdc0965bde9e0b6c3566 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Thu, 27 Aug 2026 12:38:12 +0000 Subject: [PATCH 01/20] chore: bump version to 1.3.7 --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 679aa8fa..15041403 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cab-front", - "version": "1.3.6", + "version": "1.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cab-front", - "version": "1.3.6", + "version": "1.3.7", "license": "MPL-2.0", "dependencies": { "@floating-ui/vue": "^1.0.6", diff --git a/frontend/package.json b/frontend/package.json index a3967949..1d8c21c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cab-front", - "version": "1.3.6", + "version": "1.3.7", "private": true, "license": "MPL-2.0", "type": "module", From ca4e325e67ed62ba388fd521aaec7626c4197988 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Thu, 27 Aug 2026 13:49:12 +0000 Subject: [PATCH 02/20] fix nginx conf --- README.md | 9 +++- .../dev/cab-standalone/nginx-kubernetes.conf | 17 +++++++ deploy-chart/apply-nginx-conf.sh | 50 +++++++++++++++++++ .../configmap-assistant-platform.yaml | 14 +++--- 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100755 deploy-chart/apply-nginx-conf.sh diff --git a/README.md b/README.md index 4f9394b1..f6cbbde9 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,14 @@ Key variables (see `.secrets.example` for all options and per-environment values only value that changes per environment: - Local dev : `http://host.docker.internal:5122/` (simulator container on the host) - LAN : `http://192.168.208.61:5100/` - - Public/k8s: handled by `nginx-kubernetes.conf` via the helm chart (not this variable) + - Public/k8s: same variable, but set as an env var on the **frontend pod** (see + `deploy-chart/values.ovh.yaml`). `start-webui.sh` substitutes it into the + `__POWERGRID_SIMU_UPSTREAM__` placeholder of the nginx config at container start. + Beware: in k8s the config comes from the `cab-assistant-platform-config` ConfigMap + mounted over `/etc/nginx/conf.d`, which **overrides** the `default.conf` baked into + the image — the `/powergrid-simu/` location must be present in that ConfigMap or the + apply POST falls through to the static `location /` and nginx answers 405. + nginx only reads `conf.d` at startup, so restart the frontend after changing it. - `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the deep expert agent that powers PowerGrid recommendations (see [The PowerGrid expert agent API](#the-powergrid-expert-agent-api) below to install it). A token is required in every mode: diff --git a/config/dev/cab-standalone/nginx-kubernetes.conf b/config/dev/cab-standalone/nginx-kubernetes.conf index b0a3e022..11f75cc3 100644 --- a/config/dev/cab-standalone/nginx-kubernetes.conf +++ b/config/dev/cab-standalone/nginx-kubernetes.conf @@ -205,6 +205,23 @@ server { proxy_set_header X-Forwarded-For $remote_addr; } + # PowerGrid (grid2op) simulator "apply action" proxy. Keeps the browser POST + # same-origin (no CORS): the bundle is built with VITE_POWERGRID_SIMU=/powergrid-simu + # and posts to /powergrid-simu/api/v1/recommendations. Without this location the POST + # falls through to the static `location /` and nginx answers 405. + # __POWERGRID_SIMU_UPSTREAM__ is substituted at container start by start-webui.sh from + # the POWERGRID_SIMU_UPSTREAM env var of the frontend pod, e.g. + # https://interactivepowergrid.passerelle.irt-systemx.fr/ + # No explicit Host header: nginx defaults it to $proxy_host (the authority from + # proxy_pass), which is what an ip:port upstream AND a vhost upstream both need. + # Forwarding $http_host instead would make the passerelle route to the wrong vhost. + location /powergrid-simu/ { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_ssl_server_name on; + proxy_ssl_verify off; + proxy_pass __POWERGRID_SIMU_UPSTREAM__; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/deploy-chart/apply-nginx-conf.sh b/deploy-chart/apply-nginx-conf.sh new file mode 100755 index 00000000..a361472f --- /dev/null +++ b/deploy-chart/apply-nginx-conf.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Push ONLY the nginx.conf key of configmap-assistant-platform.yaml to the live cluster, +# then restart the frontend so nginx re-reads it (nginx reads conf.d at startup only). +# +# Why a patch and not `kubectl apply -f configmap-assistant-platform.yaml`: that file is a +# dump of the whole ConfigMap (web-ui.json, users.yml, ...). Applying it wholesale would +# also revert any of those keys that drifted in the cluster since the dump was taken. +# +# Usage: ./deploy-chart/apply-nginx-conf.sh [namespace] [deployment] +set -euo pipefail + +NS="${1:-cab}" +DEPLOY="${2:-cab-frontend}" +CM=cab-assistant-platform-config +SRC="$(dirname "$0")/configmap-assistant-platform.yaml" + +command -v kubectl >/dev/null || { echo "kubectl not found" >&2; exit 1; } +[ -f "$SRC" ] || { echo "missing $SRC" >&2; exit 1; } + +# Repo -> JSON merge patch carrying just the one key. +PATCH=$(python3 - "$SRC" <<'PY' +import json, sys, yaml +conf = yaml.safe_load(open(sys.argv[1]))['data']['nginx.conf'] +assert '/powergrid-simu/' in conf, 'repo nginx.conf is missing the /powergrid-simu/ location' +print(json.dumps({'data': {'nginx.conf': conf}})) +PY +) + +echo "--- diff (live -> repo) for $NS/$CM nginx.conf" +kubectl -n "$NS" get cm "$CM" -o jsonpath='{.data.nginx\.conf}' > /tmp/nginx.conf.live || true +python3 -c "import json,sys;sys.stdout.write(json.loads(sys.argv[1])['data']['nginx.conf'])" "$PATCH" > /tmp/nginx.conf.repo +diff -u /tmp/nginx.conf.live /tmp/nginx.conf.repo || true + +read -r -p "Apply to $NS/$CM and restart $DEPLOY? [y/N] " ans +[ "$ans" = y ] || { echo "aborted"; exit 0; } + +kubectl -n "$NS" patch cm "$CM" --type merge -p "$PATCH" +kubectl -n "$NS" rollout restart "deploy/$DEPLOY" +kubectl -n "$NS" rollout status "deploy/$DEPLOY" --timeout=180s + +# The frontend pod must carry POWERGRID_SIMU_UPSTREAM: start-webui.sh substitutes it into +# the __POWERGRID_SIMU_UPSTREAM__ placeholder. Without it nginx gets the local-dev default +# (host.docker.internal) and refuses to start. +echo "--- frontend POWERGRID_SIMU_UPSTREAM:" +kubectl -n "$NS" get "deploy/$DEPLOY" \ + -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="POWERGRID_SIMU_UPSTREAM")]}{.value}{"\n"}{end}' + +echo "--- verify: must NOT be text/html (SPA fallback) any more" +kubectl -n "$NS" exec "deploy/$DEPLOY" -- \ + sh -c 'grep -c powergrid-simu /personal-conf/conf.d/default.conf' || true diff --git a/deploy-chart/configmap-assistant-platform.yaml b/deploy-chart/configmap-assistant-platform.yaml index 3a06da54..40916046 100644 --- a/deploy-chart/configmap-assistant-platform.yaml +++ b/deploy-chart/configmap-assistant-platform.yaml @@ -265,14 +265,17 @@ data: # PowerGrid (grid2op) simulator "apply action" proxy. Keeps the browser POST # same-origin (no CORS) — the frontend is built with # VITE_POWERGRID_SIMU=/powergrid-simu and posts to /powergrid-simu/api/v1/recommendations. - # Host must be the upstream vhost, not $http_host: the passerelle routes by hostname. + # __POWERGRID_SIMU_UPSTREAM__ is substituted at container start by start-webui.sh + # from the POWERGRID_SIMU_UPSTREAM env var of the frontend pod. + # No explicit Host header: nginx defaults it to $proxy_host (the authority from + # proxy_pass), so the passerelle gets the right vhost whatever the upstream is. + # Forwarding $http_host instead would make it route to the wrong vhost. location /powergrid-simu/ { add_header Cache-Control "no-cache"; - proxy_set_header Host interactivepowergrid.passerelle.irt-systemx.fr; proxy_set_header X-Forwarded-For $remote_addr; proxy_ssl_server_name on; proxy_ssl_verify off; - proxy_pass https://interactivepowergrid.passerelle.irt-systemx.fr/; + proxy_pass __POWERGRID_SIMU_UPSTREAM__; } error_page 500 502 503 504 /50x.html; @@ -726,11 +729,8 @@ kind: ConfigMap metadata: annotations: argocd.argoproj.io/tracking-id: cab:/ConfigMap:cab/cab-assistant-platform-config - kubectl.kubernetes.io/last-applied-configuration: | - {"apiVersion":"v1","data":{"businessconfig.yml":"spring:\n application:\n name: businessconfig\noperatorfabric.businessconfig:\n storage:\n path: \"/businessconfig-storage\"\n","cards-consultation.yml":"spring:\n application:\n name: cards-consultation\ncheckIfUserIsAlreadyConnected: true\n","cards-publication.yml":"spring:\n application:\n name: cards-publication\n deserializer:\n value:\n delegate:\n class: io.confluent.kafka.serializers.KafkaAvroDeserializer\n serializer:\n value:\n delegate:\n class: io.confluent.kafka.serializers.KafkaAvroSerializer\nopfab:\n kafka:\n topics:\n card:\n topicname: opfab\n response-card:\n topicname: opfab-response\n schema:\n registry:\n url: http://localhost:8081\ncheckAuthenticationForCardSending: false\n","common.yml":"management:\n endpoints:\n web:\n exposure:\n include: '*'\nspring:\n rabbitmq:\n host: cab-rabbitmq.cab.svc.cluster.local\n port: 5672\n username: ${RABBITMQ_USERNAME}\n password: ${RABBITMQ_PASSWORD}\n security:\n provider-url: https://keycloak.irtsysx.fr/auth\n oauth2:\n resourceserver:\n jwt:\n jwk-set-uri: ${spring.security.provider-url}/realms/interactiveai/protocol/openid-connect/certs\n data:\n mongodb:\n database: ${MONGODB_DB}\n uri: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@cab-mongodb.cab.svc.cluster.local:27017/${MONGODB_DB}?authSource=admin\u0026authMode=scram-sha1\nserver:\n forward-headers-strategy: framework\noperatorfabric:\n servicesUrls:\n users: \"http://cab-users.cab.svc.cluster.local:8080\"\n businessconfig: \"http://cab-businessconfig.cab.svc.cluster.local:8080\"\n","nginx.conf":"# docker-compose DNS used to resolved users service\n# resolver 127.0.0.11 ipv6=off;\n\n# Log format to have msec in time + request processing time\nmap \"$time_local:$msec\" $time_local_ms {\n ~(^\\S+)(\\s+\\S+):\\d+\\.(\\d+)$ $1.$3$2;\n}\nlog_format opfab-log '$remote_addr - $time_local_ms'\n'\"$request\" $status $request_time $body_bytes_sent ';\n\nlog_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name $host to: $upstream_addr: $request $status upstream_response_time $upstream_response_time msec $msec request_time $request_time';\n\nserver {\n listen 8080;\n server_name localhost demo.interactiveai.irt-systemx.fr;\n error_log /var/log/nginx/error.log debug;\n access_log /var/log/nginx/access.log opfab-log;\n\n ### CUSTOMIZATION - BEGIN\n # Url of the Authentication provider\n set $KeycloakBaseUrl \"https://keycloak.irtsysx.fr\";\n # Realm associated to OperatorFabric within the Authentication provider\n set $OperatorFabricRealm \"interactiveai\";\n # base64 encoded pair of authentication in the form of 'client-id:secret-id'\n set $ClientPairOFAuthentication \"b3BmYWItY2xpZW50Oklxdkh4VzBEdENDNXVMVEVTcFhyN0sxdkhtSHViOGdE\" ;\n\n ### CUSTOMIZATION - END\n\n ### OPFAB GENERIC CONFIGURATION ###\n ### BE CAREFUL WHEN MODIFYING ###\n set $BasicValue \"Basic $ClientPairOFAuthentication\";\n set $KeycloakOpenIdConnect $KeycloakBaseUrl/auth/realms/$OperatorFabricRealm/protocol/openid-connect;\n gzip on;\n gzip_types application/javascript text/css;\n\n location / {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/;\n try_files $uri $uri/ /index.html;\n }\n location = /external/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/external/;\n index index.html index.htm;\n }\n location /ui/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/;\n index index.html index.htm;\n }\n location /auth/check_token {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/token/introspect;\n }\n location /auth/token {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/token;\n }\n location /auth/code/ {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/auth?response_type=code\u0026client_id=opfab-client\u0026$args;\n }\n\n location /auth {\n add_header Cache-Control \"no-cache\";\n proxy_set_header X-Forwarded-For $proxy_protocol_addr;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_pass $KeycloakBaseUrl/auth;\n }\n\n # To be sure new files are downloaded when version change\n # we set no-cache for json config files and for i18n files\n location /config/web-ui.json {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/opfab/web-ui.json;\n }\n location /config/ui-menu.json {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/opfab/ui-menu.json;\n }\n location /ui/assets/i18n/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/assets/i18n/;\n }\n\n location /businessconfig {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-businessconfig.cab.svc.cluster.local:8080;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location ~ \"^/users/internal/(.*)\" {\n return 404;\n }\n\n location ~ \"^/users/(.*)\" {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/$1$is_args$args;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /users {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/users;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /perimeters {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/perimeters;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cards/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n ### !!!! SECURITY WARNING !!!!\n ### The following configuration is suitable only if you set checkAuthenticationForCardSending to true\n ### which is the default configuration\n ###\n\n location /cardspub/cards {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardspublication.cab.svc.cluster.local:8080/cards;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n ### if you set checkAuthenticationForCardSending to false\n ### you MUST not permit to access cards endpoint via nginx\n ### and replace the previous configuration by the following conf\n\n #location /cardspub/cards/user {\n # proxy_pass http://cards-publication:8080/cards/user;\n # proxy_set_header X-Forwarded-For $remote_addr;\n #}\n #location /cardspub/cards/translateCardField {\n # proxy_pass http://cards-publication:8080/cards/translateCardField;\n # proxy_set_header X-Forwarded-For $remote_addr;\n #}\n ###\n ### !!! END SECURITY WARNING !!!\n location /archives {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n location /externaldevices {\n add_header Cache-Control \"no-cache\";\n set $externaldevices http://external-devices.cab.svc.cluster.local:8080;\n proxy_pass $externaldevices;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n location ~ \"^/externaldevices/(.*)\" {\n add_header Cache-Control \"no-cache\";\n set $externaldevices http://external-devices.cab.svc.cluster.local:8080;\n proxy_pass $externaldevices/$1;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cabcontext/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabcontext.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_event/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabevent.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_recommendation/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabrecommendation.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cabhistoric/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabhistoric.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_capitalization/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabcapitalization.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n error_page 500 502 503 504 /50x.html;\n location = /50x.html {\n root /usr/share/nginx/html;\n }\n}\n","ui-menu.json":"{\n \"coreMenusConfiguration\":\n [\n {\n \"id\": \"feed\",\n \"visible\": true\n },\n {\n \"id\": \"archives\",\n \"visible\": true\n },\n {\n \"id\": \"monitoring\",\n \"visible\": true\n },\n {\n \"id\": \"logging\",\n \"visible\": true\n },\n {\n \"id\": \"usercard\",\n \"visible\": true\n },\n {\n \"id\": \"calendar\",\n \"visible\": true\n },\n {\n \"id\": \"admin\",\n \"visible\": true,\n \"showOnlyForGroups\": [\"ADMIN\"]\n },\n {\n \"id\": \"settings\",\n \"visible\": true\n },\n {\n \"id\": \"activityarea\",\n \"visible\": true\n },\n {\n \"id\": \"feedconfiguration\",\n \"visible\": true\n },\n {\n \"id\": \"realtimeusers\",\n \"visible\": true\n },\n {\n \"id\": \"externaldevicesconfiguration\",\n \"visible\": true,\n \"showOnlyForGroups\": [\"ADMIN\"]\n },\n {\n \"id\": \"nightdaymode\",\n \"visible\": true\n },\n {\n \"id\": \"about\",\n \"visible\": true\n },\n {\n \"id\": \"changepassword\",\n \"visible\": true\n },\n {\n \"id\": \"logout\",\n \"visible\": true\n }\n ],\n \"menus\": [\n {\n \"id\": \"menu1\",\n \"label\": \"title.single\",\n \"entries\": [\n {\n \"id\": \"uid_test_0\",\n \"url\": \"https://en.wikipedia.org/w/index.php\",\n \"label\": \"entry.single\",\n \"linkType\": \"BOTH\"\n }\n ]\n },\n {\n \"id\": \"menu2\",\n \"label\": \"title.multi\",\n \"entries\": [\n {\n \"id\": \"uid_test_1\",\n \"url\": \"https://opfab.github.io/\",\n \"label\": \"entry.entry1\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"ReadOnly\",\"Planner\"]\n },\n {\n \"id\": \"uid_test_2\",\n \"url\": \"https://www.wikipedia.org/\",\n \"label\": \"entry.entry2\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"Dispatcher\"]\n },\n {\n \"id\": \"uid_test_3\",\n \"url\": \"http://localhost:2002/external/appExample/\",\n \"label\": \"entry.entry3\",\n \"linkType\": \"BOTH\"\n }\n ]\n },\n {\n \"id\": \"adminmenu\",\n \"label\": \"title.admin\",\n \"entries\": [\n {\n \"id\": \"uid_test_3\",\n \"url\": \"https://opfab.github.io/\",\n \"label\": \"entry.admin\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"ADMIN\"]\n }\n ]\n }\n ],\n \"locales\": [\n {\n \"language\": \"en\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"First menu\"\n },\n \"entry\": {\n \"single\": \"Single menu entry\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Second menu\"\n },\n \"entry\": {\n \"entry1\": \"First menu entry\",\n \"entry2\": \"Second menu entry\",\n \"entry3\": \"External application\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu entry\"\n }\n }\n }\n },\n {\n \"language\": \"fr\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"Premier menu\"\n },\n \"entry\": {\n \"single\": \"Unique élément\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Deuxième menu\"\n },\n \"entry\": {\n \"entry1\": \"Premier élément\",\n \"entry2\": \"Deuxième élément\",\n \"entry3\": \"Application externe\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu entry\"\n }\n }\n }\n },\n {\n \"language\": \"nl\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"Eerste menu\"\n },\n \"entry\": {\n \"single\": \"Enkel menu-item\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Tweede menu\"\n },\n \"entry\": {\n \"entry1\": \"Eerste menu-item\",\n \"entry2\": \"Tweede menu-item\",\n \"entry3\": \"TExterne applicatie\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu-item\"\n }\n }\n }\n }\n ]\n}\n","users.yml":"\n# POPULATE THE USER DATABASE ON INIT\n# !!!! WARNING: VALUES SHOULD BE CHANGED FOR PRODUCTION MODE !!!!\nspring:\n application:\n name: users\noperatorfabric.users.default:\n users:\n - login: admin\n groups: [\"ADMIN\"]\n entities: [\"Railway\", \"ATM\", \"PowerGrid\"]\n - login: ilyes\n firstname : Ilyes\n lastname : KAANICH\n groups: [\"Dispatcher\",\"ReadOnly\"]\n entities: [\"IRT_MAIN\"]\n - login: railway_user\n groups: [\"Planner\", \"ReadOnly\"]\n entities: [\"Railway\"]\n - login: orange_user\n groups: [ \"PowerGrid\",\"ADMIN\",\"ReadOnly\",\"Dispatcher\"]\n entities: [ \"ORANGE\" ]\n - login: atm_user\n groups: [\"ReadOnly\",\"Dispatcher\"]\n entities: [\"ATM\"]\n - login: PowerGrid_user\n groups: [ \"PowerGrid\",\"ADMIN\",\"ReadOnly\",\"Dispatcher\"]\n entities: [ \"PowerGrid\" ]\n groups:\n - id: ADMIN\n name: ADMINISTRATORS\n description: The admin group\n - id: PowerGrid\n name: RTE France\n description: RTE TSO Group\n realtime: false\n - id: Dispatcher\n name: Dispatcher\n description: Dispatcher Group\n realtime: true\n - id: Planner\n name: Planner\n description: Planner Group\n realtime: true\n - id: Supervisor\n name: Supervisor\n description: Supervisor Group\n realtime: true\n - id: Manager\n name: Manager\n description: Manager Group\n realtime: false\n - id: ReadOnly\n name: ReadOnly\n description: ReadOnly Group\n realtime: false\n entities:\n - id: Railway\n name: National society of French railroads\n description: National society of French railroads\n parents : [\"IRT_MAIN\"]\n - id: ORANGE\n name: Orange\n description: Orange\n parents : [\"IRT_MAIN\"]\n - id: ATM\n name: ATM usecase\n description: ATM usecase\n parents : [\"IRT_MAIN\"]\n - id: PowerGrid\n name: Electricity Transmission Network\n description: Electricity Transmission Network\n parents : [\"IRT_MAIN\"]\n - id: IRT_MAIN\n name: IRT Control Centers\n description: IRT Control Centers\n entityAllowedToSendCard: false\n","web-ui.json":"{\n \"environmentName\": \"KUBERNETES ENV\",\n \"environmentColor\": \"blue\",\n \"checkIfUrlIsLocked\": true,\n \"showUserEntitiesOnTopRightOfTheScreen\": true,\n \"externalDevicesEnabled\": true,\n \"selectActivityAreaOnLogin\": false,\n \"archive\": {\n \"filters\": {\n \"page\": {\n \"size\": [\n \"10\"\n ]\n },\n \"tags\": {\n \"list\": [\n {\n \"label\": \"Label for tag 1\",\n \"value\": \"tag1\"\n },\n {\n \"label\": \"Label for tag 2\",\n \"value\": \"tag2\"\n }\n ]\n }\n }\n },\n \"logging\": {\n \"filters\": {\n \"tags\": {\n \"list\": [\n {\n \"label\": \"Label for tag 1\",\n \"value\": \"tag1\"\n }\n ]\n }\n }\n },\n \"feed\": {\n \"defaultSorting\": \"unread\",\n \"defaultAcknowledgmentFilter\": \"notack\",\n \"card\": {\n \"hideTimeFilter\": false,\n \"time\": {\n \"display\": \"BUSINESS\"\n },\n \"hideResponseFilter\": false,\n \"hideApplyFiltersToTimeLineChoice\": false,\n \"secondsBeforeLttdForClockDisplay\": 3700,\n \"hideAckAllCardsFeature\": false,\n \"titleUpperCase\": true\n },\n \"timeline\": {\n \"domains\": [\n \"TR\",\n \"J\",\n \"7D\",\n \"W\",\n \"M\",\n \"Y\"\n ]\n },\n \"geomap\": {\n \"enableMap\": false,\n \"defaultDataProjection\": \"EPSG:4326\",\n \"initialLongitude\": 5.3255,\n \"initialLatitude\": 52.1845,\n \"initialZoom\": 6,\n \"zoomLevelWhenZoomToLocation\": 14,\n \"maxZoom\": 11,\n \"zoomDuration\": 500\n },\n \"enableGroupedCards\": false\n },\n \"i18n\": {\n \"supported\": {\n \"locales\": [\n \"en\",\n \"fr\",\n \"nl\"\n ]\n }\n },\n \"security\": {\n \"jwt\": {\n \"expire-claim\": \"exp\",\n \"login-claim\": \"preferred_username\"\n },\n \"logout-url\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/logout?redirect_uri=https://demo.interactiveai.irt-systemx.fr/\",\n \"oauth2\": {\n \"client-id\": \"opfab-client\",\n \"flow\": {\n \"delegate-url\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/auth?response_type=code\u0026client_id=opfab-client\",\n \"mode\": \"PASSWORD\",\n \"provider\": \"Opfab Keycloak\"\n }\n },\n \"provider-realm\": \"interactiveai\",\n \"provider-url\": \"https://keycloak.irtsysx.fr/auth\",\n \"changePasswordUrl\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/account/#/security/signingin\"\n },\n \"settings\": {\n \"locale\": \"en\",\n \"dateTimeFormat\": \"HH:mm DD/MM/YYYY\",\n \"dateFormat\": \"DD/MM/YYYY\",\n \"styleWhenNightDayModeDesactivated\": \"NIGHT\",\n \"replayInterval\" : 10,\n \"replayEnabled\" : true\n\n },\n \"settingsScreen\": {\n \"hiddenSettings\": [\"description\"]\n },\n \"about\": {\n \"firstapplication\": {\n \"name\": \"First application\",\n \"rank\": 1,\n \"version\": \"v12.34.56\"\n },\n \"keycloack\": {\n \"name\": \"Keycloak\",\n \"rank\": 2,\n \"version\": \"6.0.1\"\n },\n \"lastapplication\": {\n \"name\": \"Wonderful Solution\",\n \"version\": \"0.1.2-RELEASE\"\n }\n },\n \"usercard\": {\n \"useDescriptionFieldForEntityList\": false\n }\n}\n"},"kind":"ConfigMap","metadata":{"annotations":{"argocd.argoproj.io/tracking-id":"cab:/ConfigMap:cab/cab-assistant-platform-config"},"labels":{"app.kubernetes.io/instance":"cab","app.kubernetes.io/managed-by":"Helm","app.kubernetes.io/name":"interactiveai-platform","app.kubernetes.io/version":"0.0.1","helm.sh/chart":"interactiveai-platform-0.2.2"},"name":"cab-assistant-platform-config","namespace":"cab"}} meta.helm.sh/release-name: cab meta.helm.sh/release-namespace: cab - creationTimestamp: "2026-01-30T10:53:28Z" labels: app.kubernetes.io/instance: cab app.kubernetes.io/managed-by: Helm @@ -739,5 +739,3 @@ metadata: helm.sh/chart: interactiveai-platform-0.2.2 name: cab-assistant-platform-config namespace: cab - resourceVersion: "21095247065" - uid: 96bf811c-fdd7-453e-a97e-4179f061c469 \ No newline at end of file From 17eca5fe7710c653ce4c70bf07fbf0d120671ae3 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Fri, 28 Aug 2026 13:04:38 +0000 Subject: [PATCH 03/20] fix: restore real applyRecommendation calls and handle apply failures The eval-demo stubs are removed. applyRecommendation in the generic services API and in the ATM and Railway entity APIs each returned a hardcoded `{ message: 'ok (simulated)' }` instead of calling the simulator, so "apply" appeared to succeed while nothing was sent. (PowerGrid was already restored in an earlier change.) Callers now await the call and react to a rejection: on failure the recommendation card is left open so the user can retry, rather than being resolved and dismissed as if the action had gone through. The error modal itself is already raised by the http plugin. Also hardens deploy-chart/apply-nginx-conf.sh, which patches the k8s nginx ConfigMap for the same apply path: - clearer failures when PyYAML is missing or the manifest is not a ConfigMap / lacks the nginx.conf key / predates the fix - accept Y/yes at the confirmation prompt, not just a bare `y` - verify against `nginx -T` (what nginx actually loaded) rather than the ConfigMap, and on failure report whether the patch stuck and whether a crashlooping pod left the old one serving --- deploy-chart/apply-nginx-conf.sh | 49 ++++++++++++++++--- frontend/src/api/services.ts | 7 +-- frontend/src/entities/ATM/CAB/Assistant.vue | 10 +++- frontend/src/entities/ATM/api.ts | 10 ++-- .../src/entities/Railway/CAB/Assistant.vue | 16 ++++-- frontend/src/entities/Railway/api.ts | 11 ++--- 6 files changed, 70 insertions(+), 33 deletions(-) diff --git a/deploy-chart/apply-nginx-conf.sh b/deploy-chart/apply-nginx-conf.sh index a361472f..fde384a1 100755 --- a/deploy-chart/apply-nginx-conf.sh +++ b/deploy-chart/apply-nginx-conf.sh @@ -19,9 +19,28 @@ command -v kubectl >/dev/null || { echo "kubectl not found" >&2; exit 1; } # Repo -> JSON merge patch carrying just the one key. PATCH=$(python3 - "$SRC" <<'PY' -import json, sys, yaml -conf = yaml.safe_load(open(sys.argv[1]))['data']['nginx.conf'] -assert '/powergrid-simu/' in conf, 'repo nginx.conf is missing the /powergrid-simu/ location' +import json, sys +try: + import yaml +except ImportError: + sys.exit('PyYAML missing on this machine: pip install pyyaml') + +path = sys.argv[1] +doc = yaml.safe_load(open(path)) +if not isinstance(doc, dict) or not isinstance(doc.get('data'), dict): + sys.exit( + f'{path} is not a ConfigMap manifest with a data: mapping ' + f'(parsed as {type(doc).__name__}). Is this file up to date? ' + 'It must be the version carrying the /powergrid-simu/ block.' + ) +conf = doc['data'].get('nginx.conf') +if not conf: + sys.exit(f'{path} has no data."nginx.conf" key; found: {sorted(doc["data"])}') +if '/powergrid-simu/' not in conf: + sys.exit( + f'{path} nginx.conf has no /powergrid-simu/ location - this copy predates the fix. ' + 'Pull the latest revision of the repo.' + ) print(json.dumps({'data': {'nginx.conf': conf}})) PY ) @@ -32,7 +51,10 @@ python3 -c "import json,sys;sys.stdout.write(json.loads(sys.argv[1])['data']['ng diff -u /tmp/nginx.conf.live /tmp/nginx.conf.repo || true read -r -p "Apply to $NS/$CM and restart $DEPLOY? [y/N] " ans -[ "$ans" = y ] || { echo "aborted"; exit 0; } +case "$ans" in + y|Y|yes|YES|Yes) ;; + *) echo "ABORTED - nothing was applied."; exit 0 ;; +esac kubectl -n "$NS" patch cm "$CM" --type merge -p "$PATCH" kubectl -n "$NS" rollout restart "deploy/$DEPLOY" @@ -45,6 +67,19 @@ echo "--- frontend POWERGRID_SIMU_UPSTREAM:" kubectl -n "$NS" get "deploy/$DEPLOY" \ -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="POWERGRID_SIMU_UPSTREAM")]}{.value}{"\n"}{end}' -echo "--- verify: must NOT be text/html (SPA fallback) any more" -kubectl -n "$NS" exec "deploy/$DEPLOY" -- \ - sh -c 'grep -c powergrid-simu /personal-conf/conf.d/default.conf' || true +# Verify against the config nginx actually loaded, not against the ConfigMap. +echo "--- verify: /powergrid-simu/ in the running nginx config" +if kubectl -n "$NS" exec "deploy/$DEPLOY" -- nginx -T 2>/dev/null | grep -q "location /powergrid-simu/"; then + echo "OK - the location is live." + kubectl -n "$NS" exec "deploy/$DEPLOY" -- nginx -T 2>/dev/null | + grep -A6 "location /powergrid-simu/" +else + echo "FAILED - the running nginx still has no /powergrid-simu/ location." >&2 + echo " ConfigMap key currently in the cluster:" >&2 + kubectl -n "$NS" get cm "$CM" -o jsonpath='{.data.nginx\.conf}' | + grep -c powergrid-simu >&2 || true + echo " (0 above = the patch did not stick, e.g. ArgoCD self-heal reverted it)" >&2 + echo " Pods (a crashlooping new pod leaves the OLD one serving):" >&2 + kubectl -n "$NS" get pods -l app.kubernetes.io/name=frontend >&2 + exit 1 +fi diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index cdfda14d..5892be71 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -51,13 +51,8 @@ export function sendTrace(payload: Trace) { return http.post>('/cabhistoric/api/v1/traces', tracePayload) } -// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release -// The real API call is disabled and replaced with a fake success response for demo purposes. export function applyRecommendation(data: Action) { - // [DISABLED] Simulator API is inactive — returning fake success for demo - // To restore: uncomment the http.post and remove the Promise.resolve - // return http.post<{ message: string }>('/api/v1/recommendations', data) - return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line + return http.post<{ message: string }>('/api/v1/recommendations', data) } export function getProcedure(event_type: string) { diff --git a/frontend/src/entities/ATM/CAB/Assistant.vue b/frontend/src/entities/ATM/CAB/Assistant.vue index 4a3a1dbb..70ca33a3 100644 --- a/frontend/src/entities/ATM/CAB/Assistant.vue +++ b/frontend/src/entities/ATM/CAB/Assistant.vue @@ -133,8 +133,14 @@ function onHover(hovered: Recommendation<'ATM'>) { } } -function onSelection(recommendation: Recommendation<'ATM'>) { - applyRecommendation(recommendation.actions[0]) +async function onSelection(recommendation: Recommendation<'ATM'>) { + try { + await applyRecommendation(recommendation.actions[0]) + } catch { + // http plugin already shows an error modal — leave the card open so the user can retry + console.error('[ATM][apply] failed — leaving card open for retry') + return + } const activeCard = appStore.card('ATM') if (activeCard) cardsStore.resolveCriticality(activeCard) mapStore.resetPolylines() diff --git a/frontend/src/entities/ATM/api.ts b/frontend/src/entities/ATM/api.ts index ec9caf8c..54bc808e 100644 --- a/frontend/src/entities/ATM/api.ts +++ b/frontend/src/entities/ATM/api.ts @@ -1,11 +1,9 @@ import http from '@/plugins/http' import type { Action } from '@/types/entities' -// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release -// The real ATM simulator API call is disabled and replaced with a fake success response for demo purposes. export function applyRecommendation(data: Action<'ATM'>) { - // [DISABLED] Simulator API is inactive — returning fake success for demo - // To restore: uncomment the http.post and remove the Promise.resolve - // return http.post<{ message: string }>(import.meta.env.VITE_ATM_SIMU + '/update-flight-plan', data) - return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line + return http.post<{ message: string }>( + import.meta.env.VITE_ATM_SIMU + '/update-flight-plan', + data + ) } diff --git a/frontend/src/entities/Railway/CAB/Assistant.vue b/frontend/src/entities/Railway/CAB/Assistant.vue index c73158ad..78c1ea93 100644 --- a/frontend/src/entities/Railway/CAB/Assistant.vue +++ b/frontend/src/entities/Railway/CAB/Assistant.vue @@ -165,16 +165,22 @@ watch( } ) -function onSelection(recommendation: any) { +async function onSelection(recommendation: any) { sendTrace({ data: recommendation, use_case: route.params.entity as Entity, step: 'AWARD' }) - applyRecommendation({ - ...recommendation.actions[0], - event_id: getRootCard(appStore.card('Railway')!).processInstanceId - }) + try { + await applyRecommendation({ + ...recommendation.actions[0], + event_id: getRootCard(appStore.card('Railway')!).processInstanceId + }) + } catch { + // http plugin already shows an error modal — leave the card open so the user can retry + console.error('[Railway][apply] failed — leaving card open for retry') + return + } const activeCard = appStore.card('Railway') if (activeCard) cardsStore.resolveCriticality(activeCard) appStore.tab.assistant = 0 diff --git a/frontend/src/entities/Railway/api.ts b/frontend/src/entities/Railway/api.ts index c1157ea1..d18ae222 100644 --- a/frontend/src/entities/Railway/api.ts +++ b/frontend/src/entities/Railway/api.ts @@ -1,12 +1,9 @@ import http from '@/plugins/http' import type { Action } from '@/types/entities' -// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release -// The real Railway simulator API call is disabled and replaced with a fake success response for demo purposes. -// To restore: uncomment the http.post line and delete the Promise.resolve line. export function applyRecommendation(data: Action<'Railway'>) { - // [DISABLED] Simulator API is inactive — returning fake success for demo - // To restore: uncomment the http.post and remove the Promise.resolve - // return http.post<{ message: string }>(import.meta.env.VITE_RAILWAY_SIMU + '/transport_plan', data) - return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line + return http.post<{ message: string }>( + import.meta.env.VITE_RAILWAY_SIMU + '/transport_plan', + data + ) } From 80524c42b7363076ec8c89e455316691a9ce832b Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Tue, 1 Sep 2026 09:24:05 +0000 Subject: [PATCH 04/20] feat(frontend): configure nginx at runtime and move the cognitive token server-side The nginx conf had exactly one runtime-substituted value, POWERGRID_SIMU_UPSTREAM, hand-rolled as a single sed in start-webui.sh. Generalise it and move the one secret that had no business being in the frontend bundle behind the same mechanism. Runtime substitution -------------------- Every environment-specific value now lives in the conf as a __NAME__ placeholder and is substituted from the matching env var by a loop over SUBST_VARS. Adding one is: default it, name it, use __NAME__ in the conf. The value is escaped before it reaches sed, so tokens and URLs containing # & \ cannot break out of the expression, and secrets are reported as "set (n chars)" rather than echoed. Both failure modes are now loud rather than silent: - a placeholder that survives substitution aborts startup naming the missing variable; - the generated conf is checked with `nginx -t`, and separately asserted to contain a listen directive - an empty or serverless conf.d file passes `nginx -t` and would leave nginx up and answering nothing. frontend/default.conf had drifted from the k8s ConfigMap and still carried the literal LAN address instead of the placeholder, so a plain image run got a dead upstream; it uses the placeholder again. Cognitive API token ------------------- VITE_COGNITIVE_TOKEN was a build-time value: inlined into the public JS bundle, readable by every visitor, and rotatable only by rebuilding the image - which is why the env var on the frontend pod did nothing. nginx now attaches the bearer token to /cognitive-api/ itself from $COGNITIVE_TOKEN, so the token stays server-side and rotating it is a secret update plus a pod restart. Dropped from the Dockerfile, CI build-args, env.d.ts and env/.env accordingly; the frontend no longer sends an Authorization header of its own. Two related fixes found while verifying this -------------------------------------------- - start-webui.sh built the resolver line from every nameserver in /etc/resolv.conf without joining them, so on a host with two the sed expression gained a newline, failed with "sed: unmatched '/'", and wrote an EMPTY default.conf - nginx started and served nothing. The addresses go on one line (nginx resolver accepts several) and an empty result aborts. - .dockerignore is a symlink to .gitignore, where `*.local` matches at any depth for git but only at the context root for Docker. env/.env.local was therefore copied into the image and Vite inlined its VITE_* values into the bundle; `**/*.local` excludes it. Verified by building the image and running it: config validates, SPA serves 200, and /cognitive-api/ reaches the upstream with the injected token (401 on a fake one). Also checked the built bundle no longer contains the token, and that the substituted default.conf, cab-standalone and ConfigMap configs all pass `nginx -t`. --- .github/workflows/docker-build-push.yml | 4 +- README.md | 42 ++++++--- config/dev/cab-standalone/.env.example | 4 + config/dev/cab-standalone/.secrets.example | 6 +- config/dev/cab-standalone/docker-compose.sh | 2 +- config/dev/cab-standalone/docker-compose.yml | 4 +- .../cab-standalone/nginx-cors-permissive.conf | 3 + deploy-chart/apply-nginx-conf.sh | 19 +++- .../configmap-assistant-platform.yaml | 18 ++-- deploy-chart/values.ovh.yaml | 8 +- frontend/.gitignore | 5 ++ frontend/Dockerfile | 2 - frontend/default.conf | 15 +++- frontend/env.d.ts | 1 - frontend/env/.env | 1 - frontend/src/api/cognitive.ts | 18 ++-- frontend/start-webui.sh | 88 +++++++++++++++++-- 17 files changed, 191 insertions(+), 49 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index b8c96916..4face8e8 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -81,8 +81,10 @@ jobs: # VITE_* values are baked into the bundle at build time. VITE_POWERGRID_SIMU # is the same-origin base the frontend posts "apply action" to; without it the # POST lands on / and nginx answers 405. Blank lines are ignored by the action. + # Secrets must NOT be passed here: a build-arg ends up readable in the public + # bundle. The cognitive API token is a runtime env var on the container instead + # (COGNITIVE_TOKEN, injected into the nginx proxy by start-webui.sh). build-args: | - ${{ matrix.service == 'cab-standalone-frontend' && format('VITE_COGNITIVE_TOKEN={0}', secrets.VITE_COGNITIVE_TOKEN) || '' }} ${{ matrix.service == 'cab-standalone-frontend' && 'VITE_POWERGRID_SIMU=/powergrid-simu' || '' }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/README.md b/README.md index f6cbbde9..37e2471c 100644 --- a/README.md +++ b/README.md @@ -84,18 +84,15 @@ Key variables (see `.secrets.example` for all options and per-environment values - `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint. Use the same-origin proxy value `/powergrid-simu` (avoids CORS); set it to `false` to disable the PowerGrid UI. `VITE_RAILWAY_SIMU` / `VITE_ATM_SIMU` are the equivalents for the other use cases. -- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`. This is the - only value that changes per environment: +- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`: - Local dev : `http://host.docker.internal:5122/` (simulator container on the host) - LAN : `http://192.168.208.61:5100/` - - Public/k8s: same variable, but set as an env var on the **frontend pod** (see - `deploy-chart/values.ovh.yaml`). `start-webui.sh` substitutes it into the - `__POWERGRID_SIMU_UPSTREAM__` placeholder of the nginx config at container start. - Beware: in k8s the config comes from the `cab-assistant-platform-config` ConfigMap - mounted over `/etc/nginx/conf.d`, which **overrides** the `default.conf` baked into - the image — the `/powergrid-simu/` location must be present in that ConfigMap or the - apply POST falls through to the static `location /` and nginx answers 405. - nginx only reads `conf.d` at startup, so restart the frontend after changing it. + - Public/k8s: same variable, set as an env var on the **frontend pod** (see + `deploy-chart/values.ovh.yaml`). +- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it to + every `/cognitive-api/` request, so the frontend never sees it. It used to be + `VITE_COGNITIVE_TOKEN`, a build-time value inlined into the public JS bundle; that meant + any visitor could read it and rotating it required a full image rebuild. - `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the deep expert agent that powers PowerGrid recommendations (see [The PowerGrid expert agent API](#the-powergrid-expert-agent-api) below to install it). A token is required in every mode: @@ -111,6 +108,31 @@ Key variables (see `.secrets.example` for all options and per-environment values > in [InteractiveAI/usecases_examples/PowerGrid/](/usecases_examples/PowerGrid/README.md). > > +### How runtime nginx configuration works + +`POWERGRID_SIMU_UPSTREAM` and `COGNITIVE_TOKEN` are **runtime** values, not +build-time ones. They appear in the nginx config as `__NAME__` placeholders, and +`frontend/start-webui.sh` substitutes them from the matching env var when the container +starts. Changing one is: update the env var (or the k8s secret) and restart the +frontend — no image rebuild. + +To add another: give it a default in `start-webui.sh`, append its name to `SUBST_VARS`, and +use `__NAME__` in the config. If a placeholder survives substitution the container exits +with the name of the missing variable, and the generated config is checked with `nginx -t` +before the daemon starts — so a misconfiguration fails loudly at startup instead of +producing a silently broken proxy. + +Two things to keep in mind: + +- **In k8s the config does not come from the image.** The `cab-assistant-platform-config` + ConfigMap is mounted over `/etc/nginx/conf.d` and **overrides** the `default.conf` baked + into the image, so every placeholder and every `location` must be present in the ConfigMap + too (`deploy-chart/apply-nginx-conf.sh` pushes just that key). A missing + `/powergrid-simu/` location, for instance, lets the apply POST fall through to the static + `location /`, and nginx answers 405. +- **nginx reads `conf.d` only at startup**, so restart the frontend after any change: + `kubectl -n cab rollout restart deploy/cab-frontend`. + 2. **Run InteractiveAI assistant** ```sh cd config/dev/cab-standalone diff --git a/config/dev/cab-standalone/.env.example b/config/dev/cab-standalone/.env.example index 825583ac..b80117a9 100644 --- a/config/dev/cab-standalone/.env.example +++ b/config/dev/cab-standalone/.env.example @@ -23,6 +23,10 @@ RL_AGENT_API_TOKEN= # not from this variable.) POWERGRID_SIMU_UPSTREAM=http://host.docker.internal:5122/ +# Bearer token for the INESCTEC cognitive API, injected into the nginx /cognitive-api/ +# proxy at container start. Runtime, not build-time: restart the container to change it. +COGNITIVE_TOKEN= + # VITE build-time variables (also required in frontend/env/.env.local for local dev) VITE_POWERGRID_SIMU=false VITE_ATM_SIMU=false diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index 2b2d2668..c27b710c 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -20,7 +20,11 @@ export RL_AGENT_API_TOKEN= # export RL_AGENT_API_TOKEN= export VITE_POWERGRID_SIMU=/powergrid-simu -export VITE_COGNITIVE_TOKEN= + +# Bearer token for the INESCTEC cognitive API. Consumed at RUNTIME by the frontend +# container: start-webui.sh injects it into the nginx /cognitive-api/ proxy, so it stays +# out of the JS bundle. Changing it needs a container restart, not a rebuild. +export COGNITIVE_TOKEN= # PowerGrid simulator upstream for the frontend's /powergrid-simu/ proxy. # Local dev : http://host.docker.internal:5122/ (default) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 6bb0ef20..7c2b40e2 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -52,7 +52,7 @@ echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.i echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env echo "POWERGRID_SIMU_UPSTREAM=${POWERGRID_SIMU_UPSTREAM:-http://host.docker.internal:5122/}" >> .env -echo "VITE_COGNITIVE_TOKEN=${VITE_COGNITIVE_TOKEN:-}" >> .env +echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env cat .env docker compose up -d diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index a3137056..077f4b77 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -138,7 +138,6 @@ services: VITE_POWERGRID_SIMU: ${VITE_POWERGRID_SIMU} VITE_ATM_SIMU: ${VITE_ATM_SIMU} VITE_RAILWAY_SIMU: ${VITE_RAILWAY_SIMU} - VITE_COGNITIVE_TOKEN: ${VITE_COGNITIVE_TOKEN} restart: unless-stopped # Lets nginx proxy /powergrid-simu/ to a simulator running on the host (local dev). extra_hosts: @@ -150,6 +149,9 @@ services: # PowerGrid simulator upstream for the /powergrid-simu/ nginx proxy: # local -> http://host.docker.internal:5122/ LAN -> http://192.168.208.61:5100/ - POWERGRID_SIMU_UPSTREAM=${POWERGRID_SIMU_UPSTREAM} + # Injected into the nginx /cognitive-api/ proxy at container start, so the token + # is never baked into the public JS bundle. Restart the container to change it. + - COGNITIVE_TOKEN=${COGNITIVE_TOKEN} volumes: - './ui-config:/usr/share/nginx/html/opfab' - './nginx-cors-permissive.conf:/etc/nginx/conf.d/default.conf' diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index 2b4dc8bd..f99158f4 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -372,8 +372,11 @@ server { return 204; } + # The bearer token is attached here, not by the browser: __COGNITIVE_TOKEN__ is + # substituted from $COGNITIVE_TOKEN at container start (see start-webui.sh). proxy_set_header Host wesenss.inesctec.pt; proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__"; proxy_ssl_server_name on; proxy_ssl_verify off; proxy_pass https://wesenss.inesctec.pt/api/v1/; diff --git a/deploy-chart/apply-nginx-conf.sh b/deploy-chart/apply-nginx-conf.sh index fde384a1..b46d7318 100755 --- a/deploy-chart/apply-nginx-conf.sh +++ b/deploy-chart/apply-nginx-conf.sh @@ -41,6 +41,12 @@ if '/powergrid-simu/' not in conf: f'{path} nginx.conf has no /powergrid-simu/ location - this copy predates the fix. ' 'Pull the latest revision of the repo.' ) +if '__COGNITIVE_TOKEN__' not in conf: + sys.exit( + f'{path} nginx.conf does not inject __COGNITIVE_TOKEN__ into /cognitive-api/ - this ' + 'copy predates the move of the token out of the frontend bundle. Pull the latest ' + 'revision of the repo.' + ) print(json.dumps({'data': {'nginx.conf': conf}})) PY ) @@ -60,13 +66,20 @@ kubectl -n "$NS" patch cm "$CM" --type merge -p "$PATCH" kubectl -n "$NS" rollout restart "deploy/$DEPLOY" kubectl -n "$NS" rollout status "deploy/$DEPLOY" --timeout=180s -# The frontend pod must carry POWERGRID_SIMU_UPSTREAM: start-webui.sh substitutes it into -# the __POWERGRID_SIMU_UPSTREAM__ placeholder. Without it nginx gets the local-dev default -# (host.docker.internal) and refuses to start. +# The frontend pod must carry the env vars that start-webui.sh substitutes into the conf's +# __NAME__ placeholders. Without POWERGRID_SIMU_UPSTREAM nginx gets the local-dev default +# (host.docker.internal) and refuses to start; without COGNITIVE_TOKEN the /cognitive-api/ +# proxy sends an empty bearer token and the cognitive panel 401s. echo "--- frontend POWERGRID_SIMU_UPSTREAM:" kubectl -n "$NS" get "deploy/$DEPLOY" \ -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="POWERGRID_SIMU_UPSTREAM")]}{.value}{"\n"}{end}' +# Only that the var is wired to a secret - never the value itself. +echo "--- frontend COGNITIVE_TOKEN source:" +kubectl -n "$NS" get "deploy/$DEPLOY" \ + -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="COGNITIVE_TOKEN")]}{.valueFrom.secretKeyRef.name}/{.valueFrom.secretKeyRef.key}{"\n"}{end}' \ + | grep . || echo "MISSING - add it to values.ovh.yaml (secret cab-frontend, key cognitive-token)" + # Verify against the config nginx actually loaded, not against the ConfigMap. echo "--- verify: /powergrid-simu/ in the running nginx config" if kubectl -n "$NS" exec "deploy/$DEPLOY" -- nginx -T 2>/dev/null | grep -q "location /powergrid-simu/"; then diff --git a/deploy-chart/configmap-assistant-platform.yaml b/deploy-chart/configmap-assistant-platform.yaml index 40916046..b4267095 100644 --- a/deploy-chart/configmap-assistant-platform.yaml +++ b/deploy-chart/configmap-assistant-platform.yaml @@ -282,13 +282,19 @@ data: location = /50x.html { root /usr/share/nginx/html; } + # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions). + # The bearer token is attached here, not by the browser: __COGNITIVE_TOKEN__ is + # substituted at container start by start-webui.sh from the COGNITIVE_TOKEN env + # var of the frontend pod, so the token stays out of the public JS bundle and is + # rotated by updating the secret and restarting the deployment. location /cognitive-api/ { - add_header Cache-Control "no-cache"; - proxy_set_header Host wesenss.inesctec.pt; - proxy_set_header X-Fordwarded-For $remote_addr; - proxy_ssl_server_name on; - proxy_ssl_verify off; - proxy_pass https://wesenss.inesctec.pt/api/v1/; + add_header Cache-Control "no-cache"; + proxy_set_header Host wesenss.inesctec.pt; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__"; + proxy_ssl_server_name on; + proxy_ssl_verify off; + proxy_pass https://wesenss.inesctec.pt/api/v1/; } } ui-menu.json: | diff --git a/deploy-chart/values.ovh.yaml b/deploy-chart/values.ovh.yaml index 9da2074b..4e6bda11 100644 --- a/deploy-chart/values.ovh.yaml +++ b/deploy-chart/values.ovh.yaml @@ -46,8 +46,12 @@ frontend: value: "/powergrid-simu" - name: POWERGRID_SIMU_UPSTREAM value: "https://interactivepowergrid.passerelle.irt-systemx.fr/" - - name: VITE_COGNITIVE_TOKEN + # Read at container start by start-webui.sh and injected into the nginx + # /cognitive-api/ proxy. Rotating it is: update the secret, then + # `kubectl -n cab rollout restart deploy/cab-frontend` (nginx reads conf.d once, + # at startup). No image rebuild - it is no longer a build-time VITE_* value. + - name: COGNITIVE_TOKEN valueFrom: secretKeyRef: name: cab-frontend - key: vite-cognitive-token + key: cognitive-token diff --git a/frontend/.gitignore b/frontend/.gitignore index d59ddeb1..a95962a6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,7 +12,12 @@ node_modules dist dist-ssr coverage +# NB: .dockerignore is a symlink to this file, and the two dialects differ. git matches +# `*.local` at any depth, Docker only at the context root - so env/.env.local was ignored by +# git but still copied into the image, where Vite inlined every VITE_* it defined into the +# public bundle. The `**/` form is what excludes it from the build context. *.local +**/*.local /cypress/videos/ /cypress/screenshots/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2c2093fd..3994922f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,11 +5,9 @@ COPY . /app ARG VITE_POWERGRID_SIMU ARG VITE_RAILWAY_SIMU ARG VITE_ATM_SIMU -ARG VITE_COGNITIVE_TOKEN ENV VITE_POWERGRID_SIMU=$VITE_POWERGRID_SIMU ENV VITE_RAILWAY_SIMU=$VITE_RAILWAY_SIMU ENV VITE_ATM_SIMU=$VITE_ATM_SIMU -ENV VITE_COGNITIVE_TOKEN=$VITE_COGNITIVE_TOKEN RUN npm ci RUN npm run build diff --git a/frontend/default.conf b/frontend/default.conf index c7a9f6d2..56495289 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -15,10 +15,14 @@ server { try_files $uri $uri/ /index.html; } - # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions) + # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions). + # The bearer token is attached here, not by the browser: __COGNITIVE_TOKEN__ is + # substituted from $COGNITIVE_TOKEN at container start (see start-webui.sh), so the + # token never reaches the public JS bundle and is rotated by restarting the pod. location /cognitive-api/ { proxy_set_header Host wesenss.inesctec.pt; proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__"; proxy_ssl_server_name on; proxy_ssl_verify off; proxy_pass https://wesenss.inesctec.pt/api/v1/; @@ -39,14 +43,17 @@ server { # Proxy for the PowerGrid (grid2op) simulator — keeps the "apply" POST same-origin # so the browser never does a cross-origin request (avoids CORS entirely). # Frontend posts to /powergrid-simu/... with VITE_POWERGRID_SIMU=/powergrid-simu. - # NB: this upstream is a LAN address reachable from cab-standalone; adjust per environment - # (public deployments use https://interactivepowergrid.passerelle.irt-systemx.fr/). + # __POWERGRID_SIMU_UPSTREAM__ is substituted from $POWERGRID_SIMU_UPSTREAM at container + # start (see start-webui.sh). Per environment: + # local -> http://host.docker.internal:5122/ (the default) + # LAN -> http://192.168.208.61:5100/ + # public -> https://interactivepowergrid.passerelle.irt-systemx.fr/ # No explicit Host header: nginx defaults it to $proxy_host, which is correct for an # ip:port upstream and for a vhost upstream behind the passerelle alike. location /powergrid-simu/ { proxy_set_header X-Forwarded-For $remote_addr; proxy_ssl_server_name on; proxy_ssl_verify off; - proxy_pass http://192.168.208.61:5100/; + proxy_pass __POWERGRID_SIMU_UPSTREAM__; } } diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 4c207149..a1b9d620 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -7,7 +7,6 @@ interface ImportMetaEnv { readonly VITE_POWERGRID_SIMU: string readonly VITE_RAILWAY_SIMU: string readonly VITE_ATM_SIMU: string - readonly VITE_COGNITIVE_TOKEN: string } interface ImportMeta { diff --git a/frontend/env/.env b/frontend/env/.env index f0556594..d76388e8 100644 --- a/frontend/env/.env +++ b/frontend/env/.env @@ -4,4 +4,3 @@ VITE_POWERGRID_SIMU=$VITE_POWERGRID_SIMU VITE_RAILWAY_SIMU=$VITE_RAILWAY_SIMU VITE_ATM_SIMU=$VITE_ATM_SIMU VITE_API="" -VITE_COGNITIVE_TOKEN= \ No newline at end of file diff --git a/frontend/src/api/cognitive.ts b/frontend/src/api/cognitive.ts index 4dbeb3cd..6ae41f11 100644 --- a/frontend/src/api/cognitive.ts +++ b/frontend/src/api/cognitive.ts @@ -3,19 +3,20 @@ * Fetches per-event, per-agent cognitive state factors and returns a * structured snapshot that is attached to every session-trace entry. * - * Requests are routed through the nginx proxy at /cognitive-api/ to avoid - * browser CORS restrictions when calling https://wesenss.inesctec.pt directly. + * Requests are routed through the nginx proxy at /cognitive-api/, which both avoids + * browser CORS restrictions when calling https://wesenss.inesctec.pt directly and + * attaches the bearer token server-side (see frontend/default.conf). The token is + * deliberately absent from this bundle: anything inlined here is readable by every + * visitor and can only be rotated by rebuilding the image. * * Hardcoded values (event_id = 885, first agent from the list) are used * until the dynamic event → cognitive-event mapping is in place. */ -// Routed through nginx /cognitive-api/ → https://wesenss.inesctec.pt/api/v1/ +// Routed through nginx /cognitive-api/ → https://wesenss.inesctec.pt/api/v1/, +// which injects the Authorization header from the COGNITIVE_TOKEN env var. const COGNITIVE_BASE_URL = '/cognitive-api' -// Injected at build time via VITE_COGNITIVE_TOKEN env var (never commit the token) -const COGNITIVE_TOKEN = import.meta.env.VITE_COGNITIVE_TOKEN ?? '' - // Hardcoded until dynamic event_id resolution is implemented const DEFAULT_EVENT_ID = 885 @@ -61,9 +62,8 @@ type LatestDataItem = { } async function cognitiveGet(path: string): Promise { - const response = await fetch(`${COGNITIVE_BASE_URL}${path}`, { - headers: { Authorization: `Bearer ${COGNITIVE_TOKEN}` } - }) + // No Authorization header: the nginx /cognitive-api/ proxy adds it. + const response = await fetch(`${COGNITIVE_BASE_URL}${path}`) if (!response.ok) throw new Error(`Cognitive API ${response.status}: ${path}`) return response.json() as Promise } diff --git a/frontend/start-webui.sh b/frontend/start-webui.sh index c906e6b7..b84f6828 100755 --- a/frontend/start-webui.sh +++ b/frontend/start-webui.sh @@ -17,7 +17,16 @@ The container will be run by running this file ' -export resolver=$(grep nameserver /etc/resolv.conf | awk '{ print $2 }') +# All nameservers on ONE line: nginx's resolver takes several addresses, and a multi-line +# value would splice a newline into the sed expression below, which fails with +# "sed: unmatched '/'" and writes an EMPTY default.conf. nginx then starts with no server +# block at all and answers nothing - a silent outage. Hosts with two nameservers in +# /etc/resolv.conf are common enough to hit this. +export resolver=$(awk '/^[[:space:]]*nameserver/ { printf "%s ", $2 }' /etc/resolv.conf) +if [ -z "$resolver" ]; then + echo "ERROR: no nameserver in /etc/resolv.conf - cannot build the nginx resolver line." >&2 + exit 1 +fi resolver_replace="resolver $resolver ipv6=off;" resolver_replaced=".*resolver.*" nginx_conf_path_default="/etc/nginx" @@ -46,18 +55,83 @@ fi echo "The resolver in the personal default.conf:" grep -e "$resolver_replaced" $defaultconf_personal +# The sed above is the one step that can fail while leaving a valid-but-useless config +# behind (an empty file passes `nginx -t`), so check the result rather than the exit code. +if [ ! -s $defaultconf_personal ]; then + echo "ERROR: $defaultconf_personal is empty - the resolver substitution failed." >&2 + exit 1 +fi + cat $nginx_conf_path_default/nginx.conf > $nginx_conf_path_personal/nginx.conf sed -i "s/$(echo $nginx_conf_path_default | sed 's/\//\\\//g')\/conf\.d/$(echo $nginx_conf_path_personal | sed 's/\//\\\//g')\/conf\.d/" $nginx_conf_path_personal/nginx.conf echo "The conf.d path in the personal nginx.conf file:" grep "conf.d" $nginx_conf_path_personal/nginx.conf -# PowerGrid simulator upstream — environment-specific, injected here so one nginx -# config serves every environment. Defaults to the local-dev host container. -# No-op for configs that don't contain the placeholder (e.g. kubernetes). -powergrid_simu_upstream="${POWERGRID_SIMU_UPSTREAM:-http://host.docker.internal:5122/}" -echo "PowerGrid simulator upstream: $powergrid_simu_upstream" -sed -i "s#__POWERGRID_SIMU_UPSTREAM__#${powergrid_simu_upstream}#g" $defaultconf_personal +: ' + Runtime configuration of the nginx conf. + Every environment-specific value lives in the conf as a __NAME__ placeholder and is + substituted here from the matching env var. To add one: give it a default below, append + its name to SUBST_VARS, and use __NAME__ in the conf. Nothing else needs to change. + + Placeholders (rather than one conf per environment) keep a single conf serving local dev, + cab-standalone and k8s alike. Secrets belong here too, not in the frontend bundle: a + VITE_* value is inlined into the public JS at build time, so it is readable by anyone + loading the app and can only be rotated by rebuilding the image. + + NB: this script only substitutes into conf.d/default.conf. In k8s that file comes from the + cab-assistant-platform-config ConfigMap mounted over /etc/nginx/conf.d, which overrides + the default.conf baked into the image - so the placeholders must be present there too. +' + +# Where nginx forwards /powergrid-simu/. Defaults to a simulator container on the host. +: "${POWERGRID_SIMU_UPSTREAM:=http://host.docker.internal:5122/}" +# Bearer token for the INESCTEC cognitive API, injected into the /cognitive-api/ proxy. +: "${COGNITIVE_TOKEN:=}" + +SUBST_VARS="POWERGRID_SIMU_UPSTREAM COGNITIVE_TOKEN" + +for name in $SUBST_VARS; do + eval "value=\$$name" + # Escape the sed replacement metacharacters, including the # delimiter, so tokens and + # URLs containing them cannot break out of the expression. + escaped=$(printf '%s' "$value" | sed -e 's/[\\&#]/\\&/g') + sed -i "s#__${name}__#${escaped}#g" $defaultconf_personal + # Secrets are not echoed; report only whether a value arrived. + case "$name" in + *TOKEN*|*SECRET*|*PASSWORD*) + if [ -n "$value" ]; then echo "$name: set (${#value} chars)"; else echo "$name: EMPTY"; fi ;; + *) echo "$name: $value" ;; + esac +done + +# A placeholder that survives means its env var was never set. nginx would either refuse to +# start on an invalid proxy_pass or, worse, serve a silently broken proxy - so fail here, +# where the reason is obvious in the container logs. +leftover=$(grep -o '__[A-Z_][A-Z_]*__' $defaultconf_personal | sort -u) +if [ -n "$leftover" ]; then + echo "ERROR: unsubstituted placeholders in $defaultconf_personal:" >&2 + echo "$leftover" >&2 + echo "Set the matching env vars, or add them to SUBST_VARS in start-webui.sh." >&2 + exit 1 +fi + +# Validate before handing over to the daemon, so a bad conf fails at startup with a message +# rather than at the first request. +if ! nginx -t -c $nginx_conf_path_personal/nginx.conf; then + echo "ERROR: nginx rejected the generated configuration (see above)." >&2 + exit 1 +fi + +# `nginx -t` accepts a config with no server block, so it would not catch a conf.d file that +# got mangled into something empty or serverless. Check we will actually serve something. +if ! grep -q "listen" $defaultconf_personal; then + echo "ERROR: no listen directive in $defaultconf_personal - nginx would start and serve" >&2 + echo "nothing. The file is $(wc -c < $defaultconf_personal) bytes; its locations are:" >&2 + # Never cat the file: it now carries the substituted secrets. + grep -n "location" $defaultconf_personal >&2 || echo " (none)" >&2 + exit 1 +fi /usr/sbin/crond From 966f97778b156c331b3fa98bd4c6ad58d78bc636 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Fri, 4 Sep 2026 10:33:48 +0000 Subject: [PATCH 05/20] bump version to 1.3.8 --- deploy-chart/values.ovh.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deploy-chart/values.ovh.yaml b/deploy-chart/values.ovh.yaml index 4e6bda11..36d5ffdc 100644 --- a/deploy-chart/values.ovh.yaml +++ b/deploy-chart/values.ovh.yaml @@ -2,25 +2,25 @@ cabcontext: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-context pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" cabevent: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-event pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" cabhistoric: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-historic pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" cabrecommendation: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-recommendation pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" extraEnv: - name: RL_AGENT_API_URL value: "https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation" @@ -34,13 +34,13 @@ cabcapitalization: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-capitalization pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" frontend: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-standalone-frontend pullPolicy: Always - tag: "1.3.7" + tag: "1.3.8" extraEnv: - name: VITE_POWERGRID_SIMU value: "/powergrid-simu" From 81c2cdd78362a5a1a1b896ff6b46d621efdd6009 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Fri, 4 Sep 2026 11:02:58 +0000 Subject: [PATCH 06/20] fix apply nginx conf & bump version to 1.3.9 --- deploy-chart/apply-nginx-conf.sh | 173 ++++++++++++++++++++++++++----- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 3 files changed, 151 insertions(+), 28 deletions(-) diff --git a/deploy-chart/apply-nginx-conf.sh b/deploy-chart/apply-nginx-conf.sh index b46d7318..71851087 100755 --- a/deploy-chart/apply-nginx-conf.sh +++ b/deploy-chart/apply-nginx-conf.sh @@ -56,6 +56,85 @@ kubectl -n "$NS" get cm "$CM" -o jsonpath='{.data.nginx\.conf}' > /tmp/nginx.con python3 -c "import json,sys;sys.stdout.write(json.loads(sys.argv[1])['data']['nginx.conf'])" "$PATCH" > /tmp/nginx.conf.repo diff -u /tmp/nginx.conf.live /tmp/nginx.conf.repo || true +# --------------------------------------------------------------------------- +# Pre-flight: never push a conf ahead of the pod that has to substitute it. +# +# Every __NAME__ placeholder in the conf is substituted at container start by +# start-webui.sh from the matching env var on the pod. Pushing a conf whose +# placeholders the running deployment cannot satisfy breaks the proxy silently, +# and how it breaks depends on the image: +# - an image whose start-webui.sh knows the name but gets no value substitutes +# EMPTY - "Bearer " with nothing after it; +# - an older image that never heard of the name leaves the placeholder to go out +# LITERALLY in the proxied request. +# nginx starts cleanly either way, so nothing surfaces it but a 401 in the browser. +# +# This is exactly how /cognitive-api/ broke: the ConfigMap gained __COGNITIVE_TOKEN__ +# while the pod still ran an image that only knew POWERGRID_SIMU_UPSTREAM, and +# proxy_set_header replaced the real token the bundle was still sending with the +# literal string "__COGNITIVE_TOKEN__". Set ALLOW_MISSING_ENV=1 to push anyway. +# --------------------------------------------------------------------------- +echo "--- pre-flight: placeholders in the conf vs env on deploy/$DEPLOY" + +placeholders=$(grep -o '__[A-Z_][A-Z_]*__' /tmp/nginx.conf.repo | sort -u || true) +if [ -z "$placeholders" ]; then + echo " (none)" +fi + +env_names=$(kubectl -n "$NS" get "deploy/$DEPLOY" \ + -o go-template='{{range .spec.template.spec.containers}}{{range .env}}{{.name}}{{"\n"}}{{end}}{{end}}') + +problems="" +for ph in $placeholders; do + name=${ph#__}; name=${name%__} + + if ! printf '%s\n' "$env_names" | grep -qx "$name"; then + problems="${problems} ${name}: no env var of that name on deploy/${DEPLOY}"$'\n' + echo " $name: MISSING from the deployment" + continue + fi + + # Declared is not the same as resolvable: a secretKeyRef to a secret or key that does + # not exist leaves the pod in CreateContainerConfigError, and the OLD pod keeps serving. + ref=$(kubectl -n "$NS" get "deploy/$DEPLOY" -o go-template="{{range .spec.template.spec.containers}}{{range .env}}{{if eq .name \"${name}\"}}{{with .valueFrom}}{{with .secretKeyRef}}{{.name}}/{{.key}}{{end}}{{end}}{{end}}{{end}}{{end}}") + + if [ -n "$ref" ]; then + sname=${ref%%/*}; skey=${ref##*/} + if ! keys=$(kubectl -n "$NS" get secret "$sname" -o go-template='{{range $k,$v := .data}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null); then + problems="${problems} ${name}: secretKeyRef -> secret '${sname}' does not exist in namespace ${NS}"$'\n' + echo " $name: <- secret $sname/$skey (SECRET NOT FOUND)" + elif ! printf '%s\n' "$keys" | grep -qx "$skey"; then + problems="${problems} ${name}: secret '${sname}' exists but has no key '${skey}' (keys: $(printf '%s ' $keys))"$'\n' + echo " $name: <- secret $sname/$skey (KEY NOT FOUND)" + else + echo " $name: <- secret $sname/$skey (ok)" + fi + continue + fi + + # A literal value. Never echo one that is named like a credential. + val=$(kubectl -n "$NS" get "deploy/$DEPLOY" -o go-template="{{range .spec.template.spec.containers}}{{range .env}}{{if eq .name \"${name}\"}}{{.value}}{{end}}{{end}}{{end}}") + case "$name" in + *TOKEN*|*SECRET*|*PASSWORD*) echo " $name: set inline (${#val} chars)" ;; + *) echo " $name: $val" ;; + esac +done + +if [ -n "$problems" ]; then + echo >&2 + echo "REFUSING to push: the conf carries placeholders this deployment cannot substitute." >&2 + printf '%s' "$problems" >&2 + echo "Fix the deployment first (add the env var, or create the secret/key), then re-run." >&2 + echo "Pushing now would leave nginx serving a silently broken proxy - it starts fine and" >&2 + echo "the failure only shows up as a 401 from the upstream." >&2 + if [ "${ALLOW_MISSING_ENV:-0}" != "1" ]; then + echo "Override with ALLOW_MISSING_ENV=1 if you really mean to." >&2 + exit 1 + fi + echo "ALLOW_MISSING_ENV=1 - continuing anyway." >&2 +fi + +echo read -r -p "Apply to $NS/$CM and restart $DEPLOY? [y/N] " ans case "$ans" in y|Y|yes|YES|Yes) ;; @@ -66,33 +145,77 @@ kubectl -n "$NS" patch cm "$CM" --type merge -p "$PATCH" kubectl -n "$NS" rollout restart "deploy/$DEPLOY" kubectl -n "$NS" rollout status "deploy/$DEPLOY" --timeout=180s -# The frontend pod must carry the env vars that start-webui.sh substitutes into the conf's -# __NAME__ placeholders. Without POWERGRID_SIMU_UPSTREAM nginx gets the local-dev default -# (host.docker.internal) and refuses to start; without COGNITIVE_TOKEN the /cognitive-api/ -# proxy sends an empty bearer token and the cognitive panel 401s. -echo "--- frontend POWERGRID_SIMU_UPSTREAM:" -kubectl -n "$NS" get "deploy/$DEPLOY" \ - -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="POWERGRID_SIMU_UPSTREAM")]}{.value}{"\n"}{end}' - -# Only that the var is wired to a secret - never the value itself. -echo "--- frontend COGNITIVE_TOKEN source:" -kubectl -n "$NS" get "deploy/$DEPLOY" \ - -o jsonpath='{range .spec.template.spec.containers[*].env[?(@.name=="COGNITIVE_TOKEN")]}{.valueFrom.secretKeyRef.name}/{.valueFrom.secretKeyRef.key}{"\n"}{end}' \ - | grep . || echo "MISSING - add it to values.ovh.yaml (secret cab-frontend, key cognitive-token)" - -# Verify against the config nginx actually loaded, not against the ConfigMap. -echo "--- verify: /powergrid-simu/ in the running nginx config" -if kubectl -n "$NS" exec "deploy/$DEPLOY" -- nginx -T 2>/dev/null | grep -q "location /powergrid-simu/"; then - echo "OK - the location is live." - kubectl -n "$NS" exec "deploy/$DEPLOY" -- nginx -T 2>/dev/null | - grep -A6 "location /powergrid-simu/" +# --------------------------------------------------------------------------- +# Verify against the config nginx ACTUALLY loaded. +# +# start-webui.sh runs `nginx -c /personal-conf/nginx.conf`, and that tree is built at +# startup from the ConfigMap with the placeholders substituted. A bare `nginx -T` ignores +# it and re-reads the default /etc/nginx/nginx.conf, which pulls in the raw ConfigMap +# mount - where `proxy_pass __POWERGRID_SIMU_UPSTREAM__;` is not a valid URL. nginx then +# exits non-zero and prints no dump at all, which reads as "the location is missing". +# That false negative is why this script reported FAILED on a healthy pod. +# --------------------------------------------------------------------------- +NGINX_CONF=/personal-conf/nginx.conf + +# Target one Running pod, not deploy/... : during a rollout that can select the pod being +# terminated and report on the config we just replaced. +SELECTOR=$(kubectl -n "$NS" get "deploy/$DEPLOY" \ + -o go-template='{{range $k,$v := .spec.selector.matchLabels}}{{$k}}={{$v}},{{end}}' | sed 's/,$//') +POD=$(kubectl -n "$NS" get pods -l "$SELECTOR" --field-selector=status.phase=Running \ + --sort-by=.metadata.creationTimestamp -o name | tail -1) + +if [ -z "$POD" ]; then + echo "FAILED - no Running pod matching '$SELECTOR' in namespace $NS." >&2 + kubectl -n "$NS" get pods -l "$SELECTOR" >&2 + exit 1 +fi +echo "--- verify: the config loaded by $POD ($NGINX_CONF)" + +# Keep stderr: if nginx rejects the config, its reason is the whole point. +if ! dump=$(kubectl -n "$NS" exec "$POD" -- nginx -T -c "$NGINX_CONF" 2>&1); then + echo "FAILED - nginx could not dump $NGINX_CONF in $POD:" >&2 + printf '%s\n' "$dump" >&2 + exit 1 +fi + +rc=0 + +for loc in /powergrid-simu/ /cognitive-api/; do + if printf '%s\n' "$dump" | grep -q "location $loc"; then + echo "OK - location $loc is live." + else + echo "FAIL - location $loc is NOT in the running config." >&2 + rc=1 + fi +done + +# The failure this script exists to catch: a placeholder that reached the running config +# unsubstituted. It means the image predates the variable, or the env var never arrived. +leftover=$(printf '%s\n' "$dump" | grep -o '__[A-Z_][A-Z_]*__' | sort -u || true) +if [ -n "$leftover" ]; then + echo "FAIL - unsubstituted placeholders in the RUNNING config:" >&2 + printf ' %s\n' $leftover >&2 + echo " The pod's image cannot substitute these. Check that its start-webui.sh lists them" >&2 + echo " in SUBST_VARS - an image older than the conf is the usual cause:" >&2 + kubectl -n "$NS" get "$POD" -o jsonpath='{.spec.containers[*].image}{"\n"}' >&2 + rc=1 else - echo "FAILED - the running nginx still has no /powergrid-simu/ location." >&2 - echo " ConfigMap key currently in the cluster:" >&2 - kubectl -n "$NS" get cm "$CM" -o jsonpath='{.data.nginx\.conf}' | - grep -c powergrid-simu >&2 || true + echo "OK - no unsubstituted placeholders in the running config." +fi + +# Never print the dump itself - it now carries the substituted token. +echo "--- /cognitive-api/ as loaded (token redacted):" +printf '%s\n' "$dump" | grep -A8 'location /cognitive-api/' | + sed -E 's/(Authorization "Bearer )[^"]*/\1/' + +if [ "$rc" -ne 0 ]; then + echo >&2 + echo " ConfigMap occurrences of powergrid-simu in the cluster:" >&2 + kubectl -n "$NS" get cm "$CM" -o jsonpath='{.data.nginx\.conf}' | grep -c powergrid-simu >&2 || true echo " (0 above = the patch did not stick, e.g. ArgoCD self-heal reverted it)" >&2 echo " Pods (a crashlooping new pod leaves the OLD one serving):" >&2 - kubectl -n "$NS" get pods -l app.kubernetes.io/name=frontend >&2 + kubectl -n "$NS" get pods -l "$SELECTOR" >&2 exit 1 fi + +echo "--- all checks passed." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 15041403..c6df97b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cab-front", - "version": "1.3.7", + "version": "1.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cab-front", - "version": "1.3.7", + "version": "1.3.9", "license": "MPL-2.0", "dependencies": { "@floating-ui/vue": "^1.0.6", diff --git a/frontend/package.json b/frontend/package.json index 1d8c21c2..d4f3f69e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cab-front", - "version": "1.3.7", + "version": "1.3.9", "private": true, "license": "MPL-2.0", "type": "module", From b534997a73d18f37ab8d1de36dcf20ae56259507 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Fri, 4 Sep 2026 11:42:56 +0000 Subject: [PATCH 07/20] release 1.4.0: fail fast on a missing cognitive token, keep it out of comments The token move in 80524c4 left two ways to be broken silently, and the OVH deploy hit both. Closing them, and bumping every image tag to 1.4.0. REQUIRED_VARS ------------- COGNITIVE_TOKEN defaulted to empty, so a pod with no env var (or a secretKeyRef to a secret that does not exist) started cleanly and served /cognitive-api/ with "Bearer " and nothing after it - visible only as a 401 from the upstream. start-webui.sh now takes REQUIRED_VARS, a list of names that must be non-empty, and aborts naming all the empty ones at once. values.ovh.yaml sets REQUIRED_VARS=COGNITIVE_TOKEN, so the pod crashloops with the reason in its log and k8s keeps the previous pod serving. It is opt-in rather than "every var is required" because an absent value is not always wrong: local dev runs the whole stack with no token and merely loses the cognitive panel, so requiring one there would block work on unrelated features. Token no longer substituted into comments ----------------------------------------- Both confs named __COGNITIVE_TOKEN__ in a comment above the directive. Substitution is a plain sed, so the real token was written into the comment as well - two copies in the generated conf, one of them where nobody would think to look. The comments now describe the placeholder instead of naming it, and say why. Same for __POWERGRID_SIMU_UPSTREAM__, for consistency. docker-compose.sh printed the secrets ------------------------------------- It ended with a plain `cat .env` on a file that now carries RL_AGENT_API_TOKEN and COGNITIVE_TOKEN, putting both in the terminal and in any CI log running the script. Credential-looking values are masked as /. Verified against the published image with start-webui.sh mounted over it: an empty required token exits 1 with the new message; local dev with no token and no REQUIRED_VARS still starts and serves the SPA (200); with a token set the running conf holds exactly one copy of it, no placeholder survives, and the bundle contains none. The substituted ConfigMap passes nginx -t with its in-cluster upstreams stubbed. --- README.md | 21 +++++++++++++ config/dev/cab-standalone/.env.example | 3 ++ config/dev/cab-standalone/.secrets.example | 3 ++ config/dev/cab-standalone/docker-compose.sh | 6 +++- .../configmap-assistant-platform.yaml | 12 ++++---- deploy-chart/values.ovh.yaml | 17 +++++++---- frontend/default.conf | 12 ++++---- frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- frontend/start-webui.sh | 30 +++++++++++++++++++ 10 files changed, 90 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 37e2471c..3b4fb7a1 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,27 @@ with the name of the missing variable, and the generated config is checked with before the daemon starts — so a misconfiguration fails loudly at startup instead of producing a silently broken proxy. +`REQUIRED_VARS` (space- or comma-separated) lists the variables that must be **non-empty**; +an empty one aborts startup. It is opt-in because an absent value is not always wrong — +local dev runs the whole stack with no cognitive token and just loses that panel — whereas +on a public deploy an empty token means nginx sends `Bearer ` with nothing after it and +every `/cognitive-api/` call 401s while the pod still reports itself healthy. +`deploy-chart/values.ovh.yaml` therefore sets `REQUIRED_VARS=COGNITIVE_TOKEN`, so the pod +crashloops with the reason in its log and k8s keeps the previous pod serving. + +Two ordering rules follow from all of this, and breaking the first is what silently broke +`/cognitive-api/` once already: + +- **Never push a conf ahead of the pod that has to substitute it.** A placeholder the + running image does not know is left in the config *literally* and goes out in the proxied + request. `deploy-chart/apply-nginx-conf.sh` now refuses to push in that case: it checks + every `__NAME__` in the conf against the deployment's env, and resolves `secretKeyRef`s + to confirm the secret and key actually exist. +- **Verify the config nginx loaded, not the ConfigMap.** nginx runs with an explicit + `-c /personal-conf/nginx.conf`; a bare `nginx -T` re-reads `/etc/nginx/nginx.conf` and the + raw ConfigMap mount, where `proxy_pass __POWERGRID_SIMU_UPSTREAM__;` is not a valid URL — + so it exits non-zero and prints nothing, which reads as a missing location. + Two things to keep in mind: - **In k8s the config does not come from the image.** The `cab-assistant-platform-config` diff --git a/config/dev/cab-standalone/.env.example b/config/dev/cab-standalone/.env.example index b80117a9..f969d319 100644 --- a/config/dev/cab-standalone/.env.example +++ b/config/dev/cab-standalone/.env.example @@ -26,6 +26,9 @@ POWERGRID_SIMU_UPSTREAM=http://host.docker.internal:5122/ # Bearer token for the INESCTEC cognitive API, injected into the nginx /cognitive-api/ # proxy at container start. Runtime, not build-time: restart the container to change it. COGNITIVE_TOKEN= +# Leaving it empty is fine here: the stack starts and only the cognitive panel is +# unavailable. Public deploys set REQUIRED_VARS=COGNITIVE_TOKEN instead, which makes +# an empty value abort container start rather than serve an unauthenticated proxy. # VITE build-time variables (also required in frontend/env/.env.local for local dev) VITE_POWERGRID_SIMU=false diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index c27b710c..3ca7c1dc 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -25,6 +25,9 @@ export VITE_POWERGRID_SIMU=/powergrid-simu # container: start-webui.sh injects it into the nginx /cognitive-api/ proxy, so it stays # out of the JS bundle. Changing it needs a container restart, not a rebuild. export COGNITIVE_TOKEN= +# Leaving it empty is fine here: the stack starts and only the cognitive panel is +# unavailable. Public deploys set REQUIRED_VARS=COGNITIVE_TOKEN instead, which makes +# an empty value abort container start rather than serve an unauthenticated proxy. # PowerGrid simulator upstream for the frontend's /powergrid-simu/ proxy. # Local dev : http://host.docker.internal:5122/ (default) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 7c2b40e2..2826d66a 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -54,5 +54,9 @@ echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env echo "POWERGRID_SIMU_UPSTREAM=${POWERGRID_SIMU_UPSTREAM:-http://host.docker.internal:5122/}" >> .env echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env -cat .env +# Echo the generated .env for confidence, with credential values masked: this file now +# carries RL_AGENT_API_TOKEN and COGNITIVE_TOKEN, and a plain `cat` put both in the +# terminal (and in any CI log that runs this script). +sed -E 's/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=(.+)$/\1=/; s/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=$/\1=/' .env + docker compose up -d diff --git a/deploy-chart/configmap-assistant-platform.yaml b/deploy-chart/configmap-assistant-platform.yaml index b4267095..6ba86d93 100644 --- a/deploy-chart/configmap-assistant-platform.yaml +++ b/deploy-chart/configmap-assistant-platform.yaml @@ -265,7 +265,7 @@ data: # PowerGrid (grid2op) simulator "apply action" proxy. Keeps the browser POST # same-origin (no CORS) — the frontend is built with # VITE_POWERGRID_SIMU=/powergrid-simu and posts to /powergrid-simu/api/v1/recommendations. - # __POWERGRID_SIMU_UPSTREAM__ is substituted at container start by start-webui.sh + # The proxy_pass placeholder below is substituted at container start by start-webui.sh # from the POWERGRID_SIMU_UPSTREAM env var of the frontend pod. # No explicit Host header: nginx defaults it to $proxy_host (the authority from # proxy_pass), so the passerelle gets the right vhost whatever the upstream is. @@ -283,10 +283,12 @@ data: root /usr/share/nginx/html; } # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions). - # The bearer token is attached here, not by the browser: __COGNITIVE_TOKEN__ is - # substituted at container start by start-webui.sh from the COGNITIVE_TOKEN env - # var of the frontend pod, so the token stays out of the public JS bundle and is - # rotated by updating the secret and restarting the deployment. + # The bearer token is attached here, not by the browser: the placeholder on the + # Authorization line is substituted at container start by start-webui.sh from the + # COGNITIVE_TOKEN env var of the frontend pod, so the token stays out of the public + # JS bundle and is rotated by updating the secret and restarting the deployment. + # The placeholder is deliberately not named in this comment: substitution is a + # plain sed, so naming it would write the token into the comment as well. location /cognitive-api/ { add_header Cache-Control "no-cache"; proxy_set_header Host wesenss.inesctec.pt; diff --git a/deploy-chart/values.ovh.yaml b/deploy-chart/values.ovh.yaml index 36d5ffdc..dfd7dfb3 100644 --- a/deploy-chart/values.ovh.yaml +++ b/deploy-chart/values.ovh.yaml @@ -2,25 +2,25 @@ cabcontext: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-context pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" cabevent: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-event pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" cabhistoric: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-historic pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" cabrecommendation: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-recommendation pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" extraEnv: - name: RL_AGENT_API_URL value: "https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation" @@ -34,13 +34,13 @@ cabcapitalization: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-capitalization pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" frontend: image: repository: harbor.irtsysx.fr/docker-proxy-cache/irtsystemx/interactiveai-cab-standalone-frontend pullPolicy: Always - tag: "1.3.8" + tag: "1.4.0" extraEnv: - name: VITE_POWERGRID_SIMU value: "/powergrid-simu" @@ -55,3 +55,8 @@ frontend: secretKeyRef: name: cab-frontend key: cognitive-token + # Makes an empty COGNITIVE_TOKEN fatal at container start instead of letting nginx + # serve /cognitive-api/ with an empty bearer token - which looks healthy and 401s. + # The pod crashloops, k8s keeps the previous one serving, and the reason is in the log. + - name: REQUIRED_VARS + value: "COGNITIVE_TOKEN" diff --git a/frontend/default.conf b/frontend/default.conf index 56495289..e76df39a 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -16,9 +16,11 @@ server { } # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions). - # The bearer token is attached here, not by the browser: __COGNITIVE_TOKEN__ is - # substituted from $COGNITIVE_TOKEN at container start (see start-webui.sh), so the - # token never reaches the public JS bundle and is rotated by restarting the pod. + # The bearer token is attached here, not by the browser: the placeholder on the + # Authorization line below is substituted from $COGNITIVE_TOKEN at container start + # (see start-webui.sh), so the token never reaches the public JS bundle and is + # rotated by restarting the pod. Deliberately not naming the placeholder in this + # comment: the substitution is a plain sed, so it would write the token here too. location /cognitive-api/ { proxy_set_header Host wesenss.inesctec.pt; proxy_set_header X-Forwarded-For $remote_addr; @@ -43,8 +45,8 @@ server { # Proxy for the PowerGrid (grid2op) simulator — keeps the "apply" POST same-origin # so the browser never does a cross-origin request (avoids CORS entirely). # Frontend posts to /powergrid-simu/... with VITE_POWERGRID_SIMU=/powergrid-simu. - # __POWERGRID_SIMU_UPSTREAM__ is substituted from $POWERGRID_SIMU_UPSTREAM at container - # start (see start-webui.sh). Per environment: + # The proxy_pass placeholder below is substituted from $POWERGRID_SIMU_UPSTREAM at + # container start (see start-webui.sh). Per environment: # local -> http://host.docker.internal:5122/ (the default) # LAN -> http://192.168.208.61:5100/ # public -> https://interactivepowergrid.passerelle.irt-systemx.fr/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c6df97b2..fb75fa68 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cab-front", - "version": "1.3.9", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cab-front", - "version": "1.3.9", + "version": "1.4.0", "license": "MPL-2.0", "dependencies": { "@floating-ui/vue": "^1.0.6", diff --git a/frontend/package.json b/frontend/package.json index d4f3f69e..f5563cdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cab-front", - "version": "1.3.9", + "version": "1.4.0", "private": true, "license": "MPL-2.0", "type": "module", diff --git a/frontend/start-webui.sh b/frontend/start-webui.sh index b84f6828..4db61630 100755 --- a/frontend/start-webui.sh +++ b/frontend/start-webui.sh @@ -91,6 +91,20 @@ grep "conf.d" $nginx_conf_path_personal/nginx.conf SUBST_VARS="POWERGRID_SIMU_UPSTREAM COGNITIVE_TOKEN" +# Names that must carry a NON-EMPTY value, space- or comma-separated. An empty required +# variable aborts startup instead of quietly substituting nothing. +# +# Opt-in rather than "everything is required" because an absent value is not always wrong. +# Local dev runs the whole stack without a cognitive token and merely loses the cognitive +# panel, so demanding one there would block work on unrelated features. A public deployment +# is the opposite case: an empty token means nginx sends "Bearer " with nothing after it and +# every /cognitive-api/ call 401s while the pod reports itself healthy. So +# deploy-chart/values.ovh.yaml sets REQUIRED_VARS=COGNITIVE_TOKEN and the pod crashloops +# instead - which in k8s leaves the previous pod serving, and puts the reason in the logs. +: "${REQUIRED_VARS:=}" + +missing_required="" + for name in $SUBST_VARS; do eval "value=\$$name" # Escape the sed replacement metacharacters, including the # delimiter, so tokens and @@ -103,8 +117,24 @@ for name in $SUBST_VARS; do if [ -n "$value" ]; then echo "$name: set (${#value} chars)"; else echo "$name: EMPTY"; fi ;; *) echo "$name: $value" ;; esac + # Collected rather than fatal on the spot, so the log names every missing variable in + # one go instead of one per restart. + if [ -z "$value" ] && printf '%s' "$REQUIRED_VARS" | tr ',' ' ' | grep -qw "$name"; then + missing_required="$missing_required $name" + fi done +if [ -n "$missing_required" ]; then + echo "ERROR: required runtime variable(s) empty:$missing_required" >&2 + echo "They are listed in REQUIRED_VARS=\"$REQUIRED_VARS\" and would have been substituted" >&2 + echo "into the nginx conf as empty strings - producing a proxy that answers requests but" >&2 + echo "sends no credentials, which surfaces only as a 401 from the upstream. Refusing to" >&2 + echo "start instead." >&2 + echo "In k8s: check the env var on the deployment, and that the secret and key it" >&2 + echo "references both exist (deploy-chart/apply-nginx-conf.sh reports this)." >&2 + exit 1 +fi + # A placeholder that survives means its env var was never set. nginx would either refuse to # start on an invalid proxy_pass or, worse, serve a silently broken proxy - so fail here, # where the reason is obvious in the container logs. From 7c6ac4efabeaf20e079f072760c1cb35ee8a9bf0 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Wed, 16 Sep 2026 13:59:03 +0000 Subject: [PATCH 08/20] fix context image type (png->svg) and add observation to logging --- frontend/src/utils/traceSessionExport.ts | 104 ++++++++++++++++++++--- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/frontend/src/utils/traceSessionExport.ts b/frontend/src/utils/traceSessionExport.ts index 5e2914e2..24b003e1 100644 --- a/frontend/src/utils/traceSessionExport.ts +++ b/frontend/src/utils/traceSessionExport.ts @@ -57,7 +57,47 @@ function loadSession(): TraceSession | undefined { } function saveSession(session: TraceSession) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) + } catch (error) { + // Quota exceeded: traces carry base64 snapshots and full observations, so a + // long session can fill the 5 MB store. Keep what is already recorded + // instead of letting the write reject and stop the recording altogether. + console.warn('Unable to persist the trace session (storage full?):', error) + } +} + +/** Narrow an unknown trace payload to something spreadable. */ +function asRecord(value: unknown): Record { + return value !== null && typeof value === 'object' ? (value as Record) : {} +} + +/** + * The grid state the operator was looking at when an event fired - the very + * payload the recommendation service is handed, kept verbatim (~20 kB of JSON + * per event). Only the PowerGrid context carries an `observation`; the other + * use cases record nothing. + * + * The services store is imported lazily: it reaches the auth store, which + * imports this module, and a static cycle would leave the import undefined at + * module-init time. + */ +async function currentObservation(useCase: Trace['use_case']): Promise { + try { + const { useServicesStore } = await import('@/stores/services') + const stored = asRecord(useServicesStore().context(useCase)?.data).observation + if (stored !== undefined) return stored + + // The store only publishes a context once its id *changes*, so it is still + // empty right after login - fetch directly rather than lose the first + // events of a session. + const { getContext } = await import('@/api/services') + const { data } = await getContext() + return asRecord(data.find((item) => item.use_case === useCase)?.data).observation + } catch (error) { + console.warn('Unable to attach the context observation to the trace:', error) + return undefined + } } function eventKey(data: unknown): string | undefined { @@ -268,6 +308,28 @@ function isLargeBlob(val: unknown): boolean { return str.length > MAX_VALUE_LENGTH } +/** + * Build an `` source from a raw base64 payload. + * + * The PowerGrid simulator renders its observation snapshots as SVG since the + * zoom feature landed (`plt.savefig(..., format="svg")`), while older sessions + * still carry PNG. A `data:image/png` URI holding SVG bytes renders as a broken + * image, so the media type is sniffed from the payload instead of assumed. + */ +function imageDataUri(base64: string): string { + if (base64.startsWith('data:')) return base64 + let head = '' + try { + // 64 chars is a whole number of base64 quanta, so a prefix decodes cleanly + head = atob(base64.slice(0, 64)).trimStart() + } catch { + // Undecodable on its own - fall back to PNG, the historical format + } + const isSvg = + head.startsWith(' @@ -279,7 +341,7 @@ function eventMetadataHtml(data: unknown): string { const key = keys[i] const val = meta[key] if (key === 'event_context' && isLargeBlob(val) && typeof val === 'string') { - const src = val.startsWith('data:') ? val : 'data:image/png;base64,' + val + const src = imageDataUri(val) rows.push('' + escapeHtml(key) + 'event context image') } else if (isLargeBlob(val)) { rows.push('' + escapeHtml(key) + '[large data omitted]') @@ -290,6 +352,23 @@ function eventMetadataHtml(data: unknown): string { return '' + rows.join('') + '
' } +/** + * The observation is ~65 arrays of floats: useful to have, unreadable inline. + * Render it folded so the event card stays scannable. + */ +function observationHtml(data: unknown): string { + const observation = asRecord(data).observation + if (!observation || typeof observation !== 'object') return '' + const fields = Object.keys(observation as Record).length + return ( + '
Grid observation (' + + fields + + ' fields)
' +
+    escapeHtml(JSON.stringify(observation, null, 2)) +
+    '
' + ) +} + function cognitiveSnapshotHtml(data: unknown): string { if (!data || typeof data !== 'object') return '' const d = data as Record @@ -428,6 +507,7 @@ function buildHtmlSummary( if (eventSummary) html += '
' + escapeHtml(eventSummary) + '
' html += '
' + formatTime(evt.date) + '
' html += eventMetadataHtml(evt.data) + html += observationHtml(evt.data) html += cognitiveSnapshotHtml(evt.data) // Decision time @@ -512,25 +592,27 @@ export async function recordTraceForSession( } } + // Snapshot the context observation alongside the event, so the export says + // what the grid actually looked like and not just which card was raised. + let baseData = trace.data + if (trace.step === 'EVENT') { + const observation = await currentObservation(trace.use_case) + if (observation !== undefined) baseData = { ...asRecord(trace.data), observation } + } + // Enrich trace data with the latest cognitive snapshot — but only when the // operator has consented. Without consent, nothing is fetched or recorded. // On API failure the snapshot contains an `error` field. - let enrichedData: unknown = trace.data + let enrichedData: unknown = baseData if (hasCognitiveConsent()) { try { const cognitiveSnapshot = await fetchCognitiveSnapshot() - const base = (trace.data !== null && typeof trace.data === 'object') - ? (trace.data as Record) - : {} - enrichedData = { ...base, cognitive_snapshot: cognitiveSnapshot } + enrichedData = { ...asRecord(baseData), cognitive_snapshot: cognitiveSnapshot } } catch (err: unknown) { // Should not happen (fetchCognitiveSnapshot never throws), but guard anyway const message = err instanceof Error ? err.message : String(err) - const base = (trace.data !== null && typeof trace.data === 'object') - ? (trace.data as Record) - : {} enrichedData = { - ...base, + ...asRecord(baseData), cognitive_snapshot: { cognitive_performance: null, stress_state: null, From 9c0ad7b16ff3cbed0dad215757f2a640330e99cf Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Wed, 16 Sep 2026 14:24:26 +0000 Subject: [PATCH 09/20] feat(frontend): on logout confirmation to delete or keep remaining alerts --- .gitignore | 3 ++- frontend/src/api/cards.ts | 11 ++++++-- frontend/src/components/molecules/Navbar.vue | 24 +++++++++++++++++ frontend/src/locales/en.json | 2 ++ frontend/src/locales/fr.json | 2 ++ frontend/src/stores/cards.ts | 28 +++++++++++++++++++- 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ae6f754d..286604b7 100644 --- a/.gitignore +++ b/.gitignore @@ -197,4 +197,5 @@ go.sh # Frontend !frontend/.vscode !frontend/env/ -!frontend/env/.env \ No newline at end of file +!frontend/env/.env +hmisurveys/ \ No newline at end of file diff --git a/frontend/src/api/cards.ts b/frontend/src/api/cards.ts index ebcea3de..bf769185 100644 --- a/frontend/src/api/cards.ts +++ b/frontend/src/api/cards.ts @@ -98,8 +98,15 @@ export function remove(id: Card['id']) { return http.delete(`/cardspub/cards/${id}`) } -export function removeEvent(uid: Card['processInstanceId']) { - return http.delete(`/cab_event/api/v1/event/${uid}`) +/** + * Deletes the event *and* its card (the event-service drops `cabProcess.{uid}` + * from the card publication service on its way out). + * + * @param silent suppress the generic error modal - used by bulk deletions that + * report once instead of one popup per card + */ +export function removeEvent(uid: Card['processInstanceId'], silent = false) { + return http.delete(`/cab_event/api/v1/event/${uid}`, { _silent: silent }) } export function acknowledge(card: Card) { diff --git a/frontend/src/components/molecules/Navbar.vue b/frontend/src/components/molecules/Navbar.vue index 9f50b0c4..29fd73f8 100644 --- a/frontend/src/components/molecules/Navbar.vue +++ b/frontend/src/components/molecules/Navbar.vue @@ -70,6 +70,7 @@ + + diff --git a/frontend/public/surveys/experience/ueq_short.html b/frontend/public/surveys/experience/ueq_short.html new file mode 100644 index 00000000..a62f1546 --- /dev/null +++ b/frontend/public/surveys/experience/ueq_short.html @@ -0,0 +1,327 @@ + + + + + + User Experience Questionnaire (UEQ) + + + + +
+

User Experience Questionnaire - Short (UEQ-S)

+

+ Please select one option per row. + The questionnaire consists of pairs of contrasting attributes that may apply to the product or system. The circles between the attributes represent + gradations between the opposites. You can express your agreement with the attributes by ticking the circle that most closely reflects your impression.

+ Please decide spontaneously. + Don’t think too long about your decision to make sure that you convey your original impression. +

+ + + + + + + + + + + + + + +
#
+ + + +
+ + + + + + + diff --git a/frontend/public/surveys/surveychainer.html b/frontend/public/surveys/surveychainer.html new file mode 100644 index 00000000..afc2acf5 --- /dev/null +++ b/frontend/public/surveys/surveychainer.html @@ -0,0 +1,241 @@ + + + + + + Survey Chain Orchestrator + + + + +
+

Survey Session

+

Press Start button below

+ + + + + + + + +
+ + + + + + diff --git a/frontend/public/surveys/understanding/understanding.html b/frontend/public/surveys/understanding/understanding.html new file mode 100644 index 00000000..8f921fde --- /dev/null +++ b/frontend/public/surveys/understanding/understanding.html @@ -0,0 +1,293 @@ + + + + + +Comprehensive AI System Understanding Assessment (Fixed) + + + +
+

System Understanding Assessment

+

+ This instrument measures three aspects of user understanding: +

    +
  • Perceived Understanding — how well the user feels they understand the system.
  • +
  • Factual Accuracy — correct recall of objective system parameters.
  • +
  • Conceptual Comprehension — correct understanding of how or why the system behaves as it does.
  • +
+

+ + + + + + + + + + + + + + + + + +
Statement / Question1
Strongly
Disagree
234
Neutral
567
Strongly
Agree
+ + +
+ + + + + + diff --git a/frontend/public/surveys/workload/mch.html b/frontend/public/surveys/workload/mch.html new file mode 100644 index 00000000..300ccea4 --- /dev/null +++ b/frontend/public/surveys/workload/mch.html @@ -0,0 +1,371 @@ + + + + +Cooper–Harper Handling Qualities / Workload Rating + + + + + +

Modified Cooper–Harper Rating Scale

+

+ Follow the boxes from top to bottom. Answer each question Yes or No. + When the path is determined, choose the rating from the corresponding 3-point group. +

+ + + + +
+ +
+ 1. Even though errors may be large or frequent, can instructed task be accomplished most of the time? +
+ + +
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/frontend/src/components/molecules/Navbar.vue b/frontend/src/components/molecules/Navbar.vue index 29fd73f8..6538a9ce 100644 --- a/frontend/src/components/molecules/Navbar.vue +++ b/frontend/src/components/molecules/Navbar.vue @@ -77,6 +77,8 @@ import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { useCardsStore } from '@/stores/cards' import type { Entity } from '@/types/entities' +import { requestSurvey, UNKNOWN_USE_CASE } from '@/utils/survey' +import { currentTraceSessionId } from '@/utils/traceSessionExport' import { asset, hashColor } from '@/utils/utils' import pkg from '../../../package.json' @@ -116,9 +118,37 @@ function logout() { }) } +/** + * End the session, then hand the operator the HMI questionnaire chain. + * + * Both identifiers are read *before* `logout()`, which clears the trace session + * and the user: the survey is tagged with the session that just ended + * (Participant ID) and the use case it was run on (Condition ID), so the + * operator only has to press Start. + */ function leave() { + const sessionId = currentTraceSessionId() + const entity = router.currentRoute.value.params.entity as Entity | undefined + // Logging out from the home page: unambiguous only when the operator has a + // single use case. + const useCase = + entity ?? + (authStore.entities.length === 1 + ? (authStore.entities[0] as Entity) + : UNKNOWN_USE_CASE) + authStore.logout() - router.push({ name: 'login' }) + + // No session recorded (e.g. a reloaded tab that never started one): nothing + // to attach answers to, so skip the survey rather than file them under a + // missing id. + if (!sessionId) { + router.push({ name: 'login' }) + return + } + + requestSurvey({ sessionId, useCase }) + router.push({ name: 'survey' }) } From c7fc2f471bce94b7695cd91b184af505c74759dd Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Wed, 16 Sep 2026 15:35:27 +0000 Subject: [PATCH 11/20] feat(frontend): offer the session report after the survey --- frontend/README.md | 15 +++++ frontend/src/components/molecules/Navbar.vue | 8 ++- frontend/src/locales/en.json | 1 + frontend/src/locales/fr.json | 1 + frontend/src/stores/auth.ts | 10 +++- frontend/src/utils/traceSessionExport.ts | 62 +++++++++++++++++--- frontend/src/views/Survey.vue | 31 +++++++++- 7 files changed, 113 insertions(+), 15 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index f0fcb567..bb4eca5c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -144,6 +144,21 @@ session id and use case **before** `logout()` clears them, queues them with so both fields are prefilled and hidden - only *Start* is left to press. +The session report (`exportTraceSession`) still writes its JSON and HTML files +on logout, but it no longer opens itself in a new tab there: that tab takes the +focus, and an operator who did not notice the survey behind it never took it. +The report is held back (`openSummary: false`) and offered once the +questionnaire is over — *Delete the remaining alerts?* → survey → *Open the +session report?* — so the full order is + +1. `Navbar.leave` exports the session, keeping the report in memory. +2. The operator answers the questionnaire, or skips it. +3. The survey page asks whether to open the report, then returns to the login + page. + +Logging out without a recorded session skips both steps and opens the report +immediately, as it always did. + ### Changing the questionnaires The chain lives at the top of `public/surveys/surveychainer.html`: diff --git a/frontend/src/components/molecules/Navbar.vue b/frontend/src/components/molecules/Navbar.vue index 6538a9ce..fd01236e 100644 --- a/frontend/src/components/molecules/Navbar.vue +++ b/frontend/src/components/molecules/Navbar.vue @@ -137,11 +137,15 @@ function leave() { ? (authStore.entities[0] as Entity) : UNKNOWN_USE_CASE) - authStore.logout() + // The session report would open on top of the questionnaire and hide it, so + // it is held back whenever a survey follows; the survey page offers it once + // the operator is done (`openDeferredSummary`). The files are written either + // way. + authStore.logout('json', { openSummary: !sessionId }) // No session recorded (e.g. a reloaded tab that never started one): nothing // to attach answers to, so skip the survey rather than file them under a - // missing id. + // missing id - and the report opens straight away, as it always did. if (!sessionId) { router.push({ name: 'login' }) return diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 369b8005..b8b84c0f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -68,6 +68,7 @@ "modal.error.SESSION_EXPIRED": "Your session has expired, please log in again", "modal.error.default": "Request to {url} failed with code {code} and message {message}", "modal.info.DELETE_ALERTS": "Delete the remaining alerts? They will not be shown at the next login.", + "modal.info.OPEN_SESSION_LOG": "Open the session report in a new tab? It is saved with the session files either way.", "modal.info.SUBSCRIPTION_ACTIVE": "A user is logged in, log them out?", "recommendations.description": "Description", "recommendations.description.more": "{sign} details", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 3b0d61f6..f6499c3a 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -68,6 +68,7 @@ "modal.error.SESSION_EXPIRED": "Votre session a expiré, veuillez vous reconnecter", "modal.error.default": "La requête à {url} a échoué avec le code {code} et le message {message}", "modal.info.DELETE_ALERTS": "Supprimer les alertes restantes ? Elles ne seront plus affichées à la prochaine connexion.", + "modal.info.OPEN_SESSION_LOG": "Ouvrir le rapport de session dans un nouvel onglet ? Il est enregistré avec les fichiers de session dans tous les cas.", "modal.info.SUBSCRIPTION_ACTIVE": "Un utilisateur est connecté, le déconnecter ?", "recommendations.description": "Description", "recommendations.description.more": "{sign} détails", diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 560a6aab..b473dc93 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -127,12 +127,18 @@ export const useAuthStore = defineStore( /** * @param format trace export format * @param options `force: false` skips the export when the session recorded - * no trace - used on session expiry so no empty file is downloaded + * no trace - used on session expiry so no empty file is downloaded. + * `openSummary: false` keeps the HTML report from taking over the tab, + * which would hide the post-logout survey; the file is written regardless */ - function logout(format: 'json' | 'csv' = 'json', options: { force?: boolean } = {}) { + function logout( + format: 'json' | 'csv' = 'json', + options: { force?: boolean; openSummary?: boolean } = {} + ) { try { exportTraceSession(format, { force: options.force ?? true, + openSummary: options.openSummary, userLogin: user.value?.userData.login }) } catch (error) { diff --git a/frontend/src/utils/traceSessionExport.ts b/frontend/src/utils/traceSessionExport.ts index e4f3bd6b..28b0c847 100644 --- a/frontend/src/utils/traceSessionExport.ts +++ b/frontend/src/utils/traceSessionExport.ts @@ -23,6 +23,13 @@ type TraceSession = { type ExportOptions = { force?: boolean userLogin?: string + /** + * Whether the HTML summary is opened in a new tab right away. The file is + * written either way. `false` holds the report back instead - the tab steals + * the focus, which would cover the post-logout survey, so it is offered once + * the questionnaire is over (see `openDeferredSummary`). + */ + openSummary?: boolean } const STORAGE_KEY = 'interactiveai.trace-session.v1' @@ -544,6 +551,46 @@ function buildHtmlSummary( return html } +/** Show an already-built report in a new tab. */ +function openSummaryTab(url: string) { + // rather than window.open(): survives popup blockers + const anchor = document.createElement('a') + anchor.href = url + anchor.target = '_blank' + anchor.rel = 'noopener' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() +} + +/** + * Report held back by `openSummary: false`, waiting for the operator to say + * whether they want to see it. Object URLs live as long as the document, so it + * survives the client-side navigation to /survey - but not a reload, which is + * harmless: the same report was downloaded as a file. + */ +let deferredSummaryUrl: string | undefined + +/** True while a report is waiting to be shown. */ +export function hasDeferredSummary(): boolean { + return !!deferredSummaryUrl +} + +/** Show the held-back report, if there is one. */ +export function openDeferredSummary(): void { + if (!deferredSummaryUrl) return + openSummaryTab(deferredSummaryUrl) + // Not revoked: the tab that was just opened is still reading from it. + deferredSummaryUrl = undefined +} + +/** Drop the held-back report unseen, freeing the blob it holds. */ +export function dropDeferredSummary(): void { + if (!deferredSummaryUrl) return + URL.revokeObjectURL(deferredSummaryUrl) + deferredSummaryUrl = undefined +} + function download(content: string, mimeType: string, fileName: string) { const blob = new Blob([content], { type: mimeType }) const url = URL.createObjectURL(blob) @@ -711,17 +758,14 @@ export function exportTraceSession(format: ExportFormat = 'json', options: Expor ) download(json, 'application/json;charset=utf-8', sessionFileName(session, 'json')) - // Open HTML summary in a new tab (use to avoid popup blocker) + // A report from an earlier export was never claimed: it will not be now. + dropDeferredSummary() const summaryBlob = new Blob([summaryHtml], { type: 'text/html;charset=utf-8' }) const summaryUrl = URL.createObjectURL(summaryBlob) - const summaryAnchor = document.createElement('a') - summaryAnchor.href = summaryUrl - summaryAnchor.target = '_blank' - summaryAnchor.rel = 'noopener' - document.body.appendChild(summaryAnchor) - summaryAnchor.click() - summaryAnchor.remove() - // Also download the HTML file as a backup + if (options.openSummary ?? true) openSummaryTab(summaryUrl) + else deferredSummaryUrl = summaryUrl + + // The HTML file is written whether or not the report is ever opened download(summaryHtml, 'text/html;charset=utf-8', sessionFileName(session, 'html')) } diff --git a/frontend/src/views/Survey.vue b/frontend/src/views/Survey.vue index 5f1a98df..173694c1 100644 --- a/frontend/src/views/Survey.vue +++ b/frontend/src/views/Survey.vue @@ -17,12 +17,21 @@ + + diff --git a/frontend/src/entities/Railway/CAB/Context.vue b/frontend/src/entities/Railway/CAB/Context.vue index 0a332317..d139b90e 100644 --- a/frontend/src/entities/Railway/CAB/Context.vue +++ b/frontend/src/entities/Railway/CAB/Context.vue @@ -1,70 +1,168 @@