Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d100c8030f | ||
|
|
bc2257efd2 | ||
|
|
f1906517a9 | ||
|
|
00fde25c50 | ||
|
|
4c5af99efa | ||
|
|
8bf12e7721 | ||
|
|
5a0ca8ef1c | ||
|
|
714602f3da | ||
|
|
4af4fa68ac |
@@ -11,17 +11,8 @@ module Public
|
|||||||
load_club_billing_context_for_pricing
|
load_club_billing_context_for_pricing
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def support
|
||||||
|
@app_store_review_chrome = true
|
||||||
def load_club_billing_context_for_pricing
|
|
||||||
return unless logged_in?
|
|
||||||
|
|
||||||
@club = current_user.primary_club
|
|
||||||
return unless @club
|
|
||||||
|
|
||||||
@subscription = @club.subscription
|
|
||||||
@team = @club.teams.order(:name).first
|
|
||||||
@entitlements = @team&.entitlements
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy
|
def privacy
|
||||||
@@ -38,5 +29,18 @@ module Public
|
|||||||
|
|
||||||
def pallavolo
|
def pallavolo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_club_billing_context_for_pricing
|
||||||
|
return unless logged_in?
|
||||||
|
|
||||||
|
@club = current_user.primary_club
|
||||||
|
return unless @club
|
||||||
|
|
||||||
|
@subscription = @club.subscription
|
||||||
|
@team = @club.teams.order(:name).first
|
||||||
|
@entitlements = @team&.entitlements
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module Public
|
|||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
|
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ module Mediamtx
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_rtmp_conns
|
||||||
|
response = @conn.get("/v3/rtmpconns/list")
|
||||||
|
return [] unless response.success?
|
||||||
|
|
||||||
|
body = response.body
|
||||||
|
body.is_a?(Hash) ? (body["items"] || []) : []
|
||||||
|
rescue Error, Faraday::Error
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
def online_path_names
|
def online_path_names
|
||||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,17 +4,37 @@ module Mediamtx
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def active?(session)
|
def active?(session)
|
||||||
|
return true if rtmp_publisher?(session)
|
||||||
|
|
||||||
active_path?(path_info(session))
|
active_path?(path_info(session))
|
||||||
end
|
end
|
||||||
|
|
||||||
def path_info(session)
|
def path_info(session)
|
||||||
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||||
|
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||||
def active_path?(info)
|
def active_path?(info)
|
||||||
return false unless info
|
return false unless info
|
||||||
|
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
|
||||||
|
|
||||||
info["online"] == true && info.dig("source", "type") == "rtmpConn"
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def rtmp_publisher?(session)
|
||||||
|
path = session.mediamtx_path_name.to_s
|
||||||
|
return false if path.blank?
|
||||||
|
|
||||||
|
Client.for_session(session).list_rtmp_conns.any? do |conn|
|
||||||
|
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
|
||||||
|
next false unless conn_path == path
|
||||||
|
|
||||||
|
state = conn["state"].to_s
|
||||||
|
state.empty? || state == "publish" || state == "idle"
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def h264_video?(info)
|
def h264_video?(info)
|
||||||
@@ -26,12 +46,14 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
def video_publishing?(session)
|
def video_publishing?(session)
|
||||||
info = path_info(session)
|
return false unless active?(session)
|
||||||
return false unless active_path?(info)
|
|
||||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
|
||||||
return false unless info.dig("source", "type") == "rtmpConn"
|
|
||||||
|
|
||||||
|
info = path_info(session)
|
||||||
h264_video?(info)
|
h264_video?(info)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rtmp_source?(type)
|
||||||
|
type.to_s.match?(/\Artmps?Conn\z/)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module Mediamtx
|
|||||||
return @session if @session.terminal?
|
return @session if @session.terminal?
|
||||||
|
|
||||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||||
|
|
||||||
if publisher_online
|
if publisher_online
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
@@ -86,7 +86,7 @@ module Mediamtx
|
|||||||
key = format("youtube:slate_disabled:%s", session.id)
|
key = format("youtube:slate_disabled:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: false)
|
Client.for_session(session).set_always_available(session, enabled: false)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
redis.del(format("youtube:slate_disabled:%s", session.id))
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
||||||
@@ -95,7 +95,7 @@ module Mediamtx
|
|||||||
def restore_slate_path!(session)
|
def restore_slate_path!(session)
|
||||||
return if session.platform == "matchlivetv"
|
return if session.platform == "matchlivetv"
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: true)
|
Client.for_session(session).set_always_available(session, enabled: true)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
@@ -128,7 +128,7 @@ module Mediamtx
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Client.new.set_path_recording(session, enabled: enabled)
|
Client.for_session(session).set_path_recording(session, enabled: enabled)
|
||||||
redis.set(key, desired, ex: 48.hours.to_i)
|
redis.set(key, desired, ex: 48.hours.to_i)
|
||||||
mark_recording_patch!(session.id)
|
mark_recording_patch!(session.id)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ module Streams
|
|||||||
|
|
||||||
def initialize(provisioner: nil)
|
def initialize(provisioner: nil)
|
||||||
@provisioner = provisioner || NodeProvisioner.new
|
@provisioner = provisioner || NodeProvisioner.new
|
||||||
|
@provisioned_this_round = []
|
||||||
end
|
end
|
||||||
|
|
||||||
def reconcile!
|
def reconcile!
|
||||||
@@ -154,6 +155,7 @@ module Streams
|
|||||||
|
|
||||||
scale_in_candidates.each do |node|
|
scale_in_candidates.each do |node|
|
||||||
next if keep_as_warm_spare?(node)
|
next if keep_as_warm_spare?(node)
|
||||||
|
next if @provisioned_this_round.include?(node.id)
|
||||||
|
|
||||||
safe_scale_in!(node)
|
safe_scale_in!(node)
|
||||||
actions << :"scale_in_#{node.slug}"
|
actions << :"scale_in_#{node.slug}"
|
||||||
@@ -191,6 +193,7 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def provision_overflow!
|
def provision_overflow!
|
||||||
|
node =
|
||||||
case self.class.kind
|
case self.class.kind
|
||||||
when "cloud"
|
when "cloud"
|
||||||
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
|
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
|
||||||
@@ -199,6 +202,8 @@ module Streams
|
|||||||
else
|
else
|
||||||
@provisioner.provision_lab!
|
@provisioner.provision_lab!
|
||||||
end
|
end
|
||||||
|
@provisioned_this_round << node.id if node
|
||||||
|
node
|
||||||
end
|
end
|
||||||
|
|
||||||
def safe_scale_in!(node)
|
def safe_scale_in!(node)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ module Streams
|
|||||||
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
||||||
body[:networks] = [network_id.to_i] if network_id
|
body[:networks] = [network_id.to_i] if network_id
|
||||||
user_data = cloud_init_user_data
|
user_data = cloud_init_user_data
|
||||||
body[:user_data] = user_data if user_data.present?
|
body[:user_data] = user_data
|
||||||
|
|
||||||
data = post("servers", body)
|
data = post("servers", body)
|
||||||
server = data["server"] || {}
|
server = data["server"] || {}
|
||||||
@@ -114,9 +114,16 @@ module Streams
|
|||||||
|
|
||||||
def cloud_init_user_data
|
def cloud_init_user_data
|
||||||
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
||||||
return File.read(path) if path && File.file?(path)
|
if path.present?
|
||||||
|
raise Error, "HCLOUD_USER_DATA_FILE non leggibile nel container: #{path}" unless File.file?(path)
|
||||||
|
|
||||||
ENV["HCLOUD_USER_DATA"].presence
|
return File.read(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
inline = ENV["HCLOUD_USER_DATA"].presence
|
||||||
|
raise Error, "Manca cloud-init: imposta HCLOUD_USER_DATA_FILE (montato) o HCLOUD_USER_DATA" if inline.blank?
|
||||||
|
|
||||||
|
inline
|
||||||
end
|
end
|
||||||
|
|
||||||
def conn
|
def conn
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Streams
|
module Streams
|
||||||
# Assegna un StreamNode a una nuova sessione (least-loaded tra i ready).
|
# Assegna un StreamNode a una nuova sessione.
|
||||||
|
# Preferisce home finché ha slot; poi least-loaded tra i nodi overflow ready.
|
||||||
# Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali.
|
# Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali.
|
||||||
class NodeRegistry
|
class NodeRegistry
|
||||||
class NoCapacityError < StandardError; end
|
class NoCapacityError < StandardError; end
|
||||||
@@ -33,10 +34,16 @@ module Streams
|
|||||||
def allocate!
|
def allocate!
|
||||||
ensure_home_from_env!
|
ensure_home_from_env!
|
||||||
|
|
||||||
|
# Overflow: riempi prima home; i nodi cloud/lab sono solo quando home è pieno
|
||||||
|
# (altrimenti lo warm spare ruberebbe tutte le sessioni).
|
||||||
|
home = StreamNode.find_by(slug: HOME_SLUG)
|
||||||
|
return home if home&.allocatable?
|
||||||
|
|
||||||
node = StreamNode.ready
|
node = StreamNode.ready
|
||||||
|
.where.not(slug: HOME_SLUG)
|
||||||
.to_a
|
.to_a
|
||||||
.select(&:allocatable?)
|
.select(&:allocatable?)
|
||||||
.min_by { |n| [n.active_publishers, n.role == "home" ? 1 : 0, n.slug] }
|
.min_by { |n| [n.active_publishers, n.slug] }
|
||||||
|
|
||||||
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
|
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,67 @@
|
|||||||
<% content_for :body_class, "admin-body" %>
|
<% content_for :body_class, "admin-body" %>
|
||||||
<h2><%= t("admin.stream_nodes.title") %></h2>
|
|
||||||
<p class="admin-muted">
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.stream_nodes.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub">
|
||||||
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="admin-muted">
|
<% m = @autoscale_metrics %>
|
||||||
<%= t(
|
<section class="kpi-grid">
|
||||||
"admin.stream_nodes.autoscale",
|
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
|
||||||
enabled: (@autoscale_metrics[:enabled] ? "ON" : "OFF"),
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
|
||||||
free: @autoscale_metrics[:free_slots],
|
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
|
||||||
soft: @autoscale_metrics[:soft_free_slots],
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
|
||||||
spare: @autoscale_metrics[:spare_ready],
|
</div>
|
||||||
warm: @autoscale_metrics[:warm_spare_min],
|
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
|
||||||
overflow: @autoscale_metrics[:overflow_nodes],
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
|
||||||
max: @autoscale_metrics[:max_overflow_nodes],
|
<div class="kpi-value"><%= m[:free_slots] %></div>
|
||||||
kind: @autoscale_metrics[:kind]
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
|
||||||
) %>
|
</div>
|
||||||
· <%= t(
|
<div class="kpi-card">
|
||||||
"admin.stream_nodes.autoscale_budget",
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
|
||||||
eur: @autoscale_metrics[:estimated_monthly_eur],
|
<div class="kpi-value"><%= m[:spare_ready] %><span class="kpi-value-unit">/<%= m[:warm_spare_min] %></span></div>
|
||||||
budget: @autoscale_metrics[:monthly_budget_eur],
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.warm_spare") %></div>
|
||||||
ok: (@autoscale_metrics[:within_budget] ? "OK" : "OVER")
|
</div>
|
||||||
) %>
|
<div class="kpi-card">
|
||||||
<% if @autoscale_metrics[:kill_switch] %>
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.overflow") %></div>
|
||||||
· <strong><%= t("admin.stream_nodes.kill_switch_active") %></strong>
|
<div class="kpi-value"><%= m[:overflow_nodes] %><span class="kpi-value-unit">/<%= m[:max_overflow_nodes] %></span></div>
|
||||||
<% end %>
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.overflow_nodes") %></div>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="kpi-card <%= m[:within_budget] ? '' : 'kpi-card--danger' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.budget") %></div>
|
||||||
|
<div class="kpi-value kpi-value--sm">€<%= m[:estimated_monthly_eur] %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.budget_of", budget: m[:monthly_budget_eur], ok: (m[:within_budget] ? "OK" : "OVER")) %></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p>
|
<div class="panel" style="margin-bottom:1.5rem">
|
||||||
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
|
<div class="admin-panel-head">
|
||||||
|
<h2><%= t("admin.stream_nodes.actions_title") %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn admin-btn--secondary" %>
|
||||||
<% if @hetzner_configured %>
|
<% if @hetzner_configured %>
|
||||||
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn",
|
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn admin-btn--primary",
|
||||||
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="admin-muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
<span class="muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @autoscale_metrics[:kill_switch] %>
|
<% if m[:kill_switch] %>
|
||||||
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn-secondary" %>
|
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn--secondary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn-danger",
|
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn--danger",
|
||||||
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
|
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="admin-table">
|
<div class="panel">
|
||||||
|
<h2><%= t("admin.stream_nodes.table_title") %></h2>
|
||||||
|
<% if @nodes.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><%= t("admin.stream_nodes.col.slug") %></th>
|
<th><%= t("admin.stream_nodes.col.slug") %></th>
|
||||||
@@ -57,27 +75,55 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @nodes.each do |node| %>
|
<% @nodes.each do |node| %>
|
||||||
|
<%
|
||||||
|
badge =
|
||||||
|
case node.status
|
||||||
|
when "ready" then "badge--ready"
|
||||||
|
when "provisioning", "draining" then "badge--connecting"
|
||||||
|
when "error", "offline" then "badge--paused"
|
||||||
|
else "badge--paused"
|
||||||
|
end
|
||||||
|
%>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code><%= node.slug %></code></td>
|
<td><strong><%= node.slug %></strong></td>
|
||||||
<td><%= node.role %></td>
|
<td class="muted"><%= node.role %></td>
|
||||||
<td><%= node.status %></td>
|
<td><span class="badge <%= badge %>"><%= node.status %></span></td>
|
||||||
<td><%= node.active_publishers %> / <%= node.max_publishers %> (free <%= node.free_slots %>)</td>
|
<td>
|
||||||
<td><code><%= node.hostname %></code></td>
|
<strong><%= node.active_publishers %></strong>
|
||||||
<td><%= node.provider %><% if node.provider_instance_id.present? %> · <%= node.provider_instance_id %><% end %></td>
|
<span class="muted">/ <%= node.max_publishers %></span>
|
||||||
|
<div class="muted" style="font-size:0.8rem"><%= t("admin.stream_nodes.free_slots", count: node.free_slots) %></div>
|
||||||
|
</td>
|
||||||
|
<td class="admin-mono"><%= node.hostname %></td>
|
||||||
|
<td class="muted">
|
||||||
|
<%= node.provider %>
|
||||||
|
<% if node.provider_instance_id.present? %>
|
||||||
|
<div class="admin-mono" style="font-size:0.75rem;margin-top:0.2rem"><%= node.provider_instance_id %></div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
<td class="admin-actions">
|
<td class="admin-actions">
|
||||||
<% if node.slug != "home" %>
|
<% if node.slug != "home" %>
|
||||||
<% if node.status == "ready" %>
|
<% if node.status == "ready" %>
|
||||||
<%= button_to t("admin.stream_nodes.drain"), drain_admin_stream_node_path(node), method: :post, class: "admin-btn admin-btn-secondary" %>
|
<%= button_to t("admin.stream_nodes.drain"), drain_admin_stream_node_path(node), method: :post, class: "admin-btn admin-btn--sm admin-btn--secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn-danger", form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
|
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.stream_nodes.empty") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% if @lab_hosts.present? %>
|
<% if @lab_hosts.present? %>
|
||||||
<h3><%= t("admin.stream_nodes.hosts_title") %></h3>
|
<div class="panel" style="margin-top:1.5rem">
|
||||||
|
<h2><%= t("admin.stream_nodes.hosts_title") %></h2>
|
||||||
<pre class="admin-pre"><%= @lab_hosts %></pre>
|
<pre class="admin-pre"><%= @lab_hosts %></pre>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<title><%= t("admin.layout.title") %></title>
|
<title><%= t("admin.layout.title") %></title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="stylesheet" href="/admin.css?v=2">
|
<link rel="stylesheet" href="/admin.css?v=4">
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= render "shared/marketing_footer" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<script src="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<% content_for :title, t("legal.support.title") %>
|
||||||
|
<% content_for :meta_description, t("legal.support.meta_description") %>
|
||||||
|
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
||||||
|
|
||||||
|
<div class="wrap legal-doc">
|
||||||
|
<h1><%= t("legal.support.h1") %></h1>
|
||||||
|
<p><%= t("legal.support.intro") %></p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t("legal.support.email_label") %>
|
||||||
|
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.access_title") %></h2>
|
||||||
|
<p><%= t("legal.support.access_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.live_title") %></h2>
|
||||||
|
<p><%= t("legal.support.live_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.team_title") %></h2>
|
||||||
|
<p><%= t("legal.support.team_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.privacy_title") %></h2>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"legal.support.privacy_body_html",
|
||||||
|
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||||
|
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||||
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
|
</div>
|
||||||
|
<div class="site-footer__legal">
|
||||||
|
<p><%= t("footer.copyright") %></p>
|
||||||
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
||||||
|
<div class="site-chrome">
|
||||||
|
<div class="site-masthead">
|
||||||
|
<div class="wrap mast-inner">
|
||||||
|
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mast-tools">
|
||||||
|
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
|
<div class="nav-panel">
|
||||||
|
<div class="nav-mobile-head">
|
||||||
|
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
<div class="nav-lang">
|
||||||
|
<%= render "shared/language_switcher" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var chrome = document.querySelector(".site-chrome");
|
||||||
|
var toggle = document.querySelector(".nav-toggle");
|
||||||
|
var backdrop = document.getElementById("nav-backdrop");
|
||||||
|
var nav = document.getElementById("site-nav");
|
||||||
|
if (!chrome || !toggle || !nav) return;
|
||||||
|
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||||
|
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||||
|
|
||||||
|
function setOpen(open) {
|
||||||
|
chrome.classList.toggle("nav-open", open);
|
||||||
|
document.body.classList.toggle("nav-menu-open", open);
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||||
|
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() { setOpen(false); }
|
||||||
|
|
||||||
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
|
function shouldCloseNavOnControl(el) {
|
||||||
|
if (!el.closest("[data-lang-switcher]")) return true;
|
||||||
|
return el.classList.contains("lang-switcher__option");
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeMenu();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -81,6 +81,12 @@ module MatchLiveTv
|
|||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||||
|
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||||
|
def support_email
|
||||||
|
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||||
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -119,14 +119,35 @@ de:
|
|||||||
view_all: "Vereine ansehen (%{count} Teams)"
|
view_all: "Vereine ansehen (%{count} Teams)"
|
||||||
stream_nodes:
|
stream_nodes:
|
||||||
title: Stream-Knoten
|
title: Stream-Knoten
|
||||||
providers: "Cloud-Provider: %{cloud} · DNS-Provider: %{dns}"
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Aktionen
|
||||||
|
table_title: Registrierte Knoten
|
||||||
|
empty: Keine Knoten registriert.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} frei"
|
||||||
|
other: "%{count} frei"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "Modus %{kind}"
|
||||||
|
free_slots: Freie Slots
|
||||||
|
soft_slots: "Soft-Schwelle ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: Warm-Spares bereit
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: Cloud/Lab-Knoten
|
||||||
|
budget: Geschätztes Budget
|
||||||
|
budget_of: "von €%{budget}/Monat (%{ok})"
|
||||||
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
autoscale_budget: "Budget €%{eur}/€%{budget} (%{ok})"
|
autoscale_budget: "Budget €%{eur}/€%{budget} (%{ok})"
|
||||||
kill_switch_active: "KILL-SWITCH AKTIV"
|
kill_switch_active: "KILL-SWITCH AKTIV"
|
||||||
engage_kill_switch: "Kill-switch ON"
|
engage_kill_switch: "Kill-switch ON"
|
||||||
clear_kill_switch: "Kill-switch OFF"
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
kill_switch_confirm: "Autoscaler sofort blockieren?"
|
kill_switch_confirm: "Autoscaler sofort blockieren?"
|
||||||
provision_lab: Lab-Knoten bereitstellen
|
provision_lab: Lab bereitstellen
|
||||||
|
provision_cloud: Hetzner bereitstellen
|
||||||
|
provision_cloud_confirm: "Hetzner Cloud Server + DNS auf mltv-stream.net erstellen?"
|
||||||
|
hetzner_token_missing: "HCLOUD_TOKEN setzen, um Cloud-Provisioning zu aktivieren."
|
||||||
drain: Drain
|
drain: Drain
|
||||||
destroy: Löschen
|
destroy: Löschen
|
||||||
destroy_confirm: "Knoten %{slug} löschen?"
|
destroy_confirm: "Knoten %{slug} löschen?"
|
||||||
|
|||||||
@@ -119,15 +119,33 @@ en:
|
|||||||
view_all: "View clubs (%{count} teams)"
|
view_all: "View clubs (%{count} teams)"
|
||||||
stream_nodes:
|
stream_nodes:
|
||||||
title: Streaming nodes
|
title: Streaming nodes
|
||||||
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Actions
|
||||||
|
table_title: Registered nodes
|
||||||
|
empty: No nodes registered.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} free"
|
||||||
|
other: "%{count} free"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "%{kind} mode"
|
||||||
|
free_slots: Free slots
|
||||||
|
soft_slots: "soft threshold ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares ready
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: cloud/lab nodes
|
||||||
|
budget: Estimated budget
|
||||||
|
budget_of: "of €%{budget}/mo (%{ok})"
|
||||||
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
kill_switch_active: "KILL-SWITCH ACTIVE"
|
kill_switch_active: "KILL-SWITCH ACTIVE"
|
||||||
engage_kill_switch: "Kill-switch ON (block autoscaler)"
|
engage_kill_switch: "Kill-switch ON"
|
||||||
clear_kill_switch: "Kill-switch OFF"
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
|
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
|
||||||
provision_lab: Provision lab node
|
provision_lab: Provision lab
|
||||||
provision_cloud: Provision Hetzner node
|
provision_cloud: Provision Hetzner
|
||||||
provision_cloud_confirm: "Create a Hetzner Cloud server + DNS record on mltv-stream.net? Billing applies until destroyed."
|
provision_cloud_confirm: "Create a Hetzner Cloud server + DNS record on mltv-stream.net? Billing applies until destroyed."
|
||||||
hetzner_token_missing: "Set HCLOUD_TOKEN to enable Cloud provisioning."
|
hetzner_token_missing: "Set HCLOUD_TOKEN to enable Cloud provisioning."
|
||||||
drain: Drain
|
drain: Drain
|
||||||
|
|||||||
@@ -119,14 +119,35 @@ es:
|
|||||||
view_all: "Ver clubes (%{count} equipos)"
|
view_all: "Ver clubes (%{count} equipos)"
|
||||||
stream_nodes:
|
stream_nodes:
|
||||||
title: Nodos streaming
|
title: Nodos streaming
|
||||||
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Acciones
|
||||||
|
table_title: Nodos registrados
|
||||||
|
empty: No hay nodos registrados.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libre"
|
||||||
|
other: "%{count} libres"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "modo %{kind}"
|
||||||
|
free_slots: Slots libres
|
||||||
|
soft_slots: "umbral soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares listos
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nodos cloud/lab
|
||||||
|
budget: Presupuesto estimado
|
||||||
|
budget_of: "de €%{budget}/mes (%{ok})"
|
||||||
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
autoscale_budget: "presupuesto €%{eur}/€%{budget} (%{ok})"
|
autoscale_budget: "presupuesto €%{eur}/€%{budget} (%{ok})"
|
||||||
kill_switch_active: "KILL-SWITCH ACTIVO"
|
kill_switch_active: "KILL-SWITCH ACTIVO"
|
||||||
engage_kill_switch: "Kill-switch ON"
|
engage_kill_switch: "Kill-switch ON"
|
||||||
clear_kill_switch: "Kill-switch OFF"
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
kill_switch_confirm: "¿Bloquear el autoscaler inmediatamente?"
|
kill_switch_confirm: "¿Bloquear el autoscaler inmediatamente?"
|
||||||
provision_lab: Provisionar nodo lab
|
provision_lab: Provisionar lab
|
||||||
|
provision_cloud: Provisionar Hetzner
|
||||||
|
provision_cloud_confirm: "¿Crear un servidor Hetzner Cloud + DNS en mltv-stream.net?"
|
||||||
|
hetzner_token_missing: "Configura HCLOUD_TOKEN para habilitar el provisioning Cloud."
|
||||||
drain: Drain
|
drain: Drain
|
||||||
destroy: Eliminar
|
destroy: Eliminar
|
||||||
destroy_confirm: "¿Eliminar el nodo %{slug}?"
|
destroy_confirm: "¿Eliminar el nodo %{slug}?"
|
||||||
|
|||||||
@@ -119,14 +119,35 @@ fr:
|
|||||||
view_all: "Voir les clubs (%{count} équipes)"
|
view_all: "Voir les clubs (%{count} équipes)"
|
||||||
stream_nodes:
|
stream_nodes:
|
||||||
title: Nœuds streaming
|
title: Nœuds streaming
|
||||||
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Actions
|
||||||
|
table_title: Nœuds enregistrés
|
||||||
|
empty: Aucun nœud enregistré.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libre"
|
||||||
|
other: "%{count} libres"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "mode %{kind}"
|
||||||
|
free_slots: Slots libres
|
||||||
|
soft_slots: "seuil soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares prêts
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nœuds cloud/lab
|
||||||
|
budget: Budget estimé
|
||||||
|
budget_of: "sur €%{budget}/mois (%{ok})"
|
||||||
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
kill_switch_active: "KILL-SWITCH ACTIF"
|
kill_switch_active: "KILL-SWITCH ACTIF"
|
||||||
engage_kill_switch: "Kill-switch ON"
|
engage_kill_switch: "Kill-switch ON"
|
||||||
clear_kill_switch: "Kill-switch OFF"
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
kill_switch_confirm: "Bloquer immédiatement l'autoscaler ?"
|
kill_switch_confirm: "Bloquer immédiatement l'autoscaler ?"
|
||||||
provision_lab: Provisionner un nœud lab
|
provision_lab: Provisionner lab
|
||||||
|
provision_cloud: Provisionner Hetzner
|
||||||
|
provision_cloud_confirm: "Créer un serveur Hetzner Cloud + DNS sur mltv-stream.net ?"
|
||||||
|
hetzner_token_missing: "Définir HCLOUD_TOKEN pour activer le provisioning Cloud."
|
||||||
drain: Drain
|
drain: Drain
|
||||||
destroy: Supprimer
|
destroy: Supprimer
|
||||||
destroy_confirm: "Supprimer le nœud %{slug} ?"
|
destroy_confirm: "Supprimer le nœud %{slug} ?"
|
||||||
|
|||||||
@@ -119,15 +119,33 @@ it:
|
|||||||
view_all: "Vedi società (%{count} squadre)"
|
view_all: "Vedi società (%{count} squadre)"
|
||||||
stream_nodes:
|
stream_nodes:
|
||||||
title: Nodi streaming
|
title: Nodi streaming
|
||||||
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Azioni
|
||||||
|
table_title: Nodi registrati
|
||||||
|
empty: Nessun nodo registrato.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libero"
|
||||||
|
other: "%{count} liberi"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "modalità %{kind}"
|
||||||
|
free_slots: Slot liberi
|
||||||
|
soft_slots: "soglia soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spare pronti
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nodi cloud/lab
|
||||||
|
budget: Budget stimato
|
||||||
|
budget_of: "su €%{budget}/mese (%{ok})"
|
||||||
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
kill_switch_active: "KILL-SWITCH ATTIVO"
|
kill_switch_active: "KILL-SWITCH ATTIVO"
|
||||||
engage_kill_switch: "Kill-switch ON (blocca autoscaler)"
|
engage_kill_switch: "Kill-switch ON"
|
||||||
clear_kill_switch: "Kill-switch OFF"
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
|
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
|
||||||
provision_lab: Provisiona nodo lab
|
provision_lab: Provisiona lab
|
||||||
provision_cloud: Provisiona nodo Hetzner
|
provision_cloud: Provisiona Hetzner
|
||||||
provision_cloud_confirm: "Creare un server Hetzner Cloud + record DNS su mltv-stream.net? Verrà addebitato fino allo spegnimento."
|
provision_cloud_confirm: "Creare un server Hetzner Cloud + record DNS su mltv-stream.net? Verrà addebitato fino allo spegnimento."
|
||||||
hetzner_token_missing: "Imposta HCLOUD_TOKEN per abilitare il provisioning Cloud."
|
hetzner_token_missing: "Imposta HCLOUD_TOKEN per abilitare il provisioning Cloud."
|
||||||
drain: Drain
|
drain: Drain
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ de:
|
|||||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: Datenschutzdokument
|
s6_privacy_doc_link_text: Datenschutzdokument
|
||||||
manage_button: Cookie-Einstellungen verwalten
|
manage_button: Cookie-Einstellungen verwalten
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||||
|
email_label: "Support-E-Mail:"
|
||||||
|
access_title: Zugangsprobleme
|
||||||
|
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||||
|
live_title: Probleme während eines Livestreams
|
||||||
|
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||||
|
team_title: Teamverwaltung
|
||||||
|
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||||
|
privacy_title: Datenschutz
|
||||||
|
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||||
|
privacy_link_text: Datenschutz
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ en:
|
|||||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: privacy document
|
s6_privacy_doc_link_text: privacy document
|
||||||
manage_button: Manage cookie preferences
|
manage_button: Manage cookie preferences
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||||
|
email_label: "Support email:"
|
||||||
|
access_title: Access issues
|
||||||
|
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||||
|
live_title: Issues during a live stream
|
||||||
|
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||||
|
team_title: Team management
|
||||||
|
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ es:
|
|||||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento de privacidad
|
s6_privacy_doc_link_text: documento de privacidad
|
||||||
manage_button: Gestionar preferencias de cookies
|
manage_button: Gestionar preferencias de cookies
|
||||||
|
support:
|
||||||
|
title: Soporte Match Live TV
|
||||||
|
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||||
|
h1: Soporte Match Live TV
|
||||||
|
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||||
|
email_label: "Correo de soporte:"
|
||||||
|
access_title: Problemas de acceso
|
||||||
|
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||||
|
live_title: Problemas durante un directo
|
||||||
|
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||||
|
team_title: Gestión del equipo
|
||||||
|
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||||
|
privacy_title: Privacidad
|
||||||
|
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||||
|
privacy_link_text: Privacidad
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ fr:
|
|||||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: document de confidentialité
|
s6_privacy_doc_link_text: document de confidentialité
|
||||||
manage_button: Gérer les préférences de cookies
|
manage_button: Gérer les préférences de cookies
|
||||||
|
support:
|
||||||
|
title: Support Match Live TV
|
||||||
|
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||||
|
h1: Support Match Live TV
|
||||||
|
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||||
|
email_label: "E-mail du support :"
|
||||||
|
access_title: Problèmes d'accès
|
||||||
|
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||||
|
live_title: Problèmes pendant un direct
|
||||||
|
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||||
|
team_title: Gestion de l'équipe
|
||||||
|
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||||
|
privacy_title: Confidentialité
|
||||||
|
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||||
|
privacy_link_text: Confidentialité
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ it:
|
|||||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento privacy
|
s6_privacy_doc_link_text: documento privacy
|
||||||
manage_button: Gestisci preferenze cookie
|
manage_button: Gestisci preferenze cookie
|
||||||
|
support:
|
||||||
|
title: Supporto Match Live TV
|
||||||
|
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||||
|
h1: Supporto Match Live TV
|
||||||
|
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||||
|
email_label: "Email di supporto:"
|
||||||
|
access_title: Problemi di accesso
|
||||||
|
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||||
|
live_title: Problemi durante una diretta
|
||||||
|
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||||
|
team_title: Gestione della squadra
|
||||||
|
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ de:
|
|||||||
save_password: Passwort aktualisieren
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ en:
|
|||||||
save_password: Update password
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ es:
|
|||||||
save_password: Actualizar contraseña
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
|
support: Soporte
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ fr:
|
|||||||
save_password: Mettre à jour le mot de passe
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ it:
|
|||||||
save_password: Aggiorna password
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Supporto
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ Rails.application.routes.draw do
|
|||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
|
get "support", to: "pages#support", as: :support
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
get "cookies", to: redirect("/cookie")
|
get "cookies", to: redirect("/cookie")
|
||||||
get "termini", to: "pages#terms", as: :termini
|
get "termini", to: "pages#terms", as: :termini
|
||||||
|
|||||||
+103
-10
@@ -228,6 +228,8 @@ body.admin-body {
|
|||||||
.badge--live { background: var(--red); color: #fff; }
|
.badge--live { background: var(--red); color: #fff; }
|
||||||
.badge--connecting { background: #ff9800; color: #111; }
|
.badge--connecting { background: #ff9800; color: #111; }
|
||||||
.badge--paused { background: #555; color: #fff; }
|
.badge--paused { background: #555; color: #fff; }
|
||||||
|
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
|
||||||
.team-list {
|
.team-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -345,28 +347,40 @@ body.admin-body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-btn--secondary {
|
|
||||||
background: #333;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn--secondary:hover {
|
|
||||||
background: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn {
|
.admin-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: #2a2a36;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.admin-btn--primary {
|
||||||
|
background: var(--red) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary {
|
||||||
|
background: #333;
|
||||||
|
color: #eee;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-btn--danger {
|
.admin-btn--danger {
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -374,6 +388,85 @@ body.admin-body {
|
|||||||
|
|
||||||
.admin-btn--danger:hover { filter: brightness(1.1); }
|
.admin-btn--danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.admin-btn--outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #555;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #0a0a0e;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #cfcfd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value-unit {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value--sm {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Public support page", type: :request do
|
||||||
|
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(MatchLiveTv.support_email)
|
||||||
|
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
||||||
|
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
||||||
|
expect(response.body).to include('rel="canonical"')
|
||||||
|
expect(response.body).to include(public_support_path)
|
||||||
|
expect(response.body).to include(public_privacy_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non espone CTA o link commerciali (App Store Review)" do
|
||||||
|
get public_support_path
|
||||||
|
body = response.body
|
||||||
|
|
||||||
|
expect(body).not_to include(public_prezzi_path)
|
||||||
|
expect(body).not_to include(public_signup_path)
|
||||||
|
expect(body).not_to include('href="/prezzi"')
|
||||||
|
expect(body).not_to include('href="/signup"')
|
||||||
|
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
||||||
|
expect(body).not_to match(/\bPremium Light\b/i)
|
||||||
|
expect(body).not_to match(/\bPremium Full\b/i)
|
||||||
|
expect(body).not_to match(/\bpiano Free\b/i)
|
||||||
|
expect(body).not_to match(/\bAbbonati\b/i)
|
||||||
|
expect(body).not_to match(/\bAcquista\b/i)
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
"it" => "Supporto Match Live TV",
|
||||||
|
"en" => "Match Live TV Support",
|
||||||
|
"fr" => "Support Match Live TV",
|
||||||
|
"de" => "Match Live TV Support",
|
||||||
|
"es" => "Soporte Match Live TV"
|
||||||
|
}.each do |locale, heading|
|
||||||
|
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||||
|
cookies[:mltv_locale] = locale
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(heading)
|
||||||
|
expect(response.body).not_to include("translation missing")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "include /support nella sitemap" do
|
||||||
|
get "/sitemap.xml"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe Mediamtx::PublisherSync do
|
RSpec.describe Mediamtx::PublisherSync do
|
||||||
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "password123", role: "coach") }
|
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "Password123", role: "coach") }
|
||||||
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||||
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
before do
|
before do
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
||||||
|
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:rtmp_publisher?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
|
||||||
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
||||||
allow(client).to receive(:set_path_recording)
|
allow(client).to receive(:set_path_recording)
|
||||||
end
|
end
|
||||||
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
|
|
||||||
it "non abilita la registrazione in connecting senza publisher online" do
|
it "non abilita la registrazione in connecting senza publisher online" do
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
path_info["online"] = false
|
path_info["online"] = false
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
@@ -53,6 +57,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||||
session.update!(status: "reconnecting")
|
session.update!(status: "reconnecting")
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,55 @@ RSpec.describe Streams::Autoscaler do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "does not scale in a node provisioned in the same reconcile round" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
|
||||||
|
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
|
||||||
|
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
|
||||||
|
"STREAM_AUTOSCALE_KIND" => "lab",
|
||||||
|
"STREAM_AUTOSCALE_MAX_NODES" => "5",
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||||
|
) do
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
user = User.create!(email: "same@example.com", name: "S", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "Same", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "same-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
2.times do
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
end
|
||||||
|
|
||||||
|
provisioner = instance_double(Streams::NodeProvisioner)
|
||||||
|
created = nil
|
||||||
|
allow(provisioner).to receive(:provision_lab!) do
|
||||||
|
created = StreamNode.create!(
|
||||||
|
slug: "ingest-lab-new",
|
||||||
|
hostname: "new.lab",
|
||||||
|
role: "lab",
|
||||||
|
status: "ready",
|
||||||
|
provider: "local",
|
||||||
|
rtmp_base_url: "rtmp://h:1935",
|
||||||
|
hls_base_url: "https://h/hls",
|
||||||
|
api_base_url: "http://h:9997",
|
||||||
|
max_publishers: 4,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
expect(provisioner).not_to receive(:decommission!)
|
||||||
|
|
||||||
|
result = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result.actions).to include(:scale_out)
|
||||||
|
expect(result.actions.map(&:to_s)).not_to include("scale_in_ingest-lab-new")
|
||||||
|
expect(StreamNode.find_by(slug: "ingest-lab-new")).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it "scales in an idle overflow node when warm spare is not required" do
|
it "scales in an idle overflow node when warm spare is not required" do
|
||||||
with_env(
|
with_env(
|
||||||
"STREAM_AUTOSCALE_ENABLED" => "1",
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
|||||||
@@ -49,7 +49,27 @@ RSpec.describe Streams::NodeRegistry do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "allocates the least loaded ready node" do
|
it "prefers home while it still has free slots even if cloud is idle" do
|
||||||
|
with_env(home_env) do
|
||||||
|
home = described_class.ensure_home_from_env!
|
||||||
|
StreamNode.create!(
|
||||||
|
slug: "ingest-01",
|
||||||
|
hostname: "ingest-01.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.2:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.allocate!).to eq(home)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allocates the least loaded cloud node when home is full" do
|
||||||
with_env(home_env) do
|
with_env(home_env) do
|
||||||
home = described_class.ensure_home_from_env!
|
home = described_class.ensure_home_from_env!
|
||||||
cloud = StreamNode.create!(
|
cloud = StreamNode.create!(
|
||||||
@@ -64,14 +84,32 @@ RSpec.describe Streams::NodeRegistry do
|
|||||||
max_publishers: 2,
|
max_publishers: 2,
|
||||||
max_relays: 2
|
max_relays: 2
|
||||||
)
|
)
|
||||||
|
cloud_busy = StreamNode.create!(
|
||||||
|
slug: "ingest-02",
|
||||||
|
hostname: "ingest-02.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-02.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-02.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.3:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
|
||||||
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
|
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
|
||||||
club = Club.create!(name: "C", sport: "volleyball")
|
club = Club.create!(name: "C", sport: "volleyball")
|
||||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
|
||||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
match2 = team.matches.create!(opponent_name: "Y", scheduled_at: 2.hours.from_now)
|
||||||
|
|
||||||
|
# Riempie home (max 2)
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
StreamSession.create!(match: match2, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
# Un publisher già su ingest-02 → least-loaded = ingest-01
|
||||||
StreamSession.create!(
|
StreamSession.create!(
|
||||||
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
|
match: team.matches.create!(opponent_name: "Z", scheduled_at: 3.hours.from_now),
|
||||||
|
user: user, platform: "matchlivetv", status: "live", stream_node: cloud_busy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(described_class.allocate!).to eq(cloud)
|
expect(described_class.allocate!).to eq(cloud)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Copia come infra/.env sul container di collaudo e genera i secret.
|
||||||
|
# cp .env.collaudo.example .env && openssl rand -hex 32 (ripeti per ogni CHANGE_ME)
|
||||||
|
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||||
|
SECRET_KEY_BASE=CHANGE_ME_openssl_rand_hex_64
|
||||||
|
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||||
|
MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# RTMP collaudo: porta host 11935 (vedi docker-compose.collaudo.yml).
|
||||||
|
# LAN: rtmp://192.168.1.157:11935
|
||||||
|
# WAN (opzionale): apri SOLO 11935 sul router → .157 — NON toccare :1935 di produzione.
|
||||||
|
MEDIAMTX_RTMP_URL=rtmp://192.168.1.157:11935
|
||||||
|
|
||||||
|
APP_PUBLIC_URL=https://collaudo.matchlivetv.it
|
||||||
|
HLS_PUBLIC_URL=https://collaudo.matchlivetv.it/hls
|
||||||
|
CORS_ORIGINS=https://collaudo.matchlivetv.it
|
||||||
|
ALLOWED_HOSTS=collaudo.matchlivetv.it,192.168.1.157,localhost
|
||||||
|
YOUTUBE_REDIRECT_URI=https://collaudo.matchlivetv.it/api/v1/youtube/callback
|
||||||
|
|
||||||
|
YOUTUBE_CLIENT_ID=
|
||||||
|
YOUTUBE_CLIENT_SECRET=
|
||||||
|
YOUTUBE_PLATFORM_REFRESH_TOKEN=
|
||||||
|
|
||||||
|
# Stripe: preferisci chiavi Test su collaudo
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||||
|
|
||||||
|
RAILS_LOG_LEVEL=info
|
||||||
|
|
||||||
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
|
MAILER_FROM=Match Live TV Collaudo <noreply@matchlivetv.it>
|
||||||
|
SMTP_ADDRESS=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_AUTH=plain
|
||||||
|
SMTP_SSL=true
|
||||||
|
SMTP_STARTTLS=false
|
||||||
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
MATCHLIVETV_VIDEOS_ROOT=/media/videos/matchlivetv
|
||||||
|
|
||||||
|
REPLAY_STORAGE_ENDPOINT=http://garage:3900
|
||||||
|
REPLAY_STORAGE_BUCKET=matchlivetv-replays
|
||||||
|
REPLAY_STORAGE_REGION=garage
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID=
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
||||||
|
REPLAY_MEDIA_PUBLIC_BASE_URL=https://collaudo.matchlivetv.it/media
|
||||||
|
REPLAY_MEDIA_REDIRECT=true
|
||||||
|
|
||||||
|
RAILS_MAX_THREADS=5
|
||||||
|
|
||||||
|
OPS_HTTP_RAILS_URL=http://edge/up
|
||||||
|
OPS_HTTP_PUBLIC_INTERVAL_SECS=900
|
||||||
|
NTFY_PUBLIC_URL=http://192.168.1.157:18090
|
||||||
|
OPS_NTFY_URL=
|
||||||
|
OPS_NTFY_TOKEN=
|
||||||
|
OPS_ALERT_EMAIL=
|
||||||
|
OPS_NOTIFY_SEVERITIES=critical,warning
|
||||||
|
OPS_NOTIFY_COOLDOWN_MINUTES=30
|
||||||
|
OPS_HEALTH_INTERVAL_SECS=180
|
||||||
|
OPS_HEALTH_TOKEN=
|
||||||
|
OPS_DISK_WARN_PERCENT=80
|
||||||
|
OPS_DISK_CRIT_PERCENT=90
|
||||||
|
OPS_RECORDINGS_WARN_GB=20
|
||||||
|
OPS_RECORDINGS_CRIT_GB=50
|
||||||
|
OPS_SIDEKIQ_STALE_SECS=300
|
||||||
|
OPS_LOG_SUBSCRIBER=false
|
||||||
|
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Stream autoscale — token Hetzner da compilare; kill-switch cloud off finché WG non è ok
|
||||||
|
HCLOUD_TOKEN=
|
||||||
|
HCLOUD_LOCATION=nbg1
|
||||||
|
HCLOUD_SERVER_TYPE=cpx12
|
||||||
|
HCLOUD_IMAGE=debian-12
|
||||||
|
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||||
|
HCLOUD_NETWORK_ID=
|
||||||
|
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||||
|
STREAM_DNS_ZONE=mltv-stream.net
|
||||||
|
STREAM_DNS_TTL=60
|
||||||
|
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||||
|
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||||
|
STREAM_NODE_ENV=collaudo
|
||||||
|
STREAM_CLOUD_PROVIDER=local_lab
|
||||||
|
RELAY_MAX_CONCURRENT=4
|
||||||
|
YOUTUBE_RELAY_WORKER=1
|
||||||
|
|
||||||
|
STREAM_AUTOSCALE_ENABLED=0
|
||||||
|
STREAM_AUTOSCALE_KIND=lab
|
||||||
|
STREAM_AUTOSCALE_ALLOW_CLOUD=0
|
||||||
|
STREAM_AUTOSCALE_SOFT_FREE_SLOTS=2
|
||||||
|
STREAM_AUTOSCALE_WARM_SPARE=1
|
||||||
|
STREAM_AUTOSCALE_IDLE_MINUTES=30
|
||||||
|
STREAM_AUTOSCALE_MAX_NODES=5
|
||||||
|
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR=0.015
|
||||||
|
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR=40
|
||||||
|
STREAM_OVERFLOW_ORPHAN_HOURS=3
|
||||||
@@ -15,6 +15,8 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ RAILS_LOG_LEVEL=info
|
|||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Collaudo Proxmox (es. 192.168.1.157) — overlay su docker-compose.prod.yml
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# export COMPOSE_FILE=docker-compose.prod.yml:docker-compose.collaudo.yml
|
||||||
|
# docker compose --env-file .env up -d --build
|
||||||
|
#
|
||||||
|
# Porte host MediaMTX diverse da produzione per non competere sullo stesso IP pubblico:
|
||||||
|
# prod WAN :1935 → 192.168.1.146
|
||||||
|
# collaudo LAN/WAN opzionale :11935 → 192.168.1.157
|
||||||
|
# HTTP resta :3000 (NPM → collaudo.matchlivetv.it); IP LAN diversi = nessun conflitto.
|
||||||
|
|
||||||
|
services:
|
||||||
|
mediamtx:
|
||||||
|
ports: !override
|
||||||
|
- "11935:1935" # RTMP collaudo (NON usare WAN :1935 di produzione)
|
||||||
|
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
||||||
|
|
||||||
|
# Passa tutto .env (HCLOUD_*, STREAM_*, …) ai processi Rails/Sidekiq
|
||||||
|
# Monta cloud-init: HCLOUD_USER_DATA_FILE punta a path host non presente nell'immagine.
|
||||||
|
rails:
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
|
sidekiq:
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
|
# ntfy collaudo su porta diversa (evita confusione se si punta per sbaglio l'host)
|
||||||
|
ntfy:
|
||||||
|
ports: !override
|
||||||
|
- "18090:80"
|
||||||
@@ -73,6 +73,7 @@ services:
|
|||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
@@ -135,6 +136,7 @@ services:
|
|||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||||
|
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||||
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
|
||||||
@@ -230,6 +232,7 @@ services:
|
|||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||||
|
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
image: dxflrs/garage:v1.0.1
|
image: dxflrs/garage:v1.0.1
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ services:
|
|||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
export COMPOSE_FILE="docker-compose.prod.yml"
|
export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||||
export ENV_FILE="${ROOT}/.env"
|
export ENV_FILE="${ROOT}/.env"
|
||||||
export CREDS_FILE="${ROOT}/garage/prod-credentials.env"
|
export CREDS_FILE="${ROOT}/garage/prod-credentials.env"
|
||||||
|
|
||||||
bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
||||||
|
|
||||||
echo "Avvio Garage..."
|
echo "Avvio Garage (COMPOSE_FILE=${COMPOSE_FILE})..."
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env up -d garage
|
docker compose --env-file .env up -d garage
|
||||||
|
|
||||||
videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
|
videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
|
||||||
capacity_df="${videos_root}"
|
capacity_df="${videos_root}"
|
||||||
@@ -27,8 +27,8 @@ echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riser
|
|||||||
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
||||||
|
|
||||||
echo "Riavvio Rails e Sidekiq..."
|
echo "Riavvio Rails e Sidekiq..."
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env up -d rails sidekiq
|
docker compose --env-file .env up -d rails sidekiq
|
||||||
|
|
||||||
echo "Verifica storage:"
|
echo "Verifica storage:"
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env exec -T rails bundle exec rails runner \
|
docker compose --env-file .env exec -T rails bundle exec rails runner \
|
||||||
'puts({local: MatchLiveTv.replay_storage_local?, backend: Recordings::Storage.new.backend_name}.inspect)'
|
'puts({local: MatchLiveTv.replay_storage_local?, backend: Recordings::Storage.new.backend_name}.inspect)'
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ write_files:
|
|||||||
done
|
done
|
||||||
docker pull bluenviron/mediamtx:latest
|
docker pull bluenviron/mediamtx:latest
|
||||||
docker rm -f mediamtx 2>/dev/null || true
|
docker rm -f mediamtx 2>/dev/null || true
|
||||||
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path
|
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path.
|
||||||
|
# MediaMTX 1.20+: AAC del publisher RTMP deve matchare la slate.
|
||||||
|
# Allineato ad app Android (BroadcastConfig: 48 kHz mono) e infra/scripts/generate_slate.sh.
|
||||||
mkdir -p /slates
|
mkdir -p /slates
|
||||||
if [ ! -f /slates/offline.mp4 ]; then
|
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=48000:cl=mono \
|
||||||
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=44100:cl=stereo \
|
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 1 -ar 48000 -shortest /slates/offline.mp4
|
||||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -shortest /slates/offline.mp4
|
|
||||||
fi
|
|
||||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||||
docker run -d --name mediamtx --restart unless-stopped --network host \
|
docker run -d --name mediamtx --restart unless-stopped --network host \
|
||||||
--security-opt apparmor=unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 31
|
versionCode = 32
|
||||||
versionName = "2.0.10-native"
|
versionName = "2.0.11-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
|
|||||||
+70
-24
@@ -17,11 +17,16 @@ import org.junit.runner.RunWith
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
||||||
|
* Usa le stringhe dell'app (locale del device), non testo hardcodato IT/EN.
|
||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class E2EWizardFlowTest {
|
class E2EWizardFlowTest {
|
||||||
private lateinit var device: UiDevice
|
private lateinit var device: UiDevice
|
||||||
private val pkg = "com.matchlivetv.match_live_tv"
|
private val pkg = "com.matchlivetv.match_live_tv"
|
||||||
|
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||||
|
|
||||||
|
private fun s(id: Int): String = ctx.getString(id)
|
||||||
|
private fun su(id: Int): String = s(id).uppercase()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -32,30 +37,42 @@ class E2EWizardFlowTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
||||||
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
|
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
|
||||||
fillLogin()
|
fillLogin()
|
||||||
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
|
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
|
||||||
tapClickableText("NUOVA PARTITA")
|
tapClickableText(su(R.string.matches_new))
|
||||||
waitForText("Avvia subito", timeoutMs = 15_000)
|
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
|
||||||
tapClickableText("Avvia subito")
|
tapClickableText(s(R.string.sheet_quick_option))
|
||||||
waitForText("01 · Partita", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("AVANTI >")
|
tapClickableText(s(R.string.wizard_action_next))
|
||||||
waitForText("02 · Trasmissione", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
|
||||||
waitForText("Piattaforma", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("AVANTI >")
|
tapClickableText(s(R.string.wizard_action_next))
|
||||||
waitForText("03 · Test rete", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000)
|
||||||
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
|
||||||
tapClickableText("AVVIA TEST RETE")
|
tapClickableText(s(R.string.wizard_network_test_start_label))
|
||||||
waitForText("INIZIA >", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
|
||||||
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
|
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("INIZIA >")
|
tapClickableText(s(R.string.wizard_action_start))
|
||||||
val diretta = waitForText("Diretta", timeoutMs = 60_000)
|
waitForAnyText(
|
||||||
assertNotNull(diretta)
|
s(R.string.broadcast_status_live),
|
||||||
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
|
s(R.string.broadcast_status_connecting),
|
||||||
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
|
s(R.string.broadcast_status_reconnecting),
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
)
|
||||||
|
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
|
||||||
|
assertTrue(
|
||||||
|
"Schermata diretta non pronta (né CLOSE SET né End live)",
|
||||||
|
waitForTextOrDesc(
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
s(R.string.broadcast_close_set_button),
|
||||||
|
s(R.string.score_action_close_set),
|
||||||
|
s(R.string.broadcast_terminate_cd),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun grantRuntimePermissions() {
|
private fun grantRuntimePermissions() {
|
||||||
@@ -69,11 +86,10 @@ class E2EWizardFlowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun launchApp() {
|
private fun launchApp() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val intent = ctx.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
} ?: error("Launch intent mancante per $pkg")
|
} ?: error("Launch intent mancante per $pkg")
|
||||||
context.startActivity(intent)
|
ctx.startActivity(intent)
|
||||||
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +97,10 @@ class E2EWizardFlowTest {
|
|||||||
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
||||||
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
||||||
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
||||||
pasteIntoField(fields[1], "password123")
|
pasteIntoField(fields[1], "Password123")
|
||||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||||
|
// Preferisci il bottone (ultimo match uppercase), non il titolo.
|
||||||
|
tapLastMatchingText(su(R.string.login_submit))
|
||||||
device.waitForIdle()
|
device.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +129,34 @@ class E2EWizardFlowTest {
|
|||||||
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun waitForTextOrDesc(timeoutMs: Long, vararg labels: String): Boolean {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
for (label in labels) {
|
||||||
|
if (device.hasObject(By.text(label)) || device.hasObject(By.desc(label))) return true
|
||||||
|
}
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapLastMatchingText(text: String) {
|
||||||
|
val nodes = device.findObjects(By.text(text))
|
||||||
|
val target = nodes.lastOrNull { it.isClickable }
|
||||||
|
?: nodes.lastOrNull()?.let { label ->
|
||||||
|
var node: UiObject2? = label
|
||||||
|
repeat(6) {
|
||||||
|
val current = node ?: return@repeat
|
||||||
|
if (current.isClickable) return@let current
|
||||||
|
node = current.parent
|
||||||
|
}
|
||||||
|
label
|
||||||
|
}
|
||||||
|
?: error("Testo non trovato: $text")
|
||||||
|
target.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
private fun tapClickableText(text: String) {
|
private fun tapClickableText(text: String) {
|
||||||
device.findObject(By.text(text).clickable(true))?.let { node ->
|
device.findObject(By.text(text).clickable(true))?.let { node ->
|
||||||
node.click()
|
node.click()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,102 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme LastUpgradeVersion="1600" version="1.7">
|
<Scheme
|
||||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
LastUpgradeVersion = "2660"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
<BuildActionEntry
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
<BuildActionEntry
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="20C695DBFB4042879308B0F5" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
|
||||||
|
BuildableName = "MatchLiveTvTests.xctest"
|
||||||
|
BlueprintName = "MatchLiveTvTests"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference skipped="NO">
|
<TestableReference
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="20C695DBFB4042879308B0F5" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "20C695DBFB4042879308B0F5"
|
||||||
|
BuildableName = "MatchLiveTvTests.xctest"
|
||||||
|
BlueprintName = "MatchLiveTvTests"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
<LaunchAction
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
buildConfiguration = "Debug"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
<ProfileAction
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
buildConfiguration = "Release"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F54F6C97361C4E8A96AEDD11" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F54F6C97361C4E8A96AEDD11"
|
||||||
|
BuildableName = "MatchLiveTv.app"
|
||||||
|
BlueprintName = "MatchLiveTv"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction buildConfiguration="Debug"/>
|
<AnalyzeAction
|
||||||
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 216 KiB |
@@ -32,7 +32,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>31</string>
|
<string>32</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
@@ -55,6 +55,14 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ lines += [
|
|||||||
app_settings = """
|
app_settings = """
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
@@ -122,7 +122,7 @@ app_debug_settings = app_settings + """
|
|||||||
test_settings = """
|
test_settings = """
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 31;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.10;
|
||||||
|
|||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Helper collaudo Proxmox: forza sempre prod + overlay porte isolate.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
INFRA="${MATCHLIVETV_INFRA:-$ROOT/infra}"
|
||||||
|
if [ -d /opt/matchlivetv/infra ]; then
|
||||||
|
INFRA=/opt/matchlivetv/infra
|
||||||
|
fi
|
||||||
|
cd "$INFRA"
|
||||||
|
export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml:docker-compose.collaudo.yml}"
|
||||||
|
exec docker compose --env-file .env "$@"
|
||||||
Reference in New Issue
Block a user