Admin protetto, dashboard KPI e aggiornamenti sito marketing.
Login admin con cambio password, metriche server (CPU/RAM/disco/banda), grafici e statistiche streaming; sezione piani ridimensionata. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
25
backend/app/controllers/admin/auth_controller.rb
Normal file
25
backend/app/controllers/admin/auth_controller.rb
Normal file
@@ -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
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class BaseController < ActionController::Base
|
class BaseController < ActionController::Base
|
||||||
layout "admin"
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class DashboardController < Admin::BaseController
|
class DashboardController < Admin::BaseController
|
||||||
def index
|
def index
|
||||||
@teams = Team.includes(:matches).order(:name)
|
@stats = DashboardStats.new.call
|
||||||
@active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match)
|
@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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
30
backend/app/controllers/admin/passwords_controller.rb
Normal file
30
backend/app/controllers/admin/passwords_controller.rb
Normal file
@@ -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
|
||||||
22
backend/app/helpers/admin_helper.rb
Normal file
22
backend/app/helpers/admin_helper.rb
Normal file
@@ -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
|
||||||
5
backend/app/models/admin_account.rb
Normal file
5
backend/app/models/admin_account.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AdminAccount < ApplicationRecord
|
||||||
|
has_secure_password
|
||||||
|
|
||||||
|
validates :username, presence: true, uniqueness: true
|
||||||
|
end
|
||||||
27
backend/app/services/admin/dashboard_stats.rb
Normal file
27
backend/app/services/admin/dashboard_stats.rb
Normal file
@@ -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
|
||||||
153
backend/app/services/admin/host_metrics.rb
Normal file
153
backend/app/services/admin/host_metrics.rb
Normal file
@@ -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
|
||||||
42
backend/app/services/admin/metrics_store.rb
Normal file
42
backend/app/services/admin/metrics_store.rb
Normal file
@@ -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
|
||||||
26
backend/app/views/admin/auth/new.html.erb
Normal file
26
backend/app/views/admin/auth/new.html.erb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<div style="max-width:360px;margin:4rem auto">
|
||||||
|
<h2>Accesso admin</h2>
|
||||||
|
<% if flash[:alert] %>
|
||||||
|
<p style="color:#ff6b6b"><%= flash[:alert] %></p>
|
||||||
|
<% end %>
|
||||||
|
<%= form_with url: admin_login_path, method: :post, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="username">Username</label><br>
|
||||||
|
<input type="text" name="username" id="username" required autofocus autocomplete="username"
|
||||||
|
style="width:100%;padding:0.5rem;margin-top:0.25rem;background:#1E1E1E;border:1px solid #333;color:#fff;border-radius:6px">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password">Password</label><br>
|
||||||
|
<input type="password" name="password" id="password" required autocomplete="current-password"
|
||||||
|
style="width:100%;padding:0.5rem;margin-top:0.25rem;background:#1E1E1E;border:1px solid #333;color:#fff;border-radius:6px">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button type="submit" style="background:#FF2D2D;color:#fff;border:0;padding:0.6rem 1.2rem;border-radius:6px;cursor:pointer;font-weight:700">
|
||||||
|
Accedi
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p style="color:#888;font-size:0.85rem;margin-top:1.5rem">
|
||||||
|
Credenziali iniziali: <code>admin</code> / <code>admin</code>. Cambia la password dopo il primo accesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -1,25 +1,131 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card kpi-card--accent">
|
||||||
|
<div class="kpi-label">Sessioni attive</div>
|
||||||
|
<div class="kpi-value kpi-value--live" id="kpi-active-sessions"><%= @stats[:active_sessions] %></div>
|
||||||
|
<div class="kpi-sub">in diretta ora</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Trasmesse oggi</div>
|
||||||
|
<div class="kpi-value" id="kpi-sessions-today"><%= @stats[:sessions_today] %></div>
|
||||||
|
<div class="kpi-sub"><%= format_duration_minutes(@stats[:stream_minutes_today]) %> di streaming</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Trasmesse questo mese</div>
|
||||||
|
<div class="kpi-value" id="kpi-sessions-month"><%= @stats[:sessions_month] %></div>
|
||||||
|
<div class="kpi-sub"><%= format_duration_minutes(@stats[:stream_minutes_month]) %> totali</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Squadre</div>
|
||||||
|
<div class="kpi-value"><%= @stats[:teams_count] %></div>
|
||||||
|
<div class="kpi-sub"><%= @stats[:users_count] %> utenti</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">CPU</div>
|
||||||
|
<div class="kpi-value" id="kpi-cpu"><%= @host[:cpu] %>%</div>
|
||||||
|
<div class="kpi-sub">load <%= @host[:load_avg]["1m"] %> / <%= @host[:load_avg]["5m"] %> / <%= @host[:load_avg]["15m"] %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">RAM</div>
|
||||||
|
<div class="kpi-value" id="kpi-mem"><%= @host[:memory][:used_percent] %>%</div>
|
||||||
|
<div class="kpi-sub"><%= format_bytes(@host[:memory][:used_bytes]) %> / <%= format_bytes(@host[:memory][:total_bytes]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Banda (totale)</div>
|
||||||
|
<div class="kpi-value" style="font-size:1.2rem" id="kpi-network"><%= format_bytes(@host[:network][:total_bytes]) %></div>
|
||||||
|
<div class="kpi-sub">↑ <%= format_bytes(@host[:network][:tx_bytes_total]) %> · ↓ <%= format_bytes(@host[:network][:rx_bytes_total]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Replay in archivio</div>
|
||||||
|
<div class="kpi-value"><%= @stats[:recordings_ready] %></div>
|
||||||
|
<div class="kpi-sub"><%= @stats[:recordings_count] %> registrazioni</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="charts-grid">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>CPU (%)</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cpu"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>RAM (%)</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-mem"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Disco sistema</h3>
|
||||||
|
<% root = @host[:disk][:root] %>
|
||||||
|
<% if root[:total_bytes] %>
|
||||||
|
<div class="disk-row">
|
||||||
|
<header><span><%= root[:path] %></span><span><%= format_bytes(root[:used_bytes]) %> / <%= format_bytes(root[:total_bytes]) %></span></header>
|
||||||
|
<div class="progress-bar <%= 'progress-bar--ok' if root[:used_percent].to_f < 80 %>"><span style="width:<%= root[:used_percent] %>%"></span></div>
|
||||||
|
<p class="kpi-sub" style="margin-top:0.35rem">Libero: <%= format_bytes(root[:free_bytes]) %> (<%= root[:used_percent] %>% usato)</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty">Metriche disco non disponibili</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Archivio registrazioni</h3>
|
||||||
|
<% rec = @host[:disk][:recordings] %>
|
||||||
|
<% if rec[:total_bytes] %>
|
||||||
|
<div class="disk-row">
|
||||||
|
<header><span><%= rec[:path] %></span><span><%= format_bytes(rec[:used_bytes]) %> / <%= format_bytes(rec[:total_bytes]) %></span></header>
|
||||||
|
<div class="progress-bar <%= 'progress-bar--ok' if rec[:used_percent].to_f < 80 %>"><span style="width:<%= rec[:used_percent] %>%"></span></div>
|
||||||
|
<% if rec[:dir_size_bytes] %>
|
||||||
|
<p class="kpi-sub" style="margin-top:0.35rem">Contenuto registrazioni: <%= format_bytes(rec[:dir_size_bytes]) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty">Percorso <%= Admin::HostMetrics::RECORDINGS_PATH %> non montato</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-panels">
|
||||||
|
<div class="panel">
|
||||||
<h2>Sessioni attive</h2>
|
<h2>Sessioni attive</h2>
|
||||||
<% if @active_sessions.any? %>
|
<% if @active_sessions.any? %>
|
||||||
<table>
|
<table class="admin-table">
|
||||||
<thead><tr><th>Match</th><th>Status</th><th>Iniziata</th><th></th></tr></thead>
|
<thead>
|
||||||
<tbody>
|
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="active-sessions-body">
|
||||||
<% @active_sessions.each do |s| %>
|
<% @active_sessions.each do |s| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||||
<td><span class="badge live"><%= s.status %></span></td>
|
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
|
||||||
<td><%= s.started_at %></td>
|
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td>
|
||||||
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p>Nessuna sessione attiva.</p>
|
<p class="empty">Nessuna sessione attiva in questo momento.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
<h2>Squadre</h2>
|
<h2>Squadre</h2>
|
||||||
<ul>
|
<ul class="team-list">
|
||||||
<% @teams.each do |t| %>
|
<% @teams.each do |t| %>
|
||||||
<li><%= link_to t.name, admin_team_path(t) %> — <%= t.matches.count %> partite</li>
|
<li>
|
||||||
|
<%= link_to t.name, admin_team_path(t) %>
|
||||||
|
<span class="muted"><%= t.matches.count %> partite</span>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
|
<% if @stats[:teams_count] > @teams.size %>
|
||||||
|
<p class="kpi-sub" style="margin-top:0.75rem"><%= link_to "Vedi tutte (#{@stats[:teams_count]})", admin_teams_path %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.adminMetricsUrl = "<%= admin_metrics_path %>";
|
||||||
|
window.adminInitialHistory = <%= raw @host[:history].to_json %>;
|
||||||
|
</script>
|
||||||
|
|||||||
29
backend/app/views/admin/passwords/edit.html.erb
Normal file
29
backend/app/views/admin/passwords/edit.html.erb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<div style="max-width:400px">
|
||||||
|
<h2>Cambia password</h2>
|
||||||
|
<% if flash[:alert] %>
|
||||||
|
<p style="color:#ff6b6b"><%= flash[:alert] %></p>
|
||||||
|
<% end %>
|
||||||
|
<%= form_with url: admin_password_path, method: :patch, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="current_password">Password attuale</label><br>
|
||||||
|
<input type="password" name="current_password" id="current_password" required autocomplete="current-password"
|
||||||
|
style="width:100%;padding:0.5rem;margin-top:0.25rem;background:#1E1E1E;border:1px solid #333;color:#fff;border-radius:6px">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password">Nuova password (min. 8 caratteri)</label><br>
|
||||||
|
<input type="password" name="password" id="password" required autocomplete="new-password"
|
||||||
|
style="width:100%;padding:0.5rem;margin-top:0.25rem;background:#1E1E1E;border:1px solid #333;color:#fff;border-radius:6px">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password_confirmation">Conferma nuova password</label><br>
|
||||||
|
<input type="password" name="password_confirmation" id="password_confirmation" required autocomplete="new-password"
|
||||||
|
style="width:100%;padding:0.5rem;margin-top:0.25rem;background:#1E1E1E;border:1px solid #333;color:#fff;border-radius:6px">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button type="submit" style="background:#FF2D2D;color:#fff;border:0;padding:0.6rem 1.2rem;border-radius:6px;cursor:pointer;font-weight:700">
|
||||||
|
Salva password
|
||||||
|
</button>
|
||||||
|
<%= link_to "Annulla", admin_root_path %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<h2>Stream Sessions</h2>
|
<h2>Stream Sessions</h2>
|
||||||
<table>
|
<table class="admin-table">
|
||||||
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
|
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @sessions.each do |s| %>
|
<% @sessions.each do |s| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||||
<td><span class="badge <%= s.live? ? 'live' : 'ended' %>"><%= s.status %></span></td>
|
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
|
||||||
<td><%= s.disconnection_count %></td>
|
<td><%= s.disconnection_count %></td>
|
||||||
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<h2>Teams</h2>
|
<h2>Teams</h2>
|
||||||
<table>
|
<table class="admin-table">
|
||||||
<thead><tr><th>Nome</th><th>Sport</th><th>YouTube</th></tr></thead>
|
<thead><tr><th>Nome</th><th>Sport</th><th>YouTube</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @teams.each do |t| %>
|
<% @teams.each do |t| %>
|
||||||
|
|||||||
@@ -3,30 +3,31 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Match Live TV Admin</title>
|
<title>Match Live TV Admin</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<meta name="robots" content="noindex, nofollow">
|
||||||
:root { --red: #FF2D2D; --bg: #0A0A0A; --card: #1E1E1E; --text: #fff; }
|
<link rel="stylesheet" href="/admin.css?v=1">
|
||||||
* { box-sizing: border-box; }
|
<% if controller_name == "dashboard" %>
|
||||||
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem 2rem; }
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
a { color: var(--red); }
|
<script src="/admin-dashboard.js?v=1" defer></script>
|
||||||
header { border-bottom: 1px solid #333; padding-bottom: 1rem; margin-bottom: 2rem; }
|
<% end %>
|
||||||
h1 span { color: var(--red); }
|
|
||||||
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 8px; overflow: hidden; }
|
|
||||||
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #333; }
|
|
||||||
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.8rem; }
|
|
||||||
.live { background: var(--red); }
|
|
||||||
.ended { background: #555; }
|
|
||||||
nav a { margin-right: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>">
|
||||||
<header>
|
<header class="admin-header">
|
||||||
<h1>MATCH <span>LIVE</span> TV — Admin</h1>
|
<h1>MATCH <span>LIVE</span> TV — Admin</h1>
|
||||||
<nav>
|
<nav class="admin-nav">
|
||||||
<%= link_to "Dashboard", admin_root_path %>
|
<% if admin_logged_in? %>
|
||||||
<%= link_to "Teams", admin_teams_path %>
|
<%= link_to "Dashboard", admin_root_path, class: ("active" if controller_name == "dashboard") %>
|
||||||
<%= link_to "Sessions", admin_sessions_path %>
|
<%= link_to "Teams", admin_teams_path, class: ("active" if controller_name == "teams") %>
|
||||||
|
<%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<%= link_to "Password", edit_admin_password_path %>
|
||||||
|
<%= button_to "Esci", admin_logout_path, method: :delete %>
|
||||||
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<% if flash[:notice] %><div class="admin-flash"><%= flash[:notice] %></div><% end %>
|
||||||
|
<% if flash[:alert] %><div class="admin-flash"><%= flash[:alert] %></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=14">
|
<link rel="stylesheet" href="/marketing.css?v=15">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render "shared/marketing_nav" %>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=14">
|
<link rel="stylesheet" href="/marketing.css?v=15">
|
||||||
<link rel="stylesheet" href="/live.css">
|
<link rel="stylesheet" href="/live.css">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ Rails.application.routes.draw do
|
|||||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
||||||
|
|
||||||
namespace :admin do
|
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"
|
root to: "dashboard#index"
|
||||||
|
get "metrics", to: "dashboard#metrics"
|
||||||
resources :teams, only: %i[index show]
|
resources :teams, only: %i[index show]
|
||||||
resources :sessions, only: %i[index show]
|
resources :sessions, only: %i[index show]
|
||||||
end
|
end
|
||||||
|
|||||||
21
backend/db/migrate/20260527130000_create_admin_accounts.rb
Normal file
21
backend/db/migrate/20260527130000_create_admin_accounts.rb
Normal file
@@ -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
|
||||||
14
backend/db/schema.rb
generated
14
backend/db/schema.rb
generated
@@ -10,11 +10,19 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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|
|
create_table "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "stream_session_id", null: false
|
t.uuid "stream_session_id", null: false
|
||||||
t.string "device_role", 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 "accepted_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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", "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 ["team_id"], name: "index_team_invitations_on_team_id"
|
||||||
t.index ["token_digest"], name: "index_team_invitations_on_token_digest", unique: true
|
t.index ["token_digest"], name: "index_team_invitations_on_token_digest", unique: true
|
||||||
end
|
end
|
||||||
@@ -168,6 +178,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_26_120000) do
|
|||||||
t.string "role", default: "member", null: false
|
t.string "role", default: "member", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 ["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", "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"
|
t.index ["user_id"], name: "index_user_teams_on_user_id"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
load Rails.root.join("db/seeds/plans.rb")
|
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|
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
u.name = "Coach Demo"
|
u.name = "Coach Demo"
|
||||||
u.password = "password123"
|
u.password = "password123"
|
||||||
|
|||||||
121
backend/public/admin-dashboard.js
Normal file
121
backend/public/admin-dashboard.js
Normal file
@@ -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);
|
||||||
|
})();
|
||||||
260
backend/public/admin.css
Normal file
260
backend/public/admin.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -459,7 +459,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
margin: 0 auto 28px;
|
margin: 0 auto 28px;
|
||||||
}
|
}
|
||||||
.plans-teaser-visual {
|
.plans-teaser-visual {
|
||||||
max-width: 920px;
|
max-width: 460px;
|
||||||
margin: 0 auto 32px;
|
margin: 0 auto 32px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
mediamtx:
|
mediamtx:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- recordings:/recordings
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend:/app
|
- ../backend:/app
|
||||||
|
- recordings:/recordings
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
Reference in New Issue
Block a user