Compare commits
2
Commits
4c5af99efa
...
f1906517a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1906517a9 | ||
|
|
00fde25c50 |
@@ -131,6 +131,7 @@ module Streams
|
||||
|
||||
def initialize(provisioner: nil)
|
||||
@provisioner = provisioner || NodeProvisioner.new
|
||||
@provisioned_this_round = []
|
||||
end
|
||||
|
||||
def reconcile!
|
||||
@@ -154,6 +155,7 @@ module Streams
|
||||
|
||||
scale_in_candidates.each do |node|
|
||||
next if keep_as_warm_spare?(node)
|
||||
next if @provisioned_this_round.include?(node.id)
|
||||
|
||||
safe_scale_in!(node)
|
||||
actions << :"scale_in_#{node.slug}"
|
||||
@@ -191,6 +193,7 @@ module Streams
|
||||
end
|
||||
|
||||
def provision_overflow!
|
||||
node =
|
||||
case self.class.kind
|
||||
when "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
|
||||
@provisioner.provision_lab!
|
||||
end
|
||||
@provisioned_this_round << node.id if node
|
||||
node
|
||||
end
|
||||
|
||||
def safe_scale_in!(node)
|
||||
|
||||
@@ -1,48 +1,66 @@
|
||||
<% 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) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="admin-muted">
|
||||
<%= t(
|
||||
"admin.stream_nodes.autoscale",
|
||||
enabled: (@autoscale_metrics[:enabled] ? "ON" : "OFF"),
|
||||
free: @autoscale_metrics[:free_slots],
|
||||
soft: @autoscale_metrics[:soft_free_slots],
|
||||
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>
|
||||
<% m = @autoscale_metrics %>
|
||||
<section class="kpi-grid">
|
||||
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
|
||||
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
|
||||
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
|
||||
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
|
||||
</div>
|
||||
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
|
||||
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
|
||||
<div class="kpi-value"><%= m[:free_slots] %></div>
|
||||
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
|
||||
<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>
|
||||
|
||||
<p>
|
||||
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
|
||||
<div class="panel" style="margin-bottom:1.5rem">
|
||||
<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 %>
|
||||
<%= 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") } } %>
|
||||
<% 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 %>
|
||||
<% if @autoscale_metrics[: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" %>
|
||||
<% 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" %>
|
||||
<% 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") } } %>
|
||||
<% 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">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -57,27 +75,55 @@
|
||||
</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><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><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-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 %>
|
||||
<%= 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 %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.stream_nodes.empty") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% 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>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<title><%= t("admin.layout.title") %></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
|
||||
@@ -119,14 +119,35 @@ de:
|
||||
view_all: "Vereine ansehen (%{count} Teams)"
|
||||
stream_nodes:
|
||||
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_budget: "Budget €%{eur}/€%{budget} (%{ok})"
|
||||
kill_switch_active: "KILL-SWITCH AKTIV"
|
||||
engage_kill_switch: "Kill-switch ON"
|
||||
clear_kill_switch: "Kill-switch OFF"
|
||||
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
|
||||
destroy: Löschen
|
||||
destroy_confirm: "Knoten %{slug} löschen?"
|
||||
|
||||
@@ -119,15 +119,33 @@ en:
|
||||
view_all: "View clubs (%{count} teams)"
|
||||
stream_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_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||
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"
|
||||
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
|
||||
provision_lab: Provision lab node
|
||||
provision_cloud: Provision Hetzner node
|
||||
provision_lab: Provision lab
|
||||
provision_cloud: Provision Hetzner
|
||||
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."
|
||||
drain: Drain
|
||||
|
||||
@@ -119,14 +119,35 @@ es:
|
||||
view_all: "Ver clubes (%{count} equipos)"
|
||||
stream_nodes:
|
||||
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_budget: "presupuesto €%{eur}/€%{budget} (%{ok})"
|
||||
kill_switch_active: "KILL-SWITCH ACTIVO"
|
||||
engage_kill_switch: "Kill-switch ON"
|
||||
clear_kill_switch: "Kill-switch OFF"
|
||||
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
|
||||
destroy: Eliminar
|
||||
destroy_confirm: "¿Eliminar el nodo %{slug}?"
|
||||
|
||||
@@ -119,14 +119,35 @@ fr:
|
||||
view_all: "Voir les clubs (%{count} équipes)"
|
||||
stream_nodes:
|
||||
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_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||
kill_switch_active: "KILL-SWITCH ACTIF"
|
||||
engage_kill_switch: "Kill-switch ON"
|
||||
clear_kill_switch: "Kill-switch OFF"
|
||||
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
|
||||
destroy: Supprimer
|
||||
destroy_confirm: "Supprimer le nœud %{slug} ?"
|
||||
|
||||
@@ -119,15 +119,33 @@ it:
|
||||
view_all: "Vedi società (%{count} squadre)"
|
||||
stream_nodes:
|
||||
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_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||
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"
|
||||
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
|
||||
provision_lab: Provisiona nodo lab
|
||||
provision_cloud: Provisiona nodo Hetzner
|
||||
provision_lab: Provisiona lab
|
||||
provision_cloud: Provisiona Hetzner
|
||||
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."
|
||||
drain: Drain
|
||||
|
||||
+103
-10
@@ -228,6 +228,8 @@ body.admin-body {
|
||||
.badge--live { background: var(--red); color: #fff; }
|
||||
.badge--connecting { background: #ff9800; color: #111; }
|
||||
.badge--paused { background: #555; color: #fff; }
|
||||
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||
|
||||
.team-list {
|
||||
list-style: none;
|
||||
@@ -345,28 +347,40 @@ body.admin-body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-btn--secondary {
|
||||
background: #333;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.admin-btn--secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
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--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 {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
@@ -374,6 +388,85 @@ body.admin-body {
|
||||
|
||||
.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); }
|
||||
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
||||
|
||||
|
||||
@@ -91,6 +91,55 @@ RSpec.describe Streams::Autoscaler do
|
||||
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
|
||||
with_env(
|
||||
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||
|
||||
Reference in New Issue
Block a user