diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py index f2c30c49..bb4a12b8 100644 --- a/backend/recommendation-service/resources/PowerGrid/manager.py +++ b/backend/recommendation-service/resources/PowerGrid/manager.py @@ -25,6 +25,9 @@ def __init__(self): self.owl_file_path = os.path.join( script_dir, "ontology/Grid2onto_v2_3_1.owl" ) + # Runtime value comes from the RL_AGENT_API_URL env var (set via + # .secrets -> docker-compose.sh -> .env for local Docker, or extraEnv + # for k8s). The fallback is a safe in-cluster default only. self.rl_agent_api_url = os.environ.get( "RL_AGENT_API_URL", "http://frontend:80/rl-api/recommendation", diff --git a/backend/recommendation-service/resources/Railway/manager.py b/backend/recommendation-service/resources/Railway/manager.py index cf44bc54..687472ec 100644 --- a/backend/recommendation-service/resources/Railway/manager.py +++ b/backend/recommendation-service/resources/Railway/manager.py @@ -2,17 +2,19 @@ import json from api.manager.base_manager import BaseRecommendationManager from .mockRecommendations.mockRecommendations import RECOMMENDATION_CATALOG +from .sncf_recommender import SNCF_RECO3, SNCF_deontic, SNCF_risk, SNCF_risk_tie_break import logging logger = logging.getLogger(__name__) + class RailwayManager(BaseRecommendationManager): def __init__(self): super().__init__() def _transform_recommendation(self, reco_json): - """Transform a recommendation from catalog to output format.""" + """Transform a recommendation from catalog format to API output format.""" reco = json.loads(reco_json) return { "title": reco["data"]["title"], @@ -25,23 +27,59 @@ def _transform_recommendation(self, reco_json): def get_recommendation(self, request_data): """ - Override to provide recommendations specific to the Railway use case. - - This method generates and returns recommendations tailored for Railway events. + Return recommendations for a Railway event. + + Supports four modes controlled by request_data["event"]["mode"]: + + - "basic" (default): best-first ordering via SNCF_RECO3 + - "deontic": filter by KPI threshold, then sort ascending via SNCF_deontic + requires: sort_type ("passengers"|"delay"|"cost"|"total_cost") + threshold_value (int, or delay string e.g. "1h30" for delay) + - "risk": sort all recommendations by KPI ascending via SNCF_risk + requires: sort_type + - "risk_tie_break": sort by primary KPI, break ties with secondary via SNCF_risk_tie_break + requires: sort_type + optional: tie_breaker (same values as sort_type) """ event_data = request_data.get("event", {}) - event_id = str(event_data.get("id_event", "1")) - - logger.info(f"Processing event_id: {event_id}") - - # Get recommendations from catalog - if event_id == "1": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - elif event_id == "2": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - elif event_id == "3": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - # Transform each recommendation to output format - return [self._transform_recommendation(reco) for reco in recommendations] - + context_data = request_data.get("context", {}) + + # Ensure id_event has a fallback so catalog lookup always has a key + event_for_sncf = {**event_data, "id_event": str(event_data.get("id_event", "1"))} + # Wrap into the structure expected by SNCF functions + event_json = json.dumps({"data": event_for_sncf}) + context_json = json.dumps({"data": context_data}) + + mode = event_data.get("mode", "deontic") + logger.info(f"Railway recommendation — event_id: {event_for_sncf['id_event']}, mode: {mode}") + + if mode == "deontic": + sort_type = event_data.get("sort_type", "cost") + threshold_value = event_data.get("threshold_value") + recommendations = SNCF_deontic( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, threshold_value=threshold_value, + ) + elif mode == "risk": + sort_type = event_data.get("sort_type", "cost") + recommendations = SNCF_risk( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, + ) + elif mode == "risk_tie_break": + sort_type = event_data.get("sort_type", "cost") + tie_breaker = event_data.get("tie_breaker") + recommendations = SNCF_risk_tie_break( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, tie_breaker=tie_breaker, + ) + else: # "basic" or any unrecognised mode + recommendations = SNCF_RECO3(event_json, context_json, RECOMMENDATION_CATALOG) + + # Surface catalog errors as an empty list rather than crashing downstream + if recommendations and isinstance(recommendations[0], dict) and "error" in recommendations[0]: + logger.error(f"Recommendation error: {recommendations[0]['error']}") + return [] + + return [self._transform_recommendation(reco) for reco in recommendations] diff --git a/backend/recommendation-service/resources/Railway/sncf_recommender.py b/backend/recommendation-service/resources/Railway/sncf_recommender.py new file mode 100644 index 00000000..bcb3f0b1 --- /dev/null +++ b/backend/recommendation-service/resources/Railway/sncf_recommender.py @@ -0,0 +1,158 @@ +import json +import re + + +def parse_delay_to_minutes(delay_str): + if delay_str is None: + return 0 + delay_str = delay_str.lower().strip() + hours = 0 + minutes = 0 + h_match = re.search(r'(\d+)h', delay_str) + m_match = re.search(r'(\d+)\s*min', delay_str) + if h_match: + hours = int(h_match.group(1)) + if m_match: + minutes = int(m_match.group(1)) + if "h" in delay_str and "min" not in delay_str: + parts = delay_str.split("h") + if len(parts) > 1 and parts[1].isdigit(): + minutes = int(parts[1]) + return hours * 60 + minutes + + +def SNCF_RECO3(event_json, context_json, recommendation_catalog): + """ + Selects recommendations using IF–THEN rules based on id_event. + Returns 4 recommendations ordered with best=True first. + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + def best_first(reco_json): + reco = json.loads(reco_json) + return 0 if reco["data"]["kpis"].get("best") == "True" else 1 + + return sorted(recos, key=best_first) + + +def SNCF_deontic(event_json, context_json, recommendation_catalog, type="passengers", threshold_value=200): + """ + Filters recommendations by a KPI threshold, then sorts by that KPI ascending. + + Parameters + ---------- + type : str + KPI to filter and sort on: "passengers", "delay", "cost", "total_cost" + threshold_value : int | str + Upper bound for the KPI (use a delay string like "1h30" for type="delay") + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + # When no threshold is provided, skip filtering and return all recommendations sorted + if threshold_value is None: + filtered = list(recos) + else: + filtered = [] + for reco_json in recos: + reco = json.loads(reco_json) + kpis = reco["data"]["kpis"] + passengers = int(kpis.get("nb_impacted_passengers", 0)) + cost = int(kpis.get("cost", 0)) + total_cost = int(kpis.get("total_cost", 0)) + delay_minutes = parse_delay_to_minutes(kpis.get("delay", "0min")) + + if type == "passengers" and passengers <= threshold_value: + filtered.append(reco_json) + elif type == "delay": + if delay_minutes <= parse_delay_to_minutes(threshold_value): + filtered.append(reco_json) + elif type == "cost" and cost <= threshold_value: + filtered.append(reco_json) + elif type == "total_cost" and total_cost <= threshold_value: + filtered.append(reco_json) + + def _key(reco_json): + reco = json.loads(reco_json) + kpis = reco["data"]["kpis"] + if type == "passengers": + return int(kpis.get("nb_impacted_passengers", 0)) + if type == "delay": + return parse_delay_to_minutes(kpis.get("delay", "0min")) + if type == "cost": + return int(kpis.get("cost", 0)) + if type == "total_cost": + return int(kpis.get("total_cost", 0)) + return 0 + + if type not in ("passengers", "delay", "cost", "total_cost"): + return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})] + + return sorted(filtered, key=_key) + + +def SNCF_risk(event_json, context_json, recommendation_catalog, type): + """ + Returns all recommendations sorted by a KPI ascending (no threshold filtering). + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + kpi_keys = { + "passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)), + "delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")), + "cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)), + "total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)), + } + + if type not in kpi_keys: + return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})] + + return sorted(recos, key=kpi_keys[type]) + + +def SNCF_risk_tie_break(event_json, context_json, recommendation_catalog, type, tie_breaker=None): + """ + Orders recommendations by a primary KPI, using a secondary KPI to break ties. + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + kpi_keys = { + "passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)), + "delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")), + "cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)), + "total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)), + } + + if type not in kpi_keys: + return [json.dumps({"error": "Invalid type"})] + + if tie_breaker is None: + return sorted(recos, key=kpi_keys[type]) + + if tie_breaker not in kpi_keys: + return [json.dumps({"error": "Invalid tie_breaker"})] + + return sorted(recos, key=lambda r: (kpi_keys[type](r), kpi_keys[tie_breaker](r))) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 67eaa4cd..6bda7543 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -50,6 +50,7 @@ if [[ -f .secrets ]]; then fi echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env +echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env echo "VITE_COGNITIVE_TOKEN=${VITE_COGNITIVE_TOKEN:-}" >> .env cat .env diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index 2cf8aa8c..d7b19b5f 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -342,6 +342,12 @@ server { proxy_set_header X-Forwarded-For $remote_addr; } + location /powergrid-simu/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://192.168.208.61:5100/; + } + location /cognitive-api/ { # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions) diff --git a/deploy-chart/configmap-assistant-platform.yaml b/deploy-chart/configmap-assistant-platform.yaml new file mode 100644 index 00000000..895d2bc5 --- /dev/null +++ b/deploy-chart/configmap-assistant-platform.yaml @@ -0,0 +1,730 @@ +apiVersion: v1 +data: + businessconfig.yml: | + spring: + application: + name: businessconfig + operatorfabric.businessconfig: + storage: + path: "/businessconfig-storage" + cards-consultation.yml: | + spring: + application: + name: cards-consultation + checkIfUserIsAlreadyConnected: true + cards-publication.yml: | + spring: + application: + name: cards-publication + deserializer: + value: + delegate: + class: io.confluent.kafka.serializers.KafkaAvroDeserializer + serializer: + value: + delegate: + class: io.confluent.kafka.serializers.KafkaAvroSerializer + opfab: + kafka: + topics: + card: + topicname: opfab + response-card: + topicname: opfab-response + schema: + registry: + url: http://localhost:8081 + checkAuthenticationForCardSending: false + common.yml: | + management: + endpoints: + web: + exposure: + include: '*' + spring: + rabbitmq: + host: cab-rabbitmq.cab.svc.cluster.local + port: 5672 + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + security: + provider-url: https://keycloak.irtsysx.fr/auth + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${spring.security.provider-url}/realms/interactiveai/protocol/openid-connect/certs + data: + mongodb: + database: ${MONGODB_DB} + uri: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@cab-mongodb.cab.svc.cluster.local:27017/${MONGODB_DB}?authSource=admin&authMode=scram-sha1 + server: + forward-headers-strategy: framework + operatorfabric: + servicesUrls: + users: "http://cab-users.cab.svc.cluster.local:8080" + businessconfig: "http://cab-businessconfig.cab.svc.cluster.local:8080" + nginx.conf: | + # docker-compose DNS used to resolved users service + # resolver 127.0.0.11 ipv6=off; + + # Log format to have msec in time + request processing time + map "$time_local:$msec" $time_local_ms { + ~(^\S+)(\s+\S+):\d+\.(\d+)$ $1.$3$2; + } + log_format opfab-log '$remote_addr - $time_local_ms' + '"$request" $status $request_time $body_bytes_sent '; + + log_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'; + + server { + listen 8080; + server_name localhost demo.interactiveai.irt-systemx.fr; + error_log /var/log/nginx/error.log debug; + access_log /var/log/nginx/access.log opfab-log; + + ### CUSTOMIZATION - BEGIN + # Url of the Authentication provider + set $KeycloakBaseUrl "https://keycloak.irtsysx.fr"; + # Realm associated to OperatorFabric within the Authentication provider + set $OperatorFabricRealm "interactiveai"; + # base64 encoded pair of authentication in the form of 'client-id:secret-id' + set $ClientPairOFAuthentication "b3BmYWItY2xpZW50Oklxdkh4VzBEdENDNXVMVEVTcFhyN0sxdkhtSHViOGdE" ; + + ### CUSTOMIZATION - END + + ### OPFAB GENERIC CONFIGURATION ### + ### BE CAREFUL WHEN MODIFYING ### + set $BasicValue "Basic $ClientPairOFAuthentication"; + set $KeycloakOpenIdConnect $KeycloakBaseUrl/auth/realms/$OperatorFabricRealm/protocol/openid-connect; + gzip on; + gzip_types application/javascript text/css; + + location / { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/; + try_files $uri $uri/ /index.html; + } + location = /external/ { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/external/; + index index.html index.htm; + } + location /ui/ { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/; + index index.html index.htm; + } + location /auth/check_token { + add_header Cache-Control "no-cache"; + proxy_set_header Host keycloak.irtsysx.fr; + proxy_set_header Authorization $BasicValue ; + proxy_pass $KeycloakOpenIdConnect/token/introspect; + } + location /auth/token { + add_header Cache-Control "no-cache"; + proxy_set_header Host keycloak.irtsysx.fr; + proxy_set_header Authorization $BasicValue ; + proxy_pass $KeycloakOpenIdConnect/token; + } + location /auth/code/ { + add_header Cache-Control "no-cache"; + proxy_set_header Host keycloak.irtsysx.fr; + proxy_set_header Authorization $BasicValue ; + proxy_pass $KeycloakOpenIdConnect/auth?response_type=code&client_id=opfab-client&$args; + } + + location /auth { + add_header Cache-Control "no-cache"; + proxy_set_header X-Forwarded-For $proxy_protocol_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host keycloak.irtsysx.fr; + proxy_pass $KeycloakBaseUrl/auth; + } + + # To be sure new files are downloaded when version change + # we set no-cache for json config files and for i18n files + location /config/web-ui.json { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/opfab/web-ui.json; + } + location /config/ui-menu.json { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/opfab/ui-menu.json; + } + location /ui/assets/i18n/ { + add_header Cache-Control "no-cache"; + alias /usr/share/nginx/html/assets/i18n/; + } + + location /businessconfig { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-businessconfig.cab.svc.cluster.local:8080; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location ~ "^/users/internal/(.*)" { + return 404; + } + + location ~ "^/users/(.*)" { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-users.cab.svc.cluster.local:8080/$1$is_args$args; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /users { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-users.cab.svc.cluster.local:8080/users; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /perimeters { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-users.cab.svc.cluster.local:8080/perimeters; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cards/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080/; + proxy_set_header X-Forwarded-For $remote_addr; + } + ### !!!! SECURITY WARNING !!!! + ### The following configuration is suitable only if you set checkAuthenticationForCardSending to true + ### which is the default configuration + ### + + location /cardspub/cards { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cardspublication.cab.svc.cluster.local:8080/cards; + proxy_set_header X-Forwarded-For $remote_addr; + } + + ### if you set checkAuthenticationForCardSending to false + ### you MUST not permit to access cards endpoint via nginx + ### and replace the previous configuration by the following conf + + #location /cardspub/cards/user { + # proxy_pass http://cards-publication:8080/cards/user; + # proxy_set_header X-Forwarded-For $remote_addr; + #} + #location /cardspub/cards/translateCardField { + # proxy_pass http://cards-publication:8080/cards/translateCardField; + # proxy_set_header X-Forwarded-For $remote_addr; + #} + ### + ### !!! END SECURITY WARNING !!! + location /archives { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080; + proxy_set_header X-Forwarded-For $remote_addr; + } + location /externaldevices { + add_header Cache-Control "no-cache"; + set $externaldevices http://external-devices.cab.svc.cluster.local:8080; + proxy_pass $externaldevices; + proxy_set_header X-Forwarded-For $remote_addr; + } + location ~ "^/externaldevices/(.*)" { + add_header Cache-Control "no-cache"; + set $externaldevices http://external-devices.cab.svc.cluster.local:8080; + proxy_pass $externaldevices/$1; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cabcontext/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cabcontext.cab.svc.cluster.local:5000/; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cab_event/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cabevent.cab.svc.cluster.local:5000/; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cab_recommendation/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cabrecommendation.cab.svc.cluster.local:5000/; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cabhistoric/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cabhistoric.cab.svc.cluster.local:5000/; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /cab_capitalization/ { + add_header Cache-Control "no-cache"; + proxy_pass http://cab-cabcapitalization.cab.svc.cluster.local:5000/; + proxy_set_header X-Forwarded-For $remote_addr; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + 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/; + } + } + ui-menu.json: | + { + "coreMenusConfiguration": + [ + { + "id": "feed", + "visible": true + }, + { + "id": "archives", + "visible": true + }, + { + "id": "monitoring", + "visible": true + }, + { + "id": "logging", + "visible": true + }, + { + "id": "usercard", + "visible": true + }, + { + "id": "calendar", + "visible": true + }, + { + "id": "admin", + "visible": true, + "showOnlyForGroups": ["ADMIN"] + }, + { + "id": "settings", + "visible": true + }, + { + "id": "activityarea", + "visible": true + }, + { + "id": "feedconfiguration", + "visible": true + }, + { + "id": "realtimeusers", + "visible": true + }, + { + "id": "externaldevicesconfiguration", + "visible": true, + "showOnlyForGroups": ["ADMIN"] + }, + { + "id": "nightdaymode", + "visible": true + }, + { + "id": "about", + "visible": true + }, + { + "id": "changepassword", + "visible": true + }, + { + "id": "logout", + "visible": true + } + ], + "menus": [ + { + "id": "menu1", + "label": "title.single", + "entries": [ + { + "id": "uid_test_0", + "url": "https://en.wikipedia.org/w/index.php", + "label": "entry.single", + "linkType": "BOTH" + } + ] + }, + { + "id": "menu2", + "label": "title.multi", + "entries": [ + { + "id": "uid_test_1", + "url": "https://opfab.github.io/", + "label": "entry.entry1", + "linkType": "BOTH", + "showOnlyForGroups": ["ReadOnly","Planner"] + }, + { + "id": "uid_test_2", + "url": "https://www.wikipedia.org/", + "label": "entry.entry2", + "linkType": "BOTH", + "showOnlyForGroups": ["Dispatcher"] + }, + { + "id": "uid_test_3", + "url": "http://localhost:2002/external/appExample/", + "label": "entry.entry3", + "linkType": "BOTH" + } + ] + }, + { + "id": "adminmenu", + "label": "title.admin", + "entries": [ + { + "id": "uid_test_3", + "url": "https://opfab.github.io/", + "label": "entry.admin", + "linkType": "BOTH", + "showOnlyForGroups": ["ADMIN"] + } + ] + } + ], + "locales": [ + { + "language": "en", + "i18n": { + "menu1": { + "title": { + "single": "First menu" + }, + "entry": { + "single": "Single menu entry" + } + }, + "menu2": { + "title": { + "multi": "Second menu" + }, + "entry": { + "entry1": "First menu entry", + "entry2": "Second menu entry", + "entry3": "External application" + } + }, + "adminmenu": { + "title": { + "admin": "Admin menu" + }, + "entry": { + "admin": "Admin menu entry" + } + } + } + }, + { + "language": "fr", + "i18n": { + "menu1": { + "title": { + "single": "Premier menu" + }, + "entry": { + "single": "Unique élément" + } + }, + "menu2": { + "title": { + "multi": "Deuxième menu" + }, + "entry": { + "entry1": "Premier élément", + "entry2": "Deuxième élément", + "entry3": "Application externe" + } + }, + "adminmenu": { + "title": { + "admin": "Admin menu" + }, + "entry": { + "admin": "Admin menu entry" + } + } + } + }, + { + "language": "nl", + "i18n": { + "menu1": { + "title": { + "single": "Eerste menu" + }, + "entry": { + "single": "Enkel menu-item" + } + }, + "menu2": { + "title": { + "multi": "Tweede menu" + }, + "entry": { + "entry1": "Eerste menu-item", + "entry2": "Tweede menu-item", + "entry3": "TExterne applicatie" + } + }, + "adminmenu": { + "title": { + "admin": "Admin menu" + }, + "entry": { + "admin": "Admin menu-item" + } + } + } + } + ] + } + users.yml: |2 + + # POPULATE THE USER DATABASE ON INIT + # !!!! WARNING: VALUES SHOULD BE CHANGED FOR PRODUCTION MODE !!!! + spring: + application: + name: users + operatorfabric.users.default: + users: + - login: admin + groups: ["ADMIN"] + entities: ["Railway", "ATM", "PowerGrid"] + - login: ilyes + firstname : Ilyes + lastname : KAANICH + groups: ["Dispatcher","ReadOnly"] + entities: ["IRT_MAIN"] + - login: railway_user + groups: ["Planner", "ReadOnly"] + entities: ["Railway"] + - login: orange_user + groups: [ "PowerGrid","ADMIN","ReadOnly","Dispatcher"] + entities: [ "ORANGE" ] + - login: atm_user + groups: ["ReadOnly","Dispatcher"] + entities: ["ATM"] + - login: PowerGrid_user + groups: [ "PowerGrid","ADMIN","ReadOnly","Dispatcher"] + entities: [ "PowerGrid" ] + groups: + - id: ADMIN + name: ADMINISTRATORS + description: The admin group + - id: PowerGrid + name: RTE France + description: RTE TSO Group + realtime: false + - id: Dispatcher + name: Dispatcher + description: Dispatcher Group + realtime: true + - id: Planner + name: Planner + description: Planner Group + realtime: true + - id: Supervisor + name: Supervisor + description: Supervisor Group + realtime: true + - id: Manager + name: Manager + description: Manager Group + realtime: false + - id: ReadOnly + name: ReadOnly + description: ReadOnly Group + realtime: false + entities: + - id: Railway + name: National society of French railroads + description: National society of French railroads + parents : ["IRT_MAIN"] + - id: ORANGE + name: Orange + description: Orange + parents : ["IRT_MAIN"] + - id: ATM + name: ATM usecase + description: ATM usecase + parents : ["IRT_MAIN"] + - id: PowerGrid + name: Electricity Transmission Network + description: Electricity Transmission Network + parents : ["IRT_MAIN"] + - id: IRT_MAIN + name: IRT Control Centers + description: IRT Control Centers + entityAllowedToSendCard: false + web-ui.json: | + { + "environmentName": "KUBERNETES ENV", + "environmentColor": "blue", + "checkIfUrlIsLocked": true, + "showUserEntitiesOnTopRightOfTheScreen": true, + "externalDevicesEnabled": true, + "selectActivityAreaOnLogin": false, + "archive": { + "filters": { + "page": { + "size": [ + "10" + ] + }, + "tags": { + "list": [ + { + "label": "Label for tag 1", + "value": "tag1" + }, + { + "label": "Label for tag 2", + "value": "tag2" + } + ] + } + } + }, + "logging": { + "filters": { + "tags": { + "list": [ + { + "label": "Label for tag 1", + "value": "tag1" + } + ] + } + } + }, + "feed": { + "defaultSorting": "unread", + "defaultAcknowledgmentFilter": "notack", + "card": { + "hideTimeFilter": false, + "time": { + "display": "BUSINESS" + }, + "hideResponseFilter": false, + "hideApplyFiltersToTimeLineChoice": false, + "secondsBeforeLttdForClockDisplay": 3700, + "hideAckAllCardsFeature": false, + "titleUpperCase": true + }, + "timeline": { + "domains": [ + "TR", + "J", + "7D", + "W", + "M", + "Y" + ] + }, + "geomap": { + "enableMap": false, + "defaultDataProjection": "EPSG:4326", + "initialLongitude": 5.3255, + "initialLatitude": 52.1845, + "initialZoom": 6, + "zoomLevelWhenZoomToLocation": 14, + "maxZoom": 11, + "zoomDuration": 500 + }, + "enableGroupedCards": false + }, + "i18n": { + "supported": { + "locales": [ + "en", + "fr", + "nl" + ] + } + }, + "security": { + "jwt": { + "expire-claim": "exp", + "login-claim": "preferred_username" + }, + "logout-url": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/logout?redirect_uri=https://demo.interactiveai.irt-systemx.fr/", + "oauth2": { + "client-id": "opfab-client", + "flow": { + "delegate-url": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/auth?response_type=code&client_id=opfab-client", + "mode": "PASSWORD", + "provider": "Opfab Keycloak" + } + }, + "provider-realm": "interactiveai", + "provider-url": "https://keycloak.irtsysx.fr/auth", + "changePasswordUrl": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/account/#/security/signingin" + }, + "settings": { + "locale": "en", + "dateTimeFormat": "HH:mm DD/MM/YYYY", + "dateFormat": "DD/MM/YYYY", + "styleWhenNightDayModeDesactivated": "NIGHT", + "replayInterval" : 10, + "replayEnabled" : true + + }, + "settingsScreen": { + "hiddenSettings": ["description"] + }, + "about": { + "firstapplication": { + "name": "First application", + "rank": 1, + "version": "v12.34.56" + }, + "keycloack": { + "name": "Keycloak", + "rank": 2, + "version": "6.0.1" + }, + "lastapplication": { + "name": "Wonderful Solution", + "version": "0.1.2-RELEASE" + } + }, + "usercard": { + "useDescriptionFieldForEntityList": false + } + } +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 + 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 + resourceVersion: "21095247065" + uid: 96bf811c-fdd7-453e-a97e-4179f061c469 \ No newline at end of file diff --git a/frontend/default.conf b/frontend/default.conf index 769e1649..1e81cc41 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -1,3 +1,4 @@ +# Resolver line is replaced at runtime by start-webui.sh resolver 127.0.0.11 ipv6=off; server { @@ -34,4 +35,14 @@ server { proxy_ssl_verify off; proxy_pass https://interactiveagent.passerelle.irt-systemx.fr/api/v1/; } + + # 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. + location /powergrid-simu/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://192.168.208.61:5100/; + } } diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 11f02fe2..4c207149 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -1 +1,15 @@ /// + +interface ImportMetaEnv { + readonly VITE_API: string + readonly VITE_DEFAULT_LOCALE: string + readonly VITE_FALLBACK_LOCALE: string + readonly VITE_POWERGRID_SIMU: string + readonly VITE_RAILWAY_SIMU: string + readonly VITE_ATM_SIMU: string + readonly VITE_COGNITIVE_TOKEN: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/src/entities/PowerGrid/CAB/Assistant.vue b/frontend/src/entities/PowerGrid/CAB/Assistant.vue index c890de03..b1fef2d3 100644 --- a/frontend/src/entities/PowerGrid/CAB/Assistant.vue +++ b/frontend/src/entities/PowerGrid/CAB/Assistant.vue @@ -106,16 +106,32 @@ watch( } ) -function onSelection(selected: any) { +async function onSelection(selected: any) { + console.info( + '[PowerGrid][apply] Apply pressed — recommendation:', + selected?.title, + '| agent_type:', + selected?.agent_type + ) + console.info('[PowerGrid][apply] action to send (selected.actions[0]):', selected?.actions?.[0]) + console.info('[PowerGrid][apply] active card id:', appStore.card('PowerGrid')?.id) sendTrace({ data: selected, use_case: route.params.entity as Entity, step: 'AWARD' }) - applyRecommendation(selected.actions[0]) - const activeCard = appStore.card('PowerGrid') - if (activeCard) cardsStore.resolveCriticality(activeCard) - appStore.tab.assistant = 0 + try { + await applyRecommendation(selected.actions[0]) + console.info( + '[PowerGrid][apply] success — marking card resolved (criticality → ND) and closing assistant' + ) + const activeCard = appStore.card('PowerGrid') + if (activeCard) cardsStore.resolveCriticality(activeCard) + appStore.tab.assistant = 0 + } catch { + console.error('[PowerGrid][apply] failed — leaving card open for retry (error modal shown by http plugin)') + // http plugin already shows an error modal — leave the card open so the user can retry + } } function primaryAction() { diff --git a/frontend/src/entities/PowerGrid/api.ts b/frontend/src/entities/PowerGrid/api.ts index d0a56506..de4dd9e6 100644 --- a/frontend/src/entities/PowerGrid/api.ts +++ b/frontend/src/entities/PowerGrid/api.ts @@ -1,15 +1,36 @@ import http from '@/plugins/http' import type { Action } from '@/types/entities' -// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release -// The real PowerGrid simulator API call is disabled and replaced with a fake success response for demo purposes. -// To restore: uncomment the http.post block and delete the Promise.resolve line. -export function applyRecommendation(data: Action<'PowerGrid'>) { - // [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_POWERGRID_SIMU + '/api/v1/recommendations', - // data - // ) - return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line +export async function applyRecommendation(data: Action<'PowerGrid'>) { + const base = import.meta.env.VITE_POWERGRID_SIMU + const url = base + '/api/v1/recommendations' + // Debug logging: confirm the apply action is actually sent to the simulator, and what is sent. + // NB: the axios instance prepends baseURL (VITE_API), which is empty in standalone => same-origin. + console.info( + `[PowerGrid][apply] POST ${url} (axios baseURL="${import.meta.env.VITE_API ?? ''}", VITE_POWERGRID_SIMU="${base ?? ''}")` + ) + console.info('[PowerGrid][apply] payload sent to simulator:', data) + try { + const res = await http.post<{ message: string }>(url, data) + const raw: unknown = res.data + const looksLikeHtml = typeof raw === 'string' && / 300 ? raw.slice(0, 300) + '… (truncated)' : res.data + console.info(`[PowerGrid][apply] simulator responded HTTP ${res.status}:`, body) + if (looksLikeHtml) + console.warn( + '[PowerGrid][apply] ⚠ Response is the SPA index.html, NOT a simulator reply. The request ' + + 'fell through to the nginx SPA fallback — there is no /powergrid-simu/ proxy in the running ' + + 'nginx config, so the action never reached the simulator.' + ) + return res + } catch (err) { + const e = err as { response?: { status?: number; data?: unknown }; message?: string } + console.error( + '[PowerGrid][apply] request FAILED — HTTP', + e.response?.status ?? '(no response / network error)', + e.response?.data ?? e.message ?? err + ) + throw err + } } diff --git a/frontend/src/plugins/i18n.ts b/frontend/src/plugins/i18n.ts index bbc6c207..ea0bb348 100644 --- a/frontend/src/plugins/i18n.ts +++ b/frontend/src/plugins/i18n.ts @@ -8,7 +8,7 @@ import { ENTITIES } from '@/types/entities' export const SUPPORT_LOCALES = ['en', 'fr'] as const Object.freeze(SUPPORT_LOCALES) -export default createI18n({ +const i18n = createI18n({ locale: window.navigator.language.split('-')[0] || import.meta.env.VITE_DEFAULT_LOCALE || 'en', fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE || 'en', legacy: false, @@ -20,10 +20,12 @@ export default createI18n({ } }) -export async function setupEntitiesLocales(i18n: ReturnType) { +export default i18n + +export async function setupEntitiesLocales(instance: typeof i18n = i18n) { for (const entity of ENTITIES) for (const locale of SUPPORT_LOCALES) { - i18n.global.setLocaleMessage( + instance.global.setLocaleMessage( `${locale}-${entity}`, (await import(`../entities/${entity}/locales/${locale}.json`)).default ) diff --git a/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py b/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py index 175609eb..0e778b04 100644 --- a/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py +++ b/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py @@ -1,27 +1,36 @@ +import json import urllib.parse from flask import Flask, jsonify, request from flask import render_template, redirect from flask import url_for, session, g from flask import Response, stream_with_context, flash from flask_socketio import SocketIO +from werkzeug.middleware.proxy_fix import ProxyFix from app.models.Communicate import Communicate from app.models.Simulator import Simulator -from config.config import set_pause +from app.models.recommendation_store import store as recommendation_store +from config.config import logging, set_pause -class Recommendation: - """Class to manage recommendations.""" - - def __init__(self): - """Initialize a new Recommendation instance.""" - self.data = {} - - -Recommend = Recommendation() - app = Flask(__name__, template_folder='app/templates') app.secret_key = 'votre_clé_secrète_ici' -socketio = SocketIO(app) +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) +socketio = SocketIO(app, cors_allowed_origins='*') + +@app.after_request +def add_cors_headers(response): + """Allow the CAB frontend to POST applied recommendations cross-origin. + + Runs for every response, including the automatic preflight OPTIONS — without + these headers the browser blocks the apply POST with a CORS error. + NB: prefer routing the apply through the frontend's nginx same-origin proxy + (/powergrid-simu/), which avoids CORS entirely; these headers only matter when + the frontend calls this simulator directly over plain HTTP. + """ + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response com = Communicate() simu = Simulator(socketio) @@ -37,10 +46,10 @@ def index(): @app.route('/dashboard') def dashboard(): """Display the dashboard.""" - # Récupérer le nom d'utilisateur + # Get username username = session.get('username', 'Invité') server = session.get('server', 'Null') - # Récupérer tous les messages + # Get all messages messages = session.pop('message', []) return render_template('dashboard.html', username=username, @@ -58,7 +67,7 @@ def load_simulation(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) return redirect(url_for('login')) @@ -72,7 +81,7 @@ def add_server(): """ new_server_url = request.json.get('url') if new_server_url: - # Ajouter le nouveau serveur à la configuration + # Add the new server to the configuration success = com.add_cab_server_url(new_server_url) if success: @@ -111,13 +120,13 @@ def login(): session['server'] = server return redirect(url_for('load_simulation')) else: - flash("Échec de la connexion. Veuillez réessayer.") + flash("Login failed. Please try again.") else: - flash("Serveur non disponible. Veuillez choisir un autre serveur.") + flash("Server unavailable. Please choose a different server.") except ConnectionRefusedError: - flash("Le serveur a refusé la connexion. Veuillez réessayer plus tard.") + flash("Server refused the connection. Please try again later.") except Exception as e: - flash("Une erreur s'est produite. La connexion n'a pas pu s'établir avec le serveur.") + flash("An error occurred. Could not establish a connection to the server.") return redirect(url_for('index')) @@ -135,7 +144,7 @@ def edit_config(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) @@ -157,7 +166,7 @@ def edit_simulation_settings(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) @@ -173,10 +182,18 @@ def start_simulation(): mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache' response.headers['Connection'] = 'keep-alive' - return response + response.headers['X-Accel-Buffering'] = 'no' + return response return redirect(url_for('dashboard')) +@app.route('/pause_simulation', methods=['POST']) +def pause_simulation(): + """Pause the simulation.""" + set_pause(True) + return "Paused", 200 + + @app.route('/continue_simulation', methods=['POST']) def continue_simulation(): """Resume the simulation after a pause.""" @@ -187,12 +204,12 @@ def continue_simulation(): @app.route('/logout') def logout(): """Log out the user.""" - # Effacer les données de session + # Clear session data session.pop('username', None) session.pop('config', None) session.pop('act', None) session.pop('message', None) - # Rediriger vers la page de connexion + # Redirect to login page return redirect(url_for('index')) @@ -211,9 +228,15 @@ def get_last_payloads(): @app.route('/api/v1/recommendations', methods=['POST']) def receive_act(): - """Receive recommendations.""" - Recommend.data = request.get_json() - print(Recommend.data) + """Receive a recommendation from InteractiveAI and store it in memory. + + The simulation loop reads it back directly from the shared store, so this + endpoint is the single entry point for recommendations. + """ + data = request.get_json() + recommendation_store.set(data) + logging.info("Recommendation received via POST from InteractiveAI: %s", + json.dumps(data)) return jsonify({ "message": "OK" }) @@ -221,11 +244,13 @@ def receive_act(): @app.route('/api/v1/recommendations', methods=['GET']) def send_act(): - """Send recommendations.""" - act_dict = {} - act_dict = Recommend.data - Recommend.data = {} - print(act_dict) + """Return and clear the stored recommendation. + + Kept for external callers; the simulation loop no longer relies on this + endpoint and reads the shared store directly instead. + """ + act_dict = recommendation_store.pop() + logging.info("Recommendation served via GET: %s", json.dumps(act_dict)) return jsonify(act_dict) diff --git a/usecases_examples/PowerGrid/RecommendationAPI.py b/usecases_examples/PowerGrid/RecommendationAPI.py index a56e96cf..b0e93e15 100644 --- a/usecases_examples/PowerGrid/RecommendationAPI.py +++ b/usecases_examples/PowerGrid/RecommendationAPI.py @@ -8,6 +8,20 @@ def __init__(self): app = Flask(__name__) +@app.after_request + +def add_cors_headers(response): + """Allow the CAB frontend to POST applied recommendations cross-origin. + + Without these headers the browser's preflight OPTIONS succeeds but the actual + POST is blocked by CORS. Prefer the nginx same-origin proxy (/powergrid-simu/) + where possible; these headers only matter for direct plain-HTTP calls. + """ + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response + @app.route("/") def home_page(): return "API for PowerGrid recommendations" diff --git a/usecases_examples/PowerGrid/app/models/Communicate.py b/usecases_examples/PowerGrid/app/models/Communicate.py index dd1ab6d4..9e779eb8 100644 --- a/usecases_examples/PowerGrid/app/models/Communicate.py +++ b/usecases_examples/PowerGrid/app/models/Communicate.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import numpy as np from config.config import logging, set_pause, get_pause_status +from app.models.recommendation_store import store as recommendation_store class Communicate: """ @@ -25,7 +26,7 @@ def __init__(self): self.act_dict = {} self.payload = {} - # Charger les paramètres depuis le fichier de configuration + # Load parameters from configuration file self.load_config() def load_config(self): @@ -44,11 +45,11 @@ def load_config(self): "differently." ) except FileNotFoundError: - logging.error("Le fichier 'config/API_POWERGRID_CAB.toml' n'a pas été trouvé.") + logging.error("File 'config/API_POWERGRID_CAB.toml' not found.") except toml.TomlDecodeError: - logging.error("Erreur de décodage du fichier 'config/API_POWERGRID_CAB.toml'.") + logging.error("Failed to decode 'config/API_POWERGRID_CAB.toml'.") except Exception as e: - logging.error("Une erreur inattendue est survenue: %s", str(e)) + logging.error("An unexpected error occurred: %s", str(e)) def edit_parameters(self, parameter_name, new_value): """ @@ -58,10 +59,10 @@ def edit_parameters(self, parameter_name, new_value): parameter_name: Name of the parameter to modify. new_value: New value of the parameter. """ - # Recharger la configuration au cas où elle aurait été modifiée + # Reload config in case it was modified self.load_config() - # Modifier les paramètres dans self.outputs_config + # Update parameters in self.outputs_config keys = parameter_name.split('.') current_param = self.outputs_config for key in keys[:-1]: @@ -69,11 +70,11 @@ def edit_parameters(self, parameter_name, new_value): current_param = current_param[key] else: logging.error( - "Le paramètre %s n'existe pas dans les paramètres.", parameter_name) + "Parameter %s does not exist in the configuration.", parameter_name) return current_param[keys[-1]] = new_value - # Enregistrer les modifications dans le fichier de configuration + # Save changes to configuration file with open("config/API_POWERGRID_CAB.toml", "w", encoding="utf-8") as file: toml.dump(self.outputs_config, file) @@ -92,7 +93,7 @@ def add_cab_server_url(self, url): if 'Connexion' not in config: config['Connexion'] = {} - # Trouver le prochain numéro disponible pour le serveur + # Find next available server number server_count = sum(1 for key in config['Connexion'] if key.startswith('cab_server_url_')) new_key = f'cab_server_url_{server_count + 1}' @@ -103,7 +104,7 @@ def add_cab_server_url(self, url): return True except Exception as e: - print(f"Une erreur est survenue lors de l'ajout du serveur: {e}") + print(f"An error occurred while adding the server: {e}") return False def delete_cab_server_url(self, url): @@ -125,15 +126,15 @@ def delete_cab_server_url(self, url): del config['Connexion'][key] with open(config_file, "w") as configfile: toml.dump(config, configfile) - print(f"Serveur supprimé : clé={key}, url={url}") + print(f"Server removed: key={key}, url={url}") return True, url - print(f"URL non trouvée : {url}") + print(f"URL not found: {url}") return False, "" else: - print("Section 'Connexion' non trouvée dans le fichier de configuration") + print("Section 'Connexion' not found in configuration file") return False, "" except Exception as e: - print(f"Une erreur est survenue lors de la suppression du serveur: {e}") + print(f"An error occurred while deleting the server: {e}") return False, "" def get_cab_server_urls(self): @@ -145,7 +146,7 @@ def get_cab_server_urls(self): """ try: config = toml.load("config/API_POWERGRID_CAB.toml") - # Accéder à la section [Connexion] et récupérer toutes les URLs + # Access [Connexion] section and retrieve all URLs urls = { key: value for key, value in config['Connexion'].items() @@ -154,14 +155,14 @@ def get_cab_server_urls(self): return urls except KeyError: print( - "La section 'Connexion' est manquante dans le fichier de configuration.") + "Section 'Connexion' is missing from the configuration file.") return {} except FileNotFoundError: - print("Le fichier de configuration n'a pas été trouvé.") + print("Configuration file not found.") return {} except Exception as e: print( - f"Erreur lors de la lecture du fichier de configuration: {e}") + f"Error reading configuration file: {e}") return {} def choose_a_cab_application(self, server_choice): @@ -178,16 +179,16 @@ def choose_a_cab_application(self, server_choice): server_url = server_choice if server_url: self.cab_url = server_url - return server_url # Retourne l'URL du serveur choisi + return server_url return None except KeyError: - print("Le choix du serveur n'est pas valide.") + print("Invalid server choice.") return None except FileNotFoundError: - print("Le fichier de configuration n'a pas été trouvé.") + print("Configuration file not found.") return None except Exception as e: - print(f"Une erreur inattendue est survenue: {e}") + print(f"An unexpected error occurred: {e}") return None def login(self, username, password): @@ -257,11 +258,11 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): response = requests.request( "POST", url, headers=headers, data=payload, timeout=15) response.raise_for_status() - logging.info("Contexte envoyé avec succès.") + logging.info("Context sent successfully.") except Exception as e: logging.error(e) logging.info( - "L'envoi du contexte a échoué : la connexion avec InteractiveAI a échoué.") + "Failed to send context: connection with InteractiveAI failed.") def send_payload_and_store_it(self, payload, obs, scn_first_step): """ @@ -574,12 +575,15 @@ def get_act_from_api(self): time.sleep(1) yield ( "data: {\"div\": \"status-div\", \"content\": " - "\"Click 'Continuer' after making your selection in InteractiveAI\"}\n\n" + "\"Click 'Resume' after making your selection in InteractiveAI\"}\n\n" ) time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'paused'})}\n\n" set_pause(True) while get_pause_status(): + yield ": keepalive\n\n" time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'running'})}\n\n" else: message = { "div": "message-container", @@ -589,23 +593,25 @@ def get_act_from_api(self): time.sleep(1) yield ( "data: {\"div\": \"status-div\", \"content\": " - "\"Repeat your selection in InteractiveAI, then click again on " - "'Continuer'\"}\n\n" + "\"Repeat your selection in InteractiveAI, then click 'Resume' again\"}\n\n" ) time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'paused'})}\n\n" set_pause(True) while get_pause_status(): + yield ": keepalive\n\n" time.sleep(1) - url = self.outputs_config['Inputs']['Act']['url'] - payload = {} - headers = {} - response = requests.request( - "GET", url, headers=headers, data=payload, timeout=15) - print(response.text) - response.raise_for_status() - # print(response.text) - # print(response.json()) - self.act_dict = response.json() + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'running'})}\n\n" + # The recommendation is delivered to this same process via the + # POST /api/v1/recommendations endpoint and held in a shared + # in-memory store. Read it directly instead of issuing an HTTP + # request back to ourselves (which is fragile behind a reverse + # proxy / loopback and was returning 404). + self.act_dict = recommendation_store.pop() + if bool(self.act_dict) is True: + logging.info( + "Recommendation payload received from InteractiveAI: %s", + json.dumps(self.act_dict)) if bool(self.act_dict) is False and get_act_counter >= 1: logging.info( "\n No recommendation has been received! \n" diff --git a/usecases_examples/PowerGrid/app/models/Simulator.py b/usecases_examples/PowerGrid/app/models/Simulator.py index 9d6683ad..fd1bb734 100644 --- a/usecases_examples/PowerGrid/app/models/Simulator.py +++ b/usecases_examples/PowerGrid/app/models/Simulator.py @@ -15,7 +15,8 @@ from app.models.utils import (create_observation_image, get_alert_lines, search_chronic_num_from_name, get_curent_lines_in_bad_kpi, get_curent_lines_lost, get_zone_where_alarm_occured, expand_act_from_cab, - load_assistant, local_xd_silly, targeted_scenario_act_fixed, generate_graph_html) + load_assistant, local_xd_silly, targeted_scenario_act_fixed, generate_graph_html, + summarize_action, verify_action_applied, format_verdict_description) BkClass = LightSimBackend @@ -50,7 +51,7 @@ def load_and_edit_config(self, params=None): config_path = "config/CONFIG.toml" self.config = toml.load(config_path) if params: - # Mettre à jour le fichier de configuration avec les nouveaux paramètres + # Update the configuration file with new parameters self.config.update(params) with open(config_path, 'w', encoding='utf-8') as config_file: toml.dump(self.config, config_file) @@ -90,9 +91,9 @@ def initialize_simulation(self, com, session): self.config['scenario_name'], self.env) self.env.set_id(id_scenario) # Scenario choice self.obs = self.env.reset() - logging.info("Le scénario chargé est : %s \n", + logging.info("Loaded scenario: %s \n", self.env.chronics_handler.get_name()) - session['message'].append(f"Le scénario chargé est : {self.env.chronics_handler.get_name()}") + session['message'].append(f"Loaded scenario: {self.env.chronics_handler.get_name()}") assistant_path = self.config['assistant_path'] assistant_seed = int(self.config['assistant_seed']) @@ -110,8 +111,8 @@ def initialize_simulation(self, com, session): except Exception as e: logging.error(e) - logging.info("Le scénario est chargé.\n") - session['message'].append("Le scénario est chargé.") + logging.info("Scenario loaded.\n") + session['message'].append("Scenario loaded.") self.listen = Listener(self.obs) return act @@ -128,6 +129,7 @@ def run_simulator(self, com): Yields: str: Status updates and messages for the simulation interface. """ + set_pause(False) paris_timezone = timezone(timedelta(hours=2)) date = datetime.now(paris_timezone) # date = datetime.now(timezone.utc) @@ -138,22 +140,63 @@ def run_simulator(self, com): silent_mode_msg_trigger = True step_counter = 0 clear_parade_flag = False + # Holds a recommendation received from InteractiveAI that is awaiting + # confirmation that it was actually applied on the next env.step. + pending_cab_act = None + + # Human-readable titles for each possible application verdict. + verdict_titles = { + "APPLIED": "Recommendation applied to the grid ✓", + "NO_OP": "Recommendation was a do-nothing action", + "NO_EFFECT": "Recommendation applied but grid unchanged", + "REJECTED": "Recommendation rejected by the grid ✗", + "OVERRIDDEN": "Recommendation overridden by scenario script ✗", + } while not done: context_date = date + timedelta(minutes=float(5))*step_counter img_b64_current = None img_b64_forecast = None - # Pour corriger la valeur de act à certain pas précis - # en vue d'avoir notre scénario cible + # Correct act value at specific steps + # to reach the target scenario act_fixed, _ = targeted_scenario_act_fixed(self.env, self.obs) + cab_act_overwritten = False if act_fixed is not None: + # A scripted scenario action takes precedence: if a recommendation + # was pending, it is being discarded before reaching the grid. + cab_act_overwritten = pending_cab_act is not None act = act_fixed - # Begining of steps : Observation updates - self.obs, _, done, _ = self.env.step(act) + # Snapshot the grid state so we can confirm whether the action that + # is about to be applied actually takes effect. + topo_before = self.obs.topo_vect.copy() + line_status_before = self.obs.line_status.copy() + + # Beginning of step: observation update + self.obs, _, done, info = self.env.step(act) + + # Confirm whether a recommendation received from InteractiveAI was + # actually applied to the simulation on this step. + if pending_cab_act is not None: + verdict = verify_action_applied(pending_cab_act, + topo_before, + line_status_before, + self.obs, + info, + overwritten=cab_act_overwritten) + logging.info("InteractiveAI action application check: %s", + json.dumps(verdict, default=str)) + verdict_msg = { + "title": verdict_titles.get(verdict["status"], + "Recommendation status"), + "description": format_verdict_description(verdict), + } + yield (f"data: {{\"div\": \"events-div\", \"content\": " + f"{json.dumps(verdict_msg)}}}\n\n") + pending_cab_act = None - # Vider le div des parades + # Clear the actions panel if self.obs.current_step >= self.config['scenario_first_step'] and clear_parade_flag: empty_message = { "title": "", @@ -163,7 +206,7 @@ def run_simulator(self, com): clear_parade_flag = False if self.obs.current_step >= self.config['scenario_first_step']: - # Pour l'affichage du graphique dans le simulateur + # Update the interactive graph graph_html = generate_graph_html(self.env, self.obs) # print("Contenu du graphique:", graph_html[:200]) @@ -174,30 +217,30 @@ def run_simulator(self, com): # To handle between "silent mode" and "stream simulation" (with or without InteractiveAI) # (The stream simulation starts at step scenario_first_step) if self.obs.current_step >= self.config['scenario_first_step']: - logging.info("Pas de simulation : %s", + logging.info("Simulation step: %s", self.obs.current_step) yield ( f"data: {{\"div\": \"status-div\", \"content\": " - f"\"Pas de simulation : {self.obs.current_step}\"}}\n\n" + f"\"Simulation step: {self.obs.current_step}\"}}\n\n" ) elif self.obs.current_step == self.config['scenario_first_step'] - 1: print("\n") - logging.info("Le simulateur est à présent connecté à InteractiveAI.\n") + logging.info("The simulator is now connected to InteractiveAI.\n") message = { "div": "message-container", - "content": "Le simulateur est à présent connecté à InteractiveAI." + "content": "The simulator is now connected to InteractiveAI." } yield f"data: {json.dumps(message)}\n\n" silent_mode_msg_trigger = False else: if silent_mode_msg_trigger: - logging.info('Status: Le scénario se déroule en arrière plan.\n' - 'Le simulateur va se connecter à InteractiveAI à partir du pas : %s', + logging.info('Status: Scenario running in background.\n' + 'The simulator will connect to InteractiveAI from step: %s', self.config['scenario_first_step']) message = ( - f"Status: Le scénario se déroule en arrière plan.\n" - f"Le simulateur va se connecter à InteractiveAI à partir du pas : " - f"{self.config['scenario_first_step']} (Voir les paramètres de configuration)" + f"Status: Scenario running in background.\n" + f"The simulator will connect to InteractiveAI from step: " + f"{self.config['scenario_first_step']} (See configuration parameters)" ) yield f"data: {{ \"div\": \"status-div\", \"content\": {json.dumps(message)} }}\n\n" silent_mode_msg_trigger = False @@ -267,17 +310,17 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une surcharge sur le réseau") + logging.info("Status: Overload detected on the network") message = { "div": "message-container", - "content": "Status: Il y a une surcharge sur le réseau" + "content": "Status: Overload detected on the network" } yield f"data: {json.dumps(message)}\n\n" yield ( f"data: {{\"div\": \"events-div\", \"content\": {{ \"title\": " - f"\"Status: Il y a une surcharge sur la ligne " + f"\"Status: Overload on line " f"{get_curent_lines_in_bad_kpi(self.obs)}\" , " - f"\"description\": \"La surcharge est de " + f"\"description\": \"Overload at " f"{np.round(np.float64(self.obs.rho.max()*100),decimals=1,out=None)}%\"" f" }}}}\n\n" ) @@ -296,24 +339,32 @@ def run_simulator(self, com): case_overload=True) if (self.obs.current_step < self.config['scenario_first_step']) or (com.cab_api_on is False): - # Utiliser XD_Silly en cache (en local) + # Use cached XD_Silly (local) act = local_xd_silly(self.obs, self.local_assistant) if com.cab_api_on is False: - logging.info("Parade : %s", act) + logging.info("Action: %s", act) parade_message = { - "title": "Parade", + "title": "Action", "description": f"{str(act)}" } yield f"data: {{\"div\": \"actions-div\", \"content\": {json.dumps(parade_message)}}}\n\n" clear_parade_flag = True else: - # Récuperer les parades de InteractiveAI + # Retrieve actions from InteractiveAI yield from com.get_act_from_api() act = expand_act_from_cab(self.env, com.act_dict) - logging.info("Parade : %s", act) + act_summary = summarize_action(act) + logging.info("Action received from InteractiveAI: %s", + json.dumps(act_summary, default=str)) + # Flag this action so the next env.step can confirm it + # was actually applied to the grid. + pending_cab_act = { + "step_issued": int(self.obs.current_step), + "summary": act_summary, + } parade_message = { - "title": "Parade", + "title": "Action", "description": f"{str(act)}" } yield f"data: {{\"div\": \"actions-div\", \"content\": {json.dumps(parade_message)}}}\n\n" @@ -336,10 +387,10 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une alarme de l'agent IA") + logging.info("Status: AI agent raised an alarm") yield ( "data: {\"div\": \"events-div\", \"content\": " - "{ \"title\": \"Status: Il y a une alerte de l'agent IA\", " + "{ \"title\": \"Status: AI agent raised an alert\", " "\"description\": \"\" } }\n\n" ) @@ -372,10 +423,10 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une alerte de l'agent IA") + logging.info("Status: AI agent raised an alert") yield ( "data: {\"div\": \"events-div\", \"content\": " - "{ \"title\": \"Status: Il y a une alerte de l'agent IA\", " + "{ \"title\": \"Status: AI agent raised an alert\", " "\"description\": \"\" } }\n\n" ) @@ -407,19 +458,19 @@ def run_simulator(self, com): context_just_sent = True logging.info( - "Status: Il y a un événement de type 'anticipation N-1' ") + "Status: N-1 anticipation event detected") message = { "div": "message-container", - "content": "Status: Il y a un événement de type 'anticipation N-1' " + "content": "Status: N-1 anticipation event detected" } yield f"data: {json.dumps(message)}\n\n" for x in self.listen.anticipation: logging.info( - "Il y a un événement d'anticipation de perte de ligne %s", x) + "N-1 anticipation event for line loss: %s", x) yield ( f"data: {{\"div\": \"events-div\", \"content\": " - f"{{ \"title\": \"Il y a un événement d'anticipation de perte de ligne\", " + f"{{ \"title\": \"N-1 anticipation event: risk of line loss\", " f"\"description\": \"{x}\"}} }}\n\n" ) @@ -441,19 +492,6 @@ def run_simulator(self, com): case_anticip=True) event_resolved_trigger = True obs_forecast = None - if self.obs.current_step >= self.config['scenario_first_step']: - message = { - "div": "message-container", - "content": "La simulation est en pause." - } - yield f"data: {json.dumps(message)}\n\n" - yield ( - "data: {\"div\": \"status-div\", \"content\": " - "\"Cliquez sur 'Continuer' pour poursuivre la simulation.\"}\n\n" - ) - set_pause(True) - while get_pause_status(): - time.sleep(1) if "Line lost" in self.listen.current_issues: if self.obs.current_step >= self.config['scenario_first_step']: @@ -470,19 +508,19 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une perte de ligne %s", + logging.info("Status: Line loss detected: %s", get_curent_lines_lost(self.obs)) - + logging.info( - "Status: Il y a un événement de type 'perte de ligne' ") + "Status: Line lost event detected") message = { "div": "message-container", - "content": "Status: Il y a une perte de ligne ' " + "content": "Status: Line loss detected" } yield f"data: {json.dumps(message)}\n\n" yield ( f"data: {{\"div\": \"events-div\", \"content\": " - f"{{ \"title\": \"Status: Il y a une perte de ligne \" , " + f"{{ \"title\": \"Status: Line loss detected\" , " f"\"description\": \"{get_curent_lines_lost(self.obs)}\" }}}}\n\n" ) @@ -500,19 +538,6 @@ def run_simulator(self, com): self.obs), case_line_lost=True) event_resolved_trigger = True - if self.obs.current_step >= self.config['scenario_first_step']: - message = { - "div": "message-container", - "content": "La simulation est en pause." - } - yield f"data: {json.dumps(message)}\n\n" - yield ( - "data: {\"div\": \"status-div\", \"content\": " - "\"Cliquez sur 'Continuer' pour poursuivre la simulation.\"}\n\n" - ) - set_pause(True) - while get_pause_status(): - time.sleep(1) # -------------------------------------------------------------------- # To reconnect lines in the grid any time this agent detect a line disconnection. @@ -525,3 +550,6 @@ def run_simulator(self, com): if self.obs.current_step >= self.config['scenario_first_step']: step_counter = step_counter + 1 time.sleep(self.config['stepDuration_s']) + while get_pause_status(): + yield ": keepalive\n\n" + time.sleep(1) diff --git a/usecases_examples/PowerGrid/app/models/recommendation_store.py b/usecases_examples/PowerGrid/app/models/recommendation_store.py new file mode 100644 index 00000000..2498f825 --- /dev/null +++ b/usecases_examples/PowerGrid/app/models/recommendation_store.py @@ -0,0 +1,37 @@ +"""Shared in-process store for the latest recommendation from InteractiveAI. + +The simulator app's ``POST /api/v1/recommendations`` endpoint writes the +recommendation here, and the simulation loop reads it back directly. Because +both live in the same process, this avoids any HTTP round-trip from the app to +itself (which is fragile behind a reverse proxy / loopback and was returning +404 Not Found). + +This module intentionally imports nothing from the rest of the application so +it can be shared by both the Flask routes and ``Communicate`` without creating +a circular import. +""" + + +class RecommendationStore: + """Holds the most recent recommendation payload in memory.""" + + def __init__(self): + self._data = {} + + def set(self, data): + """Store a recommendation payload received from InteractiveAI.""" + self._data = data or {} + + def pop(self): + """Return the stored recommendation and clear it (single consumption).""" + data = self._data + self._data = {} + return data + + def peek(self): + """Return the stored recommendation without clearing it.""" + return self._data + + +# Process-wide singleton shared by the API endpoints and the simulation loop. +store = RecommendationStore() diff --git a/usecases_examples/PowerGrid/app/models/utils.py b/usecases_examples/PowerGrid/app/models/utils.py index 4880c8be..f3069400 100644 --- a/usecases_examples/PowerGrid/app/models/utils.py +++ b/usecases_examples/PowerGrid/app/models/utils.py @@ -140,7 +140,7 @@ def expand_act_from_cab(env, act_dict): Action: An action compliant with the environment. """ act = env.action_space() - logging.info("act_dict {act_dict}") + logging.info("Expanding action received from InteractiveAI: %s", act_dict) act.from_json(act_dict) act_vect = act.to_vect() actnew = env.action_space() @@ -148,6 +148,133 @@ def expand_act_from_cab(env, act_dict): return actnew +def summarize_action(act): + """ + Build a log/UI friendly summary of the concrete impact of a grid2op action. + + Args: + act: A grid2op BaseAction. + + Returns: + dict: 'is_do_nothing' flag, the readable 'text' description and a + best-effort structured 'impact' (set_bus / set_line_status / ...). + """ + summary = {"is_do_nothing": True, "text": str(act), "impact": {}} + try: + summary["is_do_nothing"] = not act.can_affect_something() + except Exception as e: + logging.error(e) + try: + summary["impact"] = act.impact_on_objects() + except Exception as e: + logging.error(e) + return summary + + +def verify_action_applied(pending, topo_before, line_status_before, + obs_after, info, overwritten=False): + """ + Determine whether a recommendation was effectively applied to the grid. + + A grid2op action that is illegal or ambiguous is silently ignored by + ``env.step``: nothing changes on the grid. This compares the grid state + before and after the step and inspects the ``info`` returned by the step to + produce an honest verdict. + + Args: + pending: dict describing the action that was supposed to be applied + (keys: 'step_issued', 'summary'). + topo_before: obs.topo_vect captured just before env.step. + line_status_before: obs.line_status captured just before env.step. + obs_after: observation returned by env.step. + info: the info dict returned by env.step. + overwritten: True if a scripted scenario action overrode the recommendation. + + Returns: + dict: structured verification result, ready to log and to display. + """ + is_illegal = bool(info.get("is_illegal", False)) + is_ambiguous = bool(info.get("is_ambiguous", False)) + exceptions = [str(e) for e in info.get("exception", []) if e is not None] + + changes = {"changed": False, "bus_changes": [], "line_status_changes": []} + try: + diff_idx = np.where(topo_before != obs_after.topo_vect)[0] + for i in diff_idx.tolist(): + changes["bus_changes"].append({ + "topo_vect_id": int(i), + "from_bus": int(topo_before[i]), + "to_bus": int(obs_after.topo_vect[i]), + }) + ls_idx = np.where(line_status_before != obs_after.line_status)[0] + for i in ls_idx.tolist(): + changes["line_status_changes"].append({ + "line_id": int(i), + "line_name": str(obs_after.name_line[i]), + "connected": bool(obs_after.line_status[i]), + }) + changes["changed"] = bool(len(diff_idx) or len(ls_idx)) + except Exception as e: + logging.error(e) + + is_do_nothing = bool(pending.get("summary", {}).get("is_do_nothing", False)) + + if overwritten: + status = "OVERRIDDEN" + elif is_illegal or is_ambiguous: + status = "REJECTED" + elif changes["changed"]: + status = "APPLIED" + elif is_do_nothing: + status = "NO_OP" + else: + status = "NO_EFFECT" + + return { + "status": status, + "step_issued": pending.get("step_issued"), + "step_applied": int(obs_after.current_step), + "is_illegal": is_illegal, + "is_ambiguous": is_ambiguous, + "exceptions": exceptions, + "changes": changes, + } + + +def format_verdict_description(verdict): + """ + Build a short human-readable description of an action verification verdict. + + Args: + verdict: dict produced by :func:`verify_action_applied`. + + Returns: + str: one-line description suitable for the dashboard and the logs. + """ + parts = [] + bus = verdict["changes"]["bus_changes"] + lines = verdict["changes"]["line_status_changes"] + if bus: + parts.append(f"{len(bus)} bus assignment(s) changed") + if lines: + flips = ", ".join( + f"{c['line_name']} " + f"{'reconnected' if c['connected'] else 'disconnected'}" + for c in lines) + parts.append(f"line status: {flips}") + if verdict["is_illegal"]: + parts.append("action was illegal") + if verdict["is_ambiguous"]: + parts.append("action was ambiguous") + if verdict["exceptions"]: + parts.append("; ".join(verdict["exceptions"])) + if not parts: + parts.append("no observable change on the grid") + return (f"Issued at step {verdict['step_issued']}, " + f"checked at step {verdict['step_applied']}: " + + "; ".join(parts)) + + def load_assistant(assistant_path, assistant_seed, env): """ Loads the assistant agent. diff --git a/usecases_examples/PowerGrid/app/templates/dashboard.html b/usecases_examples/PowerGrid/app/templates/dashboard.html index 01f57a4b..d867da38 100644 --- a/usecases_examples/PowerGrid/app/templates/dashboard.html +++ b/usecases_examples/PowerGrid/app/templates/dashboard.html @@ -1,9 +1,9 @@ - + - Tableau de bord + Dashboard @@ -210,12 +210,12 @@

PowerGrid Simulator based on Grid2Op platform