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")) %>
+
+
- - - - - - - - - - - - - - <% @nodes.each do |node| %> - - - - - - - - - +
+
+

<%= 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 %> -
-
<%= 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") %>
<%= 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 %> -
+ <% 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? %> +
+ + + + + + + + + + + + + + <% @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 + %> + + + + + + + + + + <% end %> + +
<%= 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") %>
<%= 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 %> +
+
+ <% 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; }