diff --git a/site/app/controllers/admin/statistics_controller.rb b/site/app/controllers/admin/statistics_controller.rb new file mode 100644 index 0000000000..eb754d9e9a --- /dev/null +++ b/site/app/controllers/admin/statistics_controller.rb @@ -0,0 +1,50 @@ +class Admin::StatisticsController < AdminController + PAGES = { + kpi: { title: 'KPI & Statistiques', description: 'Appels, KPIs, taux d\'erreur et variations' }, + token_consumption: { title: 'Consommation d\'un token', description: 'Suivi de la consommation par jeton' }, + top_users: { title: 'Top 10 utilisateurs', description: 'Top consommateurs par token, datapass, éditeur et IP' }, + api_health: { title: 'État de santé global', description: 'État instantané de toutes les APIs' }, + api_status: { title: 'Statut d\'une API', description: 'Appels, cache, durée et codes HTTP par API' }, + annual_stats: { title: 'Statistiques annuelles', description: 'Appels, utilisateurs et succès sur une période' }, + api_consumers: { title: 'Consommateurs d\'une API', description: 'Liste des consommateurs et habilitations par API' }, + users_overview: { title: 'Vue générale utilisateurs', description: 'Nombre d\'utilisateurs, habilitations et jetons' }, + user_status: { title: 'Status utilisateur', description: 'Recherche par email : habilitations, jetons et statuts' } + }.freeze + + before_action :set_page, only: :show + before_action :build_filter, only: :show + before_action :build_query, only: :show + + def index; end + + def show + render page + end + + private + + attr_reader :page + + def set_page + @page = params[:page].to_sym + raise ActionController::RoutingError, 'Not Found' unless PAGES.key?(@page) + end + + def build_filter + @filter = Admin::StatisticsFilter.new(filter_params) + end + + def build_query + @query = query_class.new(@filter) + end + + def query_class + "Admin::#{page.to_s.classify}Query".constantize + end + + def filter_params + params.expect(filter: %i[date_from date_to interval domaine api email external_id token_id]) + rescue ActionController::ParameterMissing + {} + end +end diff --git a/site/app/forms/admin/statistics_filter.rb b/site/app/forms/admin/statistics_filter.rb new file mode 100644 index 0000000000..fb3611888a --- /dev/null +++ b/site/app/forms/admin/statistics_filter.rb @@ -0,0 +1,70 @@ +class Admin::StatisticsFilter + include ActiveModel::Model + include ActiveModel::Attributes + + INTERVALS = %w[jour semaine mois].freeze + DOMAINES = %w[entreprise particulier].freeze + + INTERVAL_TO_PG_UNIT = { + 'jour' => 'day', + 'semaine' => 'week', + 'mois' => 'month' + }.freeze + + def self.date_presets(today: Date.current) + { + '1m' => [today - 1.month, today], + '3m' => [today - 3.months, today], + '6m' => [today - 6.months, today], + '12m' => [today - 12.months, today], + 'last_year' => [today.last_year.beginning_of_year, today.last_year.end_of_year] + } + end + + attribute :date_from, :date + attribute :date_to, :date + attribute :interval, :string, default: 'mois' + attribute :domaine, :string + attribute :api, :string + attribute :email, :string + attribute :external_id, :string + attribute :token_id, :string + + validates :interval, inclusion: { in: INTERVALS } + validate :date_from_before_date_to + + def initialize(attributes = {}) + attrs = attributes.to_h.symbolize_keys + super(attrs) + self.date_from ||= 3.months.ago.to_date + self.date_to ||= Date.current + self.interval = 'mois' unless INTERVALS.include?(interval) + end + + def matches_preset?(key) + from, to = self.class.date_presets[key] + from == date_from && to == date_to + end + + def pg_interval_unit + INTERVAL_TO_PG_UNIT.fetch(interval, 'month') + end + + def domaine_prefix + return nil if domaine.blank? + + "api_#{domaine}" + end + + def domaine? + domaine.present? + end + + private + + def date_from_before_date_to + return if date_from.blank? || date_to.blank? + + errors.add(:date_to, :must_be_after_date_from) if date_to < date_from + end +end diff --git a/site/app/helpers/admin/statistics_helper.rb b/site/app/helpers/admin/statistics_helper.rb new file mode 100644 index 0000000000..bc11aac3d9 --- /dev/null +++ b/site/app/helpers/admin/statistics_helper.rb @@ -0,0 +1,12 @@ +module Admin::StatisticsHelper + BUCKET_FORMATS = { + 'jour' => '%d/%m', + 'semaine' => 'S%V %G', + 'mois' => '%m/%Y' + }.freeze + + def bucket_label(date, interval = 'mois') + fmt = BUCKET_FORMATS.fetch(interval, '%d/%m/%Y') + date.strftime(fmt) + end +end diff --git a/site/app/lib/seeds.rb b/site/app/lib/seeds.rb index baf05b0db6..4725a6a975 100644 --- a/site/app/lib/seeds.rb +++ b/site/app/lib/seeds.rb @@ -19,7 +19,7 @@ def perform create_provider_dashboard_data end - def flushdb + def flushdb # rubocop:disable Metrics/AbcSize raise 'Not in production!' if Rails.env.production? load_all_models! @@ -29,7 +29,7 @@ def flushdb ActiveRecord::Base.connection.transaction do MagicLink.delete_all views = ActiveRecord::Base.connection.views - ApplicationRecord.descendants.reject { |k| views.include?(k.table_name) }.each(&:delete_all) + ApplicationRecord.descendants.reject { |k| k.abstract_class? || views.include?(k.table_name) }.each(&:delete_all) AccessLog.delete_all end end diff --git a/site/app/models/controller_name.rb b/site/app/models/controller_name.rb new file mode 100644 index 0000000000..0082ee71f4 --- /dev/null +++ b/site/app/models/controller_name.rb @@ -0,0 +1,6 @@ +class ControllerName < ApplicationRecord + self.table_name = "admin_apientreprise_#{Rails.env}_access_logs_controller_name" + self.primary_key = nil + + def readonly? = true +end diff --git a/site/app/models/kpi_view.rb b/site/app/models/kpi_view.rb new file mode 100644 index 0000000000..010d6ce5aa --- /dev/null +++ b/site/app/models/kpi_view.rb @@ -0,0 +1,18 @@ +class KpiView < ApplicationRecord + self.abstract_class = true + self.primary_key = nil + + def readonly? = true + + class Kpi1 < KpiView + self.table_name = "admin_apientreprise_#{Rails.env}_access_logs_kpi1" + end + + class Kpi2 < KpiView + self.table_name = "admin_apientreprise_#{Rails.env}_access_logs_kpi2" + end + + class Kpi3 < KpiView + self.table_name = "admin_apientreprise_#{Rails.env}_access_logs_kpi3" + end +end diff --git a/site/app/queries/admin/annual_stat_query.rb b/site/app/queries/admin/annual_stat_query.rb new file mode 100644 index 0000000000..b5a58e0d38 --- /dev/null +++ b/site/app/queries/admin/annual_stat_query.rb @@ -0,0 +1,110 @@ +class Admin::AnnualStatQuery + def initialize(filter) + @filter = filter + end + + # card 440 + def total_calls + scoped.count + end + + # card 441 + def unique_calls + scoped.select("DISTINCT COALESCE(params->>'hashed_params', path)").count + end + + # card 442 + def cached_calls + scoped.where(cached: true).count + end + + # card 465 + def total_users + scoped + .joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') + .joins('INNER JOIN authorization_requests ON authorization_requests.id = tokens.authorization_request_model_id') + .select('DISTINCT authorization_requests.external_id') + .count + end + + # card 464 + def success_rate_breakdown + total = scoped.count + success = scoped.where(status: '200').count + not_found = scoped.where(status: '404').count + other = total - success - not_found + + [ + { label: 'Succès (200)', value: success }, + { label: 'Non trouvé (404)', value: not_found }, + { label: 'Autre', value: other } + ] + end + + # card 518 + def unique_sirets + scoped + .where("params->>'siret' IS NOT NULL") + .select("DISTINCT params->>'siret'") + .count + end + + # card 434 + def calls_by_month + scoped + .select("date_trunc('month', timestamp) AS period", 'COUNT(*) AS total') + .group("date_trunc('month', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, value: r.total.to_i } } + end + + # card 432 + def cumulative_calls_by_month + monthly = calls_by_month + cumul = 0 + monthly.map do |r| + cumul += r[:value] + { bucket: r[:bucket], value: cumul } + end + end + + # card 433 + def users_by_month + scoped + .joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') + .joins('INNER JOIN authorization_requests ON authorization_requests.id = tokens.authorization_request_model_id') + .select("date_trunc('month', timestamp) AS period", 'COUNT(DISTINCT authorization_requests.external_id) AS total') + .group("date_trunc('month', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, value: r.total.to_i } } + end + + # card 478 + def api_list_by_domain + scope = ControllerName.all + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.order(controller: :desc).pluck(:controller) + end + + private + + attr_reader :filter + + def scoped # rubocop:disable Metrics/AbcSize + scope = AccessLog + .where("timestamp >= date_trunc('month', ?::date::timestamp)", filter.date_from) + .where("timestamp < date_trunc('month', CURRENT_DATE)") + .where("access_logs.status NOT IN ('401', '403')") + .where.not(controller: excluded_controllers) + + scope = scope.where(controller: filter.api) if filter.api.present? + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.api.blank? && filter.domaine? + scope + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/api_consumer_query.rb b/site/app/queries/admin/api_consumer_query.rb new file mode 100644 index 0000000000..e3fd4e5c72 --- /dev/null +++ b/site/app/queries/admin/api_consumer_query.rb @@ -0,0 +1,98 @@ +class Admin::APIConsumerQuery + def initialize(filter) + @filter = filter + end + + # card 420 + def total_calls + scoped.count + end + + # card 414 + def success_breakdown + total = scoped.count + success = scoped.where(status: '200').count + not_found = scoped.where(status: '404').count + other = total - success - not_found + + [ + { label: 'Succès', value: success }, + { label: 'Non trouvé', value: not_found }, + { label: 'Autre', value: other } + ] + end + + # card 371 + def calls_by_period + scoped + .select("date_trunc('#{filter.pg_interval_unit}', timestamp) AS period", 'COUNT(*) AS total') + .group("date_trunc('#{filter.pg_interval_unit}', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, value: r.total.to_i } } + end + + # card 361 + def consumer_habilitations # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + scoped + .joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') + .joins('INNER JOIN authorization_requests ON authorization_requests.id = tokens.authorization_request_model_id') + .joins(<<~SQL.squish) + LEFT OUTER JOIN user_authorization_request_roles + ON user_authorization_request_roles.authorization_request_id = authorization_requests.id + AND user_authorization_request_roles.role = 'demandeur' + SQL + .joins('LEFT OUTER JOIN users ON users.id = user_authorization_request_roles.user_id') + .select( + 'authorization_requests.external_id', + 'authorization_requests.intitule', + 'authorization_requests.siret', + 'users.email', + 'COUNT(DISTINCT access_logs.request_id) AS calls_count' + ) + .group( + 'authorization_requests.external_id', + 'authorization_requests.intitule', + 'authorization_requests.siret', + 'users.email' + ) + .order(calls_count: :desc) + .limit(100) + .map do |r| + { + external_id: r.external_id, + intitule: r.intitule, + siret: r.siret, + email: r.email, + calls: r.calls_count.to_i + } + end + end + + # card 483 + def api_list + scope = ControllerName.all + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.order(controller: :desc).pluck(:controller) + end + + private + + attr_reader :filter + + def scoped # rubocop:disable Metrics/AbcSize + scope = AccessLog + .where(timestamp: filter.date_from.beginning_of_day..filter.date_to.end_of_day) + .where("access_logs.status NOT IN ('401', '403')") + .where.not(controller: excluded_controllers) + + scope = scope.where(controller: filter.api) if filter.api.present? + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.api.blank? && filter.domaine? + scope + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/api_health_query.rb b/site/app/queries/admin/api_health_query.rb new file mode 100644 index 0000000000..de675c1867 --- /dev/null +++ b/site/app/queries/admin/api_health_query.rb @@ -0,0 +1,57 @@ +class Admin::APIHealthQuery + def initialize(filter) + @filter = filter + end + + # card 413 + def instant_status + instant_status_scope + .select(*instant_status_columns) + .group(:controller) + .order(:controller) + .map { |r| format_instant_row(r) } + end + + # card 483 + def api_list + scope = ControllerName.all + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.order(controller: :desc).pluck(:controller) + end + + private + + attr_reader :filter + + def instant_status_scope + scope = AccessLog + .where("timestamp >= NOW() - INTERVAL '10 minutes'") + .where.not(controller: excluded_controllers) + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope + end + + def instant_status_columns + [ + 'controller', 'COUNT(*) AS total', + "SUM(CASE WHEN status = '200' THEN 1 ELSE 0 END) AS success_count", + "SUM(CASE WHEN status = '404' THEN 1 ELSE 0 END) AS not_found_count", + "SUM(CASE WHEN status NOT IN ('200', '404', '401', '403') THEN 1 ELSE 0 END) AS error_count", + "ROUND(AVG(CASE WHEN status IN ('200', '404') THEN duration::numeric END), 2) AS avg_duration_ms" + ] + end + + def format_instant_row(row) + { + controller: row.controller, total: row.total.to_i, + success: row.success_count.to_i, not_found: row.not_found_count.to_i, + errors: row.error_count.to_i, avg_duration: row.avg_duration_ms&.to_f&.round(2) || 0 + } + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/api_status_query.rb b/site/app/queries/admin/api_status_query.rb new file mode 100644 index 0000000000..e6fef805d0 --- /dev/null +++ b/site/app/queries/admin/api_status_query.rb @@ -0,0 +1,96 @@ +class Admin::APIStatusQuery + def initialize(filter) + @filter = filter + end + + # card 420 + def total_calls + scoped.count + end + + # card 419 + def unique_calls + scoped.select("DISTINCT COALESCE(params->>'hashed_params', path)").count + end + + # card 415 + def cached_calls + scoped.where(cached: true).count + end + + # card 422 + def calls_vs_unique_evolution + scoped + .select( + "date_trunc('#{filter.pg_interval_unit}', timestamp) AS period", + 'COUNT(*) AS total', + "COUNT(DISTINCT COALESCE(params->>'hashed_params', path)) AS unique_count" + ) + .group("date_trunc('#{filter.pg_interval_unit}', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, total: r.total.to_i, unique: r.unique_count.to_i } } + end + + # card 421 + def cached_evolution + scoped + .where(cached: true) + .select( + "date_trunc('#{filter.pg_interval_unit}', timestamp) AS period", + 'COUNT(*) AS total' + ) + .group("date_trunc('#{filter.pg_interval_unit}', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, total: r.total.to_i } } + end + + # card 423 + def duration_evolution + scoped + .where("status IN ('200', '404')") + .select( + "date_trunc('#{filter.pg_interval_unit}', timestamp) AS period", + 'ROUND(AVG(duration::numeric), 2) AS avg_dur' + ) + .group("date_trunc('#{filter.pg_interval_unit}', timestamp)") + .order(:period) + .map { |r| { bucket: r.period, value: r.avg_dur&.to_f || 0 } } + end + + # card 424 + def http_code_breakdown + scoped + .select('status', 'COUNT(*) AS total') + .group(:status) + .order(total: :desc) + .map { |r| { label: r.status, value: r.total.to_i } } + end + + # card 478 + def api_list + scope = ControllerName.all + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.order(controller: :desc).pluck(:controller) + end + + private + + attr_reader :filter + + def scoped # rubocop:disable Metrics/AbcSize + scope = AccessLog + .where(timestamp: filter.date_from.beginning_of_day..filter.date_to.end_of_day) + .where("access_logs.status NOT IN ('401', '403')") + .where.not(controller: excluded_controllers) + + scope = scope.where(controller: filter.api) if filter.api.present? + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.api.blank? && filter.domaine? + scope + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/kpi_query.rb b/site/app/queries/admin/kpi_query.rb new file mode 100644 index 0000000000..e3f7125adb --- /dev/null +++ b/site/app/queries/admin/kpi_query.rb @@ -0,0 +1,126 @@ +class Admin::KpiQuery + def initialize(filter) + @filter = filter + end + + # card 480 + def total_calls + scoped.sum(:appels_totaux) + end + + # card 538 + def unique_calls + scoped.sum(:appels_uniques) + end + + # card 481 + def calls_by_period + scoped + .group(date_trunc_sql) + .order(Arel.sql(date_trunc_sql)) + .pluck(Arel.sql(date_trunc_sql), coalesce_sum(:appels_totaux)) + .map { |bucket, total| { bucket:, total: total.to_i } } + end + + # card 537 + def unique_calls_by_period + scoped + .group(date_trunc_sql) + .order(Arel.sql(date_trunc_sql)) + .pluck(Arel.sql(date_trunc_sql), coalesce_sum(:appels_uniques)) + .map { |bucket, total| { bucket:, total: total.to_i } } + end + + # card 482 + def error_breakdown + success = scoped.sum(:appels_succes).to_i + not_found = scoped.sum(:appels_echecs).to_i + total = scoped.sum(:appels_totaux).to_i + other = total - success - not_found + + [ + { label: 'Succès', value: success }, + { label: 'Non trouvés', value: not_found }, + { label: 'Autre', value: other } + ] + end + + # card 29 + def kpi1_series + KpiView::Kpi1 + .where(semaine: filter.date_from..filter.date_to) + .order(:semaine) + .pluck(:semaine, :entreprises) + .map { |s, v| { bucket: s, value: v.to_i } } + end + + # card 30 + def kpi2_series + KpiView::Kpi2 + .where(semaine: filter.date_from..filter.date_to) + .order(:semaine) + .pluck(:semaine, :distinct_hits) + .map { |s, v| { bucket: s, value: v.to_i } } + end + + # card 553 + def kpi3_series + KpiView::Kpi3 + .where(semaine: filter.date_from..filter.date_to) + .order(:semaine) + .pluck(:semaine, :entreprises) + .map { |s, v| { bucket: s, value: v.to_i } } + end + + # card 597 + def top_variations # rubocop:disable Metrics/AbcSize + current = consumption_for_period(filter.date_from, filter.date_to) + duration = (filter.date_to - filter.date_from).to_i + 1 + previous = consumption_for_period(filter.date_from - duration.days, filter.date_from - 1.day) + + merged = (current.keys | previous.keys).map do |source_id| + curr = current[source_id] || 0 + prev = previous[source_id] || 0 + variation = if prev.zero? + curr.positive? ? 100.0 : 0.0 + else + ((curr - prev) * 100.0 / prev).round(2) + end + + { source_id:, current: curr, previous: prev, variation:, variation_brute: curr - prev } + end + + merged.sort_by { |r| -r[:variation_brute].abs }.first(10) + end + + # card 478 + def api_list + scope = ControllerName.all + scope = scope.where("split_part(controller, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.order(controller: :desc).pluck(:controller) + end + + private + + attr_reader :filter + + def scoped + scope = ConsumptionSummary.where(date: filter.date_from..filter.date_to) + scope = scope.where("split_part(api, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope + end + + def consumption_for_period(from, to) + scope = ConsumptionSummary.where(date: from..to) + scope = scope.where("split_part(api, '/', 1) = ?", filter.domaine_prefix) if filter.domaine? + scope.group(:source_id).sum(:appels_totaux).transform_values(&:to_i) + end + + def date_trunc_sql + "date_trunc('#{filter.pg_interval_unit}', date)" + end + + def coalesce_sum(column) + Arel.sql("COALESCE(SUM(#{column}), 0)") + end +end diff --git a/site/app/queries/admin/token_consumption_query.rb b/site/app/queries/admin/token_consumption_query.rb new file mode 100644 index 0000000000..930fd18d7e --- /dev/null +++ b/site/app/queries/admin/token_consumption_query.rb @@ -0,0 +1,111 @@ +class Admin::TokenConsumptionQuery + def initialize(filter) + @filter = filter + end + + # card 463 + def habilitations # rubocop:disable Metrics/AbcSize + scope = AuthorizationRequest + .joins(:tokens) + .joins("LEFT OUTER JOIN access_logs ON access_logs.token_id = tokens.id AND #{timestamp_condition}") + .select( + 'authorization_requests.external_id', + 'authorization_requests.intitule', + 'authorization_requests.api', + 'authorization_requests.status', + 'COUNT(DISTINCT access_logs.request_id) AS calls_count' + ) + .group('authorization_requests.id') + .order(calls_count: :desc) + + scope = scope.where(token_filter) if filter.token_id.present? + scope = scope.where(authorization_requests: { external_id: filter.external_id }) if filter.external_id.present? + scope + end + + # card 461 + def tokens_with_consumption # rubocop:disable Metrics/AbcSize + scope = Token + .joins(:authorization_request) + .joins("LEFT OUTER JOIN access_logs ON access_logs.token_id = tokens.id AND #{timestamp_condition}") + .select( + 'tokens.id AS token_id', + 'authorization_requests.external_id', + 'authorization_requests.intitule', + 'authorization_requests.api', + 'COUNT(DISTINCT access_logs.request_id) AS calls_count' + ) + .group('tokens.id', 'authorization_requests.id') + .order(calls_count: :desc) + + scope = scope.where(token_filter) if filter.token_id.present? + scope = scope.where(authorization_requests: { external_id: filter.external_id }) if filter.external_id.present? + scope.limit(50) + end + + # card 462 + def consumption_by_day + scope = access_logs_scoped + .select( + "date_trunc('day', timestamp) AS period", + "COUNT(DISTINCT COALESCE(params->>'hashed_params', path)) AS unique_calls", + "COUNT(DISTINCT request_id) - COUNT(DISTINCT COALESCE(params->>'hashed_params', path)) AS non_unique_calls" + ) + .group("date_trunc('day', timestamp)") + .order(:period) + + scope.map { |r| { bucket: r.period, unique: r.unique_calls.to_i, non_unique: r.non_unique_calls.to_i } } + end + + # card 460 + def consumption_by_api_and_status + scope = access_logs_scoped + .where.not(controller: excluded_controllers) + .select('controller', 'status', 'COUNT(DISTINCT request_id) AS calls_count') + .group(:controller, :status) + .order(:controller) + + scope.map { |r| { controller: r.controller, status: r.status, count: r.calls_count.to_i } } + end + + # card 486 + def recent_requests + access_logs_scoped + .where.not(controller: excluded_controllers) + .order(timestamp: :desc) + .limit(200) + .select(:timestamp, :controller, :status, :duration, :path, :cached) + end + + private + + attr_reader :filter + + def access_logs_scoped # rubocop:disable Metrics/AbcSize + scope = AccessLog.where(timestamp: filter.date_from.beginning_of_day..filter.date_to.end_of_day) + scope = scope.joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') if filter.token_id.present? + scope = scope.where('tokens.id = ?::uuid', filter.token_id) if filter.token_id.present? + scope = scope.where("access_logs.status NOT IN ('401', '403')") if filter.token_id.blank? + scope + end + + def token_filter + raise ActiveRecord::RecordNotFound unless filter.token_id.match?(/\A[0-9a-f-]{36}\z/i) + + ['tokens.id = ?::uuid', filter.token_id] + end + + def timestamp_condition + sanitize('access_logs.timestamp BETWEEN ? AND ?', filter.date_from.beginning_of_day, filter.date_to.end_of_day) + end + + def sanitize(*args) + ActiveRecord::Base.sanitize_sql_array(args) + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/top_user_query.rb b/site/app/queries/admin/top_user_query.rb new file mode 100644 index 0000000000..30a15999df --- /dev/null +++ b/site/app/queries/admin/top_user_query.rb @@ -0,0 +1,73 @@ +class Admin::TopUserQuery + def initialize(filter) + @filter = filter + end + + # cards 475 / 474 + def top_tokens(api_domain) + top_by(api_domain, 'tokens.id', 'tokens.id AS identifier') + end + + # cards 473 / 472 + def top_datapass(api_domain) + top_by(api_domain, 'authorization_requests.external_id', 'authorization_requests.external_id AS identifier') + end + + # cards 485 / 468 + def top_editors(api_domain) # rubocop:disable Metrics/AbcSize + AccessLog + .joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') + .joins('INNER JOIN authorization_requests ON authorization_requests.id = tokens.authorization_request_model_id') + .joins('INNER JOIN editor_delegations ON editor_delegations.authorization_request_id = authorization_requests.id') + .joins('INNER JOIN editors ON editors.id = editor_delegations.editor_id') + .where(timestamp: date_range) + .where("split_part(controller, '/', 1) = ?", "api_#{api_domain}") + .where.not(controller: excluded_controllers) + .select('editors.name AS identifier', 'COUNT(DISTINCT access_logs.request_id) AS calls_count') + .group('editors.name') + .order(calls_count: :desc) + .limit(10) + .map { |r| { identifier: r.identifier, calls: r.calls_count.to_i } } + end + + # cards 470 / 471 + def top_ips(api_domain) + AccessLog + .where(timestamp: date_range) + .where("split_part(controller, '/', 1) = ?", "api_#{api_domain}") + .where.not(controller: excluded_controllers) + .select('ip AS identifier', 'COUNT(DISTINCT request_id) AS calls_count') + .group(:ip) + .order(calls_count: :desc) + .limit(10) + .map { |r| { identifier: r.identifier, calls: r.calls_count.to_i } } + end + + private + + attr_reader :filter + + def top_by(api_domain, group_col, select_col) + AccessLog + .joins('INNER JOIN tokens ON tokens.id = access_logs.token_id') + .joins('INNER JOIN authorization_requests ON authorization_requests.id = tokens.authorization_request_model_id') + .where(timestamp: date_range) + .where("split_part(controller, '/', 1) = ?", "api_#{api_domain}") + .where.not(controller: excluded_controllers) + .select(select_col, 'COUNT(DISTINCT access_logs.request_id) AS calls_count') + .group(group_col) + .order(calls_count: :desc) + .limit(10) + .map { |r| { identifier: r.identifier, calls: r.calls_count.to_i } } + end + + def date_range + filter.date_from.beginning_of_day..filter.date_to.end_of_day + end + + def excluded_controllers + %w[uptime ping errors api_particulier/introspect api_entreprise/proxied_files + api_particulier/ping_providers api_entreprise/ping_providers + api_entreprise/inpi_proxy api_particulier/france_connect_jwks] + end +end diff --git a/site/app/queries/admin/user_status_query.rb b/site/app/queries/admin/user_status_query.rb new file mode 100644 index 0000000000..3336eaef6e --- /dev/null +++ b/site/app/queries/admin/user_status_query.rb @@ -0,0 +1,88 @@ +class Admin::UserStatusQuery + def initialize(filter) + @filter = filter + end + + # card 333 + def user_info + return [] if filter.email.blank? + + User + .where(email: filter.email) + .select(:id, :email, :created_at) + end + + # card 334 + def user_habilitations + return [] if filter.email.blank? + + AuthorizationRequest + .joins(:users) + .where(users: { email: filter.email }) + .select( + 'authorization_requests.external_id', + 'authorization_requests.intitule', + 'authorization_requests.api', + 'authorization_requests.status', + 'authorization_requests.siret', + 'authorization_requests.scopes', + 'authorization_requests.created_at' + ) + .order(created_at: :desc) + end + + # card 335 + def user_tokens + return [] if filter.email.blank? + + Token + .joins(authorization_request: :users) + .where(users: { email: filter.email }) + .select( + 'tokens.id AS token_id', + 'tokens.exp', + 'tokens.blacklisted_at', + 'authorization_requests.external_id', + 'authorization_requests.intitule' + ) + .order('tokens.exp DESC') + end + + # card 339 + def habilitations_count + scope = AuthorizationRequest.all + scope = scope.where(authorization_requests: { external_id: filter.external_id }) if filter.external_id.present? + scope.count + end + + # card 341 + def habilitation_status_breakdown + scope = AuthorizationRequest.all + scope = scope.joins(:users).where(users: { email: filter.email }) if filter.email.present? + scope + .group(:status) + .count + .map { |status, count| { label: status, value: count } } + end + + # card 343 + def token_status_breakdown # rubocop:disable Metrics/AbcSize + scope = Token.all + scope = scope.joins(authorization_request: :users).where(users: { email: filter.email }) if filter.email.present? + + now = Time.current.to_i + active = scope.where(blacklisted_at: nil).where('exp > ?', now).count + expired = scope.where(blacklisted_at: nil).where(exp: ..now).count + blacklisted = scope.where.not(blacklisted_at: nil).count + + [ + { label: 'Actif', value: active }, + { label: 'Expiré', value: expired }, + { label: 'Bloqué', value: blacklisted } + ] + end + + private + + attr_reader :filter +end diff --git a/site/app/queries/admin/users_overview_query.rb b/site/app/queries/admin/users_overview_query.rb new file mode 100644 index 0000000000..a19d73d072 --- /dev/null +++ b/site/app/queries/admin/users_overview_query.rb @@ -0,0 +1,34 @@ +class Admin::UsersOverviewQuery + def initialize(filter) + @filter = filter + end + + # card 329 + def total_users + User.count + end + + # card 330 + def total_habilitations + AuthorizationRequest.count + end + + # card 331 + def total_tokens + Token.count + end + + # card 612 + def habilitations_with_successful_call + AuthorizationRequest + .joins(:tokens) + .joins("INNER JOIN access_logs ON access_logs.token_id = tokens.id AND access_logs.status = '200'") + .where(access_logs: { timestamp: filter.date_from.beginning_of_day.. }) + .distinct + .count + end + + private + + attr_reader :filter +end diff --git a/site/app/views/admin/statistics/_filter.html.erb b/site/app/views/admin/statistics/_filter.html.erb new file mode 100644 index 0000000000..e6e0571346 --- /dev/null +++ b/site/app/views/admin/statistics/_filter.html.erb @@ -0,0 +1,85 @@ +<%# locals: (filter:, show_domaine: true, show_interval: true, show_email: false, show_token: false, show_external_id: false, show_api: false) -%> +<%= form_with model: filter, scope: :filter, method: :get, url: request.path, local: true, class: 'fr-mb-3w' do |f| %> + + +
+
+
+ <%= f.label :date_from, 'Date de début', class: 'fr-label' %> + <%= f.date_field :date_from, class: 'fr-input' %> +
+
+
+
+ <%= f.label :date_to, 'Date de fin', class: 'fr-label' %> + <%= f.date_field :date_to, class: 'fr-input' %> +
+
+ <% if show_interval %> +
+
+ <%= f.label :interval, 'Intervalle', class: 'fr-label' %> + <%= f.select :interval, + Admin::StatisticsFilter::INTERVALS.map { |i| [i.capitalize, i] }, + {}, class: 'fr-select' %> +
+
+ <% end %> + <% if show_domaine %> +
+
+ <%= f.label :domaine, 'Domaine', class: 'fr-label' %> + <%= f.select :domaine, + [['Tous', ''], ['Entreprise', 'entreprise'], ['Particulier', 'particulier']], + {}, class: 'fr-select' %> +
+
+ <% end %> + <% if show_api %> +
+
+ <%= f.label :api, 'API (controller)', class: 'fr-label' %> + <%= f.text_field :api, class: 'fr-input', placeholder: 'ex: api_entreprise/v3/insee/sirene' %> +
+
+ <% end %> + <% if show_email %> +
+
+ <%= f.label :email, 'Email', class: 'fr-label' %> + <%= f.email_field :email, class: 'fr-input' %> +
+
+ <% end %> + <% if show_external_id %> +
+
+ <%= f.label :external_id, 'External ID (datapass)', class: 'fr-label' %> + <%= f.text_field :external_id, class: 'fr-input' %> +
+
+ <% end %> + <% if show_token %> +
+
+ <%= f.label :token_id, 'Token ID', class: 'fr-label' %> + <%= f.text_field :token_id, class: 'fr-input' %> +
+
+ <% end %> +
+ + +<% end %> diff --git a/site/app/views/admin/statistics/annual_stats.html.erb b/site/app/views/admin/statistics/annual_stats.html.erb new file mode 100644 index 0000000000..8a8b66191e --- /dev/null +++ b/site/app/views/admin/statistics/annual_stats.html.erb @@ -0,0 +1,38 @@ +
+
+

