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
@@ -23,10 +23,45 @@ Wireguard Clients
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<h5 class="mt-4 mb-2">Clients — {{.server.Name}} ({{.serverID}})</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-2 flex-wrap">
|
||||
<h5 class="mb-2">Clients — {{.server.Name}} ({{.serverID}})</h5>
|
||||
<div class="btn-group" role="group" aria-label="Client view toggle" id="view-mode-toggle">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_view_card">
|
||||
<i class="fas fa-th-large"></i> Card view
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_view_list">
|
||||
<i class="fas fa-list"></i> List view
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="client-list">
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
<div id="client-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" disabled>
|
||||
Delete selected (0)
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm" id="client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2rem;"><input type="checkbox" id="chk_select_all"></th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Allocated IP</th>
|
||||
<th>Public Key</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="client-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -260,6 +295,11 @@ Wireguard Clients
|
||||
|
||||
{{define "bottom_js"}}
|
||||
<script>
|
||||
// holds the last loaded client list (array of {Client: ..., QRCode: ...})
|
||||
// so the list/table view can be rebuilt without a new API call.
|
||||
var lastClientData = [];
|
||||
var viewModeStorageKey = 'wg_client_view_mode_{{.serverID}}';
|
||||
|
||||
function populateClientList() {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
@@ -268,7 +308,9 @@ Wireguard Clients
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
lastClientData = data || [];
|
||||
renderClientList(data);
|
||||
renderClientTable(lastClientData);
|
||||
},
|
||||
error: function (jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
@@ -277,6 +319,136 @@ Wireguard Clients
|
||||
});
|
||||
}
|
||||
|
||||
// renderClientTable builds the list/table view rows from the same
|
||||
// client data used by renderClientList (see custom/js/helper.js).
|
||||
function renderClientTable(data) {
|
||||
const tbody = $("#client-table-body");
|
||||
tbody.empty();
|
||||
$.each(data, function (index, obj) {
|
||||
const client = obj.Client;
|
||||
const allocatedIps = (client.allocated_ips || []).join(', ');
|
||||
const pubKey = client.public_key || '';
|
||||
const pubKeyShort = pubKey.length > 12 ? (pubKey.substring(0, 10) + '…') : pubKey;
|
||||
const statusBadge = client.enabled
|
||||
? '<span class="badge badge-success">Enabled</span>'
|
||||
: '<span class="badge badge-secondary">Disabled</span>';
|
||||
|
||||
const row = $('<tr>').attr('id', 'client_row_' + client.id);
|
||||
row.append($('<td>').append(
|
||||
$('<input type="checkbox" class="client-row-checkbox">').attr('data-clientid', client.id)
|
||||
));
|
||||
row.append($('<td>').text(client.name));
|
||||
row.append($('<td>').text(client.email || ''));
|
||||
row.append($('<td>').text(allocatedIps));
|
||||
row.append($('<td>').attr('title', pubKey).text(pubKeyShort));
|
||||
row.append($('<td>').html(statusBadge));
|
||||
row.append($('<td>').text(client.created_at ? prettyDateTime(client.created_at) : ''));
|
||||
row.append($('<td>').text(client.updated_at ? prettyDateTime(client.updated_at) : ''));
|
||||
tbody.append(row);
|
||||
});
|
||||
updateDeleteSelectedButton();
|
||||
}
|
||||
|
||||
function updateDeleteSelectedButton() {
|
||||
const count = $(".client-row-checkbox:checked").length;
|
||||
const btn = $("#btn_delete_selected");
|
||||
btn.text('Delete selected (' + count + ')');
|
||||
btn.prop('disabled', count === 0);
|
||||
}
|
||||
|
||||
function applyViewMode(mode) {
|
||||
if (mode === 'list') {
|
||||
$("#client-list").hide();
|
||||
$("#client-table-view").show();
|
||||
$("#btn_view_list").addClass('active');
|
||||
$("#btn_view_card").removeClass('active');
|
||||
} else {
|
||||
mode = 'card';
|
||||
$("#client-table-view").hide();
|
||||
$("#client-list").show();
|
||||
$("#btn_view_card").addClass('active');
|
||||
$("#btn_view_list").removeClass('active');
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(viewModeStorageKey, mode);
|
||||
} catch (e) {
|
||||
// localStorage unavailable, ignore
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
let savedMode = 'card';
|
||||
try {
|
||||
savedMode = localStorage.getItem(viewModeStorageKey) || 'card';
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
applyViewMode(savedMode);
|
||||
|
||||
$("#btn_view_card").click(function () {
|
||||
applyViewMode('card');
|
||||
});
|
||||
$("#btn_view_list").click(function () {
|
||||
applyViewMode('list');
|
||||
});
|
||||
|
||||
$("#chk_select_all").change(function () {
|
||||
const checked = $(this).is(':checked');
|
||||
$(".client-row-checkbox").prop('checked', checked);
|
||||
updateDeleteSelectedButton();
|
||||
});
|
||||
|
||||
$("#client-table-body").on('change', '.client-row-checkbox', function () {
|
||||
if (!$(this).is(':checked')) {
|
||||
$("#chk_select_all").prop('checked', false);
|
||||
}
|
||||
updateDeleteSelectedButton();
|
||||
});
|
||||
|
||||
// removeClientRequest returns the same ajax promise as the existing
|
||||
// single-delete button (POST /servers/{id}/remove-client), reused
|
||||
// here so bulk delete follows the exact same request shape.
|
||||
function removeClientRequest(clientId) {
|
||||
return $.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/servers/{{.serverID}}/remove-client',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({"id": clientId})
|
||||
});
|
||||
}
|
||||
|
||||
$("#btn_delete_selected").click(function () {
|
||||
const ids = $(".client-row-checkbox:checked").map(function () {
|
||||
return $(this).data('clientid');
|
||||
}).get();
|
||||
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Delete ' + ids.length + ' clients? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = ids.map(function (id) {
|
||||
return removeClientRequest(id).catch(function (jqXHR) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(requests).then(function () {
|
||||
toastr.success('Removed selected clients');
|
||||
// reuse the existing full-refresh logic used elsewhere on this page
|
||||
$("#client-list").empty();
|
||||
populateClientList();
|
||||
updateApplyConfigVisibility();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setClientStatus(clientID, status) {
|
||||
const data = {"id": clientID, "status": status};
|
||||
$.ajax({
|
||||
|
||||
Reference in New Issue
Block a user