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") %>
-
-
-
- | <%= 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") %> |
- |
-
-
-
- <% @sessions.each do |s| %>
-
- | <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
- <%= s.status %> |
- <%= render "admin/sessions/ingest_cell", session: s %> |
- <%= s.disconnection_count %> |
-
-
- <% admin_session_watch_links(s).each do |link| %>
- <%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
- <% end %>
- <%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
-
- |
- <%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %> |
-
- <% end %>
-
-
+<% 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? %>
+
+
+
+
+ | <%= 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") %> |
+ |
+
+
+
+ <% @sessions.each do |s| %>
+ <% club = s.match.team.club %>
+
+ |
+ <% 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 %> |
+
+
+ <% admin_session_watch_links(s).each do |link| %>
+ <%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
+ <% end %>
+ <%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
+
+ |
+ <%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %> |
+
+ <% end %>
+
+
+
+ <% 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? %>
+
+ <% 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") %>
+
+
+
+
+ | <%= 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") %> |
+
+
+
+ <% @session.device_states.order(:device_role).each do |device| %>
+
+ | <%= 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 %>
+
+
+
+
<% 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") %>
-
-
-
- | <%= t("admin.sessions.show.table.type") %> |
- <%= t("admin.sessions.show.table.when") %> |
- <%= t("admin.sessions.show.table.meta") %> |
-
-
-
- <% @events.each do |e| %>
-
- | <%= e.event_type %> |
- <%= e.occurred_at %> |
- <%= e.metadata.to_json %> |
-
- <% end %>
-
-
+
+ <%= t("admin.sessions.show.events_title") %>
+ <% if @events.any? %>
+
+
+
+
+ | <%= t("admin.sessions.show.table.type") %> |
+ <%= t("admin.sessions.show.table.when") %> |
+ <%= t("admin.sessions.show.table.meta") %> |
+
+
+
+ <% @events.each do |e| %>
+
+ | <%= e.event_type %> |
+ <%= admin_datetime(e.occurred_at, with_seconds: true) %> |
+ <%= admin_format_event_meta(e.metadata) %> |
+
+ <% end %>
+
+
+
+ <% 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/' \