Statistiques annuelles

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: true, show_interval: false, show_api: true %> + +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Appels totaux', value: number_with_delimiter(@query.total_calls) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Appels uniques', value: number_with_delimiter(@query.unique_calls) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Appels en cache', value: number_with_delimiter(@query.cached_calls) %> +
+ +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Utilisateurs sur la période', value: number_with_delimiter(@query.total_users) %> + <%= render 'shared/dsfr_chart/kpi', label: 'SIRET uniques', value: number_with_delimiter(@query.unique_sirets) %> +
+ + <%= render 'shared/dsfr_chart/pie', + title: 'Taux de succès', + segments: @query.success_rate_breakdown %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'appels par mois', + categories: @query.calls_by_month.map { |r| r[:bucket].strftime('%m/%Y') }, + series: [{ label: 'Appels', values: @query.calls_by_month.map { |r| r[:value] } }] %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Appels cumulés par mois', + categories: @query.cumulative_calls_by_month.map { |r| r[:bucket].strftime('%m/%Y') }, + series: [{ label: 'Cumulé', values: @query.cumulative_calls_by_month.map { |r| r[:value] } }] %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'utilisateurs par mois', + categories: @query.users_by_month.map { |r| r[:bucket].strftime('%m/%Y') }, + series: [{ label: 'Utilisateurs', values: @query.users_by_month.map { |r| r[:value] } }] %> +
+
diff --git a/site/app/views/admin/statistics/api_consumers.html.erb b/site/app/views/admin/statistics/api_consumers.html.erb new file mode 100644 index 0000000000..0afa2fd311 --- /dev/null +++ b/site/app/views/admin/statistics/api_consumers.html.erb @@ -0,0 +1,47 @@ +
+
+

