diff --git a/backend/app/controllers/admin/auth_controller.rb b/backend/app/controllers/admin/auth_controller.rb index 4ba21b1..acb2ec9 100644 --- a/backend/app/controllers/admin/auth_controller.rb +++ b/backend/app/controllers/admin/auth_controller.rb @@ -1,6 +1,8 @@ module Admin class AuthController < Admin::BaseController skip_before_action :require_admin_login, only: %i[new create] + # Same as public logout: tolerate stale CSRF after long-lived admin tabs. + skip_before_action :verify_authenticity_token, only: :destroy def new redirect_to admin_root_path if admin_logged_in? diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb index f01c7ad..f3ba846 100644 --- a/backend/app/controllers/admin/sessions_controller.rb +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -1,12 +1,23 @@ module Admin class SessionsController < Admin::BaseController def index - @sessions = StreamSession.includes(:stream_node, :user, match: :team).order(created_at: :desc).limit(50) + @filters = session_filters + scope = filtered_sessions + @total_count = scope.count + @sessions = scope + .includes(:stream_node, :user, match: { team: :club }) + .order(Arel.sql("COALESCE(stream_sessions.started_at, stream_sessions.created_at) DESC")) + .limit(100) + @clubs = Club.order(:name) + @nodes = StreamNode.order(:slug) end def show - @session = StreamSession.includes(:stream_node, match: :team).find(params[:id]) - @events = @session.stream_events.recent.limit(50) + @session = StreamSession + .includes(:stream_node, :user, :device_states, :recording, match: { team: :club }) + .find(params[:id]) + @events = @session.stream_events.recent.limit(100) + @club = @session.match.team.club end def stop @@ -39,5 +50,66 @@ module Admin regia_expires_at: @session.regia_token_expires_at&.iso8601 } end + + private + + def session_filters + { + q: params[:q].to_s.strip.presence, + status: params[:status].to_s.strip.presence, + platform: params[:platform].to_s.strip.presence, + club_id: params[:club_id].to_s.strip.presence, + stream_node_id: params[:stream_node_id].to_s.strip.presence, + from: params[:from].to_s.strip.presence, + to: params[:to].to_s.strip.presence + } + end + + def filtered_sessions + scope = StreamSession.left_outer_joins(match: { team: :club }) + + if @filters[:q].present? + term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%" + scope = scope.where( + "teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term", + term: term + ) + end + + if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status]) + scope = scope.where(stream_sessions: { status: @filters[:status] }) + end + if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform]) + scope = scope.where(stream_sessions: { platform: @filters[:platform] }) + end + if @filters[:club_id].present? + scope = scope.where(teams: { club_id: @filters[:club_id] }) + end + if @filters[:stream_node_id].present? + scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] }) + end + if (from_time = parse_filter_date(@filters[:from], end_of_day: false)) + scope = scope.where( + "COALESCE(stream_sessions.started_at, stream_sessions.created_at) >= ?", + from_time + ) + end + if (to_time = parse_filter_date(@filters[:to], end_of_day: true)) + scope = scope.where( + "COALESCE(stream_sessions.started_at, stream_sessions.created_at) <= ?", + to_time + ) + end + scope + end + + def parse_filter_date(value, end_of_day:) + return nil if value.blank? + + date = Date.parse(value) + end_of_day ? date.end_of_day : date.beginning_of_day + rescue ArgumentError, TypeError + nil + end end end diff --git a/backend/app/controllers/public/sessions_controller.rb b/backend/app/controllers/public/sessions_controller.rb index e3955a7..eea373f 100644 --- a/backend/app/controllers/public/sessions_controller.rb +++ b/backend/app/controllers/public/sessions_controller.rb @@ -1,5 +1,9 @@ module Public class SessionsController < WebBaseController + # Logout must succeed even with a stale CSRF token (old tab after deploy / + # session rotate). SameSite=Lax already blocks cross-site cookie POSTs. + skip_before_action :verify_authenticity_token, only: :destroy + def new if logged_in? && current_user.primary_club redirect_to public_club_path(current_user.primary_club) diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb index 3e31adb..8d4ffc4 100644 --- a/backend/app/helpers/admin_helper.rb +++ b/backend/app/helpers/admin_helper.rb @@ -43,6 +43,75 @@ module AdminHelper I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize) end + def admin_session_status_badge_class(status) + case status.to_s + when "live" then "badge--live" + when "connecting", "reconnecting" then "badge--connecting" + when "paused", "idle" then "badge--paused" + when "ended" then "badge--ended" + when "error" then "badge--error" + else "badge--paused" + end + end + + def admin_datetime(time, with_seconds: false) + return I18n.t("admin.common.dash") if time.blank? + + format = with_seconds ? "%d/%m/%Y %H:%M:%S" : "%d/%m/%Y %H:%M" + time.in_time_zone.strftime(format) + end + + def admin_session_duration_label(session) + secs = session.total_duration_secs.to_i + if secs <= 0 && session.started_at + end_time = session.ended_at || (session.terminal? ? session.updated_at : Time.current) + secs = (end_time - session.started_at).to_i if end_time + end + return I18n.t("admin.common.dash") if secs <= 0 + + hours = secs / 3600 + mins = (secs % 3600) / 60 + rem = secs % 60 + if hours.positive? + format("%dh %02dm %02ds", hours, mins, rem) + elsif mins.positive? + format("%dm %02ds", mins, rem) + else + "#{rem}s" + end + end + + def admin_format_event_meta(metadata) + return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank? + + pairs = metadata.to_h + return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if pairs.empty? + + content_tag(:dl, class: "admin-event-meta") do + safe_join( + pairs.map do |key, value| + content_tag(:div, class: "admin-event-meta__row") do + content_tag(:dt, key.to_s) + + content_tag(:dd, admin_event_meta_value(value)) + end + end + ) + end + end + + def admin_event_meta_value(value) + case value + when Hash, Array + content_tag(:code, value.to_json) + when TrueClass, FalseClass + value ? "true" : "false" + when NilClass + I18n.t("admin.common.dash") + else + value.to_s + end + end + def admin_regia_expires_label(iso_time) return if iso_time.blank? diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index ca6aaa5..2373bcf 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -10,6 +10,7 @@ class StreamSession < ApplicationRecord belongs_to :stream_node, optional: true has_many :stream_events, dependent: :destroy has_one :score_state, dependent: :destroy + has_one :recording has_many :device_states, dependent: :destroy validates :platform, inclusion: { in: PLATFORMS } diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb index f3985b3..19623a4 100644 --- a/backend/app/views/admin/sessions/index.html.erb +++ b/backend/app/views/admin/sessions/index.html.erb @@ -1,32 +1,131 @@ -

