diff --git a/backend/app/views/admin/stream_nodes/index.html.erb b/backend/app/views/admin/stream_nodes/index.html.erb
index 10e8277..aa67b84 100644
--- a/backend/app/views/admin/stream_nodes/index.html.erb
+++ b/backend/app/views/admin/stream_nodes/index.html.erb
@@ -1,83 +1,129 @@
<% content_for :body_class, "admin-body" %>
-
<%= t("admin.stream_nodes.title") %>
-
- <%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
-
-
- <%= 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] %>
- · <%= t("admin.stream_nodes.kill_switch_active") %>
- <% end %>
-
+
+
<%= t("admin.stream_nodes.title") %>
+
+ <%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
+
+
-
- <%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
- <% if @hetzner_configured %>
- <%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn",
- form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
- <% else %>
- <%= t("admin.stream_nodes.hetzner_token_missing") %>
- <% 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" %>
- <% 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 %>
-
+<% m = @autoscale_metrics %>
+
+
+
<%= t("admin.stream_nodes.kpi.autoscaler") %>
+
<%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %>
+
<%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %>
+
+
+
<%= t("admin.stream_nodes.kpi.free_slots") %>
+
<%= m[:free_slots] %>
+
<%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %>
+
+
+
<%= t("admin.stream_nodes.kpi.spare") %>
+
<%= m[:spare_ready] %>/<%= m[:warm_spare_min] %>
+
<%= t("admin.stream_nodes.kpi.warm_spare") %>
+
+
+
<%= t("admin.stream_nodes.kpi.overflow") %>
+
<%= m[:overflow_nodes] %>/<%= m[:max_overflow_nodes] %>
+
<%= t("admin.stream_nodes.kpi.overflow_nodes") %>
+
+
+
<%= t("admin.stream_nodes.kpi.budget") %>
+
€<%= m[:estimated_monthly_eur] %>
+
<%= t("admin.stream_nodes.kpi.budget_of", budget: m[:monthly_budget_eur], ok: (m[:within_budget] ? "OK" : "OVER")) %>
+
+
-
-
-
- <%= t("admin.stream_nodes.col.slug") %>
- <%= t("admin.stream_nodes.col.role") %>
- <%= t("admin.stream_nodes.col.status") %>
- <%= t("admin.stream_nodes.col.slots") %>
- <%= t("admin.stream_nodes.col.hostname") %>
- <%= t("admin.stream_nodes.col.provider") %>
-
-
-
-
- <% @nodes.each do |node| %>
-
- <%= node.slug %>
- <%= node.role %>
- <%= node.status %>
- <%= node.active_publishers %> / <%= node.max_publishers %> (free <%= node.free_slots %>)
- <%= node.hostname %>
- <%= node.provider %><% if node.provider_instance_id.present? %> · <%= node.provider_instance_id %><% end %>
-
- <% 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 %>
-
-
+
+
+
<%= t("admin.stream_nodes.actions_title") %>
+
+
+ <%= 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 admin-btn--primary",
+ form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
+ <% else %>
+ <%= t("admin.stream_nodes.hetzner_token_missing") %>
<% end %>
-
-
+ <% 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",
+ form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
+ <% end %>
+
+
+
+
+
<%= t("admin.stream_nodes.table_title") %>
+ <% if @nodes.any? %>
+
+
+
+
+ <%= t("admin.stream_nodes.col.slug") %>
+ <%= t("admin.stream_nodes.col.role") %>
+ <%= t("admin.stream_nodes.col.status") %>
+ <%= t("admin.stream_nodes.col.slots") %>
+ <%= t("admin.stream_nodes.col.hostname") %>
+ <%= t("admin.stream_nodes.col.provider") %>
+
+
+
+
+ <% @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
+ %>
+
+ <%= node.slug %>
+ <%= node.role %>
+ <%= node.status %>
+
+ <%= node.active_publishers %>
+ / <%= node.max_publishers %>
+ <%= t("admin.stream_nodes.free_slots", count: node.free_slots) %>
+
+ <%= node.hostname %>
+
+ <%= node.provider %>
+ <% if node.provider_instance_id.present? %>
+ <%= node.provider_instance_id %>
+ <% end %>
+
+
+ <% 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 %>
+ —
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <% else %>
+
<%= t("admin.stream_nodes.empty") %>
+ <% end %>
+
<% if @lab_hosts.present? %>
- <%= t("admin.stream_nodes.hosts_title") %>
- <%= @lab_hosts %>
+
+
<%= t("admin.stream_nodes.hosts_title") %>
+
<%= @lab_hosts %>
+
<% end %>
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb
index 5c3bd99..1d1369f 100644
--- a/backend/app/views/layouts/admin.html.erb
+++ b/backend/app/views/layouts/admin.html.erb
@@ -4,7 +4,7 @@
<%= t("admin.layout.title") %>
-
+
<% if content_for?(:replay_archive_styles) %>
<% end %>
diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml
index 1144f6b..6bd756f 100644
--- a/backend/config/locales/admin.de.yml
+++ b/backend/config/locales/admin.de.yml
@@ -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?"
diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml
index 5aa7676..03df4c2 100644
--- a/backend/config/locales/admin.en.yml
+++ b/backend/config/locales/admin.en.yml
@@ -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
diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml
index 7d622e2..99d483a 100644
--- a/backend/config/locales/admin.es.yml
+++ b/backend/config/locales/admin.es.yml
@@ -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}?"
diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml
index 19ea9cd..1952c94 100644
--- a/backend/config/locales/admin.fr.yml
+++ b/backend/config/locales/admin.fr.yml
@@ -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} ?"
diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml
index 98cab44..7fd76c3 100644
--- a/backend/config/locales/admin.it.yml
+++ b/backend/config/locales/admin.it.yml
@@ -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
diff --git a/backend/public/admin.css b/backend/public/admin.css
index 9bf46dc..eb90ed5 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -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; }