Compare commits
2
Commits
4c5af99efa
...
f1906517a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1906517a9 | ||
|
|
00fde25c50 |
@@ -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,14 +193,17 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def provision_overflow!
|
def provision_overflow!
|
||||||
case self.class.kind
|
node =
|
||||||
when "cloud"
|
case self.class.kind
|
||||||
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
|
when "cloud"
|
||||||
|
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
|
||||||
|
|
||||||
@provisioner.provision_cloud!
|
@provisioner.provision_cloud!
|
||||||
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,83 +1,129 @@
|
|||||||
<% content_for :body_class, "admin-body" %>
|
<% content_for :body_class, "admin-body" %>
|
||||||
<h2><%= t("admin.stream_nodes.title") %></h2>
|
|
||||||
<p class="admin-muted">
|
|
||||||
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="admin-muted">
|
<div class="admin-page-head">
|
||||||
<%= t(
|
<h2 class="admin-page-title"><%= t("admin.stream_nodes.title") %></h2>
|
||||||
"admin.stream_nodes.autoscale",
|
<p class="muted admin-page-sub">
|
||||||
enabled: (@autoscale_metrics[:enabled] ? "ON" : "OFF"),
|
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
||||||
free: @autoscale_metrics[:free_slots],
|
</p>
|
||||||
soft: @autoscale_metrics[:soft_free_slots],
|
</div>
|
||||||
spare: @autoscale_metrics[:spare_ready],
|
|
||||||
warm: @autoscale_metrics[:warm_spare_min],
|
|
||||||
overflow: @autoscale_metrics[:overflow_nodes],
|
|
||||||
max: @autoscale_metrics[:max_overflow_nodes],
|
|
||||||
kind: @autoscale_metrics[:kind]
|
|
||||||
) %>
|
|
||||||
· <%= t(
|
|
||||||
"admin.stream_nodes.autoscale_budget",
|
|
||||||
eur: @autoscale_metrics[:estimated_monthly_eur],
|
|
||||||
budget: @autoscale_metrics[:monthly_budget_eur],
|
|
||||||
ok: (@autoscale_metrics[:within_budget] ? "OK" : "OVER")
|
|
||||||
) %>
|
|
||||||
<% if @autoscale_metrics[:kill_switch] %>
|
|
||||||
· <strong><%= t("admin.stream_nodes.kill_switch_active") %></strong>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
<% m = @autoscale_metrics %>
|
||||||
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
|
<section class="kpi-grid">
|
||||||
<% if @hetzner_configured %>
|
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
|
||||||
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn",
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
|
||||||
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
|
||||||
<% else %>
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
|
||||||
<span class="admin-muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
</div>
|
||||||
<% end %>
|
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
|
||||||
<% if @autoscale_metrics[:kill_switch] %>
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
|
||||||
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn-secondary" %>
|
<div class="kpi-value"><%= m[:free_slots] %></div>
|
||||||
<% else %>
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
|
||||||
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn-danger",
|
</div>
|
||||||
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
|
<div class="kpi-card">
|
||||||
<% end %>
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
|
||||||
</p>
|
<div class="kpi-value"><%= m[:spare_ready] %><span class="kpi-value-unit">/<%= m[:warm_spare_min] %></span></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.warm_spare") %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.overflow") %></div>
|
||||||
|
<div class="kpi-value"><%= m[:overflow_nodes] %><span class="kpi-value-unit">/<%= m[:max_overflow_nodes] %></span></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.overflow_nodes") %></div>
|
||||||
|
</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>
|
||||||
|
|
||||||
<table class="admin-table">
|
<div class="panel" style="margin-bottom:1.5rem">
|
||||||
<thead>
|
<div class="admin-panel-head">
|
||||||
<tr>
|
<h2><%= t("admin.stream_nodes.actions_title") %></h2>
|
||||||
<th><%= t("admin.stream_nodes.col.slug") %></th>
|
</div>
|
||||||
<th><%= t("admin.stream_nodes.col.role") %></th>
|
<div class="admin-toolbar">
|
||||||
<th><%= t("admin.stream_nodes.col.status") %></th>
|
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn admin-btn--secondary" %>
|
||||||
<th><%= t("admin.stream_nodes.col.slots") %></th>
|
<% if @hetzner_configured %>
|
||||||
<th><%= t("admin.stream_nodes.col.hostname") %></th>
|
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn admin-btn--primary",
|
||||||
<th><%= t("admin.stream_nodes.col.provider") %></th>
|
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
||||||
<th></th>
|
<% else %>
|
||||||
</tr>
|
<span class="muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @nodes.each do |node| %>
|
|
||||||
<tr>
|
|
||||||
<td><code><%= node.slug %></code></td>
|
|
||||||
<td><%= node.role %></td>
|
|
||||||
<td><%= node.status %></td>
|
|
||||||
<td><%= node.active_publishers %> / <%= node.max_publishers %> (free <%= node.free_slots %>)</td>
|
|
||||||
<td><code><%= node.hostname %></code></td>
|
|
||||||
<td><%= node.provider %><% if node.provider_instance_id.present? %> · <%= node.provider_instance_id %><% end %></td>
|
|
||||||
<td class="admin-actions">
|
|
||||||
<% if node.slug != "home" %>
|
|
||||||
<% 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" %>
|
|
||||||
<% 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) } } %>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
<% if m[:kill_switch] %>
|
||||||
</table>
|
<%= 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 %>
|
||||||
|
<%= 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") } } %>
|
||||||
|
<% end %>
|
||||||
|
</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">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.stream_nodes.col.slug") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.role") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.status") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.slots") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.hostname") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.provider") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @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>
|
||||||
|
<td><strong><%= node.slug %></strong></td>
|
||||||
|
<td class="muted"><%= node.role %></td>
|
||||||
|
<td><span class="badge <%= badge %>"><%= node.status %></span></td>
|
||||||
|
<td>
|
||||||
|
<strong><%= node.active_publishers %></strong>
|
||||||
|
<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">
|
||||||
|
<% if node.slug != "home" %>
|
||||||
|
<% if node.status == "ready" %>
|
||||||
|
<%= 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 %>
|
||||||
|
<%= 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 %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</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">
|
||||||
<pre class="admin-pre"><%= @lab_hosts %></pre>
|
<h2><%= t("admin.stream_nodes.hosts_title") %></h2>
|
||||||
|
<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 %>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user