<%= t("admin.sessions.index.title") %>

- - - - - - - - - - - - - <% @sessions.each do |s| %> - - - - - - - - - <% end %> - -
<%= t("admin.sessions.index.table.match") %><%= t("admin.sessions.index.table.status") %><%= t("admin.sessions.index.table.ingest") %><%= t("admin.sessions.index.table.disconnects") %><%= t("admin.sessions.index.table.link") %>
<%= s.match.team.name %> vs <%= s.match.opponent_name %><%= s.status %><%= render "admin/sessions/ingest_cell", session: s %><%= s.disconnection_count %> - - <%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %>
+<% content_for :body_class, "admin-body" %> + +
+

<%= t("admin.sessions.index.title") %>

+

<%= t("admin.sessions.index.lead") %>

+
+ +
+ <%= form_with url: admin_sessions_path, method: :get, local: true, class: "admin-filter-form" do %> +
+ + + + + + + + + + + + + +
+ +
+ <%= submit_tag t("admin.sessions.index.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> + <%= link_to t("admin.sessions.index.filters.reset"), admin_sessions_path, class: "admin-btn admin-btn--outline admin-btn--sm" %> + + <%= t("admin.sessions.index.results", shown: @sessions.size, total: @total_count) %> + +
+ <% end %> +
+ +
+ <% if @sessions.any? %> +
+ + + + + + + + + + + + + + + + + <% @sessions.each do |s| %> + <% club = s.match.team.club %> + + + + + + + + + + + + + <% end %> + +
<%= t("admin.sessions.index.table.club") %><%= t("admin.sessions.index.table.match") %><%= t("admin.sessions.index.table.status") %><%= t("admin.sessions.index.table.started") %><%= t("admin.sessions.index.table.ended") %><%= t("admin.sessions.index.table.duration") %><%= t("admin.sessions.index.table.ingest") %><%= t("admin.sessions.index.table.disconnects") %><%= t("admin.sessions.index.table.link") %>
+ <% if club %> + <%= link_to club.name, admin_club_path(club), class: "admin-table-strong" %> + <% else %> + <%= t("admin.common.dash") %> + <% end %> + +
<%= s.match.team.name %> vs <%= s.match.opponent_name %>
+
<%= s.platform %>
+
+ <%= s.status %> + <%= admin_datetime(s.started_at || s.created_at) %><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %><%= admin_session_duration_label(s) %><%= render "admin/sessions/ingest_cell", session: s %><%= s.disconnection_count %> + + <%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %>
+
+ <% else %> +

<%= t("admin.sessions.index.none") %>

+ <% end %> +
diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb index a9fd548..9dc63aa 100644 --- a/backend/app/views/admin/sessions/show.html.erb +++ b/backend/app/views/admin/sessions/show.html.erb @@ -1,49 +1,247 @@ -

<%= t("admin.sessions.show.title", id: @session.id) %>

-

<%= t("admin.sessions.show.status_label") %> <%= @session.status %>

-<% unless @session.terminal? %> -

- <%= button_to t("admin.sessions.show.stop_button"), - stop_admin_session_path(@session), - method: :post, - class: "admin-btn admin-btn--danger", - form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %> -

-<% end %> -

<%= t("admin.sessions.show.match_label", team: @session.match.team.name, opponent: @session.match.opponent_name) %>

+<% content_for :body_class, "admin-body" %> +<% team = @session.match.team %> +<% club = @club || team.club %> + +
+
+

+ <%= link_to t("admin.sessions.show.back"), admin_sessions_path %> +

+

<%= t("admin.sessions.show.heading") %>

+

+ <%= team.name %> + vs + <%= @session.match.opponent_name %> +

+ <% if club %> +

+ <%= t("admin.sessions.show.club_label") %> + <%= link_to club.name, admin_club_path(club) %> +

+ <% end %> +
+
+ <%= @session.status %> + <% unless @session.terminal? %> + <%= button_to t("admin.sessions.show.stop_button"), + stop_admin_session_path(@session), + method: :post, + class: "admin-btn admin-btn--danger admin-btn--sm", + form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %> + <% end %> +
+
+ +
+
+

<%= t("admin.sessions.show.summary_title") %>

+
+
+
<%= t("admin.sessions.show.fields.id") %>
+
<%= @session.id %>
+
+
+
<%= t("admin.sessions.show.fields.club") %>
+
<%= club&.name || t("admin.common.dash") %>
+
+
+
<%= t("admin.sessions.show.fields.team") %>
+
<%= team.name %>
+
+
+
<%= t("admin.sessions.show.fields.opponent") %>
+
<%= @session.match.opponent_name %>
+
+
+
<%= t("admin.sessions.show.fields.operator") %>
+
+ <% if @session.user %> + <%= @session.user.name.presence || @session.user.email %> + (<%= @session.user.email %>) + <% else %> + <%= t("admin.common.dash") %> + <% end %> +
+
+
+
<%= t("admin.sessions.show.fields.platform") %>
+
<%= @session.platform %>
+
+
+
<%= t("admin.sessions.show.fields.privacy") %>
+
<%= @session.privacy_status %>
+
+
+
<%= t("admin.sessions.show.fields.quality") %>
+
<%= @session.quality_preset %>
+
+
+
<%= t("admin.sessions.show.fields.min_quality") %>
+
<%= @session.min_quality_preset %>
+
+
+
<%= t("admin.sessions.show.fields.audio") %>
+
<%= @session.audio_muted? ? t("admin.sessions.show.audio_muted") : t("admin.sessions.show.audio_on") %>
+
+
+
+ +
+

<%= t("admin.sessions.show.timing_title") %>

+
+
+
<%= t("admin.sessions.show.fields.created") %>
+
<%= admin_datetime(@session.created_at, with_seconds: true) %>
+
+
+
<%= t("admin.sessions.show.fields.started") %>
+
<%= admin_datetime(@session.started_at, with_seconds: true) %>
+
+
+
<%= t("admin.sessions.show.fields.ended") %>
+
<%= admin_datetime(@session.ended_at, with_seconds: true) %>
+
+
+
<%= t("admin.sessions.show.fields.duration") %>
+
<%= admin_session_duration_label(@session) %>
+
+
+
<%= t("admin.sessions.show.fields.disconnects") %>
+
<%= @session.disconnection_count %>
+
+ <% if @session.match.scheduled_at %> +
+
<%= t("admin.sessions.show.fields.scheduled") %>
+
<%= admin_datetime(@session.match.scheduled_at) %>
+
+ <% end %> + <% if @session.match.location.present? %> +
+
<%= t("admin.sessions.show.fields.location") %>
+
<%= @session.match.location %>
+
+ <% end %> +
+
+ +
+

<%= t("admin.sessions.show.ingest_title") %>

+
+
+
<%= t("admin.sessions.show.fields.node") %>
+
+ <% if @session.stream_node %> + <%= @session.stream_node.slug %> + + <%= admin_session_ingest_role_label(@session.stream_node) %> + + (<%= @session.stream_node.provider %>) + <% else %> + <%= t("admin.sessions.ingest.none") %> + <% end %> +
+
+
+
<%= t("admin.sessions.show.fields.rtmp") %>
+
<%= @session.rtmp_ingest_url %>
+
+
+
<%= t("admin.sessions.show.fields.hls") %>
+
<%= @session.hls_playback_url %>
+
+ <% if @session.youtube_broadcast_id.present? %> +
+
<%= t("admin.sessions.show.youtube_studio") %>
+
+ + <%= t("admin.sessions.show.broadcast") %> + + (<%= @session.youtube_broadcast_id %>) +
+
+ <% end %> + <% if @session.recording %> +
+
<%= t("admin.sessions.show.fields.recording") %>
+
+ + <%= @session.recording.status %> + + <% if club %> + <%= link_to t("admin.sessions.show.open_replays"), admin_club_recordings_path(club) %> + <% end %> +
+
+ <% end %> +
+
+
<%= render "admin/sessions/links", session: @session %> -<% if @session.youtube_broadcast_id %> -

<%= t("admin.sessions.show.youtube_studio") %>: <%= t("admin.sessions.show.broadcast") %>

+<% if @session.device_states.any? %> +
+

<%= t("admin.sessions.show.devices_title") %>

+
+ + + + + + + + + + + + + <% @session.device_states.order(:device_role).each do |device| %> + + + + + + + + + <% end %> + +
<%= t("admin.sessions.show.devices.role") %><%= t("admin.sessions.show.devices.network") %><%= t("admin.sessions.show.devices.battery") %><%= t("admin.sessions.show.devices.bitrate") %><%= t("admin.sessions.show.devices.fps") %><%= t("admin.sessions.show.devices.last_seen") %>
<%= device.device_role %><%= device.network_type.presence || t("admin.common.dash") %><%= device.battery_level.nil? ? t("admin.common.dash") : "#{device.battery_level}%" %> + <% if device.current_bitrate || device.target_bitrate %> + <%= device.current_bitrate || t("admin.common.dash") %> + / <%= device.target_bitrate || t("admin.common.dash") %> + <% else %> + <%= t("admin.common.dash") %> + <% end %> + <%= device.fps || t("admin.common.dash") %><%= admin_datetime(device.last_seen_at, with_seconds: true) %>
+
+
<% end %> -

- <%= t("admin.sessions.show.ingest_node") %> - <% if @session.stream_node %> - <%= @session.stream_node.slug %> - <%= admin_session_ingest_role_label(@session.stream_node) %> - (<%= @session.stream_node.provider %>) - <% else %> - <%= t("admin.sessions.ingest.none") %> - <% end %> -

-

<%= t("admin.sessions.show.rtmp_ingest") %> <%= @session.rtmp_ingest_url %>

-

<%= t("admin.sessions.show.events_title") %>

- - - - - - - - - - <% @events.each do |e| %> - - - - - - <% end %> - -
<%= t("admin.sessions.show.table.type") %><%= t("admin.sessions.show.table.when") %><%= t("admin.sessions.show.table.meta") %>
<%= e.event_type %><%= e.occurred_at %><%= e.metadata.to_json %>
+
+

<%= t("admin.sessions.show.events_title") %>

+ <% if @events.any? %> +
+ + + + + + + + + + <% @events.each do |e| %> + + + + + + <% end %> + +
<%= t("admin.sessions.show.table.type") %><%= t("admin.sessions.show.table.when") %><%= t("admin.sessions.show.table.meta") %>
<%= e.event_type %><%= admin_datetime(e.occurred_at, with_seconds: true) %><%= admin_format_event_meta(e.metadata) %>
+
+ <% else %> +

<%= t("admin.sessions.show.events_none") %>

+ <% end %> +
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index 1b797c3..246e162 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -4,7 +4,8 @@ <%= t("admin.layout.title") %> - + <%= csrf_meta_tags %> + <% if content_for?(:replay_archive_styles) %> <% end %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index a6f3296..614442a 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -5,6 +5,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> + <%= csrf_meta_tags %> <%= render "shared/meta_tags" %> <%= yield :head %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 8544ed2..ea2fb4e 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -4,6 +4,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> + <%= csrf_meta_tags %> <%= render "shared/meta_tags" %> diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index daaf314..0a062ab 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -251,9 +251,28 @@ de: sessions: index: title: Stream-Sitzungen + lead: Suche nach Verein, Team, Gegner oder Ort. Filtere nach Status, Plattform, Knoten und Zeitraum. + results: "%{shown} von %{total} angezeigt" + none: Keine Sitzungen für diese Filter. + filters: + q: Suche + q_placeholder: Verein, Team, Gegner… + status: Status + platform: Plattform + club: Verein + node: Ingest-Knoten + from: Von + to: Bis + any: Alle + apply: Filtern + reset: Zurücksetzen table: + club: Verein match: Spiel status: Status + started: Start + ended: Ende + duration: Dauer ingest: Ingest disconnects: Verbindungsabbrüche link: Link @@ -261,19 +280,59 @@ de: regia: Regie show: title: "Sitzung %{id}" + heading: Sitzungsdetails + back: "← Zurück zu Sitzungen" + club_label: "Verein:" + summary_title: Übersicht + timing_title: Zeiten + ingest_title: Ingest & Medien + devices_title: Geräte + events_title: Ereignisse + events_none: Keine Ereignisse erfasst. status_label: "Status:" stop_button: Sitzung beenden stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet." match_label: "Spiel: %{team} gegen %{opponent}" youtube_studio: YouTube Studio broadcast: Übertragung + open_replays: Vereins-Replays öffnen + audio_muted: Audio stumm + audio_on: Audio an ingest_node: "Ingest-Knoten:" rtmp_ingest: "RTMP-Ingest:" - events_title: Ereignisse + fields: + id: ID + club: Verein + team: Team + opponent: Gegner + operator: Operator + platform: Plattform + privacy: Privacy + quality: Qualität + min_quality: Min. Qualität + audio: Audio + created: Erstellt + started: Start + ended: Ende + duration: Dauer + disconnects: Abbrüche + scheduled: Geplantes Spiel + location: Ort + node: Knoten + rtmp: RTMP-Ingest + hls: HLS + recording: Replay + devices: + role: Rolle + network: Netz + battery: Akku + bitrate: Bitrate + fps: FPS + last_seen: Zuletzt gesehen table: type: Typ when: Wann - meta: Meta + meta: Details links: watch_title: Live-Link none: Kein Video-Link für diese Sitzung verfügbar. diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 67839e5..5dcc89a 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -251,9 +251,28 @@ en: sessions: index: title: Stream sessions + lead: Search by club, team, opponent or venue. Filter by status, platform, node and date range. + results: "Showing %{shown} of %{total}" + none: No sessions match these filters. + filters: + q: Search + q_placeholder: Club, team, opponent… + status: Status + platform: Platform + club: Club + node: Ingest node + from: From + to: To + any: All + apply: Filter + reset: Reset table: + club: Club match: Match status: Status + started: Started + ended: Ended + duration: Duration ingest: Ingest disconnects: Disconnects link: Link @@ -261,19 +280,59 @@ en: regia: Control show: title: "Session %{id}" + heading: Session details + back: "← Back to sessions" + club_label: "Club:" + summary_title: Summary + timing_title: Timing + ingest_title: Ingest & media + devices_title: Devices + events_title: Events + events_none: No events recorded. status_label: "Status:" stop_button: End session stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended." match_label: "Match: %{team} vs %{opponent}" youtube_studio: YouTube Studio broadcast: Broadcast + open_replays: Open club replays + audio_muted: Audio muted + audio_on: Audio on ingest_node: "Ingest node:" rtmp_ingest: "RTMP ingest:" - events_title: Events + fields: + id: ID + club: Club + team: Team + opponent: Opponent + operator: Operator + platform: Platform + privacy: Privacy + quality: Quality + min_quality: Min quality + audio: Audio + created: Created + started: Started + ended: Ended + duration: Duration + disconnects: Disconnects + scheduled: Scheduled match + location: Venue + node: Node + rtmp: RTMP ingest + hls: HLS + recording: Replay + devices: + role: Role + network: Network + battery: Battery + bitrate: Bitrate + fps: FPS + last_seen: Last seen table: type: Type when: When - meta: Meta + meta: Details links: watch_title: Live link none: No video link available for this session. diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 47f3e8b..3935cc1 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -251,9 +251,28 @@ es: sessions: index: title: Sesiones de emisión + lead: Busca por club, equipo, rival o sede. Filtra por estado, plataforma, nodo y periodo. + results: "Mostrando %{shown} de %{total}" + none: Ninguna sesión coincide con estos filtros. + filters: + q: Buscar + q_placeholder: Club, equipo, rival… + status: Estado + platform: Plataforma + club: Club + node: Nodo ingest + from: Desde + to: Hasta + any: Todos + apply: Filtrar + reset: Restablecer table: + club: Club match: Partido status: Estado + started: Inicio + ended: Fin + duration: Duración ingest: Ingest disconnects: Desconexiones link: Enlace @@ -261,19 +280,59 @@ es: regia: Regie show: title: "Sesión %{id}" + heading: Detalle de sesión + back: "← Volver a sesiones" + club_label: "Club:" + summary_title: Resumen + timing_title: Tiempos + ingest_title: Ingest y medios + devices_title: Dispositivos + events_title: Eventos + events_none: No hay eventos registrados. status_label: "Estado:" stop_button: Finalizar sesión stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada." match_label: "Partido: %{team} vs %{opponent}" youtube_studio: YouTube Studio broadcast: Emisión + open_replays: Abrir replays del club + audio_muted: Audio silenciado + audio_on: Audio activo ingest_node: "Nodo ingest:" rtmp_ingest: "Ingesta RTMP:" - events_title: Eventos + fields: + id: ID + club: Club + team: Equipo + opponent: Rival + operator: Operador + platform: Plataforma + privacy: Privacidad + quality: Calidad + min_quality: Calidad mínima + audio: Audio + created: Creada + started: Inicio + ended: Fin + duration: Duración + disconnects: Desconexiones + scheduled: Partido programado + location: Sede + node: Nodo + rtmp: Ingesta RTMP + hls: HLS + recording: Replay + devices: + role: Rol + network: Red + battery: Batería + bitrate: Bitrate + fps: FPS + last_seen: Último contacto table: type: Tipo when: Cuándo - meta: Meta + meta: Detalles links: watch_title: Enlace en directo none: No hay ningún enlace de vídeo disponible para esta sesión. diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 9395087..05e1f52 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -251,9 +251,28 @@ fr: sessions: index: title: Sessions de diffusion + lead: Recherchez par club, équipe, adversaire ou lieu. Filtrez par statut, plateforme, nœud et période. + results: "%{shown} sur %{total} affichées" + none: Aucune session ne correspond à ces filtres. + filters: + q: Recherche + q_placeholder: Club, équipe, adversaire… + status: Statut + platform: Plateforme + club: Club + node: Nœud ingest + from: Du + to: Au + any: Tous + apply: Filtrer + reset: Réinitialiser table: + club: Club match: Match status: Statut + started: Début + ended: Fin + duration: Durée ingest: Ingest disconnects: Déconnexions link: Lien @@ -261,19 +280,59 @@ fr: regia: Régie show: title: "Session %{id}" + heading: Détail de la session + back: "← Retour aux sessions" + club_label: "Club :" + summary_title: Résumé + timing_title: Horaires + ingest_title: Ingest et médias + devices_title: Appareils + events_title: Événements + events_none: Aucun événement enregistré. status_label: "Statut :" stop_button: Terminer la session stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé." match_label: "Match : %{team} contre %{opponent}" youtube_studio: YouTube Studio broadcast: Diffusion + open_replays: Ouvrir les replays du club + audio_muted: Audio coupé + audio_on: Audio actif ingest_node: "Nœud ingest :" rtmp_ingest: "Ingestion RTMP :" - events_title: Événements + fields: + id: ID + club: Club + team: Équipe + opponent: Adversaire + operator: Opérateur + platform: Plateforme + privacy: Confidentialité + quality: Qualité + min_quality: Qualité mini + audio: Audio + created: Créée + started: Début + ended: Fin + duration: Durée + disconnects: Déconnexions + scheduled: Match programmé + location: Lieu + node: Nœud + rtmp: Ingestion RTMP + hls: HLS + recording: Replay + devices: + role: Rôle + network: Réseau + battery: Batterie + bitrate: Débit + fps: FPS + last_seen: Dernier contact table: type: Type when: Quand - meta: Méta + meta: Détails links: watch_title: Lien en direct none: Aucun lien vidéo disponible pour cette session. diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index 3b9e403..ec3a980 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -272,9 +272,28 @@ it: sessions: index: title: Sessioni di streaming + lead: Cerca per società, squadra, avversario o sede. Filtra per stato, piattaforma, nodo e periodo. + results: "Mostrate %{shown} di %{total}" + none: Nessuna sessione trovata con questi filtri. + filters: + q: Cerca + q_placeholder: Società, squadra, avversario… + status: Stato + platform: Piattaforma + club: Società + node: Nodo ingest + from: Da + to: A + any: Tutti + apply: Filtra + reset: Azzera table: + club: Società match: Match status: Stato + started: Inizio + ended: Fine + duration: Durata ingest: Ingest disconnects: Disconnessioni link: Link @@ -282,19 +301,59 @@ it: regia: Regia show: title: "Sessione %{id}" + heading: Dettaglio sessione + back: "← Torna alle sessioni" + club_label: "Società:" + summary_title: Riepilogo + timing_title: Tempi + ingest_title: Ingest e media + devices_title: Dispositivi + events_title: Eventi + events_none: Nessun evento registrato. status_label: "Stato:" stop_button: Termina sessione stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended." match_label: "Match: %{team} vs %{opponent}" youtube_studio: YouTube Studio broadcast: Broadcast + open_replays: Apri replay società + audio_muted: Audio muto + audio_on: Audio attivo ingest_node: "Nodo ingest:" rtmp_ingest: "RTMP ingest:" - events_title: Eventi + fields: + id: ID + club: Società + team: Squadra + opponent: Avversario + operator: Operatore + platform: Piattaforma + privacy: Privacy + quality: Qualità + min_quality: Qualità minima + audio: Audio + created: Creata + started: Inizio + ended: Fine + duration: Durata + disconnects: Disconnessioni + scheduled: Partita programmata + location: Sede + node: Nodo + rtmp: RTMP ingest + hls: HLS + recording: Replay + devices: + role: Ruolo + network: Rete + battery: Batteria + bitrate: Bitrate + fps: FPS + last_seen: Ultimo contatto table: type: Tipo when: Quando - meta: Meta + meta: Dettagli links: watch_title: Link diretta none: Nessun link video disponibile per questa sessione. diff --git a/backend/config/routes.rb b/backend/config/routes.rb index ba5e3f7..0ef7709 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -184,7 +184,7 @@ Rails.application.routes.draw do post "signup", to: "registrations#create" get "login", to: "sessions#new" post "login", to: "sessions#create" - delete "logout", to: "sessions#destroy" + match "logout", to: "sessions#destroy", via: %i[delete get] patch "locale", to: "locales#update", as: :locale get "password/forgot", to: "password_resets#new", as: :password_forgot post "password/forgot", to: "password_resets#create" diff --git a/backend/public/admin.css b/backend/public/admin.css index 758b934..37ad354 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -234,6 +234,8 @@ body.admin-body { .badge--ingest-home { background: #1565c0; color: #e3f2fd; } .badge--ingest-lab { background: #6a1b9a; color: #f3e5f5; } .badge--ingest-cloud { background: #e65100; color: #fff3e0; } +.badge--ended { background: #37474f; color: #eceff1; } +.badge--error { background: #b71c1c; color: #ffebee; } .admin-ingest { display: inline-flex; @@ -743,3 +745,193 @@ body.admin-body { grid-template-columns: 1fr; } } + +.admin-sessions-filters { + margin-bottom: 1rem; +} + +.admin-filter-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.admin-filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.admin-filter-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.78rem; + color: var(--muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.admin-filter-field input, +.admin-filter-field select { + font: inherit; + text-transform: none; + letter-spacing: 0; + font-weight: 500; + color: var(--text); + background: #0d0d12; + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 0.45rem 0.55rem; +} + +.admin-filter-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} + +.admin-filter-count { + font-size: 0.85rem; +} + +.admin-table-strong { + font-weight: 600; + color: var(--text); +} + +.admin-table-sub { + font-size: 0.78rem; + margin-top: 0.15rem; +} + +.admin-session-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.admin-session-head-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} + +.admin-session-match { + margin: 0.35rem 0 0; + font-size: 1.05rem; +} + +.admin-session-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 1rem; +} + +@media (max-width: 1100px) { + .admin-session-grid { + grid-template-columns: 1fr; + } +} + +.admin-session-grid .panel h3, +.panel > h3 { + margin: 0 0 0.85rem; + font-size: 0.95rem; +} + +.admin-kv { + margin: 0; + display: grid; + gap: 0.65rem; +} + +.admin-kv > div { + display: grid; + grid-template-columns: minmax(110px, 34%) 1fr; + gap: 0.5rem 0.75rem; + align-items: start; + padding-bottom: 0.55rem; + border-bottom: 1px solid rgba(42, 42, 54, 0.7); +} + +.admin-kv > div:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.admin-kv dt { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.admin-kv dd { + margin: 0; + font-size: 0.92rem; + word-break: break-word; +} + +.admin-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.admin-mono--wrap { + white-space: pre-wrap; + word-break: break-all; +} + +.admin-event-type { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + font-weight: 600; +} + +.admin-event-meta { + margin: 0; + display: grid; + gap: 0.25rem; +} + +.admin-event-meta__row { + display: grid; + grid-template-columns: minmax(90px, 28%) 1fr; + gap: 0.35rem 0.6rem; + font-size: 0.82rem; +} + +.admin-event-meta dt { + margin: 0; + color: var(--muted); + font-weight: 600; +} + +.admin-event-meta dd { + margin: 0; + word-break: break-word; +} + +.admin-event-meta code { + font-size: 0.78rem; + color: #d7d7e2; +} + +.admin-table--events td { + vertical-align: top; +} + +.admin-links-panel { + margin-bottom: 1rem; +} + diff --git a/backend/spec/requests/admin/sessions_spec.rb b/backend/spec/requests/admin/sessions_spec.rb new file mode 100644 index 0000000..a7e8335 --- /dev/null +++ b/backend/spec/requests/admin/sessions_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe "Admin sessions index", type: :request do + let!(:admin) { AdminAccount.create!(username: "ops-sessions", password: "Password123") } + let!(:coach) do + User.create!(email: "sessions-coach@test.it", name: "Coach", password: "Password123", role: "coach") + end + let!(:club_a) { Club.create!(name: "Tigers Club", sport: "volleyball") } + let!(:club_b) { Club.create!(name: "Other Club", sport: "volleyball") } + let!(:team_a) { club_a.teams.create!(name: "Tigers U16", sport_key: "pallavolo") } + let!(:team_b) { club_b.teams.create!(name: "Other U14", sport_key: "pallavolo") } + let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", sport_key: "pallavolo") } + let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", sport_key: "pallavolo") } + let!(:session_a) do + StreamSession.create!( + match: match_a, + user: coach, + platform: "matchlivetv", + status: "ended", + started_at: 2.hours.ago, + ended_at: 1.hour.ago, + total_duration_secs: 3600 + ) + end + let!(:session_b) do + StreamSession.create!( + match: match_b, + user: coach, + platform: "youtube", + status: "live", + started_at: 30.minutes.ago + ) + end + + def login! + post admin_login_path, params: { username: admin.username, password: "Password123" } + end + + before { login! } + + it "mostra società, orari e filtra per testo" do + get admin_sessions_path, params: { q: "Tigers" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("Tigers Club") + expect(response.body).to include("Tigers U16 vs Rival A") + expect(response.body).not_to include("Other U14 vs Rival B") + end + + it "filtra per stato" do + get admin_sessions_path, params: { status: "live" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("Other U14 vs Rival B") + expect(response.body).not_to include("Tigers U16 vs Rival A") + end + + it "mostra il dettaglio formattato" do + get admin_session_path(session_a) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Tigers Club") + expect(response.body).to include("Dettaglio sessione").or include("Session details") + expect(response.body).to include(session_a.id) + end +end diff --git a/backend/spec/requests/public/sessions_logout_spec.rb b/backend/spec/requests/public/sessions_logout_spec.rb new file mode 100644 index 0000000..96d25c5 --- /dev/null +++ b/backend/spec/requests/public/sessions_logout_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe "Public logout", type: :request do + let!(:user) do + User.create!(email: "logout-user@test.it", name: "Logout", password: "Password123", role: "coach") + end + + def login! + ActionController::Base.allow_forgery_protection = false + post public_login_path, params: { email: user.email, password: "Password123" } + ActionController::Base.allow_forgery_protection = true + end + + around do |example| + was = ActionController::Base.allow_forgery_protection + example.run + ensure + ActionController::Base.allow_forgery_protection = was + end + + it "disconnette anche senza authenticity_token (CSRF stale)" do + login! + expect(session[:user_id]).to eq(user.id) + + delete "/logout" + expect(response).to redirect_to(public_pricing_path) + follow_redirect! + expect(session[:user_id]).to be_nil + end + + it "accetta anche GET /logout come fallback" do + login! + get "/logout" + expect(response).to redirect_to(public_pricing_path) + expect(session[:user_id]).to be_nil + end +end diff --git a/scripts/deploy/sync_to_server.sh b/scripts/deploy/sync_to_server.sh index 999932e..5d27148 100755 --- a/scripts/deploy/sync_to_server.sh +++ b/scripts/deploy/sync_to_server.sh @@ -22,6 +22,7 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then --exclude 'backend/tmp' \ --exclude 'backend/.bundle' \ --exclude 'infra/.env' \ + --exclude 'infra/slates/custom/' \ --exclude 'infra/garage/garage.prod.toml' \ --exclude 'infra/garage/prod-credentials.env' \ --exclude 'log/' \