mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
a23b860d41
Three independent bugs combined to make 'Add Relay' silently do nothing: 1. saveRelay() in public/js/scaling.js used raw fetch() without the panel's CSRF token, so every POST/PUT/DELETE was rejected by the CSRF middleware. 2. The catch block swallowed every error and the modal closed regardless of the response code, so the user never saw the rejection. 3. The relay/rules routes proxied to /scaling/relays on the Go server, but the Go server doesn't (yet) implement those endpoints — every request ended up as a 404 and the panel's relay table stayed empty forever. Changes: - routes/scaling.routes.js: drop the dead Go proxy, persist relay-node metadata and assignment rules in the existing 'settings' table (auth.db / DATABASE_URL). Strict input validation (host:port, Unicode-safe name, ranges for sessions/bandwidth/priority), 409 on duplicate addresses, capped at 100 relays / 200 rules. Health and metrics endpoints now return explicit 'live telemetry not yet available' placeholders instead of 500s. - public/js/scaling.js: every mutating fetch() now sends x-csrf-token, validates inputs with a toast, parses JSON error bodies, only closes the modal and reloads on success. Falls back to alert() if the toast service is missing so users are never left guessing. - views/scaling.ejs: info banner above the Relay Nodes table making clear that the list is operator-side metadata and that actual relay routing is configured via RELAY_SERVERS on the BetterDesk server. - public/css/scaling.css: styles for the new info banner. - lang/en.json, lang/pl.json: i18n keys for the banner, validation messages and success/failure toasts. Note: the reporter also tried RELAY=... — only RELAY_SERVERS is read by the Go server (see config/config.go:160). Worth surfacing in docs in a follow-up. Reported-by: @Elektrik1
278 lines
17 KiB
HTML
278 lines
17 KiB
HTML
<%- include('layouts/main', {
|
|
title: title,
|
|
pageStyles: pageStyles,
|
|
pageScripts: pageScripts,
|
|
currentPage: currentPage,
|
|
breadcrumb: breadcrumb,
|
|
body: `
|
|
|
|
<div class="scaling-page">
|
|
|
|
<!-- Tab navigation -->
|
|
<div class="scaling-tabs">
|
|
<button class="scaling-tab ${activeTab === 'overview' ? 'active' : ''}" data-tab="overview" onclick="Scaling.switchTab('overview')">
|
|
<span class="material-icons">dashboard</span>
|
|
<span>${ typeof req !== 'undefined' ? req.t('scaling.tab_overview') : 'Overview' }</span>
|
|
</button>
|
|
<button class="scaling-tab ${activeTab === 'relays' ? 'active' : ''}" data-tab="relays" onclick="Scaling.switchTab('relays')">
|
|
<span class="material-icons">cell_tower</span>
|
|
<span>${ typeof req !== 'undefined' ? req.t('scaling.tab_relays') : 'Relay Nodes' }</span>
|
|
</button>
|
|
<button class="scaling-tab ${activeTab === 'rules' ? 'active' : ''}" data-tab="rules" onclick="Scaling.switchTab('rules')">
|
|
<span class="material-icons">route</span>
|
|
<span>${ typeof req !== 'undefined' ? req.t('scaling.tab_rules') : 'Assignment Rules' }</span>
|
|
</button>
|
|
<button class="scaling-tab ${activeTab === 'capacity' ? 'active' : ''}" data-tab="capacity" onclick="Scaling.switchTab('capacity')">
|
|
<span class="material-icons">speed</span>
|
|
<span>${ typeof req !== 'undefined' ? req.t('scaling.tab_capacity') : 'Capacity' }</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ======= OVERVIEW TAB ======= -->
|
|
<div class="scaling-panel" id="panel-overview" style="display: ${activeTab === 'overview' ? 'block' : 'none'};">
|
|
<!-- Architecture diagram -->
|
|
<div class="scaling-architecture">
|
|
<h3><span class="material-icons">account_tree</span> ${ typeof req !== 'undefined' ? req.t('scaling.arch_title') : 'Cluster Architecture' }</h3>
|
|
<div class="arch-diagram" id="arch-diagram">
|
|
<!-- Rendered by JS -->
|
|
<div class="arch-empty">${ typeof req !== 'undefined' ? req.t('scaling.arch_loading') : 'Loading topology…' }</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary cards -->
|
|
<div class="scaling-summary-grid">
|
|
<div class="scaling-card" id="card-total-relays">
|
|
<div class="scaling-card-icon"><span class="material-icons">cell_tower</span></div>
|
|
<div class="scaling-card-body">
|
|
<div class="scaling-card-value" id="stat-total-relays">—</div>
|
|
<div class="scaling-card-label">${ typeof req !== 'undefined' ? req.t('scaling.stat_relay_nodes') : 'Relay Nodes' }</div>
|
|
</div>
|
|
</div>
|
|
<div class="scaling-card" id="card-total-devices">
|
|
<div class="scaling-card-icon"><span class="material-icons">devices</span></div>
|
|
<div class="scaling-card-body">
|
|
<div class="scaling-card-value" id="stat-total-devices">—</div>
|
|
<div class="scaling-card-label">${ typeof req !== 'undefined' ? req.t('scaling.stat_total_devices') : 'Total Devices' }</div>
|
|
</div>
|
|
</div>
|
|
<div class="scaling-card" id="card-active-sessions">
|
|
<div class="scaling-card-icon"><span class="material-icons">swap_horiz</span></div>
|
|
<div class="scaling-card-body">
|
|
<div class="scaling-card-value" id="stat-active-sessions">—</div>
|
|
<div class="scaling-card-label">${ typeof req !== 'undefined' ? req.t('scaling.stat_active_sessions') : 'Active Sessions' }</div>
|
|
</div>
|
|
</div>
|
|
<div class="scaling-card" id="card-bandwidth">
|
|
<div class="scaling-card-icon"><span class="material-icons">network_check</span></div>
|
|
<div class="scaling-card-body">
|
|
<div class="scaling-card-value" id="stat-bandwidth">—</div>
|
|
<div class="scaling-card-label">${ typeof req !== 'undefined' ? req.t('scaling.stat_bandwidth') : 'Total Bandwidth' }</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-relay mini cards -->
|
|
<h3 class="scaling-section-title"><span class="material-icons">monitor_heart</span> ${ typeof req !== 'undefined' ? req.t('scaling.relay_health') : 'Relay Health' }</h3>
|
|
<div class="scaling-relay-grid" id="relay-health-grid">
|
|
<div class="scaling-empty">${ typeof req !== 'undefined' ? req.t('scaling.no_relays') : 'No relay nodes registered.' }</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======= RELAY NODES TAB ======= -->
|
|
<div class="scaling-panel" id="panel-relays" style="display: ${activeTab === 'relays' ? 'block' : 'none'};">
|
|
<div class="scaling-info-banner">
|
|
<span class="material-icons">info</span>
|
|
<span>${ typeof req !== 'undefined' ? req.t('scaling.relays_info_banner') : 'This list is informational. The relay servers actually used by clients are configured via the RELAY_SERVERS environment variable on the BetterDesk server.' }</span>
|
|
</div>
|
|
<div class="scaling-toolbar">
|
|
<div class="scaling-toolbar-left">
|
|
<input type="text" id="relay-search" class="scaling-input" placeholder="${ typeof req !== 'undefined' ? req.t('scaling.relay_search') : 'Search relays…' }" oninput="Scaling.filterRelays()">
|
|
</div>
|
|
<div class="scaling-toolbar-right">
|
|
<button class="btn btn-sm btn-primary" onclick="Scaling.showAddRelay()">
|
|
<span class="material-icons">add</span> ${ typeof req !== 'undefined' ? req.t('scaling.relay_add') : 'Add Relay' }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="scaling-table-wrap">
|
|
<table class="scaling-table" id="relays-table">
|
|
<thead>
|
|
<tr>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_name') : 'Name' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_address') : 'Address' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_location') : 'Location' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_devices') : 'Devices' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_sessions') : 'Sessions' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_bandwidth') : 'Bandwidth' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_status') : 'Status' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_actions') : 'Actions' }</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="relays-tbody">
|
|
<tr><td colspan="8" class="scaling-empty">${ typeof req !== 'undefined' ? req.t('scaling.no_relays') : 'No relay nodes registered.' }</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======= ASSIGNMENT RULES TAB ======= -->
|
|
<div class="scaling-panel" id="panel-rules" style="display: ${activeTab === 'rules' ? 'block' : 'none'};">
|
|
<div class="scaling-toolbar">
|
|
<div class="scaling-toolbar-left">
|
|
<p class="scaling-hint">${ typeof req !== 'undefined' ? req.t('scaling.rules_hint') : 'Rules determine which relay node a device is assigned to based on location, group, or network.' }</p>
|
|
</div>
|
|
<div class="scaling-toolbar-right">
|
|
<button class="btn btn-sm btn-primary" onclick="Scaling.showAddRule()">
|
|
<span class="material-icons">add</span> ${ typeof req !== 'undefined' ? req.t('scaling.rule_add') : 'Add Rule' }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="scaling-table-wrap">
|
|
<table class="scaling-table" id="rules-table">
|
|
<thead>
|
|
<tr>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_priority') : 'Priority' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_rule_name') : 'Rule Name' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_match') : 'Match' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_target_relay') : 'Target Relay' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_fallback') : 'Fallback' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_status') : 'Status' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.col_actions') : 'Actions' }</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rules-tbody">
|
|
<tr><td colspan="7" class="scaling-empty">${ typeof req !== 'undefined' ? req.t('scaling.no_rules') : 'No assignment rules.' }</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======= CAPACITY TAB ======= -->
|
|
<div class="scaling-panel" id="panel-capacity" style="display: ${activeTab === 'capacity' ? 'block' : 'none'};">
|
|
<div class="scaling-capacity-grid">
|
|
<!-- Per-tier capacity bars -->
|
|
<div class="capacity-section">
|
|
<h3><span class="material-icons">storage</span> ${ typeof req !== 'undefined' ? req.t('scaling.cap_tiers') : 'Capacity Tiers' }</h3>
|
|
<div class="capacity-tiers" id="capacity-tiers">
|
|
<div class="scaling-empty">${ typeof req !== 'undefined' ? req.t('scaling.cap_loading') : 'Loading capacity data…' }</div>
|
|
</div>
|
|
</div>
|
|
<!-- Recommendations -->
|
|
<div class="capacity-section">
|
|
<h3><span class="material-icons">tips_and_updates</span> ${ typeof req !== 'undefined' ? req.t('scaling.cap_recommendations') : 'Recommendations' }</h3>
|
|
<div id="capacity-recommendations" class="capacity-recs">
|
|
<div class="scaling-empty">${ typeof req !== 'undefined' ? req.t('scaling.cap_no_recs') : 'No recommendations at this time.' }</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Capacity planning table -->
|
|
<h3 class="scaling-section-title"><span class="material-icons">table_chart</span> ${ typeof req !== 'undefined' ? req.t('scaling.cap_planning') : 'Capacity Planning' }</h3>
|
|
<div class="scaling-table-wrap">
|
|
<table class="scaling-table" id="capacity-table">
|
|
<thead>
|
|
<tr>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.cap_config') : 'Configuration' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.cap_max_devices') : 'Max Devices' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.cap_max_sessions') : 'Max Sessions' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.cap_current') : 'Current' }</th>
|
|
<th>${ typeof req !== 'undefined' ? req.t('scaling.cap_utilization') : 'Utilization' }</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="capacity-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ====== ADD / EDIT RELAY MODAL ====== -->
|
|
<div class="modal-overlay" id="relay-modal" style="display:none;">
|
|
<div class="modal-card">
|
|
<div class="modal-header">
|
|
<h3 id="relay-modal-title">${ typeof req !== 'undefined' ? req.t('scaling.relay_add') : 'Add Relay' }</h3>
|
|
<button class="modal-close" onclick="Scaling.closeRelayModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="relay-edit-id">
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_name') : 'Name' }</label>
|
|
<input type="text" id="relay-name" class="scaling-input" placeholder="Building-A-Relay">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_address') : 'Address' }</label>
|
|
<input type="text" id="relay-address" class="scaling-input" placeholder="192.168.1.100:21117">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_location') : 'Location' }</label>
|
|
<input type="text" id="relay-location" class="scaling-input" placeholder="Building A, Floor 2">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.relay_max_sessions') : 'Max Sessions' }</label>
|
|
<input type="number" id="relay-max-sessions" class="scaling-input" value="50" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.relay_max_bandwidth') : 'Max Bandwidth (Mbps)' }</label>
|
|
<input type="number" id="relay-max-bw" class="scaling-input" value="100" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-secondary" onclick="Scaling.closeRelayModal()">${ typeof req !== 'undefined' ? req.t('scaling.cancel') : 'Cancel' }</button>
|
|
<button class="btn btn-sm btn-primary" onclick="Scaling.saveRelay()">${ typeof req !== 'undefined' ? req.t('scaling.save') : 'Save' }</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ====== ADD / EDIT RULE MODAL ====== -->
|
|
<div class="modal-overlay" id="rule-modal" style="display:none;">
|
|
<div class="modal-card">
|
|
<div class="modal-header">
|
|
<h3 id="rule-modal-title">${ typeof req !== 'undefined' ? req.t('scaling.rule_add') : 'Add Rule' }</h3>
|
|
<button class="modal-close" onclick="Scaling.closeRuleModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="rule-edit-id">
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_rule_name') : 'Rule Name' }</label>
|
|
<input type="text" id="rule-name" class="scaling-input" placeholder="Building A devices">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.rule_match_type') : 'Match Type' }</label>
|
|
<select id="rule-match-type" class="scaling-select">
|
|
<option value="subnet">${ typeof req !== 'undefined' ? req.t('scaling.match_subnet') : 'Subnet (CIDR)' }</option>
|
|
<option value="group">${ typeof req !== 'undefined' ? req.t('scaling.match_group') : 'Device Group' }</option>
|
|
<option value="tag">${ typeof req !== 'undefined' ? req.t('scaling.match_tag') : 'Device Tag' }</option>
|
|
<option value="location">${ typeof req !== 'undefined' ? req.t('scaling.match_location') : 'Location' }</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.rule_match_value') : 'Match Value' }</label>
|
|
<input type="text" id="rule-match-value" class="scaling-input" placeholder="192.168.1.0/24">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_target_relay') : 'Target Relay' }</label>
|
|
<select id="rule-target-relay" class="scaling-select"></select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_fallback') : 'Fallback' }</label>
|
|
<select id="rule-fallback" class="scaling-select">
|
|
<option value="master">${ typeof req !== 'undefined' ? req.t('scaling.fallback_master') : 'Master Server' }</option>
|
|
<option value="nearest">${ typeof req !== 'undefined' ? req.t('scaling.fallback_nearest') : 'Nearest Relay' }</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ typeof req !== 'undefined' ? req.t('scaling.col_priority') : 'Priority' }</label>
|
|
<input type="number" id="rule-priority" class="scaling-input" value="10" min="1" max="999">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-secondary" onclick="Scaling.closeRuleModal()">${ typeof req !== 'undefined' ? req.t('scaling.cancel') : 'Cancel' }</button>
|
|
<button class="btn btn-sm btn-primary" onclick="Scaling.saveRule()">${ typeof req !== 'undefined' ? req.t('scaling.save') : 'Save' }</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
` }) %>
|