Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b3a917f
Add pie chart DSFR primitive component
skelz0r May 12, 2026
94772e9
Add ControllerName and KpiView models for materialized views
skelz0r May 12, 2026
5eb7188
Add Admin::StatisticsFilter form object
skelz0r May 12, 2026
7f8d75d
Add Admin::KpiQuery facade
skelz0r May 12, 2026
0d64a5e
Add Admin::TokenConsumptionQuery facade
skelz0r May 12, 2026
946d33d
Add Admin::TopUsersQuery facade
skelz0r May 12, 2026
0cd8a84
Add Admin::ApiHealthQuery facade
skelz0r May 12, 2026
d8e3ce8
Add Admin::ApiStatusQuery facade
skelz0r May 12, 2026
7213022
Add Admin::AnnualStatsQuery facade
skelz0r May 12, 2026
b54c05a
Add Admin::ApiConsumersQuery facade
skelz0r May 12, 2026
bb74de8
Add Admin::UsersOverviewQuery facade
skelz0r May 12, 2026
ba46e30
Add Admin::UserStatusQuery facade
skelz0r May 12, 2026
1af560b
Add admin statistics section with 9 dashboard pages
skelz0r May 12, 2026
7a26491
Add controller_name and kpi1/kpi2/kpi3 materialized views to schema
skelz0r May 12, 2026
d2df126
Add functional documentation for admin statistics pages
skelz0r May 12, 2026
f22d1a7
Refactor statistics controller to RESTful show with :page param
skelz0r May 12, 2026
9067068
Reference Metabase card IDs on all admin query methods
skelz0r May 12, 2026
39f35d8
Fix tokens.exp comparisons: exp is an epoch integer, not a timestamp
skelz0r May 12, 2026
a936961
Validate token_id format before UUID cast in token consumption query
skelz0r May 12, 2026
026ead7
Fix off-by-one in top_variations previous period calculation
skelz0r May 12, 2026
4b37938
Fix Rubocop Rails/WhereRange offense on token expiration query
skelz0r May 12, 2026
9f77c9e
Skip abstract classes in Seeds#flushdb to prevent TypeError
skelz0r May 12, 2026
882d489
Disable AbcSize on Seeds#flushdb after adding abstract_class guard
skelz0r May 12, 2026
aea4d9c
Rename query classes for Zeitwerk/classify compatibility
skelz0r May 12, 2026
d400ea2
Qualify access_logs.status in token_consumption_query
skelz0r May 12, 2026
a0b3442
Add missing attr_reader :filter to UserStatusQuery and UsersOverviewQ…
skelz0r May 12, 2026
59b1c52
Add specs for admin statistics query objects
skelz0r May 12, 2026
c2af43c
Fix Rubocop Rails/Pluck offenses in admin query specs
skelz0r May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions site/app/controllers/admin/statistics_controller.rb
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions site/app/forms/admin/statistics_filter.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions site/app/helpers/admin/statistics_helper.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions site/app/lib/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions site/app/models/controller_name.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions site/app/models/kpi_view.rb
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions site/app/queries/admin/annual_stat_query.rb
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions site/app/queries/admin/api_consumer_query.rb
Original file line number Diff line number Diff line change
@@ -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
Loading