Consommateurs d'une API

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: true, show_interval: true, show_api: true %> + +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Appels totaux', value: number_with_delimiter(@query.total_calls) %> +
+ + <%= render 'shared/dsfr_chart/pie', + title: 'Taux de succès vs échecs', + segments: @query.success_breakdown %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'appels par période', + categories: @query.calls_by_period.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Appels', values: @query.calls_by_period.map { |r| r[:value] } }] %> + +

Habilitations consommatrices

+
+ + + + + + + + + + + + <% @query.consumer_habilitations.each do |row| %> + + + + + + + + <% end %> + +
External IDIntituléSIRETEmailAppels
<%= row[:external_id] %><%= row[:intitule] %><%= row[:siret] %><%= row[:email] %><%= number_with_delimiter(row[:calls]) %>
+
+
+
diff --git a/site/app/views/admin/statistics/api_health.html.erb b/site/app/views/admin/statistics/api_health.html.erb new file mode 100644 index 0000000000..bc3d96a895 --- /dev/null +++ b/site/app/views/admin/statistics/api_health.html.erb @@ -0,0 +1,43 @@ +
+
+

État de santé global des APIs

+

Basé sur les 10 dernières minutes.

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: true, show_interval: false %> + +
+ + + + + + + + + + + + + + <% @query.instant_status.each do |row| %> + + + + + + + + + <% end %> + +
État instantané des APIs
ControllerTotalSuccèsNon trouvéErreursDurée moy. (ms)
<%= row[:controller] %><%= number_with_delimiter(row[:total]) %><%= row[:success] %><%= row[:not_found] %> + <% if row[:errors].positive? %> + <%= row[:errors] %> + <% else %> + <%= row[:errors] %> + <% end %> + <%= row[:avg_duration] %>
+
+
+
diff --git a/site/app/views/admin/statistics/api_status.html.erb b/site/app/views/admin/statistics/api_status.html.erb new file mode 100644 index 0000000000..2a16b7b862 --- /dev/null +++ b/site/app/views/admin/statistics/api_status.html.erb @@ -0,0 +1,38 @@ +
+
+

