Add wg-quick/systemctl service control and bulk-delete list views
Per-server Start/Stop/Restart via systemd wg-quick@<iface>.service units plus live status badge, and a table/list-view toggle with checkbox bulk-delete for both the server list and per-server client list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3a89a3cb5c
commit
c29edfdcc3
@@ -16,8 +16,43 @@ All Servers
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mt-2 mb-2 flex-wrap">
|
||||
<h5 class="mb-2">All Servers</h5>
|
||||
<div class="btn-group" role="group" aria-label="Server view toggle" id="server_view_mode_toggle">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_server_view_card">
|
||||
<i class="fas fa-th-large"></i> Card view
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_server_view_list">
|
||||
<i class="fas fa-list"></i> List view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="servers-list">
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
<div id="server-table-view" style="display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="btn_delete_selected_servers" disabled>
|
||||
Delete selected (0)
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm" id="server-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2rem;"><input type="checkbox" id="chk_select_all_servers"></th>
|
||||
<th>Name</th>
|
||||
<th>Interface</th>
|
||||
<th>Listen Port</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="server-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -284,6 +319,98 @@ All Servers
|
||||
// cache of the last-fetched server list, keyed by id, so the Interface
|
||||
// modal can prefill fields without a second round trip
|
||||
var serversById = {};
|
||||
var lastServerData = [];
|
||||
var isAdminUser = {{.baseData.Admin}};
|
||||
var serverViewModeStorageKey = 'wg_server_view_mode';
|
||||
|
||||
// badgeClass/badgeLabel drive the pill content; setServerStatusBadge
|
||||
// updates the badge in place (both card view and list view share the
|
||||
// same "status-badge-<id>" marker, so one poll updates both).
|
||||
function badgeClassFor(state) {
|
||||
if (state === 'up') { return 'badge-success'; }
|
||||
if (state === 'down') { return 'badge-danger'; }
|
||||
return 'badge-secondary';
|
||||
}
|
||||
|
||||
function badgeLabelFor(state) {
|
||||
if (state === 'up') { return 'UP'; }
|
||||
if (state === 'down') { return 'DOWN'; }
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
function statusBadgeHtml(state) {
|
||||
return '<span class="badge ' + badgeClassFor(state) + ' status-badge">' + badgeLabelFor(state) + '</span>';
|
||||
}
|
||||
|
||||
// refreshServerStatus polls GET /servers/:id/service-status and updates
|
||||
// every status badge for that server id.
|
||||
function refreshServerStatus(serverId) {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: '{{.basePath}}/servers/' + serverId + '/service-status',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
setServerStatusBadge(serverId, data && data.active ? 'up' : 'down');
|
||||
},
|
||||
error: function () {
|
||||
setServerStatusBadge(serverId, 'unknown');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setServerStatusBadge(serverId, state) {
|
||||
$('.status-badge-' + serverId).removeClass('badge-success badge-danger badge-secondary')
|
||||
.addClass(badgeClassFor(state)).text(badgeLabelFor(state));
|
||||
}
|
||||
|
||||
// runServerAction disables the clicked button, shows a spinner, calls the
|
||||
// given POST action endpoint, then reflects the returned/refreshed status.
|
||||
function runServerAction(button, serverId, action) {
|
||||
const originalHtml = button.html();
|
||||
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/servers/' + serverId + '/service/' + action,
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
if (data && typeof data.active !== 'undefined') {
|
||||
setServerStatusBadge(serverId, data.active ? 'up' : 'down');
|
||||
}
|
||||
toastr.success('Server ' + action + ' successful');
|
||||
setTimeout(function () { refreshServerStatus(serverId); }, 1000);
|
||||
},
|
||||
error: function (jqXHR) {
|
||||
let message = 'Failed to ' + action + ' server';
|
||||
try {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
if (responseJson && responseJson.message) { message = responseJson.message; }
|
||||
} catch (e) {
|
||||
// ignore parse failure, use default message
|
||||
}
|
||||
toastr.error(message);
|
||||
setTimeout(function () { refreshServerStatus(serverId); }, 1000);
|
||||
},
|
||||
complete: function () {
|
||||
button.prop('disabled', false).html(originalHtml);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// serviceButtonsHtml renders Start/Stop/Restart buttons, admin-only.
|
||||
function serviceButtonsHtml(serverId) {
|
||||
if (!isAdminUser) {
|
||||
return '';
|
||||
}
|
||||
return `<div class="btn-group btn-service-actions" data-serverid="${serverId}">
|
||||
<button type="button" class="btn btn-outline-success btn-sm btn-server-start" data-serverid="${serverId}" data-action="start">Start</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm btn-server-stop" data-serverid="${serverId}" data-action="stop">Stop</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-server-restart" data-serverid="${serverId}" data-action="restart">Restart</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderServersList(data) {
|
||||
$.each(data, function (index, obj) {
|
||||
@@ -316,20 +443,86 @@ All Servers
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm btn-delete-server" data-serverid="${obj.id}" data-servername="${safeName}">Delete</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
${serviceButtonsHtml(obj.id)}
|
||||
</div>
|
||||
<hr>
|
||||
<span class="info-box-text"><i class="fas fa-server"></i> ${safeName}</span>
|
||||
<span class="info-box-text"><i class="fas fa-fingerprint"></i> ID: ${obj.id}</span>
|
||||
<span class="info-box-text"><i class="fas fa-ethernet"></i> Interface: ${interfaceName}</span>
|
||||
<span class="info-box-text"><i class="fas fa-plug"></i> Listen Port: ${listenPort}</span>
|
||||
<span class="info-box-text"><i class="fas fa-map-marker-alt"></i> Addresses: ${addresses}</span>
|
||||
<span class="info-box-text"><i class="fas fa-heartbeat"></i> Status: <span class="badge badge-secondary status-badge status-badge-${obj.id}">UNKNOWN</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
$('#servers-list').append(html);
|
||||
refreshServerStatus(obj.id);
|
||||
});
|
||||
}
|
||||
|
||||
// renderServerTable builds the list/table view rows from the same
|
||||
// server data used by renderServersList.
|
||||
function renderServerTable(data) {
|
||||
const tbody = $("#server-table-body");
|
||||
tbody.empty();
|
||||
$.each(data, function (index, obj) {
|
||||
const interfaceName = obj.Interface ? obj.Interface.name : "";
|
||||
const listenPort = obj.Interface ? obj.Interface.listen_port : "";
|
||||
const safeName = $('<div>').text(obj.name).html();
|
||||
|
||||
const row = $('<tr>').attr('id', 'server_row_' + obj.id);
|
||||
row.append($('<td>').append(
|
||||
$('<input type="checkbox" class="server-row-checkbox">').attr('data-serverid', obj.id)
|
||||
));
|
||||
row.append($('<td>').html('<a href="{{.basePath}}/servers/' + obj.id + '/clients">' + safeName + '</a>'));
|
||||
row.append($('<td>').text(interfaceName));
|
||||
row.append($('<td>').text(listenPort));
|
||||
row.append($('<td>').append(
|
||||
$('<span class="badge badge-secondary status-badge"></span>').addClass('status-badge-' + obj.id).text('UNKNOWN')
|
||||
));
|
||||
row.append($('<td>').append(
|
||||
$('<a class="btn btn-outline-primary btn-sm">Manage</a>').attr('href', '{{.basePath}}/servers/' + obj.id + '/clients')
|
||||
));
|
||||
tbody.append(row);
|
||||
refreshServerStatus(obj.id);
|
||||
});
|
||||
if (!isAdminUser) {
|
||||
$("#chk_select_all_servers").closest('th').hide();
|
||||
$(".server-row-checkbox").closest('td').hide();
|
||||
$("#btn_delete_selected_servers").hide();
|
||||
}
|
||||
updateDeleteSelectedServersButton();
|
||||
}
|
||||
|
||||
function updateDeleteSelectedServersButton() {
|
||||
const count = $(".server-row-checkbox:checked").length;
|
||||
const btn = $("#btn_delete_selected_servers");
|
||||
btn.text('Delete selected (' + count + ')');
|
||||
btn.prop('disabled', count === 0);
|
||||
}
|
||||
|
||||
function applyServerViewMode(mode) {
|
||||
if (mode === 'list') {
|
||||
$("#servers-list").hide();
|
||||
$("#server-table-view").show();
|
||||
$("#btn_server_view_list").addClass('active');
|
||||
$("#btn_server_view_card").removeClass('active');
|
||||
} else {
|
||||
mode = 'card';
|
||||
$("#server-table-view").hide();
|
||||
$("#servers-list").show();
|
||||
$("#btn_server_view_card").addClass('active');
|
||||
$("#btn_server_view_list").removeClass('active');
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(serverViewModeStorageKey, mode);
|
||||
} catch (e) {
|
||||
// localStorage unavailable, ignore
|
||||
}
|
||||
}
|
||||
|
||||
function populateServersList() {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
@@ -338,7 +531,9 @@ All Servers
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
lastServerData = data || [];
|
||||
renderServersList(data);
|
||||
renderServerTable(lastServerData);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
@@ -360,6 +555,85 @@ All Servers
|
||||
$('h1').parents(".row").append(newServerHtml);
|
||||
})
|
||||
|
||||
// view mode toggle: restore last-used mode, wire up buttons
|
||||
$(document).ready(function () {
|
||||
let savedMode = 'card';
|
||||
try {
|
||||
savedMode = localStorage.getItem(serverViewModeStorageKey) || 'card';
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
applyServerViewMode(savedMode);
|
||||
|
||||
$("#btn_server_view_card").click(function () {
|
||||
applyServerViewMode('card');
|
||||
});
|
||||
$("#btn_server_view_list").click(function () {
|
||||
applyServerViewMode('list');
|
||||
});
|
||||
|
||||
$("#chk_select_all_servers").change(function () {
|
||||
const checked = $(this).is(':checked');
|
||||
$(".server-row-checkbox").prop('checked', checked);
|
||||
updateDeleteSelectedServersButton();
|
||||
});
|
||||
|
||||
$("#server-table-body").on('change', '.server-row-checkbox', function () {
|
||||
if (!$(this).is(':checked')) {
|
||||
$("#chk_select_all_servers").prop('checked', false);
|
||||
}
|
||||
updateDeleteSelectedServersButton();
|
||||
});
|
||||
|
||||
// deleteServerRequest reuses the exact request shape of the existing
|
||||
// single-server delete button (POST /servers/{id}/delete).
|
||||
function deleteServerRequest(serverId) {
|
||||
return $.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/servers/' + serverId + '/delete',
|
||||
dataType: 'json',
|
||||
contentType: "application/json"
|
||||
});
|
||||
}
|
||||
|
||||
$("#btn_delete_selected_servers").click(function () {
|
||||
if (!isAdminUser) { return; }
|
||||
const ids = $(".server-row-checkbox:checked").map(function () {
|
||||
return $(this).data('serverid');
|
||||
}).get();
|
||||
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Delete " + ids.length + " server(s)? This only works if no clients are assigned to them anymore.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = ids.map(function (id) {
|
||||
return deleteServerRequest(id).catch(function (jqXHR) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(requests).then(function () {
|
||||
toastr.success('Removed selected servers');
|
||||
$('#servers-list').empty();
|
||||
populateServersList();
|
||||
});
|
||||
});
|
||||
|
||||
// service action buttons: event delegation since cards/rows are rendered dynamically
|
||||
$(document).on('click', '.btn-server-start, .btn-server-stop, .btn-server-restart', function () {
|
||||
const button = $(this);
|
||||
const serverId = button.data('serverid');
|
||||
const action = button.data('action');
|
||||
runServerAction(button, serverId, action);
|
||||
});
|
||||
})
|
||||
|
||||
// New server modal event: reset the form each time it is opened
|
||||
$(document).ready(function () {
|
||||
$("#modal_new_server").on('show.bs.modal', function (event) {
|
||||
|
||||
Reference in New Issue
Block a user