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
@@ -2202,3 +2202,240 @@ Keine Commits in dieser Session.
|
|||||||
- DEVLOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
- DEVLOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-07-12 23:39 – 23:39 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 067d0af Fix "no such file" error creating clients on non-default servers
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- handler/routes.go | 6 +++++-
|
||||||
|
- templates/server_clients.html | 2 +-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-12 23:40 – 23:42 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 0dbb916 Fix nil-pointer crash generating QR codes for non-default servers
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-12 23:43 – 23:43 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-12 23:44 – 23:45 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-23 23:31 – 23:31 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-23 23:33 – 23:47 (13m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 00:03 – 00:04 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- store/jsondb/jsondb.go | 56 ++++++++++++++++++++++++++++++++++++--------------------
|
||||||
|
- util/util.go | 10 +++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 00:04 – 00:05 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 388a837 Add per-server Interface/KeyPair edit hardening and OPNsense config import
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- handler/routes.go | 23 ++++++++--
|
||||||
|
- handler/routes_opnsense_import.go | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- main.go | 2 +
|
||||||
|
- model/client.go | 40 ++++++++--------
|
||||||
|
- model/server.go | 2 +-
|
||||||
|
- model/setting.go | 9 +++-
|
||||||
|
- opnsense/map.go | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- opnsense/parse.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- templates/servers.html | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
|
||||||
|
- util/util.go | 10 +++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 00:13 – 00:14 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 3a89a3c Fix OPNsense config.xml root element mismatch
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 00:18 – 00:21 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 00:22 – 00:22 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 08:59 – 09:00 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 ++++++++++++++++++++++++++++++++-----------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 23:55 – 23:55 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 ++++++++++++++++++++++++++++++++-----------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-24 23:58 – 00:04 (5m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:05 – 00:05 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:05 – 00:06 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:08 – 00:11 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:11 – 00:11 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:15 – 00:15 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-07-25 00:17 – 00:18 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** wireguard-ui-multi
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/ngoduykhanh/wireguard-ui/system"
|
"github.com/ngoduykhanh/wireguard-ui/system"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/util"
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
||||||
|
"github.com/ngoduykhanh/wireguard-ui/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$")
|
var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$")
|
||||||
@@ -896,6 +897,89 @@ func ApplyFirewallHandler(db store.IStore) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerServiceStatus reports whether a server's WireGuard interface is
|
||||||
|
// currently active/enabled at the systemd level (wg-quick@<iface>.service).
|
||||||
|
func ServerServiceStatus(db store.IStore) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
serverID := c.Param("id")
|
||||||
|
server, err := db.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
active, enabled, err := wireguard.Status(c.Request().Context(), server.Interface.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to get service status for server %s: %v", serverID, err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"active": active,
|
||||||
|
"enabled": enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerServiceStart brings up a server's WireGuard interface via
|
||||||
|
// `systemctl start wg-quick@<iface>.service`. Admin-only.
|
||||||
|
func ServerServiceStart(db store.IStore) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
serverID := c.Param("id")
|
||||||
|
server, err := db.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wireguard.Start(c.Request().Context(), server.Interface.Name); err != nil {
|
||||||
|
log.Errorf("Failed to start service for server %s: %v", serverID, err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||||
|
}
|
||||||
|
log.Infof("Started wireguard interface for server %s", serverID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerServiceStop brings down a server's WireGuard interface via
|
||||||
|
// `systemctl stop wg-quick@<iface>.service`. Admin-only.
|
||||||
|
func ServerServiceStop(db store.IStore) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
serverID := c.Param("id")
|
||||||
|
server, err := db.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wireguard.Stop(c.Request().Context(), server.Interface.Name); err != nil {
|
||||||
|
log.Errorf("Failed to stop service for server %s: %v", serverID, err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||||
|
}
|
||||||
|
log.Infof("Stopped wireguard interface for server %s", serverID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerServiceRestart restarts a server's WireGuard interface via
|
||||||
|
// `systemctl restart wg-quick@<iface>.service`. Admin-only.
|
||||||
|
func ServerServiceRestart(db store.IStore) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
serverID := c.Param("id")
|
||||||
|
server, err := db.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wireguard.Restart(c.Request().Context(), server.Interface.Name); err != nil {
|
||||||
|
log.Errorf("Failed to restart service for server %s: %v", serverID, err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||||
|
}
|
||||||
|
log.Infof("Restarted wireguard interface for server %s", serverID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"active": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient handler
|
// NewClient handler
|
||||||
func NewClient(db store.IStore) echo.HandlerFunc {
|
func NewClient(db store.IStore) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
|||||||
@@ -288,6 +288,10 @@ func main() {
|
|||||||
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId", handler.UpdateFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId", handler.UpdateFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId/delete", handler.DeleteFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId/delete", handler.DeleteFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
app.POST(util.BasePath+"/servers/:id/firewall/apply", handler.ApplyFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/servers/:id/firewall/apply", handler.ApplyFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
|
app.GET(util.BasePath+"/servers/:id/service-status", handler.ServerServiceStatus(db), handler.ValidSession, handler.RequireServerAccess(db))
|
||||||
|
app.POST(util.BasePath+"/servers/:id/service/start", handler.ServerServiceStart(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
|
app.POST(util.BasePath+"/servers/:id/service/stop", handler.ServerServiceStop(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
|
app.POST(util.BasePath+"/servers/:id/service/restart", handler.ServerServiceRestart(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
app.POST(util.BasePath+"/backup/download", handler.DownloadBackup(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/backup/download", handler.DownloadBackup(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
|
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
|
||||||
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
|
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
|
||||||
|
|||||||
@@ -23,10 +23,45 @@ Wireguard Clients
|
|||||||
{{define "page_content"}}
|
{{define "page_content"}}
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div class="container-fluid">
|
<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 class="row" id="client-list">
|
||||||
</div>
|
</div>
|
||||||
<!-- /.row -->
|
<!-- /.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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -260,6 +295,11 @@ Wireguard Clients
|
|||||||
|
|
||||||
{{define "bottom_js"}}
|
{{define "bottom_js"}}
|
||||||
<script>
|
<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() {
|
function populateClientList() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
cache: false,
|
cache: false,
|
||||||
@@ -268,7 +308,9 @@ Wireguard Clients
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
|
lastClientData = data || [];
|
||||||
renderClientList(data);
|
renderClientList(data);
|
||||||
|
renderClientTable(lastClientData);
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
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) {
|
function setClientStatus(clientID, status) {
|
||||||
const data = {"id": clientID, "status": status};
|
const data = {"id": clientID, "status": status};
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
|||||||
@@ -16,8 +16,43 @@ All Servers
|
|||||||
{{define "page_content"}}
|
{{define "page_content"}}
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div class="container-fluid">
|
<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 class="row" id="servers-list">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -284,6 +319,98 @@ All Servers
|
|||||||
// cache of the last-fetched server list, keyed by id, so the Interface
|
// cache of the last-fetched server list, keyed by id, so the Interface
|
||||||
// modal can prefill fields without a second round trip
|
// modal can prefill fields without a second round trip
|
||||||
var serversById = {};
|
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) {
|
function renderServersList(data) {
|
||||||
$.each(data, function (index, obj) {
|
$.each(data, function (index, obj) {
|
||||||
@@ -316,20 +443,86 @@ All Servers
|
|||||||
<div class="btn-group">
|
<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>
|
<button type="button" class="btn btn-outline-danger btn-sm btn-delete-server" data-serverid="${obj.id}" data-servername="${safeName}">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
${serviceButtonsHtml(obj.id)}
|
||||||
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<span class="info-box-text"><i class="fas fa-server"></i> ${safeName}</span>
|
<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-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-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-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-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>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
|
|
||||||
$('#servers-list').append(html);
|
$('#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() {
|
function populateServersList() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
cache: false,
|
cache: false,
|
||||||
@@ -338,7 +531,9 @@ All Servers
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
|
lastServerData = data || [];
|
||||||
renderServersList(data);
|
renderServersList(data);
|
||||||
|
renderServerTable(lastServerData);
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
@@ -360,6 +555,85 @@ All Servers
|
|||||||
$('h1').parents(".row").append(newServerHtml);
|
$('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
|
// New server modal event: reset the form each time it is opened
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#modal_new_server").on('show.bs.modal', function (event) {
|
$("#modal_new_server").on('show.bs.modal', function (event) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnitName returns the systemd unit name managing the given WireGuard
|
||||||
|
// interface via wg-quick, e.g. "wg-quick@wg-home.service".
|
||||||
|
func UnitName(iface string) string {
|
||||||
|
return "wg-quick@" + iface + ".service"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start brings up the given WireGuard interface via
|
||||||
|
// `systemctl start wg-quick@<iface>.service`.
|
||||||
|
func Start(ctx context.Context, iface string) error {
|
||||||
|
return runSystemctl(ctx, "start", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop brings down the given WireGuard interface via
|
||||||
|
// `systemctl stop wg-quick@<iface>.service`.
|
||||||
|
func Stop(ctx context.Context, iface string) error {
|
||||||
|
return runSystemctl(ctx, "stop", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart restarts the given WireGuard interface via
|
||||||
|
// `systemctl restart wg-quick@<iface>.service`.
|
||||||
|
func Restart(ctx context.Context, iface string) error {
|
||||||
|
return runSystemctl(ctx, "restart", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemctl(ctx context.Context, action, iface string) error {
|
||||||
|
if !util.ValidateInterfaceName(iface) {
|
||||||
|
return fmt.Errorf("invalid interface name: %q", iface)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "systemctl", action, UnitName(iface))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("systemctl %s %s failed: %w: %s", action, UnitName(iface), err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports whether the given WireGuard interface's wg-quick unit is
|
||||||
|
// currently active and whether it is enabled to start on boot. Only real
|
||||||
|
// exec failures (e.g. systemctl binary missing) are returned as err - a
|
||||||
|
// unit being inactive/disabled/failed is a normal, non-error result
|
||||||
|
// reflected in the returned booleans.
|
||||||
|
func Status(ctx context.Context, iface string) (active bool, enabled bool, err error) {
|
||||||
|
if !util.ValidateInterfaceName(iface) {
|
||||||
|
return false, false, fmt.Errorf("invalid interface name: %q", iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := UnitName(iface)
|
||||||
|
|
||||||
|
active, err = systemctlBoolCheck(ctx, "is-active", unit)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, err = systemctlBoolCheck(ctx, "is-enabled", unit)
|
||||||
|
if err != nil {
|
||||||
|
return active, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return active, enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemctlBoolCheck runs `systemctl <verb> <unit>` and interprets a
|
||||||
|
// non-zero exit code as a normal "false" result (inactive/disabled/failed),
|
||||||
|
// not an error. Only a failure to execute systemctl at all (binary missing,
|
||||||
|
// context cancelled, ...) is surfaced as err.
|
||||||
|
func systemctlBoolCheck(ctx context.Context, verb, unit string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "systemctl", verb, unit)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if _, ok := err.(*exec.ExitError); ok {
|
||||||
|
// systemctl ran fine and just reported a non-active/non-enabled
|
||||||
|
// state via its exit code - not an execution error.
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("systemctl %s %s failed to execute: %w", verb, unit, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user