Statut d'une API

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: true, show_interval: true, show_api: true %> + +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Appels totaux', value: number_with_delimiter(@query.total_calls) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Appels uniques', value: number_with_delimiter(@query.unique_calls) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Appels en cache', value: number_with_delimiter(@query.cached_calls) %> +
+ + <%= render 'shared/dsfr_chart/line', + title: 'Appels totaux vs uniques', + series: [ + { label: 'Total', points: @query.calls_vs_unique_evolution.map { |r| [bucket_label(r[:bucket], @filter.interval), r[:total]] } }, + { label: 'Uniques', points: @query.calls_vs_unique_evolution.map { |r| [bucket_label(r[:bucket], @filter.interval), r[:unique]] } } + ] %> + + <%= render 'shared/dsfr_chart/line', + title: 'Appels en cache', + series: [ + { label: 'Cache', points: @query.cached_evolution.map { |r| [bucket_label(r[:bucket], @filter.interval), r[:total]] } } + ] %> + + <%= render 'shared/dsfr_chart/line', + title: 'Durée moyenne des appels', + series: [ + { label: 'Durée (ms)', points: @query.duration_evolution.map { |r| [bucket_label(r[:bucket], @filter.interval), r[:value]] } } + ], + unit: 'ms' %> + + <%= render 'shared/dsfr_chart/pie', + title: 'Répartition des codes HTTP', + segments: @query.http_code_breakdown %> +
+
diff --git a/site/app/views/admin/statistics/index.html.erb b/site/app/views/admin/statistics/index.html.erb new file mode 100644 index 0000000000..a754c6f8ea --- /dev/null +++ b/site/app/views/admin/statistics/index.html.erb @@ -0,0 +1,21 @@ +
+
+

Statistiques

+

Tableaux de bord issus de Metabase, intégrés nativement.

