diff --git a/backend/app/controllers/admin/auth_controller.rb b/backend/app/controllers/admin/auth_controller.rb
new file mode 100644
index 0000000..3dc8b6c
--- /dev/null
+++ b/backend/app/controllers/admin/auth_controller.rb
@@ -0,0 +1,25 @@
+module Admin
+ class AuthController < Admin::BaseController
+ skip_before_action :require_admin_login, only: %i[new create]
+
+ def new
+ redirect_to admin_root_path if admin_logged_in?
+ end
+
+ def create
+ account = AdminAccount.find_by(username: params[:username]&.strip)
+ if account&.authenticate(params[:password])
+ session[:admin_account_id] = account.id
+ redirect_to session.delete(:admin_return_to) || admin_root_path, notice: "Accesso effettuato"
+ else
+ flash.now[:alert] = "Username o password non validi"
+ render :new, status: :unauthorized
+ end
+ end
+
+ def destroy
+ reset_session
+ redirect_to admin_login_path, notice: "Disconnesso"
+ end
+ end
+end
diff --git a/backend/app/controllers/admin/base_controller.rb b/backend/app/controllers/admin/base_controller.rb
index ebf3d4c..3dafc49 100644
--- a/backend/app/controllers/admin/base_controller.rb
+++ b/backend/app/controllers/admin/base_controller.rb
@@ -1,6 +1,30 @@
module Admin
class BaseController < ActionController::Base
layout "admin"
- protect_from_forgery with: :null_session
+ protect_from_forgery with: :exception
+
+ before_action :require_admin_login
+
+ include ::AdminHelper
+ helper_method :current_admin_account, :admin_logged_in?
+
+ private
+
+ def require_admin_login
+ return if admin_logged_in?
+
+ session[:admin_return_to] = request.fullpath unless request.path == admin_login_path
+ redirect_to admin_login_path, alert: "Accedi per continuare"
+ end
+
+ def current_admin_account
+ return unless session[:admin_account_id]
+
+ @current_admin_account ||= AdminAccount.find_by(id: session[:admin_account_id])
+ end
+
+ def admin_logged_in?
+ current_admin_account.present?
+ end
end
end
diff --git a/backend/app/controllers/admin/dashboard_controller.rb b/backend/app/controllers/admin/dashboard_controller.rb
index 94dd830..bbdc5ba 100644
--- a/backend/app/controllers/admin/dashboard_controller.rb
+++ b/backend/app/controllers/admin/dashboard_controller.rb
@@ -1,8 +1,19 @@
module Admin
class DashboardController < Admin::BaseController
def index
- @teams = Team.includes(:matches).order(:name)
- @active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match)
+ @stats = DashboardStats.new.call
+ @host = HostMetrics.new.sample!
+ @active_sessions = StreamSession
+ .where(status: DashboardStats::ACTIVE_STATUSES)
+ .includes(match: :team)
+ .order(started_at: :desc)
+ @teams = Team.includes(:matches).order(:name).limit(8)
+ end
+
+ def metrics
+ host = HostMetrics.new.sample!
+ stats = DashboardStats.new.call
+ render json: { host: host, stats: stats, at: Time.current.iso8601 }
end
end
end
diff --git a/backend/app/controllers/admin/passwords_controller.rb b/backend/app/controllers/admin/passwords_controller.rb
new file mode 100644
index 0000000..4d73f39
--- /dev/null
+++ b/backend/app/controllers/admin/passwords_controller.rb
@@ -0,0 +1,30 @@
+module Admin
+ class PasswordsController < Admin::BaseController
+ def edit
+ end
+
+ def update
+ unless current_admin_account.authenticate(params[:current_password])
+ flash.now[:alert] = "Password attuale non corretta"
+ return render :edit, status: :unprocessable_entity
+ end
+
+ if params[:password].blank? || params[:password].length < 8
+ flash.now[:alert] = "La nuova password deve avere almeno 8 caratteri"
+ return render :edit, status: :unprocessable_entity
+ end
+
+ if params[:password] != params[:password_confirmation]
+ flash.now[:alert] = "Le password non coincidono"
+ return render :edit, status: :unprocessable_entity
+ end
+
+ if current_admin_account.update(password: params[:password])
+ redirect_to admin_root_path, notice: "Password aggiornata"
+ else
+ flash.now[:alert] = current_admin_account.errors.full_messages.to_sentence
+ render :edit, status: :unprocessable_entity
+ end
+ end
+ end
+end
diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb
new file mode 100644
index 0000000..5b715f1
--- /dev/null
+++ b/backend/app/helpers/admin_helper.rb
@@ -0,0 +1,22 @@
+module AdminHelper
+ def format_bytes(bytes)
+ return "—" if bytes.nil?
+
+ units = %w[B KB MB GB TB]
+ size = bytes.to_f
+ unit = units.shift
+ while size >= 1024 && units.any?
+ size /= 1024
+ unit = units.shift
+ end
+ "#{size.round(size >= 10 ? 0 : 1)} #{unit}"
+ end
+
+ def format_duration_minutes(minutes)
+ return "0 min" if minutes.to_i <= 0
+
+ hours = minutes / 60
+ mins = minutes % 60
+ hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
+ end
+end
diff --git a/backend/app/models/admin_account.rb b/backend/app/models/admin_account.rb
new file mode 100644
index 0000000..37fdc0a
--- /dev/null
+++ b/backend/app/models/admin_account.rb
@@ -0,0 +1,5 @@
+class AdminAccount < ApplicationRecord
+ has_secure_password
+
+ validates :username, presence: true, uniqueness: true
+end
diff --git a/backend/app/services/admin/dashboard_stats.rb b/backend/app/services/admin/dashboard_stats.rb
new file mode 100644
index 0000000..db0d9e4
--- /dev/null
+++ b/backend/app/services/admin/dashboard_stats.rb
@@ -0,0 +1,27 @@
+module Admin
+ class DashboardStats
+ ACTIVE_STATUSES = %w[live reconnecting connecting paused].freeze
+
+ def call
+ now = Time.current
+ day_start = now.beginning_of_day
+ month_start = now.beginning_of_month
+
+ ended_today = StreamSession.where(status: "ended").where("ended_at >= ?", day_start)
+ ended_month = StreamSession.where(status: "ended").where("ended_at >= ?", month_start)
+
+ {
+ active_sessions: StreamSession.where(status: ACTIVE_STATUSES).count,
+ sessions_today: ended_today.count,
+ sessions_month: ended_month.count,
+ stream_minutes_today: (ended_today.sum(:total_duration_secs).to_i / 60.0).round,
+ stream_minutes_month: (ended_month.sum(:total_duration_secs).to_i / 60.0).round,
+ teams_count: Team.count,
+ users_count: User.count,
+ matches_count: Match.count,
+ recordings_count: Recording.count,
+ recordings_ready: Recording.where(status: "ready").count
+ }
+ end
+ end
+end
diff --git a/backend/app/services/admin/host_metrics.rb b/backend/app/services/admin/host_metrics.rb
new file mode 100644
index 0000000..9470678
--- /dev/null
+++ b/backend/app/services/admin/host_metrics.rb
@@ -0,0 +1,153 @@
+module Admin
+ class HostMetrics
+ RECORDINGS_PATH = ENV.fetch("RECORDINGS_PATH", "/recordings")
+ ROOT_PATH = ENV.fetch("ADMIN_DISK_ROOT_PATH", "/")
+
+ def sample!
+ cpu = read_cpu_percent
+ memory = read_memory
+ disk = read_disk
+ network = read_network
+
+ sample = {
+ at: Time.current.iso8601,
+ cpu: cpu,
+ mem: memory[:used_percent]
+ }
+ MetricsStore.push_sample(sample)
+
+ {
+ cpu: cpu,
+ memory: memory,
+ disk: disk,
+ network: network,
+ history: MetricsStore.history,
+ load_avg: read_load_avg
+ }
+ end
+
+ private
+
+ def read_cpu_percent
+ a = cpu_jiffies
+ sleep 0.08
+ b = cpu_jiffies
+ return 0.0 unless a && b
+
+ idle_delta = b[:idle] - a[:idle]
+ total_delta = b[:total] - a[:total]
+ return 0.0 if total_delta <= 0
+
+ ((1.0 - (idle_delta.to_f / total_delta)) * 100).round(1)
+ rescue StandardError
+ 0.0
+ end
+
+ def cpu_jiffies
+ line = File.read("/proc/stat").each_line.find { |l| l.start_with?("cpu ") }
+ return nil unless line
+
+ parts = line.split[1..-1].map(&:to_i)
+ idle = parts[3] + (parts[4] || 0)
+ total = parts.sum
+ { idle: idle, total: total }
+ rescue StandardError
+ nil
+ end
+
+ def read_memory
+ info = {}
+ File.read("/proc/meminfo").each_line do |line|
+ key, value = line.split(":", 2)
+ info[key.strip] = value.to_i
+ end
+ total = info["MemTotal"] * 1024
+ available = (info["MemAvailable"] || info["MemFree"]) * 1024
+ used = [total - available, 0].max
+ {
+ total_bytes: total,
+ used_bytes: used,
+ available_bytes: available,
+ used_percent: total.positive? ? ((used.to_f / total) * 100).round(1) : 0.0
+ }
+ rescue StandardError
+ { total_bytes: 0, used_bytes: 0, available_bytes: 0, used_percent: 0.0 }
+ end
+
+ def read_disk
+ {
+ root: disk_usage_for(ROOT_PATH),
+ recordings: disk_usage_for(RECORDINGS_PATH)
+ }
+ end
+
+ def disk_usage_for(path)
+ return unavailable_disk unless path.present? && (Dir.exist?(path) || File.exist?(path))
+
+ stat = File.statvfs(path)
+ total = stat.blocks * stat.frsize
+ free = stat.bavail * stat.frsize
+ used = total - free
+ {
+ path: path,
+ total_bytes: total,
+ used_bytes: used,
+ free_bytes: free,
+ used_percent: total.positive? ? ((used.to_f / total) * 100).round(1) : 0.0,
+ dir_size_bytes: recordings_dir_size(path)
+ }
+ rescue StandardError
+ unavailable_disk
+ end
+
+ def recordings_dir_size(path)
+ return nil unless path == RECORDINGS_PATH && Dir.exist?(path)
+
+ `du -sb #{Shellwords.escape(path)} 2>/dev/null`.split.first.to_i
+ rescue StandardError
+ nil
+ end
+
+ def unavailable_disk
+ { path: nil, total_bytes: nil, used_bytes: nil, free_bytes: nil, used_percent: nil, dir_size_bytes: nil }
+ end
+
+ def read_network
+ rx, tx = parse_net_bytes
+ prev = MetricsStore.net_totals
+ MetricsStore.save_net_totals(rx: rx, tx: tx)
+
+ delta_rx = prev[:rx] ? [rx - prev[:rx], 0].max : 0
+ delta_tx = prev[:tx] ? [tx - prev[:tx], 0].max : 0
+
+ {
+ rx_bytes_total: rx,
+ tx_bytes_total: tx,
+ total_bytes: rx + tx
+ }
+ rescue StandardError
+ { rx_bytes_total: 0, tx_bytes_total: 0, total_bytes: 0 }
+ end
+
+ def parse_net_bytes
+ rx = 0
+ tx = 0
+ File.read("/proc/net/dev").each_line.drop(2).each do |line|
+ iface = line.split(":").first.strip
+ next if iface == "lo"
+
+ cols = line.split(":").last.split.map(&:to_i)
+ rx += cols[0]
+ tx += cols[8]
+ end
+ [rx, tx]
+ end
+
+ def read_load_avg
+ loads = File.read("/proc/loadavg").split[0..2].map(&:to_f)
+ { "1m" => loads[0], "5m" => loads[1], "15m" => loads[2] }
+ rescue StandardError
+ { "1m" => 0.0, "5m" => 0.0, "15m" => 0.0 }
+ end
+ end
+end
diff --git a/backend/app/services/admin/metrics_store.rb b/backend/app/services/admin/metrics_store.rb
new file mode 100644
index 0000000..8227914
--- /dev/null
+++ b/backend/app/services/admin/metrics_store.rb
@@ -0,0 +1,42 @@
+module Admin
+ class MetricsStore
+ HISTORY_KEY = "admin:metrics:history"
+ NET_RX_KEY = "admin:metrics:net_rx"
+ NET_TX_KEY = "admin:metrics:net_tx"
+ HISTORY_LIMIT = 90
+
+ class << self
+ def redis
+ @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
+ rescue Redis::CannotConnectError
+ nil
+ end
+
+ def push_sample(sample)
+ return unless redis
+
+ redis.lpush(HISTORY_KEY, sample.to_json)
+ redis.ltrim(HISTORY_KEY, 0, HISTORY_LIMIT - 1)
+ end
+
+ def history
+ return [] unless redis
+
+ redis.lrange(HISTORY_KEY, 0, HISTORY_LIMIT - 1).map { |row| JSON.parse(row) }.reverse
+ end
+
+ def net_totals
+ return { rx: nil, tx: nil } unless redis
+
+ { rx: redis.get(NET_RX_KEY)&.to_i, tx: redis.get(NET_TX_KEY)&.to_i }
+ end
+
+ def save_net_totals(rx:, tx:)
+ return unless redis
+
+ redis.set(NET_RX_KEY, rx)
+ redis.set(NET_TX_KEY, tx)
+ end
+ end
+ end
+end
diff --git a/backend/app/views/admin/auth/new.html.erb b/backend/app/views/admin/auth/new.html.erb
new file mode 100644
index 0000000..ae1d6d9
--- /dev/null
+++ b/backend/app/views/admin/auth/new.html.erb
@@ -0,0 +1,26 @@
+
+
Accesso admin
+ <% if flash[:alert] %>
+
<%= flash[:alert] %>
+ <% end %>
+ <%= form_with url: admin_login_path, method: :post, local: true do %>
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+ Credenziali iniziali: admin / admin. Cambia la password dopo il primo accesso.
+
+
diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb
index 50bffd6..e5fb045 100644
--- a/backend/app/views/admin/dashboard/index.html.erb
+++ b/backend/app/views/admin/dashboard/index.html.erb
@@ -1,25 +1,131 @@
-Sessioni attive
-<% if @active_sessions.any? %>
-
- | Match | Status | Iniziata | |
-
- <% @active_sessions.each do |s| %>
-
- | <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
- <%= s.status %> |
- <%= s.started_at %> |
- <%= link_to "Dettaglio", admin_session_path(s) %> |
-
- <% end %>
-
-
-<% else %>
- Nessuna sessione attiva.
-<% end %>
+<% content_for :body_class, "admin-body" %>
-Squadre
-
- <% @teams.each do |t| %>
- - <%= link_to t.name, admin_team_path(t) %> — <%= t.matches.count %> partite
- <% end %>
-
+
+
+
Sessioni attive
+
<%= @stats[:active_sessions] %>
+
in diretta ora
+
+
+
Trasmesse oggi
+
<%= @stats[:sessions_today] %>
+
<%= format_duration_minutes(@stats[:stream_minutes_today]) %> di streaming
+
+
+
Trasmesse questo mese
+
<%= @stats[:sessions_month] %>
+
<%= format_duration_minutes(@stats[:stream_minutes_month]) %> totali
+
+
+
Squadre
+
<%= @stats[:teams_count] %>
+
<%= @stats[:users_count] %> utenti
+
+
+
CPU
+
<%= @host[:cpu] %>%
+
load <%= @host[:load_avg]["1m"] %> / <%= @host[:load_avg]["5m"] %> / <%= @host[:load_avg]["15m"] %>
+
+
+
RAM
+
<%= @host[:memory][:used_percent] %>%
+
<%= format_bytes(@host[:memory][:used_bytes]) %> / <%= format_bytes(@host[:memory][:total_bytes]) %>
+
+
+
Banda (totale)
+
<%= format_bytes(@host[:network][:total_bytes]) %>
+
↑ <%= format_bytes(@host[:network][:tx_bytes_total]) %> · ↓ <%= format_bytes(@host[:network][:rx_bytes_total]) %>
+
+
+
Replay in archivio
+
<%= @stats[:recordings_ready] %>
+
<%= @stats[:recordings_count] %> registrazioni
+
+
+
+
+
+
+
+
Disco sistema
+ <% root = @host[:disk][:root] %>
+ <% if root[:total_bytes] %>
+
+
<%= root[:path] %><%= format_bytes(root[:used_bytes]) %> / <%= format_bytes(root[:total_bytes]) %>
+
+
Libero: <%= format_bytes(root[:free_bytes]) %> (<%= root[:used_percent] %>% usato)
+
+ <% else %>
+
Metriche disco non disponibili
+ <% end %>
+
+
+
Archivio registrazioni
+ <% rec = @host[:disk][:recordings] %>
+ <% if rec[:total_bytes] %>
+
+
<%= rec[:path] %><%= format_bytes(rec[:used_bytes]) %> / <%= format_bytes(rec[:total_bytes]) %>
+
+ <% if rec[:dir_size_bytes] %>
+
Contenuto registrazioni: <%= format_bytes(rec[:dir_size_bytes]) %>
+ <% end %>
+
+ <% else %>
+
Percorso <%= Admin::HostMetrics::RECORDINGS_PATH %> non montato
+ <% end %>
+
+
+
+
+
+
Sessioni attive
+ <% if @active_sessions.any? %>
+
+
+ | Partita | Stato | Inizio | |
+
+
+ <% @active_sessions.each do |s| %>
+
+ | <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
+ <%= s.status %> |
+ <%= s.started_at&.strftime("%d/%m %H:%M") || "—" %> |
+ <%= link_to "Dettaglio", admin_session_path(s) %> |
+
+ <% end %>
+
+
+ <% else %>
+
Nessuna sessione attiva in questo momento.
+ <% end %>
+
+
+
+
Squadre
+
+ <% @teams.each do |t| %>
+ -
+ <%= link_to t.name, admin_team_path(t) %>
+ <%= t.matches.count %> partite
+
+ <% end %>
+
+ <% if @stats[:teams_count] > @teams.size %>
+
<%= link_to "Vedi tutte (#{@stats[:teams_count]})", admin_teams_path %>
+ <% end %>
+
+
+
+
diff --git a/backend/app/views/admin/passwords/edit.html.erb b/backend/app/views/admin/passwords/edit.html.erb
new file mode 100644
index 0000000..d13c420
--- /dev/null
+++ b/backend/app/views/admin/passwords/edit.html.erb
@@ -0,0 +1,29 @@
+
+
Cambia password
+ <% if flash[:alert] %>
+
<%= flash[:alert] %>
+ <% end %>
+ <%= form_with url: admin_password_path, method: :patch, local: true do %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= link_to "Annulla", admin_root_path %>
+
+ <% end %>
+
diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb
index 2a7a157..20b7fca 100644
--- a/backend/app/views/admin/sessions/index.html.erb
+++ b/backend/app/views/admin/sessions/index.html.erb
@@ -1,11 +1,11 @@
Stream Sessions
-
+
| Match | Status | Disconnects | |
<% @sessions.each do |s| %>
| <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
- <%= s.status %> |
+ <%= s.status %> |
<%= s.disconnection_count %> |
<%= link_to "Dettaglio", admin_session_path(s) %> |
diff --git a/backend/app/views/admin/teams/index.html.erb b/backend/app/views/admin/teams/index.html.erb
index b57ef48..41a8b65 100644
--- a/backend/app/views/admin/teams/index.html.erb
+++ b/backend/app/views/admin/teams/index.html.erb
@@ -1,5 +1,5 @@
Teams
-
+
| Nome | Sport | YouTube |
<% @teams.each do |t| %>
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb
index 28ee4c9..2501d39 100644
--- a/backend/app/views/layouts/admin.html.erb
+++ b/backend/app/views/layouts/admin.html.erb
@@ -3,30 +3,31 @@
Match Live TV Admin
-
+
+
+ <% if controller_name == "dashboard" %>
+
+
+ <% end %>
-
-
+
+
- <%= yield %>
+
+
+ <% if flash[:notice] %><%= flash[:notice] %>
<% end %>
+ <% if flash[:alert] %><%= flash[:alert] %>
<% end %>
+ <%= yield %>
+
diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb
index ec2f0ee..49bd68b 100644
--- a/backend/app/views/layouts/marketing.html.erb
+++ b/backend/app/views/layouts/marketing.html.erb
@@ -6,7 +6,7 @@
<%= content_for?(:title) ? yield(:title) : "Match Live TV" %>
<%= render "shared/meta_tags" %>
-
+
<%= render "shared/marketing_nav" %>
diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb
index 3dc6fc8..9e8a2d5 100644
--- a/backend/app/views/layouts/marketing_live.html.erb
+++ b/backend/app/views/layouts/marketing_live.html.erb
@@ -6,7 +6,7 @@
<%= content_for?(:title) ? yield(:title) : "Match Live TV" %>
<%= render "shared/meta_tags" %>
-
+
<%= yield :head %>
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 98fa609..f42bd9f 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -54,7 +54,13 @@ Rails.application.routes.draw do
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
namespace :admin do
+ get "login", to: "auth#new", as: :login
+ post "login", to: "auth#create"
+ delete "logout", to: "auth#destroy", as: :logout
+ resource :password, only: %i[edit update], controller: "passwords"
+
root to: "dashboard#index"
+ get "metrics", to: "dashboard#metrics"
resources :teams, only: %i[index show]
resources :sessions, only: %i[index show]
end
diff --git a/backend/db/migrate/20260527130000_create_admin_accounts.rb b/backend/db/migrate/20260527130000_create_admin_accounts.rb
new file mode 100644
index 0000000..8eace77
--- /dev/null
+++ b/backend/db/migrate/20260527130000_create_admin_accounts.rb
@@ -0,0 +1,21 @@
+class CreateAdminAccounts < ActiveRecord::Migration[7.2]
+ def up
+ create_table :admin_accounts, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.string :username, null: false
+ t.string :password_digest, null: false
+ t.timestamps
+ end
+
+ add_index :admin_accounts, :username, unique: true
+
+ password_digest = BCrypt::Password.create("admin")
+ execute <<~SQL.squish
+ INSERT INTO admin_accounts (id, username, password_digest, created_at, updated_at)
+ VALUES (gen_random_uuid(), 'admin', #{connection.quote(password_digest)}, NOW(), NOW())
+ SQL
+ end
+
+ def down
+ drop_table :admin_accounts
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index eefdffb..b179ee9 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,11 +10,19 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_05_26_120000) do
+ActiveRecord::Schema[7.2].define(version: 2026_05_27_130000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
+ create_table "admin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "username", null: false
+ t.string "password_digest", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["username"], name: "index_admin_accounts_on_username", unique: true
+ end
+
create_table "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "stream_session_id", null: false
t.string "device_role", null: false
@@ -149,7 +157,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_26_120000) do
t.datetime "accepted_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "staff_kind", default: "transmission", null: false
t.index ["team_id", "email"], name: "index_team_invitations_on_team_id_and_email"
+ t.index ["team_id", "staff_kind"], name: "index_team_invitations_on_team_id_and_staff_kind"
t.index ["team_id"], name: "index_team_invitations_on_team_id"
t.index ["token_digest"], name: "index_team_invitations_on_token_digest", unique: true
end
@@ -168,6 +178,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_26_120000) do
t.string "role", default: "member", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "staff_kind"
+ t.index ["team_id", "staff_kind"], name: "index_user_teams_on_team_id_and_staff_kind"
t.index ["team_id"], name: "index_user_teams_on_team_id"
t.index ["user_id", "team_id"], name: "index_user_teams_on_user_id_and_team_id", unique: true
t.index ["user_id"], name: "index_user_teams_on_user_id"
diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb
index 6b145d2..5227cb9 100644
--- a/backend/db/seeds.rb
+++ b/backend/db/seeds.rb
@@ -1,5 +1,9 @@
load Rails.root.join("db/seeds/plans.rb")
+AdminAccount.find_or_create_by!(username: "admin") do |a|
+ a.password = "admin"
+end
+
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
u.name = "Coach Demo"
u.password = "password123"
diff --git a/backend/public/admin-dashboard.js b/backend/public/admin-dashboard.js
new file mode 100644
index 0000000..e7ee091
--- /dev/null
+++ b/backend/public/admin-dashboard.js
@@ -0,0 +1,121 @@
+(function () {
+ const metricsUrl = window.adminMetricsUrl;
+ if (!metricsUrl) return;
+
+ const cpuCanvas = document.getElementById("chart-cpu");
+ const memCanvas = document.getElementById("chart-mem");
+ if (!cpuCanvas || !memCanvas || typeof Chart === "undefined") return;
+
+ const chartOptions = {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: { duration: 300 },
+ scales: {
+ x: {
+ ticks: { maxTicksLimit: 6, color: "#9a9aad", font: { size: 10 } },
+ grid: { color: "rgba(255,255,255,0.06)" }
+ },
+ y: {
+ min: 0,
+ max: 100,
+ ticks: { color: "#9a9aad", font: { size: 10 }, callback: (v) => v + "%" },
+ grid: { color: "rgba(255,255,255,0.06)" }
+ }
+ },
+ plugins: {
+ legend: { display: false }
+ }
+ };
+
+ function labelsFromHistory(history) {
+ return history.map((p) => {
+ const d = new Date(p.at);
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ });
+ }
+
+ const initial = window.adminInitialHistory || [];
+ const cpuChart = new Chart(cpuCanvas, {
+ type: "line",
+ data: {
+ labels: labelsFromHistory(initial),
+ datasets: [{
+ data: initial.map((p) => p.cpu),
+ borderColor: "#e53935",
+ backgroundColor: "rgba(229, 57, 53, 0.12)",
+ fill: true,
+ tension: 0.35,
+ pointRadius: 0,
+ borderWidth: 2
+ }]
+ },
+ options: chartOptions
+ });
+
+ const memChart = new Chart(memCanvas, {
+ type: "line",
+ data: {
+ labels: labelsFromHistory(initial),
+ datasets: [{
+ data: initial.map((p) => p.mem),
+ borderColor: "#43a047",
+ backgroundColor: "rgba(67, 160, 71, 0.12)",
+ fill: true,
+ tension: 0.35,
+ pointRadius: 0,
+ borderWidth: 2
+ }]
+ },
+ options: chartOptions
+ });
+
+ function updateCharts(history) {
+ const labels = labelsFromHistory(history);
+ cpuChart.data.labels = labels;
+ cpuChart.data.datasets[0].data = history.map((p) => p.cpu);
+ cpuChart.update("none");
+ memChart.data.labels = labels;
+ memChart.data.datasets[0].data = history.map((p) => p.mem);
+ memChart.update("none");
+ }
+
+ function setText(id, value) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = value;
+ }
+
+ function formatBytes(bytes) {
+ if (bytes == null) return "—";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let size = bytes;
+ let i = 0;
+ while (size >= 1024 && i < units.length - 1) {
+ size /= 1024;
+ i += 1;
+ }
+ return (size >= 10 ? size.toFixed(0) : size.toFixed(1)) + " " + units[i];
+ }
+
+ async function poll() {
+ try {
+ const res = await fetch(metricsUrl, { headers: { Accept: "application/json" }, credentials: "same-origin" });
+ if (!res.ok) return;
+ const data = await res.json();
+ const host = data.host || {};
+ const stats = data.stats || {};
+
+ setText("kpi-active-sessions", stats.active_sessions);
+ setText("kpi-sessions-today", stats.sessions_today);
+ setText("kpi-sessions-month", stats.sessions_month);
+ setText("kpi-cpu", (host.cpu ?? 0) + "%");
+ setText("kpi-mem", (host.memory?.used_percent ?? 0) + "%");
+ if (host.network) setText("kpi-network", formatBytes(host.network.total_bytes));
+
+ if (host.history) updateCharts(host.history);
+ } catch (_e) {
+ /* ignore polling errors */
+ }
+ }
+
+ setInterval(poll, 8000);
+})();
diff --git a/backend/public/admin.css b/backend/public/admin.css
new file mode 100644
index 0000000..a735ceb
--- /dev/null
+++ b/backend/public/admin.css
@@ -0,0 +1,260 @@
+:root {
+ --red: #e53935;
+ --red-dim: rgba(229, 57, 53, 0.15);
+ --bg: #0a0a0e;
+ --card: #14141c;
+ --card-border: #2a2a36;
+ --text: #f5f5f7;
+ --muted: #9a9aad;
+ --green: #43a047;
+}
+
+* { box-sizing: border-box; }
+
+body.admin-body {
+ font-family: "Segoe UI", system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+}
+
+.admin-header {
+ border-bottom: 1px solid var(--card-border);
+ padding: 1rem 1.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ background: #0d0d12;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.admin-header h1 {
+ margin: 0;
+ font-size: 1.15rem;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.admin-header h1 span { color: var(--red); }
+
+.admin-nav {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 1rem;
+}
+
+.admin-nav a,
+.admin-nav button {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 0.9rem;
+ background: none;
+ border: 0;
+ cursor: pointer;
+ font: inherit;
+ padding: 0;
+}
+
+.admin-nav a:hover,
+.admin-nav a.active { color: var(--red); }
+
+.admin-main {
+ padding: 1.5rem;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.admin-flash {
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+ background: var(--red-dim);
+ border: 1px solid rgba(229, 57, 53, 0.35);
+}
+
+.admin-section-title {
+ margin: 0 0 1rem;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 14px;
+ margin-bottom: 1.5rem;
+}
+
+.kpi-card {
+ background: var(--card);
+ border: 1px solid var(--card-border);
+ border-radius: 12px;
+ padding: 1rem 1.1rem;
+}
+
+.kpi-card--accent {
+ border-color: rgba(229, 57, 53, 0.45);
+ background: linear-gradient(145deg, #1a1214 0%, var(--card) 60%);
+}
+
+.kpi-label {
+ font-size: 0.75rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.35rem;
+}
+
+.kpi-value {
+ font-size: 1.75rem;
+ font-weight: 800;
+ line-height: 1.1;
+}
+
+.kpi-sub {
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-top: 0.35rem;
+}
+
+.kpi-value--live { color: var(--red); }
+
+.charts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 14px;
+ margin-bottom: 1.5rem;
+}
+
+.chart-card {
+ background: var(--card);
+ border: 1px solid var(--card-border);
+ border-radius: 12px;
+ padding: 1rem;
+}
+
+.chart-card h3 {
+ margin: 0 0 0.75rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.chart-wrap {
+ position: relative;
+ height: 200px;
+}
+
+.admin-panels {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr;
+ gap: 14px;
+}
+
+@media (max-width: 960px) {
+ .admin-panels { grid-template-columns: 1fr; }
+}
+
+.panel {
+ background: var(--card);
+ border: 1px solid var(--card-border);
+ border-radius: 12px;
+ padding: 1rem;
+ overflow: hidden;
+}
+
+.panel h2 {
+ margin: 0 0 1rem;
+ font-size: 1rem;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.admin-table th,
+.admin-table td {
+ padding: 0.65rem 0.5rem;
+ text-align: left;
+ border-bottom: 1px solid var(--card-border);
+}
+
+.admin-table th {
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.badge--live { background: var(--red); color: #fff; }
+.badge--connecting { background: #ff9800; color: #111; }
+.badge--paused { background: #555; color: #fff; }
+
+.team-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.team-list li {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.55rem 0;
+ border-bottom: 1px solid var(--card-border);
+}
+
+.team-list a { color: var(--text); text-decoration: none; }
+.team-list a:hover { color: var(--red); }
+
+.muted { color: var(--muted); }
+.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
+
+.progress-bar {
+ height: 6px;
+ background: #2a2a36;
+ border-radius: 3px;
+ overflow: hidden;
+ margin-top: 0.5rem;
+}
+
+.progress-bar span {
+ display: block;
+ height: 100%;
+ background: var(--red);
+ border-radius: 3px;
+}
+
+.progress-bar--ok span { background: var(--green); }
+
+.disk-row {
+ margin-bottom: 0.85rem;
+}
+
+.disk-row:last-child { margin-bottom: 0; }
+
+.disk-row header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
+ margin-bottom: 0.25rem;
+}
diff --git a/backend/public/marketing.css b/backend/public/marketing.css
index 5879049..2249f26 100644
--- a/backend/public/marketing.css
+++ b/backend/public/marketing.css
@@ -459,7 +459,7 @@ body.nav-menu-open { overflow: hidden; }
margin: 0 auto 28px;
}
.plans-teaser-visual {
- max-width: 920px;
+ max-width: 460px;
margin: 0 auto 32px;
line-height: 0;
}
diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml
index 3b24fb1..44412e0 100644
--- a/infra/docker-compose.prod.yml
+++ b/infra/docker-compose.prod.yml
@@ -81,6 +81,8 @@ services:
condition: service_healthy
mediamtx:
condition: service_started
+ volumes:
+ - recordings:/recordings
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
interval: 15s
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 62066ae..c491aec 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -75,6 +75,7 @@ services:
condition: service_started
volumes:
- ../backend:/app
+ - recordings:/recordings
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
interval: 15s