Compare commits

...
2 Commits
Author SHA1 Message Date
eminuxandCursor f1906517a9 Rende leggibile la pagina admin Nodi streaming con KPI e toolbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:04:12 +02:00
eminuxandCursor 00fde25c50 Evita scale-in del nodo appena creato nello stesso reconcile.
Con IDLE_MINUTES=0 (o race stretta) lo scale-out veniva annullato subito; lo smoke AutoscalerJob Cloud su collaudo ora completa scale-out e scale-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:42:35 +02:00
10 changed files with 399 additions and 107 deletions
@@ -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)
@@ -1,48 +1,66 @@
<% 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>
<div class="panel">
<h2><%= t("admin.stream_nodes.table_title") %></h2>
<% if @nodes.any? %>
<div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
@@ -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 %>
+1 -1
View File
@@ -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 %>
+23 -2
View File
@@ -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?"
+22 -4
View File
@@ -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
+23 -2
View File
@@ -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}?"
+23 -2
View File
@@ -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} ?"
+22 -4
View File
@@ -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
+103 -10
View File
@@ -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; }
@@ -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",