From c520cf9b5d05e2b857132e52b33a6edd1d713267 Mon Sep 17 00:00:00 2001 From: hamz2a Date: Tue, 31 Dec 2024 16:08:58 +0100 Subject: [PATCH] editoast: endpoints to retrieve stdcm payloads Signed-off-by: hamz2a --- editoast/editoast_authz/src/authorizer.rs | 4 + editoast/editoast_common/src/tracing.rs | 3 +- editoast/editoast_models/src/tables.rs | 2 +- .../src/train_schedule/margins.rs | 2 +- .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 5 + .../up.sql | 1 + editoast/openapi.yaml | 520 ++++++++++++------ editoast/src/client/runserver.rs | 6 + editoast/src/core/conflict_detection.rs | 2 +- editoast/src/core/simulation.rs | 2 +- editoast/src/core/stdcm.rs | 11 +- editoast/src/main.rs | 7 +- editoast/src/models/stdcm_log.rs | 17 +- editoast/src/views/mod.rs | 3 + editoast/src/views/stdcm_logs.rs | 445 +++++++++++++++ editoast/src/views/test_app.rs | 34 +- editoast/src/views/timetable/stdcm.rs | 273 +++++---- editoast/src/views/timetable/stdcm/request.rs | 57 +- front/public/locales/en/errors.json | 3 + front/public/locales/fr/errors.json | 3 + front/src/common/api/generatedEditoastApi.ts | 243 ++++++-- 23 files changed, 1265 insertions(+), 380 deletions(-) create mode 100644 editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql create mode 100644 editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql create mode 100644 editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/down.sql create mode 100644 editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/up.sql create mode 100644 editoast/src/views/stdcm_logs.rs diff --git a/editoast/editoast_authz/src/authorizer.rs b/editoast/editoast_authz/src/authorizer.rs index 93de37ba827..5117a95c328 100644 --- a/editoast/editoast_authz/src/authorizer.rs +++ b/editoast/editoast_authz/src/authorizer.rs @@ -103,6 +103,10 @@ impl Authorizer { self.user_roles.contains(&S::BuiltinRole::superuser()) } + pub fn is_superuser_stub(&self) -> bool { + self.user_roles.contains(&S::BuiltinRole::superuser()) && self.user_id == -1 + } + /// Returns whether a user with some id exists #[tracing::instrument(skip_all, fields(user_id = %user_id), ret(level = Level::DEBUG), err)] pub async fn user_exists(&self, user_id: i64) -> Result { diff --git a/editoast/editoast_common/src/tracing.rs b/editoast/editoast_common/src/tracing.rs index 0054b8ab576..0ff3e4d960b 100644 --- a/editoast/editoast_common/src/tracing.rs +++ b/editoast/editoast_common/src/tracing.rs @@ -31,11 +31,12 @@ pub struct TracingConfig { pub fn create_tracing_subscriber( tracing_config: TracingConfig, + log_level: tracing_subscriber::filter::LevelFilter, exporter: T, ) -> impl tracing::Subscriber { let env_filter_layer = tracing_subscriber::EnvFilter::builder() // Set the default log level to 'info' - .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .with_default_directive(log_level.into()) .from_env_lossy(); let fmt_layer = tracing_subscriber::fmt::layer() .pretty() diff --git a/editoast/editoast_models/src/tables.rs b/editoast/editoast_models/src/tables.rs index d702cfbcec8..2abec3f381f 100644 --- a/editoast/editoast_models/src/tables.rs +++ b/editoast/editoast_models/src/tables.rs @@ -625,7 +625,7 @@ diesel::table! { stdcm_logs (id) { id -> Int8, #[max_length = 32] - trace_id -> Varchar, + trace_id -> Nullable, request -> Jsonb, response -> Jsonb, created -> Timestamptz, diff --git a/editoast/editoast_schemas/src/train_schedule/margins.rs b/editoast/editoast_schemas/src/train_schedule/margins.rs index d8994c0abde..01497b58f1b 100644 --- a/editoast/editoast_schemas/src/train_schedule/margins.rs +++ b/editoast/editoast_schemas/src/train_schedule/margins.rs @@ -44,7 +44,7 @@ impl<'de> Deserialize<'de> for Margins { } } -#[derive(Debug, Copy, Clone, PartialEq, Derivative)] +#[derive(Debug, Copy, Clone, PartialEq, Derivative, ToSchema)] #[derivative(Hash, Default)] pub enum MarginValue { #[derivative(Default)] diff --git a/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql new file mode 100644 index 00000000000..d8d227ec325 --- /dev/null +++ b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS stdcm_logs_trace_id; diff --git a/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql new file mode 100644 index 00000000000..dd8dfef85c2 --- /dev/null +++ b/editoast/migrations/2025-01-07-163822_index_stdcm_log_trace_id/up.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS stdcm_logs_trace_id ON stdcm_logs (trace_id); diff --git a/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/down.sql b/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/down.sql new file mode 100644 index 00000000000..6cca729f404 --- /dev/null +++ b/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/down.sql @@ -0,0 +1,5 @@ +UPDATE stdcm_logs +SET trace_id = REPLACE(gen_random_uuid()::text, '-', '') +WHERE trace_id IS NULL; + +ALTER TABLE stdcm_logs ALTER COLUMN trace_id SET NOT NULL; diff --git a/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/up.sql b/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/up.sql new file mode 100644 index 00000000000..19b21b98d97 --- /dev/null +++ b/editoast/migrations/2025-01-14-154647_make_stdcm_log_trace_id_nullable/up.sql @@ -0,0 +1 @@ +ALTER TABLE stdcm_logs ALTER COLUMN trace_id DROP NOT NULL; diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index b51a192e43f..aa2f2eb9e0a 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -2590,6 +2590,61 @@ paths: application/json: schema: $ref: '#/components/schemas/StdcmSearchEnvironment' + /stdcm_logs: + get: + tags: + - stdcm_log + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int64 + default: 1 + minimum: 1 + - name: page_size + in: query + required: false + schema: + type: integer + format: int64 + default: 25 + nullable: true + minimum: 1 + responses: + '200': + description: The list of STDCM Logs + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PaginationStats' + - type: object + required: + - results + properties: + results: + type: array + items: + $ref: '#/components/schemas/StdcmLog' + /stdcm_logs/{trace_id}: + get: + tags: + - stdcm_log + parameters: + - name: trace_id + in: path + required: true + schema: + type: string + responses: + '200': + description: The STDCM log + content: + application/json: + schema: + $ref: '#/components/schemas/StdcmLog' /temporary_speed_limit_group: post: tags: @@ -4742,6 +4797,7 @@ components: - $ref: '#/components/schemas/EditoastStdcmErrorTimetableNotFound' - $ref: '#/components/schemas/EditoastStdcmErrorTowedRollingStockNotFound' - $ref: '#/components/schemas/EditoastStdcmErrorTrainSimulationFail' + - $ref: '#/components/schemas/EditoastStdcmLogErrorNotFound' - $ref: '#/components/schemas/EditoastStudyErrorNotFound' - $ref: '#/components/schemas/EditoastStudyErrorStartDateAfterEndDate' - $ref: '#/components/schemas/EditoastTemporarySpeedLimitErrorNameAlreadyUsed' @@ -5980,6 +6036,30 @@ components: type: string enum: - editoast:stdcm_v2:TrainSimulationFail + EditoastStdcmLogErrorNotFound: + type: object + required: + - type + - status + - message + properties: + context: + type: object + required: + - trace_id + properties: + trace_id: + type: string + message: + type: string + status: + type: integer + enum: + - 404 + type: + type: string + enum: + - editoast:stdcm_log:NotFound EditoastStudyErrorNotFound: type: object required: @@ -8295,7 +8375,26 @@ components: $ref: '#/components/schemas/PathItemLocation' timing_data: allOf: - - $ref: '#/components/schemas/StepTimingData' + - type: object + required: + - arrival_time + - arrival_time_tolerance_before + - arrival_time_tolerance_after + properties: + arrival_time: + type: string + format: date-time + description: Time at which the train should arrive at the location + arrival_time_tolerance_after: + type: integer + format: int64 + description: The train may arrive up to this duration after the expected arrival time + minimum: 0 + arrival_time_tolerance_before: + type: integer + format: int64 + description: The train may arrive up to this duration before the expected arrival time + minimum: 0 nullable: true PathfindingNotFound: oneOf: @@ -8942,147 +9041,6 @@ components: type: integer format: int64 minimum: 0 - Request: - type: object - description: An STDCM request - required: - - steps - - rolling_stock_id - - comfort - properties: - comfort: - $ref: '#/components/schemas/Comfort' - electrical_profile_set_id: - type: integer - format: int64 - nullable: true - loading_gauge_type: - allOf: - - $ref: '#/components/schemas/LoadingGaugeType' - nullable: true - margin: - type: string - description: Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` - example: - - 5% - - 2min/100km - nullable: true - max_speed: - type: number - format: double - description: Maximum speed of the consist in km/h - nullable: true - maximum_departure_delay: - type: integer - format: int64 - description: |- - By how long we can shift the departure time in milliseconds - Deprecated, first step data should be used instead - nullable: true - minimum: 0 - maximum_run_time: - type: integer - format: int64 - description: |- - Specifies how long the total run time can be in milliseconds - Deprecated, first step data should be used instead - nullable: true - minimum: 0 - rolling_stock_id: - type: integer - format: int64 - speed_limit_tags: - type: string - description: Train categories for speed limits - nullable: true - start_time: - type: string - format: date-time - description: Deprecated, first step arrival time should be used instead - nullable: true - steps: - type: array - items: - $ref: '#/components/schemas/PathfindingItem' - temporary_speed_limit_group_id: - type: integer - format: int64 - nullable: true - time_gap_after: - type: integer - format: int64 - description: |- - Margin after the train passage in milliseconds - - Enforces that the path used by the train should be free and - available at least that many milliseconds after its passage. - minimum: 0 - time_gap_before: - type: integer - format: int64 - description: |- - Margin before the train passage in seconds - - Enforces that the path used by the train should be free and - available at least that many milliseconds before its passage. - minimum: 0 - total_length: - type: number - format: double - description: Total length of the consist in meters - nullable: true - total_mass: - type: number - format: double - description: Total mass of the consist in kg - nullable: true - towed_rolling_stock_id: - type: integer - format: int64 - nullable: true - work_schedule_group_id: - type: integer - format: int64 - nullable: true - Response: - oneOf: - - type: object - required: - - simulation - - path - - departure_time - - status - properties: - departure_time: - type: string - format: date-time - path: - $ref: '#/components/schemas/PathfindingResultSuccess' - simulation: - $ref: '#/components/schemas/SimulationResponse' - status: - type: string - enum: - - success - - type: object - required: - - status - properties: - status: - type: string - enum: - - path_not_found - - type: object - required: - - error - - status - properties: - error: - $ref: '#/components/schemas/SimulationResponse' - status: - type: string - enum: - - preprocessing_simulation_error RjsPowerRestrictionRange: type: object description: A range along the train path where a power restriction is applied. @@ -10735,7 +10693,6 @@ components: type: object required: - id - - trace_id - request - response - created @@ -10747,15 +10704,267 @@ components: type: integer format: int64 request: - $ref: '#/components/schemas/Request' + $ref: '#/components/schemas/StdcmRequest' response: - $ref: '#/components/schemas/Response' + $ref: '#/components/schemas/StdcmResponse' trace_id: type: string + nullable: true user_id: type: integer format: int64 nullable: true + StdcmRequest: + type: object + required: + - infra + - expected_version + - path_items + - rolling_stock_loading_gauge + - rolling_stock_supported_signaling_systems + - comfort + - physics_consist + - trains_requirements + - start_time + - maximum_departure_delay + - maximum_run_time + - time_gap_before + - time_gap_after + - work_schedules + - temporary_speed_limits + properties: + comfort: + $ref: '#/components/schemas/Comfort' + expected_version: + type: string + description: Infrastructure expected version + infra: + type: integer + format: int64 + description: Infrastructure id + margin: + allOf: + - oneOf: + - type: object + required: + - Percentage + properties: + Percentage: + type: number + format: double + - type: object + required: + - MinPer100Km + properties: + MinPer100Km: + type: number + format: double + nullable: true + maximum_departure_delay: + type: integer + format: int64 + description: Maximum departure delay in milliseconds. + minimum: 0 + maximum_run_time: + type: integer + format: int64 + description: Maximum run time of the simulation in milliseconds + minimum: 0 + path_items: + type: array + items: + $ref: '#/components/schemas/PathItem' + description: List of waypoints. Each waypoint is a list of track offset. + physics_consist: + type: object + required: + - effort_curves + - length + - max_speed + - startup_time + - startup_acceleration + - comfort_acceleration + - const_gamma + - inertia_coefficient + - mass + - rolling_resistance + properties: + base_power_class: + type: string + nullable: true + comfort_acceleration: + type: number + format: double + description: In m/s² + const_gamma: + type: number + format: double + description: |- + The constant gamma braking coefficient used when NOT circulating + under ETCS/ERTMS signaling system in m/s^2 + effort_curves: + $ref: '#/components/schemas/EffortCurves' + electrical_power_startup_time: + type: integer + format: int64 + description: |- + The time the train takes before actually using electrical power (in milliseconds). + Is null if the train is not electric or the value not specified. + nullable: true + minimum: 0 + etcs_brake_params: + allOf: + - $ref: '#/components/schemas/EtcsBrakeParams' + nullable: true + inertia_coefficient: + type: number + format: double + length: + type: integer + format: int64 + description: Length of the rolling stock in mm + minimum: 0 + mass: + type: integer + format: int64 + description: Mass of the rolling stock in kg + minimum: 0 + max_speed: + type: number + format: double + description: Maximum speed of the rolling stock in m/s + power_restrictions: + type: object + description: Mapping of power restriction code to power class + additionalProperties: + type: string + raise_pantograph_time: + type: integer + format: int64 + description: |- + The time it takes to raise this train's pantograph in milliseconds. + Is null if the train is not electric or the value not specified. + nullable: true + minimum: 0 + rolling_resistance: + $ref: '#/components/schemas/RollingResistance' + startup_acceleration: + type: number + format: double + description: In m/s² + startup_time: + type: integer + format: int64 + minimum: 0 + rolling_stock_loading_gauge: + $ref: '#/components/schemas/LoadingGaugeType' + rolling_stock_supported_signaling_systems: + $ref: '#/components/schemas/RollingStockSupportedSignalingSystems' + speed_limit_tag: + type: string + nullable: true + start_time: + type: string + format: date-time + temporary_speed_limits: + type: array + items: + type: object + description: Lighter description of a work schedule with only the relevant information for core + required: + - speed_limit + - track_ranges + properties: + speed_limit: + type: number + format: double + description: Speed limitation in m/s + track_ranges: + type: array + items: + $ref: '#/components/schemas/TrackRange' + description: Track ranges on which the speed limitation applies + description: List of applicable temporary speed limits between the train departure and arrival + time_gap_after: + type: integer + format: int64 + description: Gap between the created train and following trains in milliseconds + minimum: 0 + time_gap_before: + type: integer + format: int64 + description: Gap between the created train and previous trains in milliseconds + minimum: 0 + time_step: + type: integer + format: int64 + description: Numerical integration time step in milliseconds. Use default value if not specified. + nullable: true + minimum: 0 + trains_requirements: + type: object + additionalProperties: + type: object + required: + - start_time + - spacing_requirements + - routing_requirements + properties: + routing_requirements: + type: array + items: + $ref: '#/components/schemas/RoutingRequirement' + spacing_requirements: + type: array + items: + $ref: '#/components/schemas/SpacingRequirement' + start_time: + type: string + format: date-time + work_schedules: + type: array + items: + $ref: '#/components/schemas/WorkSchedule' + description: List of planned work schedules + StdcmResponse: + oneOf: + - type: object + required: + - simulation + - path + - departure_time + - status + properties: + departure_time: + type: string + format: date-time + path: + $ref: '#/components/schemas/PathfindingResultSuccess' + simulation: + $ref: '#/components/schemas/SimulationResponse' + status: + type: string + enum: + - success + - type: object + required: + - status + properties: + status: + type: string + enum: + - path_not_found + - type: object + required: + - error + - status + properties: + error: + $ref: '#/components/schemas/SimulationResponse' + status: + type: string + enum: + - preprocessing_simulation_error StdcmSearchEnvironment: type: object required: @@ -10821,27 +11030,6 @@ components: type: integer format: int64 nullable: true - StepTimingData: - type: object - required: - - arrival_time - - arrival_time_tolerance_before - - arrival_time_tolerance_after - properties: - arrival_time: - type: string - format: date-time - description: Time at which the train should arrive at the location - arrival_time_tolerance_after: - type: integer - format: int64 - description: The train may arrive up to this duration after the expected arrival time - minimum: 0 - arrival_time_tolerance_before: - type: integer - format: int64 - description: The train may arrive up to this duration before the expected arrival time - minimum: 0 Study: type: object required: diff --git a/editoast/src/client/runserver.rs b/editoast/src/client/runserver.rs index 1b86336c6c0..5ca5a6886bc 100644 --- a/editoast/src/client/runserver.rs +++ b/editoast/src/client/runserver.rs @@ -43,6 +43,10 @@ pub struct RunserverArgs { // only recieve 401 responses. #[clap(long, env = "EDITOAST_DISABLE_AUTHORIZATION", default_value_t = true)] disable_authorization: bool, + /// If this option is set, logging for the STDCM will be enabled. + /// When enabled, relevant logs will be captured to aid in debugging and monitoring. + #[clap(long, env = "ENABLE_STDCM_LOGGING", default_value_t = true)] + enable_stdcm_logging: bool, #[clap(long, env = "OSRDYNE_API_URL", default_value_t = Url::parse("http://127.0.0.1:4242/").unwrap())] osrdyne_api_url: Url, /// The timeout to use when performing the healthcheck, in milliseconds @@ -64,6 +68,7 @@ pub async fn runserver( core_client_channels_size, }, disable_authorization, + enable_stdcm_logging, osrdyne_api_url, health_check_timeout_ms, }: RunserverArgs, @@ -76,6 +81,7 @@ pub async fn runserver( health_check_timeout: Duration::milliseconds(health_check_timeout_ms as i64), map_layers_max_zoom: map_layers_config.max_zoom as u8, disable_authorization, + enable_stdcm_logging, postgres_config: postgres.into(), osrdyne_config: views::OsrdyneConfig { mq_url, diff --git a/editoast/src/core/conflict_detection.rs b/editoast/src/core/conflict_detection.rs index 45689d7f083..0f5e1c118d4 100644 --- a/editoast/src/core/conflict_detection.rs +++ b/editoast/src/core/conflict_detection.rs @@ -29,7 +29,7 @@ pub struct ConflictDetectionRequest { pub work_schedules: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TrainRequirements { pub start_time: DateTime, pub spacing_requirements: Vec, diff --git a/editoast/src/core/simulation.rs b/editoast/src/core/simulation.rs index f1f108f590f..830096d20a1 100644 --- a/editoast/src/core/simulation.rs +++ b/editoast/src/core/simulation.rs @@ -34,7 +34,7 @@ editoast_common::schemas! { SimulationResponse, } -#[derive(Debug, Clone, Serialize, Deserialize, Derivative)] +#[derive(Debug, Clone, Serialize, Deserialize, Derivative, ToSchema)] #[derivative(Hash)] pub struct PhysicsConsist { pub effort_curves: EffortCurves, diff --git a/editoast/src/core/stdcm.rs b/editoast/src/core/stdcm.rs index 654635e3626..ad5b592eb02 100644 --- a/editoast/src/core/stdcm.rs +++ b/editoast/src/core/stdcm.rs @@ -20,10 +20,12 @@ use crate::core::AsCoreRequest; use crate::core::Json; editoast_common::schemas! { + Request, Response, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = StdcmRequest)] pub struct Request { /// Infrastructure id pub infra: i64, @@ -42,9 +44,11 @@ pub struct Request { /// The comfort of the train pub comfort: Comfort, pub speed_limit_tag: Option, + #[schema(inline)] pub physics_consist: PhysicsConsist, // STDCM search parameters + #[schema(inline)] pub trains_requirements: HashMap, /// Numerical integration time step in milliseconds. Use default value if not specified. pub time_step: Option, @@ -58,10 +62,12 @@ pub struct Request { /// Gap between the created train and following trains in milliseconds pub time_gap_after: u64, /// Margin to apply to the whole train + #[schema(inline)] pub margin: Option, /// List of planned work schedules pub work_schedules: Vec, /// List of applicable temporary speed limits between the train departure and arrival + #[schema(inline)] pub temporary_speed_limits: Vec, } @@ -98,7 +104,7 @@ pub struct WorkSchedule { } /// Lighter description of a work schedule with only the relevant information for core -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TemporarySpeedLimit { /// Speed limitation in m/s pub speed_limit: f64, @@ -123,6 +129,7 @@ pub struct UndirectedTrackRange { // We accepted the difference of memory size taken by variants // Since there is only on success and others are error cases #[allow(clippy::large_enum_variant)] +#[schema(as = StdcmResponse)] pub enum Response { Success { simulation: SimulationResponse, diff --git a/editoast/src/main.rs b/editoast/src/main.rs index 754c6999d67..022bc5c88c4 100644 --- a/editoast/src/main.rs +++ b/editoast/src/main.rs @@ -115,7 +115,12 @@ async fn run() -> Result<(), Box> { stream: EditoastMode::from_client(&client).into(), telemetry, }; - create_tracing_subscriber(tracing_config, exporter).init(); + create_tracing_subscriber( + tracing_config, + tracing_subscriber::filter::LevelFilter::INFO, + exporter, + ) + .init(); let pg_config = client.postgres_config; let db_pool = diff --git a/editoast/src/models/stdcm_log.rs b/editoast/src/models/stdcm_log.rs index ae77062bd6c..13d855eae00 100644 --- a/editoast/src/models/stdcm_log.rs +++ b/editoast/src/models/stdcm_log.rs @@ -2,10 +2,8 @@ use chrono::DateTime; use chrono::Utc; use editoast_derive::Model; use editoast_models::DbConnection; -use opentelemetry::trace::TraceContextExt; use serde::Deserialize; use serde::Serialize; -use tracing_opentelemetry::OpenTelemetrySpanExt; use utoipa::ToSchema; use crate::core::stdcm::Request; @@ -18,13 +16,16 @@ editoast_common::schemas! { #[derive(Clone, Debug, Serialize, Deserialize, Model, ToSchema)] #[model(table = editoast_models::tables::stdcm_logs)] -#[model(gen(ops = c))] +#[model(gen(ops = crd, list))] pub struct StdcmLog { pub id: i64, - pub trace_id: String, + #[model(identifier)] + pub trace_id: Option, #[model(json)] + #[schema(value_type = StdcmRequest)] pub request: Request, #[model(json)] + #[schema(value_type = StdcmResponse)] pub response: Response, pub created: DateTime, pub user_id: Option, @@ -33,17 +34,13 @@ pub struct StdcmLog { impl StdcmLog { pub async fn log( mut conn: DbConnection, + trace_id: Option, request: Request, response: Response, user_id: Option, ) { - let trace_id = tracing::Span::current() - .context() - .span() - .span_context() - .trace_id(); let stdcm_log_changeset = StdcmLog::changeset() - .trace_id(trace_id.to_string()) + .trace_id(trace_id) .request(request) .response(response.clone()) .user_id(user_id); diff --git a/editoast/src/views/mod.rs b/editoast/src/views/mod.rs index e82231dd5bb..d6c9f928c93 100644 --- a/editoast/src/views/mod.rs +++ b/editoast/src/views/mod.rs @@ -14,6 +14,7 @@ pub mod scenario; pub mod search; pub mod speed_limit_tags; pub mod sprites; +pub mod stdcm_logs; pub mod stdcm_search_environment; pub mod study; pub mod temporary_speed_limits; @@ -111,6 +112,7 @@ crate::routes! { &train_schedule, &timetable, &path, + &stdcm_logs, &scenario, } @@ -363,6 +365,7 @@ pub struct ServerConfig { pub health_check_timeout: Duration, pub map_layers_max_zoom: u8, pub disable_authorization: bool, + pub enable_stdcm_logging: bool, pub postgres_config: PostgresConfig, pub osrdyne_config: OsrdyneConfig, pub valkey_config: ValkeyConfig, diff --git a/editoast/src/views/stdcm_logs.rs b/editoast/src/views/stdcm_logs.rs new file mode 100644 index 00000000000..059dd23d20b --- /dev/null +++ b/editoast/src/views/stdcm_logs.rs @@ -0,0 +1,445 @@ +use axum::extract::Path; +use axum::extract::Query; +use axum::extract::State; +use axum::Extension; +use axum::Json; +use editoast_authz::BuiltinRole; +use editoast_derive::EditoastError; +use editoast_models::DbConnectionPoolV2; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use utoipa::IntoParams; +use utoipa::ToSchema; + +use crate::error::Result; +use crate::models::stdcm_log::StdcmLog; +use crate::Retrieve; + +use super::pagination::PaginatedList; +use super::pagination::PaginationQueryParams; +use super::pagination::PaginationStats; +use super::AuthenticationExt; +use super::AuthorizationError; + +crate::routes! { + "/stdcm_logs" => { + list_stdcm_logs, + "/{trace_id}" => { + stdcm_log_by_trace_id, + }, + }, +} + +#[derive(Debug, Error, EditoastError)] +#[editoast_error(base_id = "stdcm_log")] +enum StdcmLogError { + #[error("STDCM log entry '{trace_id}' could not be found")] + #[editoast_error(status = 404)] + NotFound { trace_id: String }, +} + +#[derive(Serialize, ToSchema)] +#[cfg_attr(test, derive(Deserialize))] +struct StdcmLogListResponse { + results: Vec, + #[serde(flatten)] + stats: PaginationStats, +} + +#[utoipa::path( + get, path = "", + tag = "stdcm_log", + params(PaginationQueryParams), + responses( + (status = 200, body = inline(StdcmLogListResponse), description = "The list of STDCM Logs"), + ) +)] +async fn list_stdcm_logs( + State(db_pool): State, + Extension(auth): AuthenticationExt, + Query(pagination_params): Query, +) -> Result> { + let authorized = auth + .check_roles([BuiltinRole::Superuser].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + let conn = &mut db_pool.get().await?; + + let settings = pagination_params.validate(10)?.into_selection_settings(); + + let (stdcm_logs, stats) = StdcmLog::list_paginated(conn, settings).await?; + + Ok(Json(StdcmLogListResponse { + results: stdcm_logs, + stats, + })) +} + +#[derive(IntoParams, Deserialize)] +struct StdcmLogTraceIdParam { + trace_id: String, +} + +#[utoipa::path( + get, path = "", + params(StdcmLogTraceIdParam), + tag = "stdcm_log", + responses( + (status = 200, body = StdcmLog, description = "The STDCM log"), + ) +)] +async fn stdcm_log_by_trace_id( + State(db_pool): State, + Extension(auth): AuthenticationExt, + Path(StdcmLogTraceIdParam { trace_id }): Path, +) -> Result> { + let authorized = auth + .check_roles([BuiltinRole::Superuser].into()) + .await + .map_err(AuthorizationError::AuthError)?; + if !authorized { + return Err(AuthorizationError::Forbidden.into()); + } + + let conn = &mut db_pool.get().await?; + + let stdcm_log = StdcmLog::retrieve_or_fail(conn, Some(trace_id.clone()), || { + StdcmLogError::NotFound { trace_id } + }) + .await?; + + Ok(Json(stdcm_log)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::str::FromStr; + + use axum::http::StatusCode; + use chrono::DateTime; + use editoast_authz::authorizer::UserInfo; + use editoast_authz::BuiltinRole; + use editoast_models::DbConnectionPoolV2; + use editoast_schemas::train_schedule::Comfort; + use editoast_schemas::train_schedule::MarginValue; + use editoast_schemas::train_schedule::OperationalPointIdentifier; + use editoast_schemas::train_schedule::OperationalPointReference; + use editoast_schemas::train_schedule::PathItemLocation; + use pretty_assertions::assert_eq; + use rstest::rstest; + use uuid::Uuid; + + use crate::core::mocking::MockingClient; + use crate::core::pathfinding::PathfindingResultSuccess; + use crate::core::simulation::CompleteReportTrain; + use crate::core::simulation::ElectricalProfiles; + use crate::core::simulation::ReportTrain; + use crate::core::simulation::SimulationResponse; + use crate::core::simulation::SpeedLimitProperties; + use crate::models::fixtures::create_fast_rolling_stock; + use crate::models::fixtures::create_small_infra; + use crate::models::fixtures::create_timetable; + use crate::models::stdcm_log::StdcmLog; + use crate::views::path::pathfinding::PathfindingResult; + use crate::views::stdcm_logs::StdcmLogListResponse; + use crate::views::test_app::TestApp; + use crate::views::test_app::TestAppBuilder; + use crate::views::test_app::TestRequestExt; + use crate::views::timetable::stdcm::request::PathfindingItem; + use crate::views::timetable::stdcm::request::Request; + use crate::views::timetable::stdcm::request::StepTimingData; + + fn stdcm_payload(rolling_stock_id: i64) -> Request { + Request { + start_time: None, + steps: vec![ + PathfindingItem { + duration: Some(0), + location: PathItemLocation::OperationalPointReference( + OperationalPointReference { + reference: OperationalPointIdentifier::OperationalPointDescription { + trigram: "WS".into(), + secondary_code: Some("BV".to_string()), + }, + track_reference: None, + }, + ), + timing_data: Some(StepTimingData { + arrival_time: DateTime::from_str("2024-09-17T20:05:00+02:00") + .expect("Failed to parse datetime"), + arrival_time_tolerance_before: 0, + arrival_time_tolerance_after: 0, + }), + }, + PathfindingItem { + duration: Some(0), + location: PathItemLocation::OperationalPointReference( + OperationalPointReference { + reference: OperationalPointIdentifier::OperationalPointDescription { + trigram: "MWS".into(), + secondary_code: Some("BV".to_string()), + }, + track_reference: None, + }, + ), + timing_data: None, + }, + ], + rolling_stock_id, + towed_rolling_stock_id: None, + electrical_profile_set_id: None, + work_schedule_group_id: None, + temporary_speed_limit_group_id: None, + comfort: Comfort::Standard, + maximum_departure_delay: None, + maximum_run_time: None, + speed_limit_tags: Some("AR120".to_string()), + time_gap_before: 35000, + time_gap_after: 35000, + margin: Some(MarginValue::MinPer100Km(4.5)), + total_mass: None, + total_length: None, + max_speed: None, + loading_gauge_type: None, + } + } + + fn core_mocking_client() -> MockingClient { + let mut core = MockingClient::new(); + core.stub("/v2/pathfinding/blocks") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(PathfindingResult::Success(pathfinding_result_success())) + .finish(); + core.stub("/v2/standalone_simulation") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(simulation_response()) + .finish(); + core + } + + fn pathfinding_result_success() -> PathfindingResultSuccess { + PathfindingResultSuccess { + blocks: vec![], + routes: vec![], + track_section_ranges: vec![], + length: 0, + path_item_positions: vec![0, 10], + } + } + + fn simulation_response() -> SimulationResponse { + SimulationResponse::Success { + base: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + provisional: ReportTrain { + positions: vec![], + times: vec![0, 10], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + final_output: CompleteReportTrain { + report_train: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + signal_critical_positions: vec![], + zone_updates: vec![], + spacing_requirements: vec![], + routing_requirements: vec![], + }, + mrsp: SpeedLimitProperties { + boundaries: vec![], + values: vec![], + }, + electrical_profiles: ElectricalProfiles { + boundaries: vec![], + values: vec![], + }, + } + } + + struct TestContextConfig { + disable_superuser: bool, + enable_authorization: bool, + enable_stdcm_logging: bool, + enable_telemetry: bool, + } + + struct TestContextOutput { + app: TestApp, + user: UserInfo, + } + + fn init_app(config: TestContextConfig) -> TestContextOutput { + let db_pool = DbConnectionPoolV2::for_tests(); + let mut core = core_mocking_client(); + core.stub("/v2/stdcm") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(crate::core::stdcm::Response::Success { + simulation: simulation_response(), + path: pathfinding_result_success(), + departure_time: DateTime::from_str("2024-01-02T00:00:00Z") + .expect("Failed to parse datetime"), + }) + .finish(); + + let user = UserInfo { + identity: "superuser_id".to_owned(), + name: "superuser_name".to_owned(), + }; + + let mut roles = HashSet::from([BuiltinRole::Stdcm, BuiltinRole::Superuser]); + + if config.disable_superuser { + roles.remove(&BuiltinRole::Superuser); + } + + let app = TestAppBuilder::new() + .db_pool(db_pool.clone()) + .core_client(core.into()) + .enable_authorization(config.enable_authorization) + .enable_stdcm_logging(config.enable_stdcm_logging) + .enable_telemetry(config.enable_telemetry) + .user(user.clone()) + .roles(roles) + .build(); + + TestContextOutput { app, user } + } + + async fn execute_stdcm_request(app: &TestApp, user: UserInfo) -> String { + let small_infra = create_small_infra(&mut app.db_pool().get_ok()).await; + let timetable = create_timetable(&mut app.db_pool().get_ok()).await; + let rolling_stock = + create_fast_rolling_stock(&mut app.db_pool().get_ok(), &Uuid::new_v4().to_string()) + .await; + let trace_id = "cd62312a1bd0df0a612ff9ade3d03635".to_string(); + let request = app + .post(format!("/timetable/{}/stdcm?infra={}", timetable.id, small_infra.id).as_str()) + .json(&stdcm_payload(rolling_stock.id)) + .add_header("traceparent", format!("00-{trace_id}-18ae0228cf2d63b0-01")) + .by_user(user); + + app.fetch(request).assert_status(StatusCode::OK); + + trace_id + } + + #[rstest] + async fn list_stdcm_logs_return_success() { + let config = TestContextConfig { + disable_superuser: false, + enable_authorization: true, + enable_stdcm_logging: true, + enable_telemetry: true, + }; + let TestContextOutput { app, user } = init_app(config); + let trace_id = execute_stdcm_request(&app, user.clone()).await; + let request = app.get("/stdcm_logs?page_size=10").by_user(user); + let stdcm_logs_response: StdcmLogListResponse = + app.fetch(request).assert_status(StatusCode::OK).json_into(); + + assert_eq!(stdcm_logs_response.results.len(), 1); + assert_eq!(stdcm_logs_response.results[0].trace_id, Some(trace_id)); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_success() { + let config = TestContextConfig { + disable_superuser: false, + enable_authorization: true, + enable_stdcm_logging: true, + enable_telemetry: true, + }; + let TestContextOutput { app, user } = init_app(config); + let trace_id = execute_stdcm_request(&app, user.clone()).await; + let request = app + .get(format!("/stdcm_logs/{trace_id}").as_str()) + .by_user(user); + let stdcm_log: StdcmLog = app.fetch(request).assert_status(StatusCode::OK).json_into(); + assert_eq!(stdcm_log.trace_id, Some(trace_id)); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_not_found() { + let config = TestContextConfig { + disable_superuser: false, + enable_authorization: true, + enable_stdcm_logging: true, + enable_telemetry: true, + }; + let TestContextOutput { app, user } = init_app(config); + let _ = execute_stdcm_request(&app, user.clone()).await; + let request = app.get("/stdcm_logs/not_existing_trace_id").by_user(user); + app.fetch(request).assert_status(StatusCode::NOT_FOUND); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_unauthorized() { + let config = TestContextConfig { + disable_superuser: true, + enable_authorization: true, + enable_stdcm_logging: true, + enable_telemetry: true, + }; + let TestContextOutput { app, user } = init_app(config); + let trace_id = execute_stdcm_request(&app, user.clone()).await; + let request = app + .get(format!("/stdcm_logs/{trace_id}").as_str()) + .by_user(user); + app.fetch(request).assert_status(StatusCode::FORBIDDEN); + } + + #[rstest] + async fn get_stdcm_log_by_trace_id_return_empty_used_id() { + let config = TestContextConfig { + disable_superuser: false, + enable_authorization: false, + enable_stdcm_logging: true, + enable_telemetry: true, + }; + let TestContextOutput { app, user } = init_app(config); + let trace_id = execute_stdcm_request(&app, user.clone()).await; + let request = app + .get(format!("/stdcm_logs/{trace_id}").as_str()) + .by_user(user); + let stdcm_log: StdcmLog = app.fetch(request).assert_status(StatusCode::OK).json_into(); + assert_eq!(stdcm_log.user_id, None); + } + + #[rstest] + async fn list_stdcm_logs_return_empty_trace_id() { + let config = TestContextConfig { + disable_superuser: false, + enable_authorization: true, + enable_stdcm_logging: true, + enable_telemetry: false, + }; + let TestContextOutput { app, user } = init_app(config); + let _ = execute_stdcm_request(&app, user.clone()).await; + let request = app.get("/stdcm_logs?page_size=10").by_user(user); + let stdcm_logs_response: StdcmLogListResponse = + app.fetch(request).assert_status(StatusCode::OK).json_into(); + + assert_eq!(stdcm_logs_response.results.len(), 1); + assert_eq!(stdcm_logs_response.results[0].trace_id, None); + } +} diff --git a/editoast/src/views/test_app.rs b/editoast/src/views/test_app.rs index eba92675b2c..cb5a9e2c44e 100644 --- a/editoast/src/views/test_app.rs +++ b/editoast/src/views/test_app.rs @@ -63,6 +63,8 @@ pub(crate) struct TestAppBuilder { core_client: Option, osrdyne_client: Option, enable_authorization: bool, + enable_stdcm_logging: bool, + enable_telemetry: bool, user: Option, roles: HashSet, } @@ -74,6 +76,8 @@ impl TestAppBuilder { core_client: None, osrdyne_client: None, enable_authorization: false, + enable_stdcm_logging: false, + enable_telemetry: true, user: None, roles: HashSet::new(), } @@ -102,6 +106,16 @@ impl TestAppBuilder { self } + pub fn enable_stdcm_logging(mut self, enable_stdcm_logging: bool) -> Self { + self.enable_stdcm_logging = enable_stdcm_logging; + self + } + + pub fn enable_telemetry(mut self, enable_telemetry: bool) -> Self { + self.enable_telemetry = enable_telemetry; + self + } + pub fn user(mut self, user: UserInfo) -> Self { assert!(self.user.is_none()); self.user = Some(user); @@ -130,6 +144,7 @@ impl TestAppBuilder { address: String::default(), health_check_timeout: chrono::Duration::milliseconds(500), disable_authorization: !self.enable_authorization, + enable_stdcm_logging: self.enable_stdcm_logging, map_layers_max_zoom: 18, postgres_config: PostgresConfig { database_url: Url::parse("postgres://osrd:password@localhost:5432/osrd").unwrap(), @@ -152,14 +167,23 @@ impl TestAppBuilder { }; // Setup tracing - let tracing_config = TracingConfig { - stream: Stream::Stdout, - telemetry: Some(Telemetry { + let telemetry = if self.enable_telemetry { + Some(Telemetry { service_name: "osrd-editoast".into(), endpoint: Url::parse("http://localhost:4317").unwrap(), - }), + }) + } else { + None + }; + let tracing_config = TracingConfig { + stream: Stream::Stdout, + telemetry, }; - let sub = create_tracing_subscriber(tracing_config, NoopSpanExporter); + let sub = create_tracing_subscriber( + tracing_config, + tracing_subscriber::filter::LevelFilter::DEBUG, + NoopSpanExporter, + ); let tracing_guard = tracing::subscriber::set_default(sub); // Config valkey diff --git a/editoast/src/views/timetable/stdcm.rs b/editoast/src/views/timetable/stdcm.rs index 9a191072330..522054be7c1 100644 --- a/editoast/src/views/timetable/stdcm.rs +++ b/editoast/src/views/timetable/stdcm.rs @@ -1,13 +1,14 @@ mod failure_handler; -mod request; +pub(crate) mod request; use axum::extract::Json; use axum::extract::Path; use axum::extract::Query; use axum::extract::State; use axum::Extension; +use chrono::DateTime; +use chrono::Duration; use chrono::Utc; -use chrono::{DateTime, Duration}; use editoast_authz::BuiltinRole; use editoast_derive::EditoastError; use editoast_models::DbConnectionPoolV2; @@ -17,6 +18,8 @@ use editoast_schemas::train_schedule::Margins; use editoast_schemas::train_schedule::ReceptionSignal; use editoast_schemas::train_schedule::ScheduleItem; use failure_handler::SimulationFailureHandler; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::TraceId; use request::convert_steps; use request::Request; use serde::Deserialize; @@ -25,6 +28,7 @@ use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use tracing::Instrument; +use tracing_opentelemetry::OpenTelemetrySpanExt; use utoipa::IntoParams; use utoipa::ToSchema; use validator::Validate; @@ -125,6 +129,7 @@ struct InfraIdQueryParam { )] async fn stdcm( State(AppState { + config, db_pool, valkey: valkey_client, core_client, @@ -143,6 +148,14 @@ async fn stdcm( return Err(AuthorizationError::Forbidden.into()); } + let trace_id = tracing::Span::current() + .context() + .span() + .span_context() + .trace_id(); + + let trace_id = Some(trace_id).filter(|trace_id| *trace_id != TraceId::INVALID); + stdcm_request.validate()?; let mut conn = db_pool.get().await?; @@ -265,20 +278,33 @@ async fn stdcm( let stdcm_response = stdcm_request.fetch(core_client.as_ref()).await?; - // 6. Check if the current tracing level is debug or greater, and if so, log STDCM request and response - if tracing::level_filters::LevelFilter::current() >= tracing::Level::DEBUG { + // 6. Log STDCM request and response if logging is enabled + if config.enable_stdcm_logging { let user_id = auth.authorizer().map_or_else( |e| { tracing::error!("Authorization failed: {e}. Unable to retrieve user ID."); None }, - |auth| Some(auth.user_id()), + |auth| { + if auth.is_superuser_stub() { + return None; + } + Some(auth.user_id()) + }, ); + let _ = tokio::spawn( // We just don't await the creation of the log entry since we want // the endpoint to return as soon as possible, and because failing // to persist a log entry is not a very important error here. - StdcmLog::log(conn, stdcm_request, stdcm_response.clone(), user_id).in_current_span(), + StdcmLog::log( + conn, + trace_id.map(|trace_id| trace_id.to_string()), + stdcm_request, + stdcm_response.clone(), + user_id, + ) + .in_current_span(), ) .await; } @@ -488,6 +514,10 @@ mod tests { use chrono::DateTime; use editoast_models::DbConnectionPoolV2; use editoast_schemas::rolling_stock::RollingResistance; + use editoast_schemas::train_schedule::Comfort; + use editoast_schemas::train_schedule::OperationalPointIdentifier; + use editoast_schemas::train_schedule::OperationalPointReference; + use editoast_schemas::train_schedule::PathItemLocation; use pretty_assertions::assert_eq; use rstest::rstest; use serde_json::json; @@ -497,12 +527,10 @@ mod tests { use crate::core::conflict_detection::Conflict; use crate::core::conflict_detection::ConflictType; use crate::core::mocking::MockingClient; - use crate::core::pathfinding::PathfindingResultSuccess; use crate::core::simulation::CompleteReportTrain; use crate::core::simulation::ElectricalProfiles; use crate::core::simulation::PhysicsConsist; use crate::core::simulation::ReportTrain; - use crate::core::simulation::SimulationResponse; use crate::core::simulation::SpeedLimitProperties; use crate::models::fixtures::create_fast_rolling_stock; use crate::models::fixtures::create_simple_rolling_stock; @@ -510,10 +538,133 @@ mod tests { use crate::models::fixtures::create_timetable; use crate::models::fixtures::create_towed_rolling_stock; use crate::views::test_app::TestAppBuilder; + use crate::views::timetable::stdcm::request::PathfindingItem; + use crate::views::timetable::stdcm::request::StepTimingData; use crate::views::timetable::stdcm::PathfindingResult; + use crate::views::timetable::stdcm::Request; use super::*; + fn stdcm_payload(rolling_stock_id: i64) -> Request { + Request { + start_time: None, + steps: vec![ + PathfindingItem { + duration: Some(0), + location: PathItemLocation::OperationalPointReference( + OperationalPointReference { + reference: OperationalPointIdentifier::OperationalPointDescription { + trigram: "WS".into(), + secondary_code: Some("BV".to_string()), + }, + track_reference: None, + }, + ), + timing_data: Some(StepTimingData { + arrival_time: DateTime::from_str("2024-09-17T20:05:00+02:00") + .expect("Failed to parse datetime"), + arrival_time_tolerance_before: 0, + arrival_time_tolerance_after: 0, + }), + }, + PathfindingItem { + duration: Some(0), + location: PathItemLocation::OperationalPointReference( + OperationalPointReference { + reference: OperationalPointIdentifier::OperationalPointDescription { + trigram: "MWS".into(), + secondary_code: Some("BV".to_string()), + }, + track_reference: None, + }, + ), + timing_data: None, + }, + ], + rolling_stock_id, + towed_rolling_stock_id: None, + electrical_profile_set_id: None, + work_schedule_group_id: None, + temporary_speed_limit_group_id: None, + comfort: Comfort::Standard, + maximum_departure_delay: None, + maximum_run_time: None, + speed_limit_tags: Some("AR120".to_string()), + time_gap_before: 35000, + time_gap_after: 35000, + margin: Some(MarginValue::MinPer100Km(4.5)), + total_mass: None, + total_length: None, + max_speed: None, + loading_gauge_type: None, + } + } + + fn pathfinding_result_success() -> PathfindingResultSuccess { + PathfindingResultSuccess { + blocks: vec![], + routes: vec![], + track_section_ranges: vec![], + length: 0, + path_item_positions: vec![0, 10], + } + } + + fn core_mocking_client() -> MockingClient { + let mut core = MockingClient::new(); + core.stub("/v2/pathfinding/blocks") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(PathfindingResult::Success(pathfinding_result_success())) + .finish(); + core.stub("/v2/standalone_simulation") + .method(reqwest::Method::POST) + .response(StatusCode::OK) + .json(simulation_response()) + .finish(); + core + } + + fn simulation_response() -> SimulationResponse { + SimulationResponse::Success { + base: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + provisional: ReportTrain { + positions: vec![], + times: vec![0, 10], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + final_output: CompleteReportTrain { + report_train: ReportTrain { + positions: vec![], + times: vec![], + speeds: vec![], + energy_consumption: 0.0, + path_item_times: vec![0, 10], + }, + signal_critical_positions: vec![], + zone_updates: vec![], + spacing_requirements: vec![], + routing_requirements: vec![], + }, + mrsp: SpeedLimitProperties { + boundaries: vec![], + values: vec![], + }, + electrical_profiles: ElectricalProfiles { + boundaries: vec![], + values: vec![], + }, + } + } + #[test] fn simulation_with_towed_rolling_stock_parameters() { let mut rolling_stock = create_simple_rolling_stock(); @@ -659,94 +810,6 @@ mod tests { assert_eq!(physics_consist.max_speed, 20_f64); } - fn pathfinding_result_success() -> PathfindingResult { - PathfindingResult::Success(PathfindingResultSuccess { - blocks: vec![], - routes: vec![], - track_section_ranges: vec![], - length: 0, - path_item_positions: vec![0, 10], - }) - } - - fn simulation_response() -> SimulationResponse { - SimulationResponse::Success { - base: ReportTrain { - positions: vec![], - times: vec![], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - provisional: ReportTrain { - positions: vec![], - times: vec![0, 10], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - final_output: CompleteReportTrain { - report_train: ReportTrain { - positions: vec![], - times: vec![], - speeds: vec![], - energy_consumption: 0.0, - path_item_times: vec![0, 10], - }, - signal_critical_positions: vec![], - zone_updates: vec![], - spacing_requirements: vec![], - routing_requirements: vec![], - }, - mrsp: SpeedLimitProperties { - boundaries: vec![], - values: vec![], - }, - electrical_profiles: ElectricalProfiles { - boundaries: vec![], - values: vec![], - }, - } - } - - fn stdcm_payload(rolling_stock_id: i64) -> serde_json::Value { - json!({ - "comfort": "STANDARD", - "margin": "4.5min/100km", - "rolling_stock_id": rolling_stock_id, - "speed_limit_tags": "AR120", - "steps": [ - { - "duration": 0, - "location": { "trigram": "WS", "secondary_code": "BV" }, - "timing_data": { - "arrival_time": "2024-09-17T20:05:00+02:00", - "arrival_time_tolerance_before": 0, - "arrival_time_tolerance_after": 0 - } - }, - { "duration": 0, "location": { "trigram": "MWS", "secondary_code": "BV" } } - ], - "time_gap_after": 35000, - "time_gap_before": 35000 - }) - } - - fn core_mocking_client() -> MockingClient { - let mut core = MockingClient::new(); - core.stub("/v2/pathfinding/blocks") - .method(reqwest::Method::POST) - .response(StatusCode::OK) - .json(pathfinding_result_success()) - .finish(); - core.stub("/v2/standalone_simulation") - .method(reqwest::Method::POST) - .response(StatusCode::OK) - .json(serde_json::to_value(simulation_response()).unwrap()) - .finish(); - core - } - fn conflict_data() -> Conflict { Conflict { train_ids: vec![0, 1], @@ -766,12 +829,12 @@ mod tests { core.stub("/v2/stdcm") .method(reqwest::Method::POST) .response(StatusCode::OK) - .json(json!({ - "status": "success", - "simulation": serde_json::to_value(simulation_response()).unwrap(), - "path": serde_json::to_value(pathfinding_result_success()).unwrap(), - "departure_time": "2024-01-02T00:00:00Z" - })) + .json(crate::core::stdcm::Response::Success { + simulation: simulation_response(), + path: pathfinding_result_success(), + departure_time: DateTime::from_str("2024-01-02T00:00:00Z") + .expect("Failed to parse datetime"), + }) .finish(); let app = TestAppBuilder::new() @@ -790,7 +853,9 @@ mod tests { let stdcm_response: StdcmResponse = app.fetch(request).assert_status(StatusCode::OK).json_into(); - if let PathfindingResult::Success(path) = pathfinding_result_success() { + if let PathfindingResult::Success(path) = + PathfindingResult::Success(pathfinding_result_success()) + { assert_eq!( stdcm_response, StdcmResponse::Success { @@ -842,7 +907,7 @@ mod tests { assert_eq!( stdcm_response, StdcmResponse::Conflicts { - pathfinding_result: pathfinding_result_success(), + pathfinding_result: PathfindingResult::Success(pathfinding_result_success()), conflicts: vec![conflict], } ); diff --git a/editoast/src/views/timetable/stdcm/request.rs b/editoast/src/views/timetable/stdcm/request.rs index f3c682533e3..7f8c3864f7a 100644 --- a/editoast/src/views/timetable/stdcm/request.rs +++ b/editoast/src/views/timetable/stdcm/request.rs @@ -28,19 +28,18 @@ use crate::SelectionSettings; use super::StdcmError; editoast_common::schemas! { - Request, PathfindingItem, - StepTimingData, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] -pub(super) struct PathfindingItem { +pub(crate) struct PathfindingItem { /// The stop duration in milliseconds, None if the train does not stop. - duration: Option, + pub(crate) duration: Option, /// The associated location - location: PathItemLocation, + pub(crate) location: PathItemLocation, /// Time at which the train should arrive at the location, if specified - timing_data: Option, + #[schema(inline)] + pub(crate) timing_data: Option, } /// Convert the list of pathfinding items into a list of path item @@ -56,62 +55,62 @@ pub(super) fn convert_steps(steps: &[PathfindingItem]) -> Vec { } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] -struct StepTimingData { +pub(crate) struct StepTimingData { /// Time at which the train should arrive at the location - arrival_time: DateTime, + pub(crate) arrival_time: DateTime, /// The train may arrive up to this duration before the expected arrival time - arrival_time_tolerance_before: u64, + pub(crate) arrival_time_tolerance_before: u64, /// The train may arrive up to this duration after the expected arrival time - arrival_time_tolerance_after: u64, + pub(crate) arrival_time_tolerance_after: u64, } /// An STDCM request #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate, ToSchema)] -pub(super) struct Request { +pub(crate) struct Request { /// Deprecated, first step arrival time should be used instead - pub(super) start_time: Option>, - pub(super) steps: Vec, - pub(super) rolling_stock_id: i64, - pub(super) towed_rolling_stock_id: Option, - pub(super) electrical_profile_set_id: Option, - pub(super) work_schedule_group_id: Option, - pub(super) temporary_speed_limit_group_id: Option, - pub(super) comfort: Comfort, + pub(crate) start_time: Option>, + pub(crate) steps: Vec, + pub(crate) rolling_stock_id: i64, + pub(crate) towed_rolling_stock_id: Option, + pub(crate) electrical_profile_set_id: Option, + pub(crate) work_schedule_group_id: Option, + pub(crate) temporary_speed_limit_group_id: Option, + pub(crate) comfort: Comfort, /// By how long we can shift the departure time in milliseconds /// Deprecated, first step data should be used instead - pub(super) maximum_departure_delay: Option, + pub(crate) maximum_departure_delay: Option, /// Specifies how long the total run time can be in milliseconds /// Deprecated, first step data should be used instead - pub(super) maximum_run_time: Option, + pub(crate) maximum_run_time: Option, /// Train categories for speed limits // TODO: rename the field and its description - pub(super) speed_limit_tags: Option, + pub(crate) speed_limit_tags: Option, /// Margin before the train passage in seconds /// /// Enforces that the path used by the train should be free and /// available at least that many milliseconds before its passage. #[serde(default)] - pub(super) time_gap_before: u64, + pub(crate) time_gap_before: u64, /// Margin after the train passage in milliseconds /// /// Enforces that the path used by the train should be free and /// available at least that many milliseconds after its passage. #[serde(default)] - pub(super) time_gap_after: u64, + pub(crate) time_gap_after: u64, /// Can be a percentage `X%`, a time in minutes per 100 kilometer `Xmin/100km` #[serde(default)] #[schema(value_type = Option, example = json!(["5%", "2min/100km"]))] - pub(super) margin: Option, + pub(crate) margin: Option, /// Total mass of the consist in kg #[validate(range(exclusive_min = 0.0))] - pub(super) total_mass: Option, + pub(crate) total_mass: Option, /// Total length of the consist in meters #[validate(range(exclusive_min = 0.0))] - pub(super) total_length: Option, + pub(crate) total_length: Option, /// Maximum speed of the consist in km/h #[validate(range(exclusive_min = 0.0))] - pub(super) max_speed: Option, - pub(super) loading_gauge_type: Option, + pub(crate) max_speed: Option, + pub(crate) loading_gauge_type: Option, } impl Request { diff --git a/front/public/locales/en/errors.json b/front/public/locales/en/errors.json index a985172b2d7..bbb46f6eab7 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -186,6 +186,9 @@ "stdcm": { "InfraNotFound": "Infrastructure '{{infra_id}}' does not exist" }, + "stdcm_log": { + "NotFound": "STDCM Log '{{trace_id}}' does not exist" + }, "stdcm_v2": { "InfraNotFound": "Infrastructure '{{infra_id}}' does not exist", "InvalidPathItems": "Invalid waypoint(s) {{items}}", diff --git a/front/public/locales/fr/errors.json b/front/public/locales/fr/errors.json index 988881c0d9a..33132d9c93f 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -188,6 +188,9 @@ "stdcm": { "InfraNotFound": "Infrastructure {{infra_id}} non trouvée" }, + "stdcm_log": { + "NotFound": "STDCM Log '{{trace_id}}' non trouvée" + }, "stdcm_v2": { "InfraNotFound": "Infrastructure '{{infra_id}}' non trouvée", "InvalidPathItems": "Point(s) de passage {{items}} invalide(s)", diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index cbb681724ef..2578d3114b2 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -18,6 +18,7 @@ export const addTagTypes = [ 'speed_limit_tags', 'sprites', 'stdcm_search_environment', + 'stdcm_log', 'temporary_speed_limits', 'timetable', 'stdcm', @@ -769,6 +770,20 @@ const injectedRtkApi = api }), invalidatesTags: ['stdcm_search_environment'], }), + getStdcmLogs: build.query({ + query: (queryArg) => ({ + url: `/stdcm_logs`, + params: { page: queryArg.page, page_size: queryArg.pageSize }, + }), + providesTags: ['stdcm_log'], + }), + getStdcmLogsByTraceId: build.query< + GetStdcmLogsByTraceIdApiResponse, + GetStdcmLogsByTraceIdApiArg + >({ + query: (queryArg) => ({ url: `/stdcm_logs/${queryArg.traceId}` }), + providesTags: ['stdcm_log'], + }), postTemporarySpeedLimitGroup: build.mutation< PostTemporarySpeedLimitGroupApiResponse, PostTemporarySpeedLimitGroupApiArg @@ -1636,6 +1651,17 @@ export type PostStdcmSearchEnvironmentApiResponse = /** status 201 */ StdcmSear export type PostStdcmSearchEnvironmentApiArg = { stdcmSearchEnvironmentCreateForm: StdcmSearchEnvironmentCreateForm; }; +export type GetStdcmLogsApiResponse = /** status 200 The list of STDCM Logs */ PaginationStats & { + results: StdcmLog[]; +}; +export type GetStdcmLogsApiArg = { + page?: number; + pageSize?: number | null; +}; +export type GetStdcmLogsByTraceIdApiResponse = /** status 200 The STDCM log */ StdcmLog; +export type GetStdcmLogsByTraceIdApiArg = { + traceId: string; +}; export type PostTemporarySpeedLimitGroupApiResponse = /** status 201 The id of the created temporary speed limit group. */ { group_id: number; @@ -3303,30 +3329,116 @@ export type StdcmSearchEnvironmentCreateForm = { timetable_id: number; work_schedule_group_id?: number | null; }; -export type TimetableResult = { - timetable_id: number; +export type RoutingZoneRequirement = { + /** Time in ms */ + end_time: number; + entry_detector: string; + exit_detector: string; + switches: { + [key: string]: string; + }; + zone: string; }; -export type TimetableDetailedResult = { - timetable_id: number; - train_ids: number[]; +export type RoutingRequirement = { + /** Time in ms */ + begin_time: number; + route: string; + zones: RoutingZoneRequirement[]; }; -export type ConflictRequirement = { - end_time: string; - start_time: string; +export type SpacingRequirement = { + begin_time: number; + end_time: number; zone: string; }; -export type Conflict = { - conflict_type: 'Spacing' | 'Routing'; - /** Datetime of the end of the conflict */ - end_time: string; - /** List of requirements causing the conflict */ - requirements: ConflictRequirement[]; - /** Datetime of the start of the conflict */ +export type WorkScheduleType = 'CATENARY' | 'TRACK'; +export type WorkSchedule = { + end_date_time: string; + id: number; + obj_id: string; + start_date_time: string; + track_ranges: TrackRange[]; + work_schedule_group_id: number; + work_schedule_type: WorkScheduleType; +}; +export type StdcmRequest = { + comfort: Comfort; + /** Infrastructure expected version */ + expected_version: string; + /** Infrastructure id */ + infra: number; + margin?: + | ( + | { + Percentage: number; + } + | { + MinPer100Km: number; + } + ) + | null; + /** Maximum departure delay in milliseconds. */ + maximum_departure_delay: number; + /** Maximum run time of the simulation in milliseconds */ + maximum_run_time: number; + /** List of waypoints. Each waypoint is a list of track offset. */ + path_items: PathItem[]; + physics_consist: { + base_power_class?: string | null; + /** In m/s² */ + comfort_acceleration: number; + /** The constant gamma braking coefficient used when NOT circulating + under ETCS/ERTMS signaling system in m/s^2 */ + const_gamma: number; + effort_curves: EffortCurves; + /** The time the train takes before actually using electrical power (in milliseconds). + Is null if the train is not electric or the value not specified. */ + electrical_power_startup_time?: number | null; + etcs_brake_params?: EtcsBrakeParams | null; + inertia_coefficient: number; + /** Length of the rolling stock in mm */ + length: number; + /** Mass of the rolling stock in kg */ + mass: number; + /** Maximum speed of the rolling stock in m/s */ + max_speed: number; + /** Mapping of power restriction code to power class */ + power_restrictions?: { + [key: string]: string; + }; + /** The time it takes to raise this train's pantograph in milliseconds. + Is null if the train is not electric or the value not specified. */ + raise_pantograph_time?: number | null; + rolling_resistance: RollingResistance; + /** In m/s² */ + startup_acceleration: number; + startup_time: number; + }; + rolling_stock_loading_gauge: LoadingGaugeType; + rolling_stock_supported_signaling_systems: RollingStockSupportedSignalingSystems; + speed_limit_tag?: string | null; start_time: string; - /** List of train ids involved in the conflict */ - train_ids: number[]; - /** List of work schedule ids involved in the conflict */ - work_schedule_ids: number[]; + /** List of applicable temporary speed limits between the train departure and arrival */ + temporary_speed_limits: { + /** Speed limitation in m/s */ + speed_limit: number; + /** Track ranges on which the speed limitation applies */ + track_ranges: TrackRange[]; + }[]; + /** Gap between the created train and following trains in milliseconds */ + time_gap_after: number; + /** Gap between the created train and previous trains in milliseconds */ + time_gap_before: number; + /** Numerical integration time step in milliseconds. Use default value if not specified. */ + time_step?: number | null; + trains_requirements: { + [key: string]: { + routing_requirements: RoutingRequirement[]; + spacing_requirements: SpacingRequirement[]; + start_time: string; + }; + }; + /** List of planned work schedules */ + work_schedules: WorkSchedule[]; }; export type ReportTrain = { /** Total energy consumption */ @@ -3341,22 +3453,6 @@ export type ReportTrain = { speeds: number[]; times: number[]; }; -export type RoutingZoneRequirement = { - /** Time in ms */ - end_time: number; - entry_detector: string; - exit_detector: string; - switches: { - [key: string]: string; - }; - zone: string; -}; -export type RoutingRequirement = { - /** Time in ms */ - begin_time: number; - route: string; - zones: RoutingZoneRequirement[]; -}; export type SignalCriticalPosition = { /** Position in mm */ position: number; @@ -3365,11 +3461,6 @@ export type SignalCriticalPosition = { /** Time in ms */ time: number; }; -export type SpacingRequirement = { - begin_time: number; - end_time: number; - zone: string; -}; export type ZoneUpdate = { is_entry: boolean; position: number; @@ -3438,19 +3529,65 @@ export type SimulationResponse = core_error: InternalError; status: 'simulation_failed'; }; -export type StepTimingData = { - /** Time at which the train should arrive at the location */ - arrival_time: string; - /** The train may arrive up to this duration after the expected arrival time */ - arrival_time_tolerance_after: number; - /** The train may arrive up to this duration before the expected arrival time */ - arrival_time_tolerance_before: number; +export type StdcmResponse = + | { + departure_time: string; + path: PathfindingResultSuccess; + simulation: SimulationResponse; + status: 'success'; + } + | { + status: 'path_not_found'; + } + | { + error: SimulationResponse; + status: 'preprocessing_simulation_error'; + }; +export type StdcmLog = { + created: string; + id: number; + request: StdcmRequest; + response: StdcmResponse; + trace_id?: string | null; + user_id?: number | null; +}; +export type TimetableResult = { + timetable_id: number; +}; +export type TimetableDetailedResult = { + timetable_id: number; + train_ids: number[]; +}; +export type ConflictRequirement = { + end_time: string; + start_time: string; + zone: string; +}; +export type Conflict = { + conflict_type: 'Spacing' | 'Routing'; + /** Datetime of the end of the conflict */ + end_time: string; + /** List of requirements causing the conflict */ + requirements: ConflictRequirement[]; + /** Datetime of the start of the conflict */ + start_time: string; + /** List of train ids involved in the conflict */ + train_ids: number[]; + /** List of work schedule ids involved in the conflict */ + work_schedule_ids: number[]; }; export type PathfindingItem = { /** The stop duration in milliseconds, None if the train does not stop. */ duration?: number | null; location: PathItemLocation; - timing_data?: StepTimingData | null; + timing_data?: { + /** Time at which the train should arrive at the location */ + arrival_time: string; + /** The train may arrive up to this duration after the expected arrival time */ + arrival_time_tolerance_after: number; + /** The train may arrive up to this duration before the expected arrival time */ + arrival_time_tolerance_before: number; + } | null; }; export type Distribution = 'STANDARD' | 'MARECO'; export type TrainScheduleBase = { @@ -3638,16 +3775,6 @@ export type WorkScheduleItemForm = { track_ranges: TrackRange[]; work_schedule_type: 'CATENARY' | 'TRACK'; }; -export type WorkScheduleType = 'CATENARY' | 'TRACK'; -export type WorkSchedule = { - end_date_time: string; - id: number; - obj_id: string; - start_date_time: string; - track_ranges: TrackRange[]; - work_schedule_group_id: number; - work_schedule_type: WorkScheduleType; -}; export type Intersection = { /** Distance of the end of the intersection relative to the beginning of the path */ end: number;