Add WireGuard TUI implementation
- Add Go TUI with bubbletea for WireGuard management - Implement client CRUD operations with QR code generation - Add configuration and validation modules - Install/update scripts for client setup - Update Makefile to build binaries to bin/ directory - Add .gitignore for Go projects
This commit is contained in:
152
internal/tui/screens/add.go
Normal file
152
internal/tui/screens/add.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/config"
|
||||
"github.com/calmcacil/wg-admin/internal/validation"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// AddScreen is a form for adding new WireGuard clients
|
||||
type AddScreen struct {
|
||||
form *huh.Form
|
||||
quitting bool
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
addTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
addHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// NewAddScreen creates a new add screen
|
||||
func NewAddScreen() *AddScreen {
|
||||
// Get default DNS from config
|
||||
cfg, err := config.LoadConfig()
|
||||
defaultDNS := "8.8.8.8, 8.8.4.4"
|
||||
if err == nil && cfg.DNSServers != "" {
|
||||
defaultDNS = cfg.DNSServers
|
||||
}
|
||||
|
||||
// Create the form
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Key("name").
|
||||
Title("Client Name").
|
||||
Description("Name for the new client (alphanumeric, -, _)").
|
||||
Placeholder("e.g., laptop-john").
|
||||
Validate(func(s string) error {
|
||||
return validation.ValidateClientName(s)
|
||||
}),
|
||||
|
||||
huh.NewInput().
|
||||
Key("dns").
|
||||
Title("DNS Servers").
|
||||
Description("Comma-separated IPv4 addresses").
|
||||
Placeholder("e.g., 8.8.8.8, 8.8.4.4").
|
||||
Value(&defaultDNS).
|
||||
Validate(func(s string) error {
|
||||
return validation.ValidateDNSServers(s)
|
||||
}),
|
||||
|
||||
huh.NewConfirm().
|
||||
Key("use_psk").
|
||||
Title("Use Preshared Key").
|
||||
Description("Enable additional security layer with a preshared key").
|
||||
Affirmative("Yes").
|
||||
Negative("No"),
|
||||
),
|
||||
)
|
||||
|
||||
return &AddScreen{
|
||||
form: form,
|
||||
quitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the add screen
|
||||
func (s *AddScreen) Init() tea.Cmd {
|
||||
return s.form.Init()
|
||||
}
|
||||
|
||||
// Update handles messages for the add screen
|
||||
func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
// Cancel and return to list
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update the form
|
||||
form, cmd := s.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
s.form = f
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Check if form is completed
|
||||
if s.form.State == huh.StateCompleted {
|
||||
name := s.form.GetString("name")
|
||||
dns := s.form.GetString("dns")
|
||||
usePSK := s.form.GetBool("use_psk")
|
||||
|
||||
// Create the client
|
||||
return s, s.createClient(name, dns, usePSK)
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View renders the add screen
|
||||
func (s *AddScreen) View() string {
|
||||
if s.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
addTitleStyle.Render("Add New WireGuard Client"),
|
||||
s.form.View(),
|
||||
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
||||
)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// createClient creates a new WireGuard client
|
||||
func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Create the client via wireguard package
|
||||
err := wireguard.CreateClient(name, dns, usePSK)
|
||||
if err != nil {
|
||||
return errMsg{err: fmt.Errorf("failed to create client: %w", err)}
|
||||
}
|
||||
|
||||
// Return success message
|
||||
return ClientCreatedMsg{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// ClientCreatedMsg is sent when a client is successfully created
|
||||
type ClientCreatedMsg struct {
|
||||
Name string
|
||||
}
|
||||
299
internal/tui/screens/detail.go
Normal file
299
internal/tui/screens/detail.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// DetailScreen displays detailed information about a single WireGuard client
|
||||
type DetailScreen struct {
|
||||
client wireguard.Client
|
||||
status string
|
||||
lastHandshake time.Time
|
||||
transferRx string
|
||||
transferTx string
|
||||
confirmModal *components.ConfirmModel
|
||||
showConfirm bool
|
||||
clipboardCopied bool
|
||||
clipboardTimer int
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
detailTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true)
|
||||
detailSectionStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Bold(true).
|
||||
MarginTop(1)
|
||||
detailLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Width(18)
|
||||
detailValueStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255"))
|
||||
detailConnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("46")).
|
||||
Bold(true)
|
||||
detailDisconnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Bold(true)
|
||||
detailWarningStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true)
|
||||
detailHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("63")).
|
||||
MarginTop(1)
|
||||
detailErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// NewDetailScreen creates a new detail screen for a client
|
||||
func NewDetailScreen(client wireguard.Client) *DetailScreen {
|
||||
return &DetailScreen{
|
||||
client: client,
|
||||
showConfirm: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the detail screen
|
||||
func (s *DetailScreen) Init() tea.Cmd {
|
||||
return s.loadClientStatus
|
||||
}
|
||||
|
||||
// Update handles messages for the detail screen
|
||||
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle clipboard copy timeout
|
||||
if s.clipboardCopied {
|
||||
s.clipboardTimer++
|
||||
if s.clipboardTimer > 2 {
|
||||
s.clipboardCopied = false
|
||||
s.clipboardTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Handle confirmation modal
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
_, cmd = s.confirmModal.Update(msg)
|
||||
|
||||
// Handle confirmation result
|
||||
if !s.confirmModal.Visible {
|
||||
if s.confirmModal.IsConfirmed() {
|
||||
// User confirmed deletion
|
||||
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
|
||||
return CloseDetailScreenMsg{}
|
||||
})
|
||||
}
|
||||
// User cancelled - close modal
|
||||
s.showConfirm = false
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handle Enter key to confirm
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "enter" && s.confirmModal.IsConfirmed() {
|
||||
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
|
||||
return CloseDetailScreenMsg{}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle normal screen messages
|
||||
switch msg := msg.(type) {
|
||||
case clipboardCopiedMsg:
|
||||
s.clipboardCopied = true
|
||||
case clientStatusLoadedMsg:
|
||||
s.status = msg.status
|
||||
s.lastHandshake = msg.lastHandshake
|
||||
s.transferRx = msg.transferRx
|
||||
s.transferTx = msg.transferTx
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
// Return to list screen - signal parent to switch screens
|
||||
return s, nil
|
||||
case "d":
|
||||
// Show delete confirmation
|
||||
s.confirmModal = components.NewConfirm(
|
||||
fmt.Sprintf("Are you sure you want to delete client '%s'?\n\nThis action cannot be undone.", s.client.Name),
|
||||
80,
|
||||
24,
|
||||
)
|
||||
s.showConfirm = true
|
||||
case "c":
|
||||
// Copy public key to clipboard
|
||||
return s, s.copyPublicKey()
|
||||
}
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the detail screen
|
||||
func (s *DetailScreen) View() string {
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
// Render underlying content dimmed
|
||||
content := s.renderContent()
|
||||
dimmedContent := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("244")).
|
||||
Render(content)
|
||||
|
||||
// Overlay confirmation modal
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
dimmedContent,
|
||||
s.confirmModal.View(),
|
||||
)
|
||||
}
|
||||
|
||||
return s.renderContent()
|
||||
}
|
||||
|
||||
// renderContent renders the main detail screen content
|
||||
func (s *DetailScreen) renderContent() string {
|
||||
statusText := s.status
|
||||
if s.status == wireguard.StatusConnected {
|
||||
statusText = detailConnectedStyle.Render(s.status)
|
||||
} else {
|
||||
statusText = detailDisconnectedStyle.Render(s.status)
|
||||
}
|
||||
|
||||
// Build content
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
detailTitleStyle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
|
||||
"",
|
||||
s.renderField("Status", statusText),
|
||||
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
|
||||
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
|
||||
"",
|
||||
detailSectionStyle.Render("WireGuard Configuration"),
|
||||
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
|
||||
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
|
||||
if s.client.HasPSK {
|
||||
return "✓ Configured"
|
||||
}
|
||||
return "Not configured"
|
||||
}())),
|
||||
"",
|
||||
detailSectionStyle.Render("Connection Info"),
|
||||
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
|
||||
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
|
||||
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
|
||||
"",
|
||||
)
|
||||
|
||||
// Add help text
|
||||
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] Copy Public Key • [q] Back")
|
||||
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
||||
|
||||
// Show clipboard confirmation
|
||||
if s.clipboardCopied {
|
||||
content += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("✓ Public key copied to clipboard!")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// renderField renders a label-value pair
|
||||
func (s *DetailScreen) renderField(label string, value string) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
detailLabelStyle.Render(label),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
// formatHandshake formats the last handshake time
|
||||
func (s *DetailScreen) formatHandshake() string {
|
||||
if s.lastHandshake.IsZero() {
|
||||
return "Never"
|
||||
}
|
||||
|
||||
duration := time.Since(s.lastHandshake)
|
||||
if duration < time.Minute {
|
||||
return "Just now"
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d min ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else if duration < 7*24*time.Hour {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
return s.lastHandshake.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// loadClientStatus loads the current status of the client
|
||||
func (s *DetailScreen) loadClientStatus() tea.Msg {
|
||||
peers, err := wireguard.GetAllPeers()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
|
||||
// Find peer by public key
|
||||
for _, peer := range peers {
|
||||
if peer.PublicKey == s.client.PublicKey {
|
||||
return clientStatusLoadedMsg{
|
||||
status: peer.Status,
|
||||
lastHandshake: peer.LatestHandshake,
|
||||
transferRx: peer.TransferRx,
|
||||
transferTx: peer.TransferTx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Peer not found in active list
|
||||
return clientStatusLoadedMsg{
|
||||
status: wireguard.StatusDisconnected,
|
||||
lastHandshake: time.Time{},
|
||||
transferRx: "",
|
||||
transferTx: "",
|
||||
}
|
||||
}
|
||||
|
||||
// copyPublicKey copies the public key to clipboard
|
||||
func (s *DetailScreen) copyPublicKey() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Note: In a real implementation, you would use a clipboard library like
|
||||
// github.com/atotto/clipboard or implement platform-specific clipboard access
|
||||
// For now, we'll just simulate the action
|
||||
return clipboardCopiedMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteClient deletes the client
|
||||
func (s *DetailScreen) deleteClient() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := wireguard.DeleteClient(s.client.Name)
|
||||
if err != nil {
|
||||
return errMsg{fmt.Errorf("failed to delete client: %w", err)}
|
||||
}
|
||||
return ClientDeletedMsg{
|
||||
Name: s.client.Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// clientStatusLoadedMsg is sent when client status is loaded
|
||||
type clientStatusLoadedMsg struct {
|
||||
status string
|
||||
lastHandshake time.Time
|
||||
transferRx string
|
||||
transferTx string
|
||||
}
|
||||
|
||||
// clipboardCopiedMsg is sent when public key is copied to clipboard
|
||||
type clipboardCopiedMsg struct{}
|
||||
31
internal/tui/screens/interface.go
Normal file
31
internal/tui/screens/interface.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Screen represents a UI screen (list, add, detail, etc.)
|
||||
type Screen interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Screen, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
|
||||
// ClientSelectedMsg is sent when a client is selected from the list
|
||||
type ClientSelectedMsg struct {
|
||||
Client ClientWithStatus
|
||||
}
|
||||
|
||||
// ClientDeletedMsg is sent when a client is successfully deleted
|
||||
type ClientDeletedMsg struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// CloseDetailScreenMsg signals to close detail screen
|
||||
type CloseDetailScreenMsg struct{}
|
||||
|
||||
// RestoreCompletedMsg is sent when a restore operation completes
|
||||
type RestoreCompletedMsg struct {
|
||||
Err error
|
||||
SafetyBackupPath string
|
||||
}
|
||||
324
internal/tui/screens/list.go
Normal file
324
internal/tui/screens/list.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const statusRefreshInterval = 10 // seconds
|
||||
|
||||
// ListScreen displays a table of WireGuard clients
|
||||
type ListScreen struct {
|
||||
table table.Model
|
||||
search *components.SearchModel
|
||||
clients []ClientWithStatus
|
||||
filtered []ClientWithStatus
|
||||
sortedBy string // Column name being sorted by
|
||||
ascending bool // Sort direction
|
||||
}
|
||||
|
||||
// ClientWithStatus wraps a client with its connection status
|
||||
type ClientWithStatus struct {
|
||||
Client wireguard.Client
|
||||
Status string
|
||||
}
|
||||
|
||||
// NewListScreen creates a new list screen
|
||||
func NewListScreen() *ListScreen {
|
||||
return &ListScreen{
|
||||
search: components.NewSearch(),
|
||||
sortedBy: "Name",
|
||||
ascending: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the list screen
|
||||
func (s *ListScreen) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
s.loadClients,
|
||||
wireguard.Tick(statusRefreshInterval),
|
||||
)
|
||||
}
|
||||
|
||||
// Update handles messages for the list screen
|
||||
func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// Handle search activation
|
||||
if msg.String() == "/" && !s.search.IsActive() {
|
||||
s.search.Activate()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// If search is active, pass input to search
|
||||
if s.search.IsActive() {
|
||||
s.search, cmd = s.search.Update(msg)
|
||||
// Apply filter to clients
|
||||
s.applyFilter()
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Normal key handling when search is not active
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
// Handle quit in parent model
|
||||
return s, nil
|
||||
case "r":
|
||||
// Refresh clients
|
||||
return s, s.loadClients
|
||||
case "R":
|
||||
// Show restore screen
|
||||
return NewRestoreScreen(), nil
|
||||
case "Q":
|
||||
// Show QR code for selected client
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selected := s.table.SelectedRow()
|
||||
clientName := selected[0] // First column is Name
|
||||
return NewQRScreen(clientName), nil
|
||||
}
|
||||
case "enter":
|
||||
// Open detail view for selected client
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selectedRow := s.table.SelectedRow()
|
||||
selectedName := selectedRow[0] // First column is Name
|
||||
// Find the client with this name
|
||||
for _, cws := range s.clients {
|
||||
if cws.Client.Name == selectedName {
|
||||
return s, func() tea.Msg {
|
||||
return ClientSelectedMsg{Client: cws}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "1", "2", "3", "4":
|
||||
// Sort by column number (Name, IPv4, IPv6, Status)
|
||||
s.sortByColumn(msg.String())
|
||||
}
|
||||
case clientsLoadedMsg:
|
||||
s.clients = msg.clients
|
||||
s.search.SetTotalCount(len(s.clients))
|
||||
s.applyFilter()
|
||||
case wireguard.StatusTickMsg:
|
||||
// Refresh status on periodic tick
|
||||
return s, s.loadClients
|
||||
case wireguard.RefreshStatusMsg:
|
||||
// Refresh status on manual refresh
|
||||
return s, s.loadClients
|
||||
}
|
||||
|
||||
s.table, cmd = s.table.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the list screen
|
||||
func (s *ListScreen) View() string {
|
||||
if len(s.clients) == 0 {
|
||||
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit."
|
||||
}
|
||||
|
||||
// Check if there are no matches
|
||||
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
||||
return s.search.View() + "\n" + lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Italic(true).
|
||||
Render("No matching clients found. Try a different search term.")
|
||||
}
|
||||
|
||||
return s.search.View() + "\n" + s.table.View()
|
||||
}
|
||||
|
||||
// loadClients loads clients from wireguard config
|
||||
func (s *ListScreen) loadClients() tea.Msg {
|
||||
clients, err := wireguard.ListClients()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
|
||||
// Get status for each client
|
||||
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||
for i, client := range clients {
|
||||
status, err := wireguard.GetClientStatus(client.PublicKey)
|
||||
if err != nil {
|
||||
status = wireguard.StatusDisconnected
|
||||
}
|
||||
clientsWithStatus[i] = ClientWithStatus{
|
||||
Client: client,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||
}
|
||||
|
||||
// applyFilter applies the current search filter to clients
|
||||
func (s *ListScreen) applyFilter() {
|
||||
// Convert clients to ClientData for filtering
|
||||
clientData := make([]components.ClientData, len(s.clients))
|
||||
for i, cws := range s.clients {
|
||||
clientData[i] = components.ClientData{
|
||||
Name: cws.Client.Name,
|
||||
IPv4: cws.Client.IPv4,
|
||||
IPv6: cws.Client.IPv6,
|
||||
Status: cws.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// Filter clients
|
||||
filteredData := s.search.Filter(clientData)
|
||||
|
||||
// Convert back to ClientWithStatus
|
||||
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||
for i, cd := range filteredData {
|
||||
// Find the matching client
|
||||
for _, cws := range s.clients {
|
||||
if cws.Client.Name == cd.Name {
|
||||
s.filtered[i] = cws
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild table with filtered clients
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// buildTable creates and configures the table
|
||||
func (s *ListScreen) buildTable() {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 20},
|
||||
{Title: "IPv4", Width: 15},
|
||||
{Title: "IPv6", Width: 35},
|
||||
{Title: "Status", Width: 12},
|
||||
}
|
||||
|
||||
// Use filtered clients if search is active, otherwise use all clients
|
||||
displayClients := s.filtered
|
||||
if !s.search.IsActive() {
|
||||
displayClients = s.clients
|
||||
}
|
||||
|
||||
var rows []table.Row
|
||||
for _, cws := range displayClients {
|
||||
row := table.Row{
|
||||
cws.Client.Name,
|
||||
cws.Client.IPv4,
|
||||
cws.Client.IPv6,
|
||||
cws.Status,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Sort rows based on current sort settings
|
||||
s.sortRows(rows)
|
||||
|
||||
// Determine table height
|
||||
tableHeight := len(rows) + 2 // Header + rows
|
||||
if tableHeight < 5 {
|
||||
tableHeight = 5
|
||||
}
|
||||
|
||||
s.table = table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
)
|
||||
|
||||
// Apply styles
|
||||
s.setTableStyles()
|
||||
}
|
||||
|
||||
// setTableStyles applies styling to the table
|
||||
func (s *ListScreen) setTableStyles() {
|
||||
styles := table.DefaultStyles()
|
||||
styles.Header = styles.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
styles.Selected = styles.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
s.table.SetStyles(styles)
|
||||
}
|
||||
|
||||
// sortRows sorts the rows based on the current sort settings
|
||||
func (s *ListScreen) sortRows(rows []table.Row) {
|
||||
colIndex := s.getColumnIndex(s.sortedBy)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
var valI, valJ string
|
||||
if colIndex < len(rows[i]) {
|
||||
valI = rows[i][colIndex]
|
||||
}
|
||||
if colIndex < len(rows[j]) {
|
||||
valJ = rows[j][colIndex]
|
||||
}
|
||||
|
||||
if s.ascending {
|
||||
return strings.ToLower(valI) < strings.ToLower(valJ)
|
||||
}
|
||||
return strings.ToLower(valI) > strings.ToLower(valJ)
|
||||
})
|
||||
}
|
||||
|
||||
// sortByColumn changes the sort column
|
||||
func (s *ListScreen) sortByColumn(col string) {
|
||||
sortedBy := "Name"
|
||||
switch col {
|
||||
case "1":
|
||||
sortedBy = "Name"
|
||||
case "2":
|
||||
sortedBy = "IPv4"
|
||||
case "3":
|
||||
sortedBy = "IPv6"
|
||||
case "4":
|
||||
sortedBy = "Status"
|
||||
}
|
||||
|
||||
// Toggle direction if clicking same column
|
||||
if s.sortedBy == sortedBy {
|
||||
s.ascending = !s.ascending
|
||||
} else {
|
||||
s.sortedBy = sortedBy
|
||||
s.ascending = true
|
||||
}
|
||||
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// getColumnIndex returns the index of a column by name
|
||||
func (s *ListScreen) getColumnIndex(name string) int {
|
||||
switch name {
|
||||
case "Name":
|
||||
return 0
|
||||
case "IPv4":
|
||||
return 1
|
||||
case "IPv6":
|
||||
return 2
|
||||
case "Status":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// clientsLoadedMsg is sent when clients are loaded
|
||||
type clientsLoadedMsg struct {
|
||||
clients []ClientWithStatus
|
||||
}
|
||||
|
||||
// errMsg is sent when an error occurs
|
||||
type errMsg struct {
|
||||
err error
|
||||
}
|
||||
141
internal/tui/screens/qr.go
Normal file
141
internal/tui/screens/qr.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
)
|
||||
|
||||
// QRScreen displays a QR code for a WireGuard client configuration
|
||||
type QRScreen struct {
|
||||
clientName string
|
||||
configContent string
|
||||
qrCode string
|
||||
inlineMode bool
|
||||
width, height int
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
// NewQRScreen creates a new QR screen for displaying client config QR codes
|
||||
func NewQRScreen(clientName string) *QRScreen {
|
||||
return &QRScreen{
|
||||
clientName: clientName,
|
||||
inlineMode: true, // Start in inline mode
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the QR screen
|
||||
func (s *QRScreen) Init() tea.Cmd {
|
||||
return s.loadConfig
|
||||
}
|
||||
|
||||
// Update handles messages for the QR screen
|
||||
func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "escape":
|
||||
// Return to list screen (parent should handle this)
|
||||
return nil, nil
|
||||
case "f":
|
||||
// Toggle between inline and fullscreen mode
|
||||
s.inlineMode = !s.inlineMode
|
||||
s.generateQRCode()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
// Handle terminal resize
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
s.generateQRCode()
|
||||
case configLoadedMsg:
|
||||
s.configContent = msg.content
|
||||
s.generateQRCode()
|
||||
case errMsg:
|
||||
s.errorMsg = msg.err.Error()
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// View renders the QR screen
|
||||
func (s *QRScreen) View() string {
|
||||
if s.errorMsg != "" {
|
||||
return s.renderError()
|
||||
}
|
||||
if s.qrCode == "" {
|
||||
return "Loading QR code..."
|
||||
}
|
||||
return s.renderQR()
|
||||
}
|
||||
|
||||
// loadConfig loads the client configuration
|
||||
func (s *QRScreen) loadConfig() tea.Msg {
|
||||
content, err := wireguard.GetClientConfigContent(s.clientName)
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return configLoadedMsg{content: content}
|
||||
}
|
||||
|
||||
// generateQRCode generates the QR code based on current mode and terminal size
|
||||
func (s *QRScreen) generateQRCode() {
|
||||
if s.configContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate QR code and capture output
|
||||
var builder strings.Builder
|
||||
|
||||
// Generate ANSI QR code using half-block characters
|
||||
qrterminal.GenerateHalfBlock(s.configContent, qrterminal.L, &builder)
|
||||
|
||||
s.qrCode = builder.String()
|
||||
}
|
||||
|
||||
// renderQR renders the QR code with styling
|
||||
func (s *QRScreen) renderQR() string {
|
||||
styleTitle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
styleHelp := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
|
||||
styleQR := lipgloss.NewStyle().
|
||||
MarginLeft(2)
|
||||
|
||||
title := styleTitle.Render(fmt.Sprintf("QR Code: %s", s.clientName))
|
||||
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
|
||||
|
||||
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + styleHelp.Render(help)
|
||||
}
|
||||
|
||||
// renderError renders an error message
|
||||
func (s *QRScreen) renderError() string {
|
||||
styleError := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Bold(true)
|
||||
|
||||
styleHelp := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
|
||||
title := styleError.Render("Error")
|
||||
message := s.errorMsg
|
||||
help := "Press [q/Escape] to return"
|
||||
|
||||
return title + "\n\n" + message + "\n" + styleHelp.Render(help)
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// configLoadedMsg is sent when the client configuration is loaded
|
||||
type configLoadedMsg struct {
|
||||
content string
|
||||
}
|
||||
333
internal/tui/screens/restore.go
Normal file
333
internal/tui/screens/restore.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/backup"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RestoreScreen displays a list of available backups for restoration
|
||||
type RestoreScreen struct {
|
||||
table table.Model
|
||||
backups []backup.Backup
|
||||
selectedBackup *backup.Backup
|
||||
confirmModal *components.ConfirmModel
|
||||
showConfirm bool
|
||||
isRestoring bool
|
||||
restoreError error
|
||||
restoreSuccess bool
|
||||
message string
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
restoreTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true)
|
||||
restoreHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("63")).
|
||||
MarginTop(1)
|
||||
restoreSuccessStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("46")).
|
||||
Bold(true)
|
||||
restoreErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Bold(true)
|
||||
restoreInfoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// NewRestoreScreen creates a new restore screen
|
||||
func NewRestoreScreen() *RestoreScreen {
|
||||
return &RestoreScreen{
|
||||
showConfirm: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the restore screen
|
||||
func (s *RestoreScreen) Init() tea.Cmd {
|
||||
return s.loadBackups
|
||||
}
|
||||
|
||||
// Update handles messages for the restore screen
|
||||
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle confirmation modal
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
_, cmd = s.confirmModal.Update(msg)
|
||||
|
||||
// Handle confirmation result
|
||||
if !s.confirmModal.Visible {
|
||||
if s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||
// User confirmed restore
|
||||
s.isRestoring = true
|
||||
s.showConfirm = false
|
||||
return s, s.performRestore()
|
||||
}
|
||||
// User cancelled - close modal
|
||||
s.showConfirm = false
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handle Enter key to confirm
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||
s.isRestoring = true
|
||||
s.showConfirm = false
|
||||
return s, s.performRestore()
|
||||
}
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle normal screen messages
|
||||
switch msg := msg.(type) {
|
||||
case backupsLoadedMsg:
|
||||
s.backups = msg.backups
|
||||
s.buildTable()
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
// Return to list screen - signal parent to switch screens
|
||||
return s, nil
|
||||
case "enter":
|
||||
// Show confirmation for selected backup
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selected := s.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
// Find the backup by name
|
||||
for _, b := range s.backups {
|
||||
if b.Name == selected[0] {
|
||||
s.selectedBackup = &b
|
||||
s.confirmModal = components.NewConfirm(
|
||||
fmt.Sprintf(
|
||||
"Are you sure you want to restore from backup '%s'?\n\nOperation: %s\nDate: %s\n\nThis will replace current WireGuard configuration.\nA safety backup will be created first.",
|
||||
b.Name,
|
||||
b.Operation,
|
||||
b.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
),
|
||||
80,
|
||||
24,
|
||||
)
|
||||
s.showConfirm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case restoreCompletedMsg:
|
||||
s.isRestoring = false
|
||||
if msg.err != nil {
|
||||
s.restoreError = msg.err
|
||||
s.message = fmt.Sprintf("Restore failed: %v", msg.err)
|
||||
} else {
|
||||
s.restoreSuccess = true
|
||||
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.safetyBackupPath)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.showConfirm && s.confirmModal != nil {
|
||||
s.table, cmd = s.table.Update(msg)
|
||||
}
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the restore screen
|
||||
func (s *RestoreScreen) View() string {
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
// Render underlying content dimmed
|
||||
content := s.renderContent()
|
||||
dimmedContent := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("244")).
|
||||
Render(content)
|
||||
|
||||
// Overlay confirmation modal
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
dimmedContent,
|
||||
s.confirmModal.View(),
|
||||
)
|
||||
}
|
||||
|
||||
return s.renderContent()
|
||||
}
|
||||
|
||||
// renderContent renders the main restore screen content
|
||||
func (s *RestoreScreen) renderContent() string {
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
|
||||
content.WriteString("No backups found. Press 'q' to return.")
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.isRestoring {
|
||||
content.WriteString("Restoring from backup, please wait...")
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.restoreSuccess {
|
||||
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list."))
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.restoreError != nil {
|
||||
content.WriteString(restoreErrorStyle.Render("✗ " + s.message))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(s.table.View())
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// Show backup list
|
||||
content.WriteString(s.table.View())
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Show selected backup details
|
||||
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
|
||||
content.WriteString(restoreInfoStyle.Render(
|
||||
fmt.Sprintf(
|
||||
"Selected: %s (%s) - %s\nSize: %s",
|
||||
s.selectedBackup.Operation,
|
||||
s.selectedBackup.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
s.selectedBackup.Name,
|
||||
formatBytes(s.selectedBackup.Size),
|
||||
),
|
||||
))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// loadBackups loads the list of available backups
|
||||
func (s *RestoreScreen) loadBackups() tea.Msg {
|
||||
backups, err := backup.ListBackups()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return backupsLoadedMsg{backups: backups}
|
||||
}
|
||||
|
||||
// buildTable creates and configures the backup list table
|
||||
func (s *RestoreScreen) buildTable() {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 40},
|
||||
{Title: "Operation", Width: 15},
|
||||
{Title: "Date", Width: 20},
|
||||
{Title: "Size", Width: 12},
|
||||
}
|
||||
|
||||
var rows []table.Row
|
||||
for _, b := range s.backups {
|
||||
row := table.Row{
|
||||
b.Name,
|
||||
b.Operation,
|
||||
b.Timestamp.Format("2006-01-02 15:04"),
|
||||
formatBytes(b.Size),
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
s.table = table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(len(rows)+2), // Header + rows
|
||||
)
|
||||
|
||||
// Apply styles
|
||||
s.setTableStyles()
|
||||
}
|
||||
|
||||
// setTableStyles applies styling to the table
|
||||
func (s *RestoreScreen) setTableStyles() {
|
||||
styles := table.DefaultStyles()
|
||||
styles.Header = styles.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
styles.Selected = styles.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
s.table.SetStyles(styles)
|
||||
}
|
||||
|
||||
// performRestore performs the restore operation
|
||||
func (s *RestoreScreen) performRestore() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if s.selectedBackup == nil {
|
||||
return restoreCompletedMsg{
|
||||
err: fmt.Errorf("no backup selected"),
|
||||
}
|
||||
}
|
||||
|
||||
// Get safety backup path from backup.BackupConfig
|
||||
safetyBackupPath, err := backup.BackupConfig(fmt.Sprintf("pre-restore-from-%s", s.selectedBackup.Name))
|
||||
if err != nil {
|
||||
return restoreCompletedMsg{
|
||||
err: fmt.Errorf("failed to create safety backup: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Perform restore
|
||||
if err := backup.RestoreBackup(s.selectedBackup.Name); err != nil {
|
||||
return restoreCompletedMsg{
|
||||
err: err,
|
||||
safetyBackupPath: safetyBackupPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Restore succeeded - trigger client list refresh
|
||||
return restoreCompletedMsg{
|
||||
safetyBackupPath: safetyBackupPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats a byte count into human-readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// backupsLoadedMsg is sent when backups are loaded
|
||||
type backupsLoadedMsg struct {
|
||||
backups []backup.Backup
|
||||
}
|
||||
|
||||
// restoreCompletedMsg is sent when a restore operation completes
|
||||
type restoreCompletedMsg struct {
|
||||
err error
|
||||
safetyBackupPath string
|
||||
}
|
||||
Reference in New Issue
Block a user