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| %>
+
+ <% Admin::StatisticsFilter.date_presets.each do |key, (from, to)| %>
+ <% active = filter.matches_preset?(key) %>
+
+ <%= link_to key,
+ url_for(request.query_parameters.deep_merge(filter: { date_from: from.iso8601, date_to: to.iso8601 })),
+ class: "fr-btn fr-btn--sm #{'fr-btn--secondary' unless active}".strip %>
+
+ <% end %>
+
+
+
+
+
+ <%= 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 %>
+
+
+
+ <%= f.submit 'Appliquer', class: 'fr-btn' %>
+ <%= link_to 'Réinitialiser', request.path, class: 'fr-btn fr-btn--secondary' %>
+
+<% 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
+
+
+
+
+ External ID
+ Intitulé
+ SIRET
+ Email
+ Appels
+
+
+
+ <% @query.consumer_habilitations.each do |row| %>
+
+ <%= row[:external_id] %>
+ <%= row[:intitule] %>
+ <%= row[:siret] %>
+ <%= row[:email] %>
+ <%= number_with_delimiter(row[:calls]) %>
+
+ <% end %>
+
+
+
+
+
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 %>
+
+
+
+ État instantané des APIs
+
+
+ Controller
+ Total
+ Succès
+ Non trouvé
+ Erreurs
+ Durée moy. (ms)
+
+
+
+ <% @query.instant_status.each do |row| %>
+
+ <%= 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] %>
+
+ <% end %>
+
+
+
+
+
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| %>
+
+
+
+
+
+ <%= link_to meta[:title], admin_statistic_path(page: action) %>
+
+
<%= meta[:description] %>
+
+
+
+
+ <% 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
+
+
+
+
+ Datapass
+ Appels actuels
+ Appels précédents
+ Variation %
+ Variation brute
+
+
+
+ <% @query.top_variations.each do |row| %>
+
+ <%= row[:source_id] %>
+ <%= number_with_delimiter(row[:current]) %>
+ <%= number_with_delimiter(row[:previous]) %>
+ <%= row[:variation] %>%
+ <%= number_with_delimiter(row[:variation_brute]) %>
+
+ <% end %>
+
+
+
+
+
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
+
+
+
+
+ External ID
+ Intitulé
+ API
+ Statut
+ Appels
+
+
+
+ <% @query.habilitations.each do |row| %>
+
+ <%= row.external_id %>
+ <%= row.intitule %>
+ <%= row.api %>
+ <%= row.status %>
+ <%= number_with_delimiter(row.calls_count) %>
+
+ <% end %>
+
+
+
+
+
Jetons et consommation
+
+
+
+
+ Token ID
+ External ID
+ Intitulé
+ API
+ Appels
+
+
+
+ <% @query.tokens_with_consumption.each do |row| %>
+
+ <%= row.token_id %>
+ <%= row.external_id %>
+ <%= row.intitule %>
+ <%= row.api %>
+ <%= number_with_delimiter(row.calls_count) %>
+
+ <% end %>
+
+
+
+
+ <% 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
+
+
+
+
+ Date
+ Controller
+ Statut
+ Durée (ms)
+ Chemin
+ Cache
+
+
+
+ <% @query.recent_requests.each do |log| %>
+
+ <%= log.timestamp.strftime('%d/%m %H:%M') %>
+ <%= log.controller %>
+ <%= log.status %>
+ <%= log.duration %>
+ <%= truncate(log.path, length: 60) %>
+ <%= log.cached ? 'Oui' : 'Non' %>
+
+ <% end %>
+
+
+
+ <% 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| %>
+
+
+
+ <%= label %>
+
+
+ <%= label.singularize %>
+ Appels
+
+
+
+ <% @query.public_send(method, domaine).each do |row| %>
+
+ <%= truncate(row[:identifier].to_s, length: 40) %>
+ <%= number_with_delimiter(row[:calls]) %>
+
+ <% end %>
+
+
+
+
+ <% 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
+
+
+
+
+ Email
+ Créé le
+
+
+
+ <% @query.user_info.each do |user| %>
+
+ <%= user.email %>
+ <%= user.created_at.strftime('%d/%m/%Y') %>
+
+ <% end %>
+
+
+
+
+
Habilitations
+
+
+
+
+ External ID
+ Intitulé
+ API
+ Statut
+ SIRET
+ Créé le
+
+
+
+ <% @query.user_habilitations.each do |ar| %>
+
+ <%= ar.external_id %>
+ <%= ar.intitule %>
+ <%= ar.api %>
+ <%= ar.status %>
+ <%= ar.siret %>
+ <%= ar.created_at.strftime('%d/%m/%Y') %>
+
+ <% end %>
+
+
+
+
+
Jetons
+
+
+
+
+ Token ID
+ External ID
+ Intitulé
+ Expiration
+ Bloqué le
+
+
+
+ <% @query.user_tokens.each do |token| %>
+
+ <%= 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 %>
+
+
+
+ <% 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