+
+ + <% Admin::StatisticsController::PAGES.each do |action, meta| %> +
+ +
+ <% end %> +
diff --git a/site/app/views/admin/statistics/kpi.html.erb b/site/app/views/admin/statistics/kpi.html.erb new file mode 100644 index 0000000000..4339f87787 --- /dev/null +++ b/site/app/views/admin/statistics/kpi.html.erb @@ -0,0 +1,71 @@ +
+
+

KPI & Statistiques

+ + <%= render 'admin/statistics/filter', filter: @filter, show_domaine: true, show_interval: true %> + +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Appels totaux', value: number_with_delimiter(@query.total_calls) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Appels uniques', value: number_with_delimiter(@query.unique_calls) %> +
+ + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'appels totaux par période', + categories: @query.calls_by_period.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Appels totaux', values: @query.calls_by_period.map { |r| r[:total] } }] %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'appels uniques par période', + categories: @query.unique_calls_by_period.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Appels uniques', values: @query.unique_calls_by_period.map { |r| r[:total] } }] %> + + <%= render 'shared/dsfr_chart/pie', + title: 'Répartition succès / erreurs', + segments: @query.error_breakdown %> + + <% if !@filter.domaine? || @filter.domaine == 'entreprise' %> +

KPIs API Entreprise

+ + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'entreprises aidées (KPI 1)', + categories: @query.kpi1_series.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Entreprises', values: @query.kpi1_series.map { |r| r[:value] } }] %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'informations non redemandées (KPI 2)', + categories: @query.kpi2_series.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Chemins uniques', values: @query.kpi2_series.map { |r| r[:value] } }] %> + + <%= render 'shared/dsfr_chart/bar', + title: 'Nombre d\'établissements aidés (KPI 3)', + categories: @query.kpi3_series.map { |r| bucket_label(r[:bucket], @filter.interval) }, + series: [{ label: 'Établissements', values: @query.kpi3_series.map { |r| r[:value] } }] %> + <% end %> + +

Top 10 des variations

+
+ + + + + + + + + + + + <% @query.top_variations.each do |row| %> + + + + + + + + <% end %> + +
DatapassAppels actuelsAppels précédentsVariation %Variation brute
<%= row[:source_id] %><%= number_with_delimiter(row[:current]) %><%= number_with_delimiter(row[:previous]) %><%= row[:variation] %>%<%= number_with_delimiter(row[:variation_brute]) %>
+
+
+
diff --git a/site/app/views/admin/statistics/token_consumption.html.erb b/site/app/views/admin/statistics/token_consumption.html.erb new file mode 100644 index 0000000000..7ff20ea59d --- /dev/null +++ b/site/app/views/admin/statistics/token_consumption.html.erb @@ -0,0 +1,115 @@ +
+
+

Consommation d'un token

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: false, show_interval: false, + show_token: true, show_external_id: true, show_email: true %> + +

Habilitations

+
+ + + + + + + + + + + + <% @query.habilitations.each do |row| %> + + + + + + + + <% end %> + +
External IDIntituléAPIStatutAppels
<%= row.external_id %><%= row.intitule %><%= row.api %><%= row.status %><%= number_with_delimiter(row.calls_count) %>
+
+ +

Jetons et consommation

+
+ + + + + + + + + + + + <% @query.tokens_with_consumption.each do |row| %> + + + + + + + + <% end %> + +
Token IDExternal IDIntituléAPIAppels
<%= row.token_id %><%= row.external_id %><%= row.intitule %><%= row.api %><%= number_with_delimiter(row.calls_count) %>
+
+ + <% if @filter.token_id.present? %> +

Consommation par jour

+ <%= render 'shared/dsfr_chart/bar', + title: 'Appels uniques vs non-uniques par jour', + categories: @query.consumption_by_day.map { |r| r[:bucket].strftime('%d/%m') }, + series: [ + { label: 'Uniques', values: @query.consumption_by_day.map { |r| r[:unique] } }, + { label: 'Non uniques', values: @query.consumption_by_day.map { |r| r[:non_unique] } } + ], + stacked: true %> + +

Consommation par API et code erreur

+ <% + by_api = @query.consumption_by_api_and_status + controllers = by_api.map { |r| r[:controller] }.uniq + statuses = by_api.map { |r| r[:status] }.uniq.sort + %> + <%= render 'shared/dsfr_chart/bar', + title: 'Appels par API et statut HTTP', + categories: controllers, + series: statuses.map { |s| + { label: s, values: controllers.map { |c| by_api.find { |r| r[:controller] == c && r[:status] == s }&.dig(:count) || 0 } } + }, + stacked: true %> + +

200 dernières requêtes

+
+ + + + + + + + + + + + + <% @query.recent_requests.each do |log| %> + + + + + + + + + <% end %> + +
DateControllerStatutDurée (ms)CheminCache
<%= log.timestamp.strftime('%d/%m %H:%M') %><%= log.controller %><%= log.status %><%= log.duration %><%= truncate(log.path, length: 60) %><%= log.cached ? 'Oui' : 'Non' %>
+
+ <% end %> +
+
diff --git a/site/app/views/admin/statistics/top_users.html.erb b/site/app/views/admin/statistics/top_users.html.erb new file mode 100644 index 0000000000..dc30028ef2 --- /dev/null +++ b/site/app/views/admin/statistics/top_users.html.erb @@ -0,0 +1,38 @@ +
+
+

Top 10 utilisateurs

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: false, show_interval: false %> + + <% %w[entreprise particulier].each do |domaine| %> +

API <%= domaine.capitalize %>

+ +
+ <% { 'Éditeurs' => :top_editors, 'Tokens' => :top_tokens, 'Datapass' => :top_datapass, 'Adresses IP' => :top_ips }.each do |label, method| %> +
+
+ + + + + + + + + + <% @query.public_send(method, domaine).each do |row| %> + + + + + <% end %> + +
<%= label %>
<%= label.singularize %>Appels
<%= truncate(row[:identifier].to_s, length: 40) %><%= number_with_delimiter(row[:calls]) %>
+
+
+ <% end %> +
+ <% end %> +
+
diff --git a/site/app/views/admin/statistics/user_status.html.erb b/site/app/views/admin/statistics/user_status.html.erb new file mode 100644 index 0000000000..da9645077f --- /dev/null +++ b/site/app/views/admin/statistics/user_status.html.erb @@ -0,0 +1,97 @@ +
+
+

Status utilisateur, datapass et jetons

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: false, show_interval: false, show_email: true, show_external_id: true %> + + <% if @filter.email.present? %> +

Informations utilisateur

+
+ + + + + + + + + <% @query.user_info.each do |user| %> + + + + + <% end %> + +
EmailCréé le
<%= user.email %><%= user.created_at.strftime('%d/%m/%Y') %>
+
+ +

Habilitations

+
+ + + + + + + + + + + + + <% @query.user_habilitations.each do |ar| %> + + + + + + + + + <% end %> + +
External IDIntituléAPIStatutSIRETCréé le
<%= ar.external_id %><%= ar.intitule %><%= ar.api %><%= ar.status %><%= ar.siret %><%= ar.created_at.strftime('%d/%m/%Y') %>
+
+ +

Jetons

+
+ + + + + + + + + + + + <% @query.user_tokens.each do |token| %> + + + + + + + + <% end %> + +
Token IDExternal IDIntituléExpirationBloqué le
<%= token.token_id %><%= token.external_id %><%= token.intitule %><%= token.exp ? Time.zone.at(token.exp).strftime('%d/%m/%Y') : '' %><%= token.blacklisted_at&.strftime('%d/%m/%Y') %>
+
+ <% end %> + +
+
+ <%= render 'shared/dsfr_chart/pie', + title: 'Statut des habilitations', + segments: @query.habilitation_status_breakdown %> +
+
+ <%= render 'shared/dsfr_chart/pie', + title: 'Statut des jetons', + segments: @query.token_status_breakdown %> +
+
+
+
diff --git a/site/app/views/admin/statistics/users_overview.html.erb b/site/app/views/admin/statistics/users_overview.html.erb new file mode 100644 index 0000000000..669a1baaec --- /dev/null +++ b/site/app/views/admin/statistics/users_overview.html.erb @@ -0,0 +1,20 @@ +
+
+

Vue générale des utilisateurs

