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? %> - - - - <% @active_sessions.each do |s| %> - - - - - - - <% end %> - -
MatchStatusIniziata
<%= s.match.team.name %> vs <%= s.match.opponent_name %><%= s.status %><%= s.started_at %><%= link_to "Dettaglio", admin_session_path(s) %>
-<% else %> -

Nessuna sessione attiva.

-<% end %> +<% content_for :body_class, "admin-body" %> -

Squadre

- +
+
+
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
+
+
+ +
+
+

CPU (%)

+
+
+
+

RAM (%)

+
+
+
+ +
+
+

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? %> + + + + + + <% @active_sessions.each do |s| %> + + + + + + + <% end %> + +
PartitaStatoInizio
<%= 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) %>
+ <% else %> +

Nessuna sessione attiva in questo momento.

+ <% end %> +
+ +
+

Squadre

+ + <% 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

- +
<% @sessions.each do |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

-
MatchStatusDisconnects
<%= s.match.team.name %> vs <%= s.match.opponent_name %><%= s.status %><%= s.status %> <%= s.disconnection_count %> <%= link_to "Dettaglio", admin_session_path(s) %>
+
<% @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 %> - -
+ +

MATCH LIVE TV — Admin

-
- <%= 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
NomeSportYouTube