+ + <%= render 'admin/statistics/filter', filter: @filter, + show_domaine: false, show_interval: false %> + +
+ <%= render 'shared/dsfr_chart/kpi', label: 'Nombre total d\'utilisateurs', value: number_with_delimiter(@query.total_users) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Nombre total d\'habilitations', value: number_with_delimiter(@query.total_habilitations) %> + <%= render 'shared/dsfr_chart/kpi', label: 'Nombre total de jetons', value: number_with_delimiter(@query.total_tokens) %> +
+ +
+ <%= render 'shared/dsfr_chart/kpi', + label: 'Habilitations avec au moins 1 appel réussi (depuis la date de début)', + value: number_with_delimiter(@query.habilitations_with_successful_call) %> +
+
+
diff --git a/site/app/views/shared/admin/_header.html.erb b/site/app/views/shared/admin/_header.html.erb index bd82744d53..514dcd9f1e 100644 --- a/site/app/views/shared/admin/_header.html.erb +++ b/site/app/views/shared/admin/_header.html.erb @@ -62,6 +62,12 @@ +
  • + + Statistiques + +
  • +
  • Notifications d'audit diff --git a/site/app/views/shared/dsfr_chart/_pie.html.erb b/site/app/views/shared/dsfr_chart/_pie.html.erb new file mode 100644 index 0000000000..1a7f3c5932 --- /dev/null +++ b/site/app/views/shared/dsfr_chart/_pie.html.erb @@ -0,0 +1,13 @@ +<%# locals: (title:, segments:) -%> +<%# Each segment: { label:, value: } -%> +<% + x = segments.map { |s| s[:label].to_s } + y = segments.map { |s| s[:value] } +%> +
    +
    <%= title %>
    + + +
    diff --git a/site/config/routes.rb b/site/config/routes.rb index 2c371672ff..71a66f853c 100644 --- a/site/config/routes.rb +++ b/site/config/routes.rb @@ -29,6 +29,7 @@ end resources :audit_notifications, only: %i[index new create] resources :api_requests, only: %i[index create] + resources :statistics, only: %i[index show], param: :page end get '/editeur', to: redirect('/editeur/habilitations'), as: :editor diff --git a/site/db/schema.rb b/site/db/schema.rb index 4fba2fc744..d4f687ff8d 100644 --- a/site/db/schema.rb +++ b/site/db/schema.rb @@ -337,6 +337,50 @@ add_foreign_key "user_authorization_request_roles", "authorization_requests" add_foreign_key "user_authorization_request_roles", "users" + create_view "admin_apientreprise_#{Rails.env}_access_logs_controller_name", sql_definition: <<-SQL + SELECT DISTINCT (al.controller) AS controller + FROM access_logs al + WHERE ((al."timestamp" >= (now() + '-183 days'::interval)) + AND ((al.controller)::text <> ALL ((ARRAY['uptime'::character varying, 'ping'::character varying, 'errors'::character varying, 'api_particulier/introspect'::character varying, 'api_entreprise/proxied_files'::character varying, 'api_particulier/ping_providers'::character varying, 'api_entreprise/ping_providers'::character varying, 'api_entreprise/inpi_proxy'::character varying, 'api_particulier/france_connect_jwks'::character varying])::text[]))) + ORDER BY al.controller DESC; + SQL + + create_view "admin_apientreprise_#{Rails.env}_access_logs_kpi1", sql_definition: <<-SQL + SELECT filtered.semaine, count(filtered.siren) AS entreprises + FROM ( SELECT DISTINCT date_trunc('week'::text, al."timestamp") AS semaine, (al.params -> 'siren'::text) AS siren + FROM access_logs al + WHERE ((al."timestamp" >= (now() + '-183 days'::interval)) + AND (split_part((al.controller)::text, '/'::text, 1) = 'api_entreprise'::text) + AND ((al.status)::integer <> ALL (ARRAY[401, 403])) + AND ((al.path)::text !~~ ALL ((ARRAY['%/ping'::character varying, '%/uptime'::character varying])::text[])))) filtered + GROUP BY filtered.semaine + ORDER BY filtered.semaine; + SQL + + create_view "admin_apientreprise_#{Rails.env}_access_logs_kpi2", sql_definition: <<-SQL + SELECT filtered.semaine, count(filtered.path) AS distinct_hits + FROM ( SELECT DISTINCT date_trunc('week'::text, al."timestamp") AS semaine, al.path + FROM access_logs al + WHERE ((al."timestamp" >= (now() + '-183 days'::interval)) + AND (split_part((al.controller)::text, '/'::text, 1) = 'api_entreprise'::text) + AND ((al.status)::integer <> ALL (ARRAY[401, 403])) + AND ((al.path)::text !~~ ALL ((ARRAY['%/ping'::character varying, '%/uptime'::character varying])::text[])))) filtered + GROUP BY filtered.semaine + ORDER BY filtered.semaine; + SQL + + create_view "admin_apientreprise_#{Rails.env}_access_logs_kpi3", sql_definition: <<-SQL + SELECT filtered.semaine, count(filtered.siret) AS entreprises + FROM ( SELECT DISTINCT date_trunc('week'::text, al."timestamp") AS semaine, (al.params -> 'siret'::text) AS siret + FROM access_logs al + WHERE ((al."timestamp" >= (now() + '-183 days'::interval)) + AND (split_part((al.controller)::text, '/'::text, 1) = 'api_entreprise'::text) + AND ((al.status)::integer <> ALL (ARRAY[401, 403])) + AND ((al.path)::text !~~ ALL ((ARRAY['%/ping'::character varying, '%/uptime'::character varying])::text[])))) filtered + GROUP BY filtered.semaine + ORDER BY filtered.semaine; + SQL + create_view "admin_apientreprise_#{Rails.env}_access_logs_consumption_summary", sql_definition: <<-SQL SELECT date(al."timestamp") AS date, CASE diff --git a/site/docs/admin-statistics.md b/site/docs/admin-statistics.md new file mode 100644 index 0000000000..32d1d51dcc --- /dev/null +++ b/site/docs/admin-statistics.md @@ -0,0 +1,111 @@ +# Statistiques admin (migration Metabase) + +Migration des tableaux de bord Metabase vers des pages natives dans `/admin/statistics`. + +## Origine + +Issue [API-6748](https://linear.app/pole-api/issue/API-6748). Les tableaux étaient dans 4 collections Metabase : + +| Collection | Contenu | +|---|---| +| 67 - KPI API Entreprise/Particulier | KPIs d'impact, appels par période, taux d'erreur, top variations | +| 66 - Suivi consommation utilisateurs | Consommation par token, top 10 par éditeur/token/datapass/IP | +| 63 - Suivi des APIs | Santé instantanée, statut par API, stats annuelles, consommateurs | +| 54 - Suivi des comptes | Vue générale utilisateurs, statuts habilitations/jetons | + +## Pages + +Accessible via **Admin > Statistiques** (`/admin/statistics`). + +### 1. KPI & Statistiques +Fusion des dashboards Metabase 90 (API Entreprise) et 91 (API Particulier) avec filtre domaine. +- KPIs : appels totaux, appels uniques +- Graphiques : appels par période (bar), taux d'erreur (pie) +- KPIs spécifiques Entreprise : entreprises aidées, infos non redemandées, établissements aidés (issus des vues kpi1/kpi2/kpi3) +- Tableau : top 10 des variations vs période précédente + +### 2. Consommation d'un token +Dashboard Metabase 88. Filtrage par token ID, external ID ou email. +- Tableaux : habilitations, jetons et leur consommation +- Graphiques (si token sélectionné) : consommation par jour (bar empilé), par API et code erreur (bar empilé) +- Tableau : 200 dernières requêtes + +### 3. Top 10 utilisateurs +Dashboard Metabase 89. Pour chaque domaine (entreprise/particulier) : +- Top 10 éditeurs, tokens, datapass, adresses IP + +### 4. État de santé global +Dashboard Metabase 83. Vue temps réel (10 dernières minutes). +- Tableau : chaque API avec total, succès, non trouvé, erreurs, durée moyenne + +### 5. Statut d'une API +Dashboard Metabase 84. Filtrage par API (controller) et domaine. +- KPIs : appels totaux, uniques, en cache +- Graphiques : évolution appels vs uniques (line), cache (line), durée (line), codes HTTP (pie) + +### 6. Statistiques annuelles +Fusion des dashboards Metabase 86 (Entreprise) et 87 (Particulier) avec filtre domaine. +- KPIs : appels totaux, uniques, cache, utilisateurs, SIRET uniques +- Graphiques : taux de succès (pie), appels par mois (bar), appels cumulés (bar), utilisateurs par mois (bar) + +### 7. Consommateurs d'une API +Dashboard Metabase 61. Filtrage par API et domaine. +- KPI : appels totaux +- Graphiques : taux succès (pie), appels par période (bar) +- Tableau : habilitations consommatrices avec external ID, intitulé, SIRET, email, nb appels + +### 8. Vue générale utilisateurs +Dashboard Metabase 54. +- KPIs : nombre total d'utilisateurs, d'habilitations, de jetons +- KPI : habilitations avec au moins 1 appel réussi + +### 9. Status utilisateur, datapass et jetons +Dashboard Metabase 55. Filtrage par email et/ou external ID. +- Tableaux (si email renseigné) : infos utilisateur, habilitations, jetons +- Graphiques : statut des habilitations (pie), statut des jetons (pie) + +## Dashboards exclus + +- Dashboard 49 (Suivi migration tokens) - obsolète +- Dashboard 43 (EdL anciens jetons API Particulier) - obsolète +- Dashboard 101 (Suivi FQF Editeurs) - obsolète + +## Architecture + +``` +app/ + controllers/admin/statistics_controller.rb # Controller unique, 1 action par page + forms/admin/statistics_filter.rb # Filtre partagé (dates, domaine, interval...) + queries/admin/ # 1 query object par page + kpi_query.rb + token_consumption_query.rb + top_users_query.rb + api_health_query.rb + api_status_query.rb + annual_stats_query.rb + api_consumers_query.rb + users_overview_query.rb + user_status_query.rb + helpers/admin/statistics_helper.rb # bucket_label pour le formatage des dates + views/admin/statistics/ # 1 vue par page + filtre partagé + index + models/ + controller_name.rb # Vue matérialisée controller_name + kpi_view.rb # Vues matérialisées kpi1/kpi2/kpi3 +``` + +### Composants graphiques DSFR + +| Composant | Fichier | Usage | +|---|---|---| +| KPI tile | `shared/dsfr_chart/_kpi.html.erb` | Scalars, compteurs | +| Line chart | `shared/dsfr_chart/_line.html.erb` | Évolutions temporelles | +| Bar chart | `shared/dsfr_chart/_bar.html.erb` | Répartitions par période/endpoint | +| Pie chart | `shared/dsfr_chart/_pie.html.erb` | Taux succès/erreur, statuts (nouveau) | + +### Données + +Les queries utilisent principalement : +- `ConsumptionSummary` (vue matérialisée pré-agrégée, refresh quotidien) pour les stats de consommation +- `AccessLog` (table brute) pour les vues temps réel et les stats par statut HTTP +- `AuthorizationRequest`, `Token`, `User` pour les données de comptes +- `KpiView::Kpi1/Kpi2/Kpi3` (vues matérialisées, refresh horaire) pour les KPIs API Entreprise diff --git a/site/spec/queries/admin/annual_stat_query_spec.rb b/site/spec/queries/admin/annual_stat_query_spec.rb new file mode 100644 index 0000000000..53ae8d4171 --- /dev/null +++ b/site/spec/queries/admin/annual_stat_query_spec.rb @@ -0,0 +1,90 @@ +RSpec.describe Admin::AnnualStatQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new(date_from: 6.months.ago.to_date, date_to: Date.current) } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + let(:token) { create(:token) } + + before do + Timecop.freeze(2.months.ago.beginning_of_month + 1.day) do + create(:access_log, controller: controller_name, status: '200', cached: false, duration: '500', token: token, + params: { 'siret' => '12345678901234' }) + create(:access_log, controller: controller_name, status: '404', cached: true, duration: '300', token: token, + params: { 'siret' => '12345678901234' }) + create(:access_log, controller: controller_name, status: '502', cached: false, duration: '700', token: token) + end + end + + describe '#total_calls' do + it 'counts all non-401/403 calls' do + expect(query.total_calls).to eq(3) + end + end + + describe '#cached_calls' do + it 'counts cached calls' do + expect(query.cached_calls).to eq(1) + end + end + + describe '#total_users' do + it 'counts distinct authorization request external_ids' do + expect(query.total_users).to eq(1) + end + end + + describe '#success_rate_breakdown' do + it 'splits by 200/404/other' do + result = query.success_rate_breakdown + + expect(result).to contain_exactly( + { label: 'Succès (200)', value: 1 }, + { label: 'Non trouvé (404)', value: 1 }, + { label: 'Autre', value: 1 } + ) + end + end + + describe '#unique_sirets' do + it 'counts distinct sirets from params' do + expect(query.unique_sirets).to eq(1) + end + end + + describe '#calls_by_month' do + it 'returns monthly buckets' do + result = query.calls_by_month + + expect(result).to be_an(Array) + expect(result.first).to include(:bucket, :value) + expect(result.sum { |r| r[:value] }).to eq(3) + end + end + + describe '#cumulative_calls_by_month' do + it 'returns cumulative totals' do + result = query.cumulative_calls_by_month + + expect(result.last[:value]).to eq(3) + end + end + + describe '#users_by_month' do + it 'returns monthly distinct user counts' do + result = query.users_by_month + + expect(result).to be_an(Array) + expect(result.first[:value]).to eq(1) + end + end + + describe 'excluded controllers' do + it 'ignores ping traffic' do + Timecop.freeze(2.months.ago.beginning_of_month + 1.day) do + create(:access_log, controller: 'ping', status: '200') + end + + expect(query.total_calls).to eq(3) + end + end +end diff --git a/site/spec/queries/admin/api_consumer_query_spec.rb b/site/spec/queries/admin/api_consumer_query_spec.rb new file mode 100644 index 0000000000..cd5fb635f1 --- /dev/null +++ b/site/spec/queries/admin/api_consumer_query_spec.rb @@ -0,0 +1,76 @@ +RSpec.describe Admin::APIConsumerQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + + let(:user) { create(:user, email: 'demandeur@example.com') } + let(:authorization_request) do + create(:authorization_request, :with_organization, + intitule: 'Mairie de X', + external_id: 'AR-1', + api: 'entreprise', + siret: '13002526500013', + status: :validated) + end + let(:token) do + create(:token, authorization_request: authorization_request, intitule: 'Mairie de X') + end + + before do + create(:user_authorization_request_role, user: user, authorization_request: authorization_request, role: 'demandeur') + create(:access_log, controller: controller_name, status: '200', token: token) + create(:access_log, controller: controller_name, status: '404', token: token) + create(:access_log, controller: controller_name, status: '502', token: token) + end + + describe '#total_calls' do + it 'counts all non-401/403 calls' do + expect(query.total_calls).to eq(3) + end + end + + describe '#success_breakdown' do + it 'splits by success/not_found/other' do + result = query.success_breakdown + + expect(result).to contain_exactly( + { label: 'Succès', value: 1 }, + { label: 'Non trouvé', value: 1 }, + { label: 'Autre', value: 1 } + ) + end + end + + describe '#calls_by_period' do + it 'returns buckets with totals' do + result = query.calls_by_period + + expect(result).to be_an(Array) + expect(result.first).to include(:bucket, :value) + expect(result.sum { |r| r[:value] }).to eq(3) + end + end + + describe '#consumer_habilitations' do + it 'returns consumers with demandeur email and call count' do + result = query.consumer_habilitations + + consumer = result.find { |r| r[:external_id] == 'AR-1' } + expect(consumer).to include( + intitule: 'Mairie de X', + email: 'demandeur@example.com', + siret: '13002526500013' + ) + expect(consumer[:calls]).to eq(3) + end + end + + describe 'excluded controllers' do + it 'ignores ping traffic' do + create(:access_log, controller: 'ping', status: '200', token: token) + + expect(query.total_calls).to eq(3) + end + end +end diff --git a/site/spec/queries/admin/api_health_query_spec.rb b/site/spec/queries/admin/api_health_query_spec.rb new file mode 100644 index 0000000000..c4abef00e7 --- /dev/null +++ b/site/spec/queries/admin/api_health_query_spec.rb @@ -0,0 +1,45 @@ +RSpec.describe Admin::APIHealthQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + + describe '#instant_status' do + before do + create(:access_log, controller: controller_name, status: '200', duration: '500', timestamp: 2.minutes.ago) + create(:access_log, controller: controller_name, status: '404', duration: '300', timestamp: 3.minutes.ago) + create(:access_log, controller: controller_name, status: '502', duration: '700', timestamp: 5.minutes.ago) + end + + it 'aggregates status counts for the last 10 minutes' do + result = query.instant_status + + expect(result.size).to eq(1) + row = result.first + expect(row[:controller]).to eq(controller_name) + expect(row[:total]).to eq(3) + expect(row[:success]).to eq(1) + expect(row[:not_found]).to eq(1) + expect(row[:errors]).to eq(1) + end + + it 'computes average duration for 200/404 only' do + row = query.instant_status.first + + expect(row[:avg_duration]).to be_within(0.01).of(400.0) + end + + it 'excludes calls older than 10 minutes' do + create(:access_log, controller: controller_name, status: '200', duration: '100', timestamp: 15.minutes.ago) + + expect(query.instant_status.first[:total]).to eq(3) + end + + it 'excludes ping/uptime controllers' do + create(:access_log, controller: 'ping', status: '200', duration: '10', timestamp: 1.minute.ago) + + controllers = query.instant_status.pluck(:controller) + expect(controllers).not_to include('ping') + end + end +end diff --git a/site/spec/queries/admin/api_status_query_spec.rb b/site/spec/queries/admin/api_status_query_spec.rb new file mode 100644 index 0000000000..90d468eeb8 --- /dev/null +++ b/site/spec/queries/admin/api_status_query_spec.rb @@ -0,0 +1,95 @@ +RSpec.describe Admin::APIStatusQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + + before do + create(:access_log, controller: controller_name, status: '200', cached: false, duration: '500') + create(:access_log, controller: controller_name, status: '404', cached: true, duration: '300') + create(:access_log, controller: controller_name, status: '502', cached: false, duration: '700') + end + + describe '#total_calls' do + it 'counts all non-401/403 calls' do + expect(query.total_calls).to eq(3) + end + + it 'excludes 401 and 403 calls' do + create(:access_log, controller: controller_name, status: '401') + create(:access_log, controller: controller_name, status: '403') + + expect(query.total_calls).to eq(3) + end + end + + describe '#unique_calls' do + it 'counts distinct paths' do + expect(query.unique_calls).to eq(3) + end + end + + describe '#cached_calls' do + it 'counts cached calls only' do + expect(query.cached_calls).to eq(1) + end + end + + describe '#calls_vs_unique_evolution' do + it 'returns buckets with total and unique counts' do + result = query.calls_vs_unique_evolution + + expect(result).to be_an(Array) + expect(result.first).to include(:bucket, :total, :unique) + expect(result.sum { |r| r[:total] }).to eq(3) + end + end + + describe '#cached_evolution' do + it 'returns buckets with cached totals' do + result = query.cached_evolution + + expect(result).to be_an(Array) + expect(result.sum { |r| r[:total] }).to eq(1) + end + end + + describe '#duration_evolution' do + it 'returns average duration for 200/404 statuses only' do + result = query.duration_evolution + + expect(result).to be_an(Array) + expect(result.first[:value]).to be_within(0.01).of(400.0) + end + end + + describe '#http_code_breakdown' do + it 'groups by HTTP status code' do + result = query.http_code_breakdown + + expect(result.pluck(:label)).to contain_exactly('200', '404', '502') + end + end + + describe 'api filter' do + let(:other_controller) { 'api_entreprise/v3_and_more/dgfip/chiffres_affaires' } + + before do + create(:access_log, controller: other_controller, status: '200') + end + + it 'narrows to a specific API when filter.api is set' do + filtered = Admin::StatisticsFilter.new(api: controller_name) + + expect(described_class.new(filtered).total_calls).to eq(3) + end + end + + describe 'excluded controllers' do + it 'ignores ping/uptime traffic' do + create(:access_log, controller: 'ping', status: '200') + + expect(query.total_calls).to eq(3) + end + end +end diff --git a/site/spec/queries/admin/token_consumption_query_spec.rb b/site/spec/queries/admin/token_consumption_query_spec.rb new file mode 100644 index 0000000000..8cbfec6bf9 --- /dev/null +++ b/site/spec/queries/admin/token_consumption_query_spec.rb @@ -0,0 +1,83 @@ +RSpec.describe Admin::TokenConsumptionQuery do + subject(:query) { described_class.new(filter) } + + let(:token) { create(:token) } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + + before do + create(:access_log, controller: controller_name, status: '200', token: token, duration: '500') + create(:access_log, controller: controller_name, status: '404', token: token, duration: '300') + end + + describe 'with token_id filter' do + let(:filter) { Admin::StatisticsFilter.new(token_id: token.id) } + + describe '#habilitations' do + it 'returns authorization requests with call counts' do + result = query.habilitations.to_a + + expect(result.size).to eq(1) + expect(result.first.calls_count.to_i).to eq(2) + end + end + + describe '#tokens_with_consumption' do + it 'returns tokens with call counts' do + result = query.tokens_with_consumption.to_a + + expect(result.size).to eq(1) + expect(result.first.calls_count.to_i).to eq(2) + end + end + + describe '#consumption_by_day' do + it 'returns daily breakdown with unique/non_unique' do + result = query.consumption_by_day + + expect(result).to be_an(Array) + expect(result.first).to include(:bucket, :unique, :non_unique) + end + end + + describe '#consumption_by_api_and_status' do + it 'groups by controller and status' do + result = query.consumption_by_api_and_status + + expect(result.size).to eq(2) + expect(result.pluck(:status)).to contain_exactly('200', '404') + end + end + + describe '#recent_requests' do + it 'returns recent access logs ordered by timestamp desc' do + result = query.recent_requests + + expect(result.size).to eq(2) + expect(result.first.timestamp).to be >= result.last.timestamp + end + end + end + + describe 'with external_id filter' do + let(:filter) { Admin::StatisticsFilter.new(external_id: token.authorization_request.external_id) } + + it 'filters habilitations by external_id' do + other_token = create(:token) + create(:access_log, controller: controller_name, status: '200', token: other_token) + + result = query.habilitations.to_a + external_ids = result.map(&:external_id) + + expect(external_ids).to include(token.authorization_request.external_id) + expect(external_ids).not_to include(other_token.authorization_request.external_id) + end + end + + describe 'token_id validation' do + let(:filter) { Admin::StatisticsFilter.new(token_id: 'not-a-uuid') } + + it 'raises RecordNotFound for invalid UUID' do + expect { query.habilitations.to_a }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/site/spec/queries/admin/top_user_query_spec.rb b/site/spec/queries/admin/top_user_query_spec.rb new file mode 100644 index 0000000000..d8b67e66b8 --- /dev/null +++ b/site/spec/queries/admin/top_user_query_spec.rb @@ -0,0 +1,63 @@ +RSpec.describe Admin::TopUserQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new } + let(:controller_name) { 'api_entreprise/v3_and_more/insee/etablissements' } + + let(:token) { create(:token) } + let(:other_token) { create(:token) } + + before do + create(:access_log, controller: controller_name, status: '200', token: token) + create(:access_log, controller: controller_name, status: '200', token: token) + create(:access_log, controller: controller_name, status: '200', token: other_token) + end + + describe '#top_tokens' do + it 'groups by token and returns call counts' do + result = query.top_tokens('entreprise') + + expect(result.size).to eq(2) + top = result.first + expect(top[:calls]).to eq(2) + expect(top[:identifier]).to eq(token.id) + end + + it 'limits to 10 results' do + expect(query.top_tokens('entreprise').size).to be <= 10 + end + end + + describe '#top_datapass' do + it 'groups by authorization request external_id' do + result = query.top_datapass('entreprise') + + expect(result).to be_an(Array) + expect(result.first).to include(:identifier, :calls) + end + end + + describe '#top_editors' do + let(:editor) { create(:editor, :delegable) } + + before do + EditorDelegation.create!(editor: editor, authorization_request: token.authorization_request) + end + + it 'groups by editor name' do + result = query.top_editors('entreprise') + + expect(result.size).to eq(1) + expect(result.first[:identifier]).to eq(editor.name) + expect(result.first[:calls]).to eq(2) + end + end + + describe 'excluded controllers' do + it 'ignores ping/uptime traffic' do + create(:access_log, controller: 'ping', status: '200', token: token) + + expect(query.top_tokens('entreprise').size).to eq(2) + end + end +end diff --git a/site/spec/queries/admin/user_status_query_spec.rb b/site/spec/queries/admin/user_status_query_spec.rb new file mode 100644 index 0000000000..02a6219398 --- /dev/null +++ b/site/spec/queries/admin/user_status_query_spec.rb @@ -0,0 +1,96 @@ +RSpec.describe Admin::UserStatusQuery do + subject(:query) { described_class.new(filter) } + + let(:user) { create(:user, email: 'test@example.com') } + let(:filter) { Admin::StatisticsFilter.new(email: user.email) } + + describe '#user_info' do + it 'returns user matching email' do + result = query.user_info + + expect(result.size).to eq(1) + expect(result.first.email).to eq('test@example.com') + end + + it 'returns empty when email is blank' do + blank_filter = Admin::StatisticsFilter.new + expect(described_class.new(blank_filter).user_info).to eq([]) + end + end + + describe '#user_habilitations' do + let(:authorization_request) do + create(:authorization_request, :with_organization, + intitule: 'Mairie de X', external_id: 'AR-1', api: 'entreprise', + siret: '13002526500013', status: :validated) + end + + before do + create(:user_authorization_request_role, user: user, authorization_request: authorization_request, role: 'demandeur') + end + + it 'returns habilitations for the user' do + result = query.user_habilitations + + expect(result.size).to eq(1) + expect(result.first.external_id).to eq('AR-1') + end + + it 'returns empty when email is blank' do + blank_filter = Admin::StatisticsFilter.new + expect(described_class.new(blank_filter).user_habilitations).to eq([]) + end + end + + describe '#user_tokens' do + before do + create(:token, authorization_request: create(:authorization_request, :with_demandeur, demandeur: user)) + end + + it 'returns tokens for the user' do + result = query.user_tokens + + expect(result.size).to eq(1) + expect(result.first.token_id).to be_present + end + + it 'returns empty when email is blank' do + blank_filter = Admin::StatisticsFilter.new + expect(described_class.new(blank_filter).user_tokens).to eq([]) + end + end + + describe '#habilitation_status_breakdown' do + before do + ar1 = create(:authorization_request, status: :validated) + ar2 = create(:authorization_request, status: :draft) + create(:user_authorization_request_role, user: user, authorization_request: ar1, role: 'demandeur') + create(:user_authorization_request_role, user: user, authorization_request: ar2, role: 'demandeur') + end + + it 'groups by status' do + result = query.habilitation_status_breakdown + + expect(result.pluck(:label)).to contain_exactly('validated', 'draft') + end + end + + describe '#token_status_breakdown' do + before do + ar = create(:authorization_request, :with_demandeur, demandeur: user, status: :validated) + create(:token, authorization_request: ar, exp: 1.year.from_now.to_i, blacklisted_at: nil) + create(:token, authorization_request: ar, exp: 1.year.ago.to_i, blacklisted_at: nil) + create(:token, authorization_request: ar, exp: 1.year.from_now.to_i, blacklisted_at: 1.month.ago) + end + + it 'splits into active/expired/blacklisted' do + result = query.token_status_breakdown + + expect(result).to contain_exactly( + { label: 'Actif', value: 1 }, + { label: 'Expiré', value: 1 }, + { label: 'Bloqué', value: 1 } + ) + end + end +end diff --git a/site/spec/queries/admin/users_overview_query_spec.rb b/site/spec/queries/admin/users_overview_query_spec.rb new file mode 100644 index 0000000000..7a9631d33e --- /dev/null +++ b/site/spec/queries/admin/users_overview_query_spec.rb @@ -0,0 +1,52 @@ +RSpec.describe Admin::UsersOverviewQuery do + subject(:query) { described_class.new(filter) } + + let(:filter) { Admin::StatisticsFilter.new } + + describe '#total_users' do + it 'returns user count' do + create_list(:user, 2) + + expect(query.total_users).to eq(User.count) + end + end + + describe '#total_habilitations' do + it 'returns authorization request count' do + create(:token) + + expect(query.total_habilitations).to eq(AuthorizationRequest.count) + end + end + + describe '#total_tokens' do + it 'returns token count' do + create_list(:token, 3) + + expect(query.total_tokens).to eq(Token.count) + end + end + + describe '#habilitations_with_successful_call' do + it 'counts distinct authorization requests with a 200 access log' do + token = create(:token) + create(:access_log, token: token, status: '200', controller: 'api_entreprise/v3_and_more/insee/etablissements') + + other_token = create(:token) + create(:access_log, token: other_token, status: '404', controller: 'api_entreprise/v3_and_more/insee/etablissements') + + expect(query.habilitations_with_successful_call).to eq(1) + end + + it 'respects date_from filter' do + token = create(:token) + create(:access_log, token: token, status: '200', + controller: 'api_entreprise/v3_and_more/insee/etablissements', + timestamp: 6.months.ago) + + recent_filter = Admin::StatisticsFilter.new(date_from: 1.month.ago.to_date) + + expect(described_class.new(recent_filter).habilitations_with_successful_call).to eq(0) + end + end +end