feat(app): add WGRplane web UI and backend features
- Add Vue 3 frontend with glassmorphism design (Tailwind CSS) - Add Go backend handlers: auth, webhooks, stats, scheduler, validation - Add i18n support (EN, ID, ZH) - Add Swagger docs and API handlers - Add nftables integration and plugins support - Remove deprecated go.mod (migrated to wgrplane)
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o wgrplane ./...
|
||||||
|
|
||||||
|
# Run stage
|
||||||
|
FROM alpine:3.18
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/wgrplane .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./wgrplane"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"greeting": "Hello",
|
||||||
|
"farewell": "Goodbye"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"greeting": "Halo",
|
||||||
|
"farewell": "Selamat tinggal"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"greeting": "你好",
|
||||||
|
"farewell": "再见"
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple in-memory storage for TOTPs per user. In production, use a DB.
|
||||||
|
var totpSecrets = map[string]string{}
|
||||||
|
|
||||||
|
// API key (default) - can be overridden by WG_API_KEY env var.
|
||||||
|
var apiKey string
|
||||||
|
|
||||||
|
// JWT secret (default) - can be overridden by JWT_SECRET env var.
|
||||||
|
var jwtSecret string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
apiKey = os.Getenv("WG_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = "test-api-key" // default for testing
|
||||||
|
}
|
||||||
|
jwtSecret = os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "secret" // default for testing
|
||||||
|
}
|
||||||
|
// Preload a test user so JWTs can be generated in tests if needed
|
||||||
|
if _, ok := totpSecrets["test"]; !ok {
|
||||||
|
// generate a random-looking secret for test user if desired
|
||||||
|
// but we won't force it here; user can call /auth/setup-totp?user=test
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateJWT creates a short-lived JWT for a given username
|
||||||
|
func generateJWT(username string) (string, error) {
|
||||||
|
claims := &Claims{
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "wgrplane",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(jwtSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJWT(tokenStr string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpSetupHandler returns a new TOTP secret and provisioning URI for a user
|
||||||
|
func totpSetupHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := r.URL.Query().Get("user")
|
||||||
|
if user == "" {
|
||||||
|
http.Error(w, "missing user", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: "WGRplane",
|
||||||
|
AccountName: user,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to generate secret", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret := key.Secret()
|
||||||
|
totpSecrets[user] = secret
|
||||||
|
resp := map[string]string{
|
||||||
|
"secret": secret,
|
||||||
|
"provisioning_uri": key.URL(),
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware protects selected routes with API Key or JWT + optional TOTP
|
||||||
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 1) API Key path
|
||||||
|
if key := r.Header.Get("wg-rplane-datadunia"); key != "" {
|
||||||
|
if subtleConstantTimeEquals(key, apiKey) {
|
||||||
|
// If user has a TOTP, require current OTP in header
|
||||||
|
// The username isn't known from API Key alone; skip TOTP check here
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) JWT path
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
claims, err := parseJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If user has a TOTp secret, verify it
|
||||||
|
if secret, ok := totpSecrets[claims.Username]; ok {
|
||||||
|
otp := r.Header.Get("X-TOTP")
|
||||||
|
if otp == "" || !totp.Validate(otp, secret) {
|
||||||
|
http.Error(w, "Unauthorized (TOTp)", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Attach username to context for downstream handlers if needed
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid auth provided
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper for constant-time string comparison
|
||||||
|
func subtleConstantTimeEquals(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var diff byte
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
diff |= a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return diff == 0
|
||||||
|
}
|
||||||
+1244
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,777 @@
|
|||||||
|
basePath: /
|
||||||
|
definitions:
|
||||||
|
main.Peer:
|
||||||
|
properties:
|
||||||
|
allowAccess:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
allowInternet:
|
||||||
|
type: boolean
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
currentDataUsageBytes:
|
||||||
|
description: CurrentDataUsageBytes tracks the amount of data used by this
|
||||||
|
peer (in bytes)
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
dataLimitGB:
|
||||||
|
description: DataLimitGB defines the monthly data limit per peer (in GB).
|
||||||
|
0 means unlimited.
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
enabled:
|
||||||
|
description: Enabled indicates whether the peer is active. Auto-restrict disables
|
||||||
|
the peer if over the limit.
|
||||||
|
type: boolean
|
||||||
|
expiresAt:
|
||||||
|
description: ExpiresAt defines when this peer should be considered expired
|
||||||
|
and eligible for auto-deletion
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
ip:
|
||||||
|
type: string
|
||||||
|
publicKey:
|
||||||
|
type: string
|
||||||
|
serverID:
|
||||||
|
type: integer
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
main.SMTPSettings:
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
fromEmail:
|
||||||
|
type: string
|
||||||
|
fromName:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
server:
|
||||||
|
type: string
|
||||||
|
useAuth:
|
||||||
|
type: boolean
|
||||||
|
useTLS:
|
||||||
|
type: boolean
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
main.Server:
|
||||||
|
properties:
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
endpoint:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
peers:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
type: array
|
||||||
|
publicKey:
|
||||||
|
type: string
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
webhooks:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/main.Webhook'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
main.Webhook:
|
||||||
|
properties:
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
customBody:
|
||||||
|
type: string
|
||||||
|
customHeaders:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
defaultPayload:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
isEnabled:
|
||||||
|
type: boolean
|
||||||
|
isGlobal:
|
||||||
|
type: boolean
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
serverID:
|
||||||
|
type: integer
|
||||||
|
subscribedActions:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
template:
|
||||||
|
type: string
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
verifySSL:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
host: localhost:10087
|
||||||
|
info:
|
||||||
|
contact:
|
||||||
|
name: API Support
|
||||||
|
description: WireGuard Control Plane with Dynamic Policy Firewall. REST API for
|
||||||
|
managing WireGuard servers, peers, webhooks, SMTP settings, and real-time stats.
|
||||||
|
title: WGRplane API
|
||||||
|
version: "1.0"
|
||||||
|
paths:
|
||||||
|
/api/peers/{id}:
|
||||||
|
delete:
|
||||||
|
description: Deletes a peer, cleans up nftables rules, and triggers webhooks.
|
||||||
|
parameters:
|
||||||
|
- description: Peer ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete a peer
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Updates a peer and computes diffs to apply incremental nftables
|
||||||
|
changes.
|
||||||
|
parameters:
|
||||||
|
- description: Peer ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Updated peer fields
|
||||||
|
in: body
|
||||||
|
name: peer
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update a peer
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
/api/peers/{id}/config:
|
||||||
|
get:
|
||||||
|
description: Downloads the WireGuard .conf file for a specific peer.
|
||||||
|
parameters:
|
||||||
|
- description: Peer ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- text/plain
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: WireGuard configuration file
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get peer config
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
/api/peers/{id}/qrcode:
|
||||||
|
get:
|
||||||
|
description: Returns a QR code PNG image of the peer config for mobile import.
|
||||||
|
parameters:
|
||||||
|
- description: Peer ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- image/png
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: QR code PNG image
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get peer QR code
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
/api/servers:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Returns a list of all registered WireGuard servers.
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
type: array
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List all servers
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Creates a new WireGuard server with the provided configuration.
|
||||||
|
parameters:
|
||||||
|
- description: Server configuration
|
||||||
|
in: body
|
||||||
|
name: server
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Create a server
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
/api/servers/{id}:
|
||||||
|
delete:
|
||||||
|
description: Deletes a WireGuard server and cascade-removes its peers and webhooks.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete a server
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
get:
|
||||||
|
description: Returns a single WireGuard server by its ID.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get a server
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Updates fields of an existing WireGuard server.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Updated server fields
|
||||||
|
in: body
|
||||||
|
name: server
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Server'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update a server
|
||||||
|
tags:
|
||||||
|
- servers
|
||||||
|
/api/servers/{id}/peers:
|
||||||
|
get:
|
||||||
|
description: Returns all peers belonging to a specific WireGuard server.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
type: array
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List server peers
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Creates a new WireGuard peer. Applies nftables rules in forward
|
||||||
|
mode or triggers webhooks in standalone mode.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Peer configuration
|
||||||
|
in: body
|
||||||
|
name: peer
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Peer'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Create a peer
|
||||||
|
tags:
|
||||||
|
- peers
|
||||||
|
/api/servers/{id}/stats:
|
||||||
|
get:
|
||||||
|
description: Returns per-server statistics including peer count and webhook
|
||||||
|
count.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get server stats
|
||||||
|
tags:
|
||||||
|
- stats
|
||||||
|
/api/servers/{id}/webhooks:
|
||||||
|
get:
|
||||||
|
description: Returns all webhooks configured for a specific WireGuard server.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/main.Webhook'
|
||||||
|
type: array
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List server webhooks
|
||||||
|
tags:
|
||||||
|
- webhooks
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Creates a new webhook configuration for a WireGuard server.
|
||||||
|
parameters:
|
||||||
|
- description: Server ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Webhook configuration
|
||||||
|
in: body
|
||||||
|
name: webhook
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Webhook'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.Webhook'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Create a webhook
|
||||||
|
tags:
|
||||||
|
- webhooks
|
||||||
|
/api/settings/smtp:
|
||||||
|
get:
|
||||||
|
description: Returns the current SMTP configuration for email notifications.
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.SMTPSettings'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get SMTP settings
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Saves or updates SMTP configuration for email notifications.
|
||||||
|
parameters:
|
||||||
|
- description: SMTP configuration
|
||||||
|
in: body
|
||||||
|
name: settings
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.SMTPSettings'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/main.SMTPSettings'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Save SMTP settings
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
/api/stats:
|
||||||
|
get:
|
||||||
|
description: Returns global statistics including total servers, peers, and webhooks.
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get global stats
|
||||||
|
tags:
|
||||||
|
- stats
|
||||||
|
/api/webhooks/{id}:
|
||||||
|
delete:
|
||||||
|
description: Deletes a webhook by its ID.
|
||||||
|
parameters:
|
||||||
|
- description: Webhook ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No content
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete a webhook
|
||||||
|
tags:
|
||||||
|
- webhooks
|
||||||
|
securityDefinitions:
|
||||||
|
ApiKeyAuth:
|
||||||
|
in: header
|
||||||
|
name: wg-rplane-datadunia
|
||||||
|
type: apiKey
|
||||||
|
BearerAuth:
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
type: apiKey
|
||||||
|
swagger: "2.0"
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>WGRplane - WireGuard Control</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+675
-22
@@ -11,19 +11,38 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"vue": "^3.5.33",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9"
|
"vue-sonner": "^2.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"postcss": "^8.5.13",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -31,7 +50,6 @@
|
|||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -39,7 +57,6 @@
|
|||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.0"
|
||||||
},
|
},
|
||||||
@@ -53,7 +70,6 @@
|
|||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
@@ -111,10 +127,96 @@
|
|||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kurkle/color": {
|
"node_modules/@kurkle/color": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
@@ -427,6 +529,289 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.19.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.32.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.8.1",
|
||||||
|
"@emnapi/runtime": "^1.8.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.2.4",
|
||||||
|
"@tailwindcss/oxide": "4.2.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "4.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/virtual-core": {
|
"node_modules/@tanstack/virtual-core": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
||||||
@@ -470,10 +855,33 @@
|
|||||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
|
"version": "6.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
||||||
|
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"vue": "^3.2.25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.0-rc.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||||
|
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.2",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.33",
|
||||||
@@ -485,7 +893,6 @@
|
|||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.33",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
@@ -494,7 +901,6 @@
|
|||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.2",
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.33",
|
||||||
@@ -510,7 +916,6 @@
|
|||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.33",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
@@ -525,7 +930,6 @@
|
|||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
}
|
}
|
||||||
@@ -533,7 +937,6 @@
|
|||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.33",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
@@ -542,7 +945,6 @@
|
|||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.33",
|
||||||
"@vue/runtime-core": "3.5.33",
|
"@vue/runtime-core": "3.5.33",
|
||||||
@@ -553,7 +955,6 @@
|
|||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.33",
|
"@vue/compiler-ssr": "3.5.33",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.33"
|
||||||
@@ -564,8 +965,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "14.3.0",
|
"version": "14.3.0",
|
||||||
@@ -605,6 +1005,111 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.2",
|
||||||
|
"caniuse-lite": "^1.0.30001787",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/baseline-browser-mapping": {
|
||||||
|
"version": "2.10.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
|
||||||
|
"integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserslist": {
|
||||||
|
"version": "4.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
|
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
|
"caniuse-lite": "^1.0.30001782",
|
||||||
|
"electron-to-chromium": "^1.5.328",
|
||||||
|
"node-releases": "^2.0.36",
|
||||||
|
"update-browserslist-db": "^1.2.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browserslist": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/caniuse-lite": {
|
||||||
|
"version": "1.0.30001791",
|
||||||
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||||
|
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
|
},
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -619,8 +1124,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
@@ -630,10 +1134,30 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-to-chromium": {
|
||||||
|
"version": "1.5.349",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||||
|
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
|
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
},
|
},
|
||||||
@@ -641,10 +1165,19 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -662,6 +1195,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -677,6 +1224,23 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -949,7 +1513,6 @@
|
|||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
@@ -970,6 +1533,13 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-releases": {
|
||||||
|
"version": "2.0.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||||
|
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
@@ -987,6 +1557,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||||
|
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1011,6 +1583,13 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1050,6 +1629,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1085,6 +1685,37 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/update-browserslist-db": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escalade": "^3.2.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"update-browserslist-db": "cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"browserslist": ">= 4.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1163,8 +1794,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
||||||
|
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.33",
|
||||||
"@vue/compiler-sfc": "3.5.33",
|
"@vue/compiler-sfc": "3.5.33",
|
||||||
@@ -1191,6 +1823,27 @@
|
|||||||
"vue": "^3.0.0-0 || ^2.7.0"
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||||
|
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"postcss": "^8.5.13",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
},
|
},
|
||||||
@@ -16,7 +21,9 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"vue": "^3.5.33",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9"
|
"vue-sonner": "^2.0.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Mobile Top Navbar -->
|
||||||
|
<header class="top-navbar lg:hidden">
|
||||||
|
<div class="flex items-center justify-between p-4">
|
||||||
|
<button @click="toggleSidebar" class="text-white/80 hover:text-cyan-400 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold text-cyan-400">WGRplane</h1>
|
||||||
|
<div class="w-6"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
class="sidebar"
|
||||||
|
:class="{ '-translate-x-full': !sidebarOpen, 'translate-x-0': sidebarOpen, 'lg:translate-x-0': true }"
|
||||||
|
>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1 class="text-2xl font-bold text-cyan-400">WGRplane</h1>
|
||||||
|
<p class="text-sm text-white/50">WireGuard Control</p>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'active': $route.path === '/' }"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/settings"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'active': $route.path === '/settings' }"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Overlay for mobile sidebar -->
|
||||||
|
<div
|
||||||
|
v-if="sidebarOpen"
|
||||||
|
class="sidebar-overlay lg:hidden"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content" :class="{ 'lg:ml-64': true }">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
sidebarOpen.value = !sidebarOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16rem;
|
||||||
|
z-index: 50;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(6, 182, 212, 0.2);
|
||||||
|
color: #22d3ee;
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-top: 4rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.main-content {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
margin-left: 16rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">Subscribed Actions</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label
|
||||||
|
v-for="action in availableActions"
|
||||||
|
:key="action.value"
|
||||||
|
class="flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 hover:border-cyan-400/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="action.value"
|
||||||
|
:checked="modelValue.includes(action.value)"
|
||||||
|
@change="toggleAction(action.value)"
|
||||||
|
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-white">{{ action.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Action {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
availableActions?: Action[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const defaultActions: Action[] = [
|
||||||
|
{ label: 'Peer Connected', value: 'peer.connected' },
|
||||||
|
{ label: 'Peer Disconnected', value: 'peer.disconnected' },
|
||||||
|
{ label: 'Peer Added', value: 'peer.added' },
|
||||||
|
{ label: 'Peer Removed', value: 'peer.removed' },
|
||||||
|
{ label: 'Policy Updated', value: 'policy.updated' },
|
||||||
|
{ label: 'Server Started', value: 'server.started' },
|
||||||
|
{ label: 'Server Stopped', value: 'server.stopped' },
|
||||||
|
{ label: 'Login Failed', value: 'auth.failed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const availableActions = props.availableActions || defaultActions
|
||||||
|
|
||||||
|
const toggleAction = (value: string) => {
|
||||||
|
const current = [...props.modelValue]
|
||||||
|
const index = current.indexOf(value)
|
||||||
|
if (index === -1) {
|
||||||
|
current.push(value)
|
||||||
|
} else {
|
||||||
|
current.splice(index, 1)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="inputValue"
|
||||||
|
type="text"
|
||||||
|
:placeholder="placeholder || 'Add CIDR (e.g., 192.168.1.0/24)'"
|
||||||
|
class="flex-1 bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/30 focus:outline-none focus:border-cyan-400/50 transition-colors"
|
||||||
|
@keydown.enter="addCIDR"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="addCIDR"
|
||||||
|
class="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 shadow-xl hover:bg-white/20 hover:border-cyan-400/50 transition-all duration-300 text-cyan-400"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(cidr, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-1 px-3 py-1 bg-white/10 backdrop-blur-md border border-white/20 rounded-full text-white/90 text-sm hover:border-cyan-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{{ cidr }}</span>
|
||||||
|
<button
|
||||||
|
@click="removeCIDR(index)"
|
||||||
|
class="text-white/50 hover:text-red-400 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputValue = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const validateCIDR = (cidr: string): boolean => {
|
||||||
|
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/(\d{1,2})$/
|
||||||
|
if (!cidrRegex.test(cidr)) {
|
||||||
|
error.value = 'Invalid CIDR format. Use e.g., 192.168.1.0/24'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const [ip, prefixStr] = cidr.split('/')
|
||||||
|
const octets = ip.split('.').map(Number)
|
||||||
|
const prefix = Number(prefixStr)
|
||||||
|
|
||||||
|
if (octets.some(octet => octet < 0 || octet > 255)) {
|
||||||
|
error.value = 'Invalid IP octet (must be 0-255)'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (prefix < 0 || prefix > 32) {
|
||||||
|
error.value = 'Invalid prefix (must be 0-32)'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCIDR = () => {
|
||||||
|
const trimmed = inputValue.value.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
if (props.modelValue.includes(trimmed)) {
|
||||||
|
error.value = 'CIDR already exists'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (validateCIDR(trimmed)) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, trimmed])
|
||||||
|
inputValue.value = ''
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCIDR = (index: number) => {
|
||||||
|
const newList = [...props.modelValue]
|
||||||
|
newList.splice(index, 1)
|
||||||
|
emit('update:modelValue', newList)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">Custom Body (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
:value="modelValue"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
rows="6"
|
||||||
|
placeholder='{ "text": "Webhook triggered", "event": "{{event}}" }'
|
||||||
|
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-cyan-500 resize-y"
|
||||||
|
></textarea>
|
||||||
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
<p class="text-xs text-white/50">
|
||||||
|
Use {{event}}, {{timestamp}}, {{peer}} as placeholders for dynamic values.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
error.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(val)
|
||||||
|
error.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Invalid JSON format'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">Headers</label>
|
||||||
|
<div
|
||||||
|
v-for="(header, index) in headers"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Key"
|
||||||
|
:value="header.key"
|
||||||
|
@input="updateHeader(index, 'key', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
:value="header.value"
|
||||||
|
@input="updateHeader(index, 'value', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeHeader(index)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Remove header"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="addHeader"
|
||||||
|
class="text-sm text-cyan-400 hover:text-cyan-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Header
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Header {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Header[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: Header[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const headers = props.modelValue.length ? props.modelValue : [{ key: '', value: '' }]
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeHeader = (index: number) => {
|
||||||
|
const newHeaders = [...props.modelValue]
|
||||||
|
newHeaders.splice(index, 1)
|
||||||
|
emit('update:modelValue', newHeaders.length ? newHeaders : [{ key: '', value: '' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHeader = (index: number, field: keyof Header, value: string) => {
|
||||||
|
const newHeaders = [...props.modelValue]
|
||||||
|
newHeaders[index] = { ...newHeaders[index], [field]: value }
|
||||||
|
emit('update:modelValue', newHeaders)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only peer"
|
||||||
|
:checked="modelValue"
|
||||||
|
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-12 h-6 rounded-full transition-colors duration-300 backdrop-blur-sm border"
|
||||||
|
:class="modelValue ? 'bg-cyan-500/30 border-cyan-400/50' : 'bg-gray-700/50 border-white/10'"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md transition-transform duration-300"
|
||||||
|
:class="modelValue ? 'translate-x-6 bg-cyan-400' : 'translate-x-0 bg-gray-300'"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-white/90 text-sm">{{ modelValue ? 'Allowed' : 'Blocked' }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<GlassCard class="p-6 overflow-x-auto">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400">Peer Management</h2>
|
||||||
|
<GlassButton @click="isAddPeerModalOpen = true" class="from-cyan-500 to-blue-500">
|
||||||
|
+ Add Peer
|
||||||
|
</GlassButton>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-left text-white/90">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="p-3 text-white/70">Peer Name</th>
|
||||||
|
<th class="p-3 text-white/70">Public Key</th>
|
||||||
|
<th class="p-3 text-white/70">IP Address</th>
|
||||||
|
<th class="p-3 text-white/70">Allow Access (CIDRs)</th>
|
||||||
|
<th class="p-3 text-white/70">Allow Internet</th>
|
||||||
|
<th class="p-3 text-white/70">Expiry Date</th>
|
||||||
|
<th class="p-3 text-white/70">Data Usage</th>
|
||||||
|
<th class="p-3 text-white/70">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="peer in peers" :key="peer.id" class="border-b border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="p-3">{{ peer.name }}</td>
|
||||||
|
<td class="p-3 font-mono text-sm text-white/70 truncate max-w-[200px]" :title="peer.publicKey">{{ peer.publicKey }}</td>
|
||||||
|
<td class="p-3 font-mono text-sm text-white/70">{{ peer.ip }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<CIDRTagInput v-model="peer.allowedAccess" placeholder="Add CIDR" />
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<InternetToggle v-model="peer.allowInternet" />
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-white/70">
|
||||||
|
{{ peer.expiresAt ? new Date(peer.expiresAt).toLocaleDateString() : 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-white/70">
|
||||||
|
{{ peer.currentDataUsageBytes ? formatBytes(peer.currentDataUsageBytes) : '0 B' }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 flex gap-2">
|
||||||
|
<button @click="openEditPeer(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-yellow-400/50 transition-all text-yellow-400 text-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button @click="showQRCode(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-cyan-400/50 transition-all text-cyan-400 text-sm">
|
||||||
|
QR Code
|
||||||
|
</button>
|
||||||
|
<button @click="downloadConfig(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-green-400/50 transition-all text-green-400 text-sm">
|
||||||
|
Config
|
||||||
|
</button>
|
||||||
|
<button @click="deletePeer(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-red-400/50 transition-all text-red-400 text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Add Peer Modal -->
|
||||||
|
<TransitionRoot appear :show="isAddPeerModalOpen" as="template">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isAddPeerModalOpen = false" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
|
||||||
|
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Add New Peer</DialogTitle>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Public Key</label>
|
||||||
|
<GlassInput v-model="newPeer.publicKey" placeholder="Paste Public Key (or auto-generate)" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">IP Address</label>
|
||||||
|
<GlassInput v-model="newPeer.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
|
||||||
|
<CIDRTagInput v-model="newPeer.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/70">Allow Internet</span>
|
||||||
|
<InternetToggle v-model="newPeer.allowInternet" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Expiry Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="newPeer.expiresAt"
|
||||||
|
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
|
||||||
|
<GlassInput
|
||||||
|
v-model.number="newPeer.dataLimitGB"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="Leave empty for no limit"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-6 justify-end">
|
||||||
|
<GlassButton @click="isAddPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
|
||||||
|
<GlassButton @click="addPeer" class="from-cyan-500 to-blue-500">Add Peer</GlassButton>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
<!-- Edit Peer Modal -->
|
||||||
|
<TransitionRoot appear :show="isEditPeerModalOpen" as="template">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isEditPeerModalOpen = false" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
|
||||||
|
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Edit Peer</DialogTitle>
|
||||||
|
<div class="space-y-4" v-if="selectedPeerForEdit">
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Public Key</label>
|
||||||
|
<GlassInput v-model="selectedPeerForEdit.publicKey" placeholder="Public Key" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">IP Address</label>
|
||||||
|
<GlassInput v-model="selectedPeerForEdit.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
|
||||||
|
<CIDRTagInput v-model="selectedPeerForEdit.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/70">Allow Internet</span>
|
||||||
|
<InternetToggle v-model="selectedPeerForEdit.allowInternet" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Expiry Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="selectedPeerForEdit.expiresAt"
|
||||||
|
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
|
||||||
|
<GlassInput
|
||||||
|
v-model.number="selectedPeerForEdit.dataLimitGB"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="Leave empty for no limit"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-6 justify-end">
|
||||||
|
<GlassButton @click="isEditPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
|
||||||
|
<GlassButton @click="updatePeer" class="from-cyan-500 to-blue-500">Save Changes</GlassButton>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<TransitionRoot appear :show="isQRCodeModalOpen" as="template">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isQRCodeModalOpen = false" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
|
||||||
|
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Peer QR Code</DialogTitle>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<img
|
||||||
|
v-if="selectedPeerForQR"
|
||||||
|
:src="`/api/peers/${selectedPeerForQR.id}/qrcode`"
|
||||||
|
alt="Peer QR Code"
|
||||||
|
class="w-64 h-64 bg-white p-4 rounded-lg"
|
||||||
|
/>
|
||||||
|
<p v-if="selectedPeerForQR" class="text-white/70 text-sm text-center">{{ selectedPeerForQR.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-6">
|
||||||
|
<GlassButton @click="isQRCodeModalOpen = false" class="from-gray-500 to-gray-600">Close</GlassButton>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</TransitionRoot>
|
||||||
|
</GlassCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
|
||||||
|
import GlassCard from './glass/GlassCard.vue'
|
||||||
|
import GlassInput from './glass/GlassInput.vue'
|
||||||
|
import GlassButton from './glass/GlassButton.vue'
|
||||||
|
import CIDRTagInput from './CIDRTagInput.vue'
|
||||||
|
import InternetToggle from './InternetToggle.vue'
|
||||||
|
|
||||||
|
interface Peer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
publicKey: string
|
||||||
|
ip: string
|
||||||
|
allowedAccess: string[]
|
||||||
|
allowInternet: boolean
|
||||||
|
expiresAt?: string | null
|
||||||
|
dataLimitGB?: number | null
|
||||||
|
currentDataUsageBytes?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
serverId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const peers = ref<Peer[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Client 1',
|
||||||
|
publicKey: 'xTIBA5rboUvnH3h2+JQ0qf0pI0n6KJ7g5Uc5gG0q0=',
|
||||||
|
ip: '10.0.0.2/32',
|
||||||
|
allowedAccess: ['192.168.1.0/24', '10.0.0.1/32'],
|
||||||
|
allowInternet: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Client 2',
|
||||||
|
publicKey: 'yUIBA6scpVwoH4h3+KR1qf1qJ1o7KJ8h6Vd6hH1r1=',
|
||||||
|
ip: '10.0.0.3/32',
|
||||||
|
allowedAccess: [],
|
||||||
|
allowInternet: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Client 3',
|
||||||
|
publicKey: 'zVJCB7tdqWxpI5h4+LS2rg2rK2p8LK9i7We7iI2s2=',
|
||||||
|
ip: '10.0.0.4/32',
|
||||||
|
allowedAccess: ['172.16.0.0/16'],
|
||||||
|
allowInternet: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const isAddPeerModalOpen = ref(false)
|
||||||
|
const isQRCodeModalOpen = ref(false)
|
||||||
|
const isEditPeerModalOpen = ref(false)
|
||||||
|
const selectedPeerForQR = ref<Peer | null>(null)
|
||||||
|
const selectedPeerForEdit = ref<Peer | null>(null)
|
||||||
|
const newPeer = ref({
|
||||||
|
publicKey: '',
|
||||||
|
ip: '',
|
||||||
|
allowedAccess: [] as string[],
|
||||||
|
allowInternet: false,
|
||||||
|
expiresAt: null as string | null,
|
||||||
|
dataLimitGB: null as number | null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditPeer = (peer: Peer) => {
|
||||||
|
selectedPeerForEdit.value = { ...peer }
|
||||||
|
isEditPeerModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePeer = async () => {
|
||||||
|
if (!selectedPeerForEdit.value) return
|
||||||
|
try {
|
||||||
|
const peer = selectedPeerForEdit.value
|
||||||
|
const response = await fetch(`/api/peers/${peer.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
publicKey: peer.publicKey,
|
||||||
|
ip: peer.ip,
|
||||||
|
allowedAccess: peer.allowedAccess,
|
||||||
|
allowInternet: peer.allowInternet,
|
||||||
|
expiresAt: peer.expiresAt,
|
||||||
|
dataLimitGB: peer.dataLimitGB
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to update peer')
|
||||||
|
const updatedPeer = await response.json()
|
||||||
|
const index = peers.value.findIndex(p => p.id === updatedPeer.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
peers.value[index] = updatedPeer
|
||||||
|
}
|
||||||
|
isEditPeerModalOpen.value = false
|
||||||
|
selectedPeerForEdit.value = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update peer error:', error)
|
||||||
|
alert('Failed to update peer. Check console for details.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPeer = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${props.serverId}/peers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newPeer.value)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to add peer')
|
||||||
|
const createdPeer = await response.json()
|
||||||
|
peers.value.push({ ...createdPeer, name: `Client ${peers.value.length + 1}` })
|
||||||
|
isAddPeerModalOpen.value = false
|
||||||
|
newPeer.value = { publicKey: '', ip: '', allowedAccess: [], allowInternet: false, expiresAt: null, dataLimitGB: null }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Add peer error:', error)
|
||||||
|
alert('Failed to add peer. Check console for details.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePeer = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this peer?')) return
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/peers/${id}`, { method: 'DELETE' })
|
||||||
|
if (!response.ok) throw new Error('Failed to delete peer')
|
||||||
|
peers.value = peers.value.filter(peer => peer.id !== id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete peer error:', error)
|
||||||
|
alert('Failed to delete peer. Check console for details.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showQRCode = (peer: Peer) => {
|
||||||
|
selectedPeerForQR.value = peer
|
||||||
|
isQRCodeModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadConfig = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/peers/${id}/config`)
|
||||||
|
if (!response.ok) throw new Error('Failed to download config')
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `peer-${id}.conf`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download config error:', error)
|
||||||
|
alert('Failed to download config. Check console for details.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">Template</label>
|
||||||
|
<select
|
||||||
|
:value="modelValue"
|
||||||
|
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||||
|
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%23ffffff%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%20011.06-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:20px] bg-[right_8px_center] bg-no-repeat"
|
||||||
|
>
|
||||||
|
<option value="" class="bg-gray-900">No Template (Custom Body)</option>
|
||||||
|
<option value="slack" class="bg-gray-900">Slack Message</option>
|
||||||
|
<option value="discord" class="bg-gray-900">Discord Embed</option>
|
||||||
|
<option value="telegram" class="bg-gray-900">Telegram Message</option>
|
||||||
|
<option value="generic" class="bg-gray-900">Generic JSON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="My Webhook"
|
||||||
|
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">URL</label>
|
||||||
|
<input
|
||||||
|
v-model="form.url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://example.com/webhook"
|
||||||
|
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplateDropdown v-model="form.template" />
|
||||||
|
|
||||||
|
<CustomBodyEditor
|
||||||
|
v-if="!form.template"
|
||||||
|
v-model="form.customBody"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderKeyValue v-model="form.headers" />
|
||||||
|
|
||||||
|
<ActionCheckboxes v-model="form.subscribedActions" />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-white/70 text-sm">
|
||||||
|
<input
|
||||||
|
v-model="form.verifySSL"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
Verify SSL
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-white/70 text-sm">
|
||||||
|
<input
|
||||||
|
v-model="form.enabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300"
|
||||||
|
>
|
||||||
|
{{ submitLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showCancel"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
class="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import type { Webhook, WebhookHeader } from '../types/webhook'
|
||||||
|
import TemplateDropdown from './TemplateDropdown.vue'
|
||||||
|
import CustomBodyEditor from './CustomBodyEditor.vue'
|
||||||
|
import HeaderKeyValue from './HeaderKeyValue.vue'
|
||||||
|
import ActionCheckboxes from './ActionCheckboxes.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
webhook?: Webhook
|
||||||
|
submitLabel?: string
|
||||||
|
showCancel?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submit', webhook: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getDefaultForm = (): Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'> => ({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
template: '',
|
||||||
|
customBody: '',
|
||||||
|
headers: [{ key: '', value: '' }],
|
||||||
|
subscribedActions: [],
|
||||||
|
verifySSL: true,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>>(getDefaultForm())
|
||||||
|
|
||||||
|
watch(() => props.webhook, (newWebhook) => {
|
||||||
|
if (newWebhook) {
|
||||||
|
Object.assign(form, {
|
||||||
|
name: newWebhook.name,
|
||||||
|
url: newWebhook.url,
|
||||||
|
template: newWebhook.template,
|
||||||
|
customBody: newWebhook.customBody,
|
||||||
|
headers: [...newWebhook.headers],
|
||||||
|
subscribedActions: [...newWebhook.subscribedActions],
|
||||||
|
verifySSL: newWebhook.verifySSL,
|
||||||
|
enabled: newWebhook.enabled
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Object.assign(form, getDefaultForm())
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('submit', { ...form })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Configured Webhooks</h3>
|
||||||
|
<button
|
||||||
|
@click="$emit('add')"
|
||||||
|
class="px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="webhooks.length === 0" class="text-center py-8 text-white/50">
|
||||||
|
No webhooks configured yet. Click "Add Webhook" to create one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="webhook in webhooks"
|
||||||
|
:key="webhook.id"
|
||||||
|
class="bg-white/5 backdrop-blur-md border border-white/10 rounded-xl p-4 hover:border-cyan-400/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="font-medium text-white">{{ webhook.name }}</h4>
|
||||||
|
<span
|
||||||
|
v-if="webhook.enabled"
|
||||||
|
class="px-2 py-0.5 text-xs bg-green-500/20 text-green-400 rounded-full"
|
||||||
|
>
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="px-2 py-0.5 text-xs bg-red-500/20 text-red-400 rounded-full"
|
||||||
|
>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="webhook.template"
|
||||||
|
class="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded-full"
|
||||||
|
>
|
||||||
|
{{ webhook.template }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/70">{{ webhook.url }}</p>
|
||||||
|
<p class="text-xs text-white/50">
|
||||||
|
Subscribed to: {{ webhook.subscribedActions.join(', ') || 'None' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="$emit('edit', webhook)"
|
||||||
|
class="p-2 text-cyan-400 hover:text-cyan-300 hover:bg-cyan-500/10 rounded-lg transition-colors"
|
||||||
|
title="Edit webhook"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="$emit('delete', webhook.id)"
|
||||||
|
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Delete webhook"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Webhook } from '../types/webhook'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
webhooks: Webhook[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'add'): void
|
||||||
|
(e: 'edit', webhook: Webhook): void
|
||||||
|
(e: 'delete', id: string): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:opacity-90 transition-opacity"
|
class="px-6 py-2 bg-gradient-to-r dark:from-blue-500 dark:to-purple-600 from-blue-600 to-purple-700 text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white/10 backdrop-blur-md border border-white/20 rounded-xl shadow-glass p-6">
|
<div class="dark:bg-white/10 dark:border-white/20 bg-gray-100/80 border-gray-200/50 backdrop-blur-md rounded-xl shadow-glass p-6">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
class="w-full bg-white/5 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
class="w-full dark:bg-white/5 dark:border-white/20 bg-gray-100 border-gray-300 rounded-lg px-4 py-2 dark:text-white text-gray-900 dark:placeholder-white/50 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="relative w-12 h-6 rounded-full transition-colors"
|
class="relative w-12 h-6 rounded-full transition-colors"
|
||||||
:class="modelValue ? 'bg-blue-500' : 'bg-gray-600'"
|
:class="modelValue ? 'bg-blue-500' : 'dark:bg-gray-600 bg-gray-300'"
|
||||||
@click="$emit('update:modelValue', !modelValue)"
|
@click="$emit('update:modelValue', !modelValue)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
|
import { useDark, useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'light' | 'auto'
|
||||||
|
|
||||||
|
const theme = useStorage<Theme>('wgrplane-theme', 'auto')
|
||||||
|
|
||||||
|
const isDark = useDark({
|
||||||
|
selector: 'html',
|
||||||
|
attribute: 'class',
|
||||||
|
valueDark: 'dark',
|
||||||
|
valueLight: 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(theme, (newTheme) => {
|
||||||
|
if (newTheme === 'auto') {
|
||||||
|
localStorage.removeItem('vueuse-color-scheme')
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('vueuse-color-scheme', newTheme)
|
||||||
|
}
|
||||||
|
if (newTheme === 'dark') {
|
||||||
|
isDark.value = true
|
||||||
|
} else if (newTheme === 'light') {
|
||||||
|
isDark.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
isDark,
|
||||||
|
toggleTheme: (newTheme: Theme) => {
|
||||||
|
theme.value = newTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { ref, onUnmounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface PeerStats {
|
||||||
|
publicKey: string
|
||||||
|
ip: string
|
||||||
|
rxBytes: number
|
||||||
|
txBytes: number
|
||||||
|
lastHandshake: string
|
||||||
|
isOnline: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficDataPoint {
|
||||||
|
timestamp: number
|
||||||
|
rxBytes: number
|
||||||
|
txBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketStats {
|
||||||
|
totalPeers: number
|
||||||
|
activePeers: number
|
||||||
|
totalRules: number
|
||||||
|
trafficHistory: TrafficDataPoint[]
|
||||||
|
peers: PeerStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseWebSocketReturn {
|
||||||
|
isConnected: Ref<boolean>
|
||||||
|
stats: Ref<WebSocketStats>
|
||||||
|
error: Ref<string | null>
|
||||||
|
connect: () => void
|
||||||
|
disconnect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATS: WebSocketStats = {
|
||||||
|
totalPeers: 0,
|
||||||
|
activePeers: 0,
|
||||||
|
totalRules: 0,
|
||||||
|
trafficHistory: [],
|
||||||
|
peers: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(url: string = 'ws://localhost:8080/ws/stats'): UseWebSocketReturn {
|
||||||
|
const isConnected = ref(false)
|
||||||
|
const stats = ref<WebSocketStats>({ ...DEFAULT_STATS })
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const reconnectDelay = 3000
|
||||||
|
let trafficBuffer: TrafficDataPoint[] = []
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
isConnected.value = true
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (data.peers !== undefined) {
|
||||||
|
stats.value.peers = data.peers
|
||||||
|
stats.value.totalPeers = data.peers.length
|
||||||
|
stats.value.activePeers = data.peers.filter((p: PeerStats) => p.isOnline).length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.totalRules !== undefined) {
|
||||||
|
stats.value.totalRules = data.totalRules
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.traffic) {
|
||||||
|
const point: TrafficDataPoint = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rxBytes: data.traffic.rxBytes || 0,
|
||||||
|
txBytes: data.traffic.txBytes || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trafficBuffer.push(point)
|
||||||
|
if (trafficBuffer.length > 60) trafficBuffer.shift()
|
||||||
|
|
||||||
|
stats.value.trafficHistory = [...trafficBuffer]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.stats) {
|
||||||
|
stats.value = { ...stats.value, ...data.stats }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse WebSocket message:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
error.value = 'WebSocket connection error'
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
isConnected.value = false
|
||||||
|
ws = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Failed to connect: ${e}`
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect()
|
||||||
|
}, reconnectDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
isConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
stats,
|
||||||
|
error,
|
||||||
|
connect,
|
||||||
|
disconnect
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import en from './locales/en.json'
|
||||||
|
import id from './locales/id.json'
|
||||||
|
import zh from './locales/zh.json'
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: {
|
||||||
|
en,
|
||||||
|
id,
|
||||||
|
zh
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"settings": "Settings",
|
||||||
|
"language": "Language",
|
||||||
|
"theme": "Theme",
|
||||||
|
"dark": "Dark",
|
||||||
|
"light": "Light",
|
||||||
|
"auto": "Auto",
|
||||||
|
"english": "English",
|
||||||
|
"indonesian": "Indonesian",
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"clients": "Clients",
|
||||||
|
"logs": "Logs",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"dashboardSettings": "Dashboard Settings",
|
||||||
|
"totpSetup": "TOTP Setup",
|
||||||
|
"smtpSettings": "SMTP Settings",
|
||||||
|
"enableSMTP": "Enable SMTP",
|
||||||
|
"server": "Server",
|
||||||
|
"port": "Port",
|
||||||
|
"useTLS": "Use TLS",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"fromEmail": "From Email",
|
||||||
|
"fromName": "From Name",
|
||||||
|
"testEmail": "Test Email",
|
||||||
|
"saveSettings": "Save Settings"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"settings": "Pengaturan",
|
||||||
|
"language": "Bahasa",
|
||||||
|
"theme": "Tema",
|
||||||
|
"dark": "Gelap",
|
||||||
|
"light": "Terang",
|
||||||
|
"auto": "Otomatis",
|
||||||
|
"english": "Inggris",
|
||||||
|
"indonesian": "Indonesia",
|
||||||
|
"chinese": "Mandarin",
|
||||||
|
"dashboard": "Dasbor",
|
||||||
|
"clients": "Klien",
|
||||||
|
"logs": "Log",
|
||||||
|
"save": "Simpan",
|
||||||
|
"cancel": "Batal",
|
||||||
|
"dashboardSettings": "Pengaturan Dasbor",
|
||||||
|
"totpSetup": "Pengaturan TOTP",
|
||||||
|
"smtpSettings": "Pengaturan SMTP",
|
||||||
|
"enableSMTP": "Aktifkan SMTP",
|
||||||
|
"server": "Server",
|
||||||
|
"port": "Port",
|
||||||
|
"useTLS": "Gunakan TLS",
|
||||||
|
"username": "Nama Pengguna",
|
||||||
|
"password": "Kata Sandi",
|
||||||
|
"fromEmail": "Email Pengirim",
|
||||||
|
"fromName": "Nama Pengirim",
|
||||||
|
"testEmail": "Tes Email",
|
||||||
|
"saveSettings": "Simpan Pengaturan"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"settings": "设置",
|
||||||
|
"language": "语言",
|
||||||
|
"theme": "主题",
|
||||||
|
"dark": "深色",
|
||||||
|
"light": "浅色",
|
||||||
|
"auto": "自动",
|
||||||
|
"english": "英语",
|
||||||
|
"indonesian": "印尼语",
|
||||||
|
"chinese": "中文",
|
||||||
|
"dashboard": "仪表盘",
|
||||||
|
"clients": "客户端",
|
||||||
|
"logs": "日志",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"dashboardSettings": "仪表盘设置",
|
||||||
|
"totpSetup": "TOTP 设置",
|
||||||
|
"smtpSettings": "SMTP 设置",
|
||||||
|
"enableSMTP": "启用 SMTP",
|
||||||
|
"server": "服务器",
|
||||||
|
"port": "端口",
|
||||||
|
"useTLS": "使用 TLS",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"fromEmail": "发件人邮箱",
|
||||||
|
"fromName": "发件人名称",
|
||||||
|
"testEmail": "测试邮件",
|
||||||
|
"saveSettings": "保存设置"
|
||||||
|
}
|
||||||
@@ -1,60 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import { i18n } from './i18n'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import typescriptLogo from './assets/typescript.svg'
|
|
||||||
import viteLogo from './assets/vite.svg'
|
|
||||||
import heroImg from './assets/hero.png'
|
|
||||||
import { setupCounter } from './counter.ts'
|
|
||||||
|
|
||||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
const app = createApp(App)
|
||||||
<section id="center">
|
app.use(router)
|
||||||
<div class="hero">
|
app.use(i18n)
|
||||||
<img src="${heroImg}" class="base" width="170" height="179">
|
app.mount('#app')
|
||||||
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
|
|
||||||
<img src="${viteLogo}" class="vite" alt="Vite logo" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1>Get started</h1>
|
|
||||||
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
|
|
||||||
</div>
|
|
||||||
<button id="counter" type="button" class="counter"></button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img class="logo" src="${viteLogo}" alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://www.typescriptlang.org" target="_blank">
|
|
||||||
<img class="button-icon" src="${typescriptLogo}" alt="">
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
|
|
||||||
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
|
|
||||||
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
|
|
||||||
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
`
|
|
||||||
|
|
||||||
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('../views/HomeView.vue'),
|
||||||
|
meta: { title: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/server/:id',
|
||||||
|
name: 'server-detail',
|
||||||
|
component: () => import('../views/ServerDetailView.vue'),
|
||||||
|
meta: { title: 'Server Detail' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/server/:id/peers',
|
||||||
|
name: 'peers',
|
||||||
|
component: () => import('../views/PeersView.vue'),
|
||||||
|
meta: { title: 'Peer Management' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/server/:id/webhooks',
|
||||||
|
name: 'webhooks',
|
||||||
|
component: () => import('../views/WebhooksView.vue'),
|
||||||
|
meta: { title: 'Webhook Management' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('../views/SettingsView.vue'),
|
||||||
|
meta: { title: 'Settings' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
document.title = `${to.meta.title || 'WGRplane'} - WGRplane`
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
+6
-289
@@ -1,296 +1,13 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
--text: #6b6375;
|
@tailwind components;
|
||||||
--text-h: #08060d;
|
@tailwind utilities;
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 1126px;
|
min-height: 100vh;
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 0;
|
|
||||||
border-left-color: var(--border);
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
right: 0;
|
|
||||||
border-right-color: var(--border);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface WebhookHeader {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Webhook {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
template: string
|
||||||
|
customBody: string
|
||||||
|
headers: WebhookHeader[]
|
||||||
|
subscribedActions: string[]
|
||||||
|
verifySSL: boolean
|
||||||
|
enabled: boolean
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockWebhooks: Webhook[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Slack Alerts',
|
||||||
|
url: 'https://hooks.slack.com/services/xxx/yyy/zzz',
|
||||||
|
template: 'slack',
|
||||||
|
customBody: '',
|
||||||
|
headers: [{ key: 'Content-Type', value: 'application/json' }],
|
||||||
|
subscribedActions: ['peer.connected', 'peer.disconnected'],
|
||||||
|
verifySSL: true,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2026-04-15T08:30:00Z',
|
||||||
|
updatedAt: '2026-05-01T14:20:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Discord Notifications',
|
||||||
|
url: 'https://discord.com/api/webhooks/xxx/yyy',
|
||||||
|
template: 'discord',
|
||||||
|
customBody: '',
|
||||||
|
headers: [{ key: 'Content-Type', value: 'application/json' }],
|
||||||
|
subscribedActions: ['policy.updated', 'server.started'],
|
||||||
|
verifySSL: true,
|
||||||
|
enabled: false,
|
||||||
|
createdAt: '2026-04-20T10:15:00Z',
|
||||||
|
updatedAt: '2026-04-28T09:45:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-3xl font-bold text-white">Dashboard</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
:class="isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'"
|
||||||
|
></div>
|
||||||
|
<span class="text-sm text-white/70">
|
||||||
|
{{ isConnected ? 'Live' : 'Disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-white/50 text-sm">Total Peers</p>
|
||||||
|
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ stats.totalPeers }}</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-white/50 text-sm">Active Peers</p>
|
||||||
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold text-green-400">{{ stats.activePeers }}</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-white/50 text-sm">Active Rules</p>
|
||||||
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-3xl font-bold text-purple-400">{{ stats.totalRules }}</p>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Traffic Overview</h2>
|
||||||
|
<div class="h-64">
|
||||||
|
<Line v-if="chartData.datasets[0].data.length > 0" :data="chartData" :options="chartOptions" />
|
||||||
|
<div v-else class="flex items-center justify-center h-full text-white/30">
|
||||||
|
Waiting for data...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Status</h2>
|
||||||
|
<div v-if="stats.peers.length > 0" class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="peer in stats.peers"
|
||||||
|
:key="peer.publicKey"
|
||||||
|
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
:class="peer.isOnline ? 'bg-green-400' : 'bg-gray-500'"
|
||||||
|
></div>
|
||||||
|
<span class="text-white font-mono text-sm">{{ peer.ip }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-white/50 text-xs">
|
||||||
|
RX: {{ formatBytes(peer.rxBytes) }} | TX: {{ formatBytes(peer.txBytes) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-white/30">No peer data available</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<div v-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
import GlassCard from '../components/glass/GlassCard.vue'
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
)
|
||||||
|
|
||||||
|
const { isConnected, stats, error, connect } = useWebSocket('ws://localhost:8080/ws/stats')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const history = stats.value.trafficHistory || []
|
||||||
|
return {
|
||||||
|
labels: history.map((_, i) => `${i}s`),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Download (RX)',
|
||||||
|
data: history.map(p => p.rxBytes),
|
||||||
|
borderColor: 'rgb(34, 211, 238)',
|
||||||
|
backgroundColor: 'rgba(34, 211, 238, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Upload (TX)',
|
||||||
|
data: history.map(p => p.txBytes),
|
||||||
|
borderColor: 'rgb(168, 85, 247)',
|
||||||
|
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#fff',
|
||||||
|
callbacks: {
|
||||||
|
label: (context: any) => {
|
||||||
|
return `${context.dataset.label}: ${formatBytes(context.parsed.y)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: 'rgba(255, 255, 255, 0.5)' },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
callback: (value: number) => formatBytes(value)
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<GlassButton @click="$router.back()" class="!px-3 !py-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</GlassButton>
|
||||||
|
<h1 class="text-3xl font-bold text-white">Peer Management</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
|
||||||
|
|
||||||
|
<PeerTable :server-id="$route.params.id" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GlassButton from '../components/glass/GlassButton.vue'
|
||||||
|
import PeerTable from '../components/PeerTable.vue'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<GlassButton @click="$router.back()" class="!px-3 !py-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</GlassButton>
|
||||||
|
<h1 class="text-3xl font-bold text-white">Server Detail</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Server Information</h2>
|
||||||
|
<p class="text-white/50">Server details will be displayed here.</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Table</h2>
|
||||||
|
<p class="text-white/50">Peer list for this server will be displayed here.</p>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import GlassCard from '../components/glass/GlassCard.vue'
|
||||||
|
import GlassButton from '../components/glass/GlassButton.vue'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold text-white">{{ t('settings') }}</h1>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('dashboardSettings') }}</h2>
|
||||||
|
<p class="text-white/50">Dashboard configuration options will be displayed here.</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Language Settings -->
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('language') }}</h2>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<GlassButton
|
||||||
|
@click="switchLanguage('en')"
|
||||||
|
:class="locale === 'en' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('english') }}
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
@click="switchLanguage('id')"
|
||||||
|
:class="locale === 'id' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('indonesian') }}
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
@click="switchLanguage('zh')"
|
||||||
|
:class="locale === 'zh' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('chinese') }}
|
||||||
|
</GlassButton>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Theme Settings -->
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('theme') }}</h2>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<GlassButton
|
||||||
|
@click="toggleTheme('dark')"
|
||||||
|
:class="theme === 'dark' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('dark') }}
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
@click="toggleTheme('light')"
|
||||||
|
:class="theme === 'light' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('light') }}
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
@click="toggleTheme('auto')"
|
||||||
|
:class="theme === 'auto' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
|
||||||
|
class="px-4 py-2"
|
||||||
|
>
|
||||||
|
{{ t('auto') }}
|
||||||
|
</GlassButton>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('totpSetup') }}</h2>
|
||||||
|
<p class="text-white/50">Two-factor authentication setup will be displayed here.</p>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('smtpSettings') }}</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<span class="text-white">{{ t('enableSMTP') }}</span>
|
||||||
|
<GlassToggle v-model="smtpSettings.Enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('server') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.Server" placeholder="smtp.example.com" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('port') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.Port" type="number" placeholder="587" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/70">{{ t('useTLS') }}</span>
|
||||||
|
<GlassToggle v-model="smtpSettings.UseTLS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('username') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.Username" placeholder="user@example.com" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('password') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.Password" type="password" placeholder="SMTP Password" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('fromEmail') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.FromEmail" type="email" placeholder="noreply@example.com" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-white/70 mb-1">{{ t('fromName') }}</label>
|
||||||
|
<GlassInput v-model="smtpSettings.FromName" placeholder="WireGuard VPN" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 mt-6">
|
||||||
|
<GlassButton @click="testEmail" class="from-cyan-500 to-blue-500">{{ t('testEmail') }}</GlassButton>
|
||||||
|
<GlassButton @click="saveSettings" class="from-green-500 to-teal-500">{{ t('saveSettings') }}</GlassButton>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import GlassCard from '../components/glass/GlassCard.vue'
|
||||||
|
import GlassInput from '../components/glass/GlassInput.vue'
|
||||||
|
import GlassToggle from '../components/glass/GlassToggle.vue'
|
||||||
|
import GlassButton from '../components/glass/GlassButton.vue'
|
||||||
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const smtpSettings = reactive({
|
||||||
|
Enabled: false,
|
||||||
|
Server: '',
|
||||||
|
Port: 587,
|
||||||
|
UseTLS: true,
|
||||||
|
Username: '',
|
||||||
|
Password: '',
|
||||||
|
FromEmail: '',
|
||||||
|
FromName: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchLanguage = (lang: string) => {
|
||||||
|
locale.value = lang
|
||||||
|
}
|
||||||
|
|
||||||
|
const testEmail = () => {
|
||||||
|
console.log('Test Email with settings:', JSON.parse(JSON.stringify(smtpSettings)))
|
||||||
|
alert('Test email functionality (mock) - check console for settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSettings = () => {
|
||||||
|
console.log('Saving SMTP settings (mock):', JSON.parse(JSON.stringify(smtpSettings)))
|
||||||
|
alert('SMTP settings saved (mock)')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<GlassButton @click="$router.back()" class="!px-3 !py-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</GlassButton>
|
||||||
|
<h1 class="text-3xl font-bold text-white">Webhook Management</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
|
||||||
|
|
||||||
|
<GlassCard class="p-6">
|
||||||
|
<WebhookList
|
||||||
|
:webhooks="webhooks"
|
||||||
|
@add="showAddForm"
|
||||||
|
@edit="showEditForm"
|
||||||
|
@delete="deleteWebhook"
|
||||||
|
/>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Add/Edit Form Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50"
|
||||||
|
@click.self="closeForm"
|
||||||
|
>
|
||||||
|
<div class="bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl rounded-2xl p-6 border border-white/20 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-cyan-400">
|
||||||
|
{{ editingWebhook ? 'Edit Webhook' : 'Add New Webhook' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="closeForm"
|
||||||
|
class="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<WebhookForm
|
||||||
|
:webhook="editingWebhook"
|
||||||
|
:submit-label="editingWebhook ? 'Update Webhook' : 'Create Webhook'"
|
||||||
|
:show-cancel="true"
|
||||||
|
@submit="saveWebhook"
|
||||||
|
@cancel="closeForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import GlassCard from '../components/glass/GlassCard.vue'
|
||||||
|
import GlassButton from '../components/glass/GlassButton.vue'
|
||||||
|
import WebhookList from '../components/WebhookList.vue'
|
||||||
|
import WebhookForm from '../components/WebhookForm.vue'
|
||||||
|
import { mockWebhooks, type Webhook } from '../types/webhook'
|
||||||
|
|
||||||
|
const webhooks = ref<Webhook[]>([...mockWebhooks])
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingWebhook = ref<Webhook | undefined>(undefined)
|
||||||
|
|
||||||
|
const showAddForm = () => {
|
||||||
|
editingWebhook.value = undefined
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEditForm = (webhook: Webhook) => {
|
||||||
|
editingWebhook.value = webhook
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
showForm.value = false
|
||||||
|
editingWebhook.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveWebhook = (webhookData: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
|
if (editingWebhook.value) {
|
||||||
|
// Update existing webhook
|
||||||
|
const index = webhooks.value.findIndex(w => w.id === editingWebhook.value!.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
webhooks.value[index] = {
|
||||||
|
...webhooks.value[index],
|
||||||
|
...webhookData,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new webhook
|
||||||
|
const newWebhook: Webhook = {
|
||||||
|
...webhookData,
|
||||||
|
id: Date.now().toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
webhooks.value.push(newWebhook)
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteWebhook = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this webhook?')) {
|
||||||
|
webhooks.value = webhooks.value.filter(w => w.id !== id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ export default {
|
|||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
backdropBlur: {
|
backdropBlur: {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
module git.datadunia.com/hainzero/WGRplane
|
|
||||||
|
|
||||||
go 1.20
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
|
||||||
golang.org/x/text v0.20.0 // indirect
|
|
||||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
|
||||||
gorm.io/gorm v1.31.1 // indirect
|
|
||||||
)
|
|
||||||
+41
-6
@@ -1,18 +1,53 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
|
|||||||
+785
@@ -0,0 +1,785 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper wrappers
|
||||||
|
func respondJSON(w http.ResponseWriter, status int, payload interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondError(w http.ResponseWriter, status int, message string) {
|
||||||
|
respondJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServers lists all registered WireGuard servers.
|
||||||
|
// @Summary List all servers
|
||||||
|
// @Description Returns a list of all registered WireGuard servers.
|
||||||
|
// @Tags servers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} Server
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers [get]
|
||||||
|
func getServers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var servers []Server
|
||||||
|
if err := db.Find(&servers).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServer creates a new WireGuard server entry.
|
||||||
|
// @Summary Create a server
|
||||||
|
// @Description Creates a new WireGuard server with the provided configuration.
|
||||||
|
// @Tags servers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param server body Server true "Server configuration"
|
||||||
|
// @Success 201 {object} Server
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers [post]
|
||||||
|
func createServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var s Server
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Input validation
|
||||||
|
if !ValidatePublicKey(s.PublicKey) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid public key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&s).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServer retrieves a single WireGuard server by ID.
|
||||||
|
// @Summary Get a server
|
||||||
|
// @Description Returns a single WireGuard server by its ID.
|
||||||
|
// @Tags servers
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Success 200 {object} Server
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id} [get]
|
||||||
|
func getServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServer updates an existing WireGuard server.
|
||||||
|
// @Summary Update a server
|
||||||
|
// @Description Updates fields of an existing WireGuard server.
|
||||||
|
// @Tags servers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Param server body Server true "Updated server fields"
|
||||||
|
// @Success 200 {object} Server
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id} [put]
|
||||||
|
func updateServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updates Server
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Input validation for updated fields
|
||||||
|
if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid public key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Apply updates using fields defined in models.go
|
||||||
|
s.Name = updates.Name
|
||||||
|
s.Mode = updates.Mode
|
||||||
|
s.PublicKey = updates.PublicKey
|
||||||
|
s.Endpoint = updates.Endpoint
|
||||||
|
if err := db.Save(&s).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServer removes a WireGuard server and cascades to its peers and webhooks.
|
||||||
|
// @Summary Delete a server
|
||||||
|
// @Description Deletes a WireGuard server and cascade-removes its peers and webhooks.
|
||||||
|
// @Tags servers
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Success 204 "No content"
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id} [delete]
|
||||||
|
func deleteServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Delete(&s).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServerPeers lists all peers for a given WireGuard server.
|
||||||
|
// @Summary List server peers
|
||||||
|
// @Description Returns all peers belonging to a specific WireGuard server.
|
||||||
|
// @Tags peers
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Success 200 {array} Peer
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id}/peers [get]
|
||||||
|
func getServerPeers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
// Ensure server exists
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var peers []Peer
|
||||||
|
if err := db.Where("server_id = ?", id).Find(&peers).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServerPeer creates a new peer for a WireGuard server.
|
||||||
|
// @Summary Create a peer
|
||||||
|
// @Description Creates a new WireGuard peer. Applies nftables rules in forward mode or triggers webhooks in standalone mode.
|
||||||
|
// @Tags peers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Param peer body Peer true "Peer configuration"
|
||||||
|
// @Success 201 {object} Peer
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id}/peers [post]
|
||||||
|
func createServerPeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
// ensure server exists
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p Peer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Input validation for peer
|
||||||
|
if !ValidatePublicKey(p.PublicKey) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid public key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ValidateIP(p.IP) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid ip address for peer IP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Optional: validate AllowAccess CIDRs if provided
|
||||||
|
if len(p.AllowAccess) > 0 {
|
||||||
|
var targets []string
|
||||||
|
if err := json.Unmarshal(p.AllowAccess, &targets); err == nil {
|
||||||
|
for _, t := range targets {
|
||||||
|
if !ValidateCIDR(t) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.ServerID = s.ID
|
||||||
|
if err := db.Create(&p).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Trigger mode-specific actions after creation
|
||||||
|
policyChanges := []string{"peer_created"}
|
||||||
|
if s.Mode == "forward" {
|
||||||
|
// Apply nftables rules for the new peer
|
||||||
|
if len(p.AllowAccess) > 0 {
|
||||||
|
var targets []string
|
||||||
|
if err := json.Unmarshal(p.AllowAccess, &targets); err == nil {
|
||||||
|
for _, t := range targets {
|
||||||
|
_ = AddAccessRule(p.IP, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = SetInternetAccess(p.IP, p.AllowInternet)
|
||||||
|
// Notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges)
|
||||||
|
} else if s.Mode == "standalone" {
|
||||||
|
// Notify remote via webhooks
|
||||||
|
_ = TriggerWebhook("peer_created", &s, &p, "created", []string{})
|
||||||
|
// Also notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges)
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePeer updates an existing WireGuard peer.
|
||||||
|
// @Summary Update a peer
|
||||||
|
// @Description Updates a peer and computes diffs to apply incremental nftables changes.
|
||||||
|
// @Tags peers
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Peer ID"
|
||||||
|
// @Param peer body Peer true "Updated peer fields"
|
||||||
|
// @Success 200 {object} Peer
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/peers/{id} [put]
|
||||||
|
func updatePeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var p Peer
|
||||||
|
if err := db.First(&p, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "peer not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updates Peer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Preserve the original peer for diff computation
|
||||||
|
oldP := p
|
||||||
|
// Validate updated fields
|
||||||
|
if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid public key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updates.IP != "" && !ValidateIP(updates.IP) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid ip address for peer IP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(updates.AllowAccess) > 0 {
|
||||||
|
var targets []string
|
||||||
|
if err := json.Unmarshal(updates.AllowAccess, &targets); err == nil {
|
||||||
|
for _, t := range targets {
|
||||||
|
if !ValidateCIDR(t) {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Build a list of policy-related changes for notification purposes
|
||||||
|
changes := []string{}
|
||||||
|
if updates.PublicKey != "" && updates.PublicKey != p.PublicKey {
|
||||||
|
changes = append(changes, "PublicKey updated")
|
||||||
|
}
|
||||||
|
if updates.IP != "" && updates.IP != p.IP {
|
||||||
|
changes = append(changes, "IP updated")
|
||||||
|
}
|
||||||
|
if len(updates.AllowAccess) > 0 && string(updates.AllowAccess) != string(p.AllowAccess) {
|
||||||
|
changes = append(changes, "AllowAccess updated")
|
||||||
|
}
|
||||||
|
if updates.AllowInternet != p.AllowInternet {
|
||||||
|
changes = append(changes, "AllowInternet updated")
|
||||||
|
}
|
||||||
|
// Apply updates based on the Peer model fields
|
||||||
|
p.PublicKey = updates.PublicKey
|
||||||
|
p.IP = updates.IP
|
||||||
|
p.AllowAccess = updates.AllowAccess
|
||||||
|
p.AllowInternet = updates.AllowInternet
|
||||||
|
if err := db.Save(&p).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If policy-related fields changed, apply mode-specific actions
|
||||||
|
if len(changes) > 0 {
|
||||||
|
var server Server
|
||||||
|
if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
|
||||||
|
if server.Mode == "forward" {
|
||||||
|
// nftables-based updates: diff AllowAccess and IP/internet changes
|
||||||
|
// Compute targets diffs between old and new
|
||||||
|
var oldTargets []string
|
||||||
|
if len(oldP.AllowAccess) > 0 {
|
||||||
|
_ = json.Unmarshal(oldP.AllowAccess, &oldTargets)
|
||||||
|
}
|
||||||
|
var newTargets []string
|
||||||
|
if len(p.AllowAccess) > 0 {
|
||||||
|
_ = json.Unmarshal(p.AllowAccess, &newTargets)
|
||||||
|
}
|
||||||
|
// Determine additions/removals
|
||||||
|
added := []string{}
|
||||||
|
for _, t := range newTargets {
|
||||||
|
found := false
|
||||||
|
for _, o := range oldTargets {
|
||||||
|
if o == t { found = true; break }
|
||||||
|
}
|
||||||
|
if !found { added = append(added, t) }
|
||||||
|
}
|
||||||
|
removed := []string{}
|
||||||
|
for _, o := range oldTargets {
|
||||||
|
found := false
|
||||||
|
for _, t := range newTargets {
|
||||||
|
if o == t { found = true; break }
|
||||||
|
}
|
||||||
|
if !found { removed = append(removed, o) }
|
||||||
|
}
|
||||||
|
// Apply removals first for safety
|
||||||
|
fromIP := oldP.IP
|
||||||
|
if oldP.IP == "" {
|
||||||
|
fromIP = p.IP
|
||||||
|
}
|
||||||
|
for _, t := range removed {
|
||||||
|
_ = RemoveAccessRule(fromIP, t)
|
||||||
|
}
|
||||||
|
// Apply additions for the current IP
|
||||||
|
toIP := p.IP
|
||||||
|
for _, t := range added {
|
||||||
|
_ = AddAccessRule(toIP, t)
|
||||||
|
}
|
||||||
|
// Internet access flag
|
||||||
|
_ = SetInternetAccess(p.IP, p.AllowInternet)
|
||||||
|
// Notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &server, &p, "updated", changes)
|
||||||
|
} else if server.Mode == "standalone" {
|
||||||
|
_ = TriggerWebhook("peer_updated", &server, &p, "updated", changes)
|
||||||
|
// Also notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &server, &p, "updated", changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePeer removes a WireGuard peer and cleans up nftables rules.
|
||||||
|
// @Summary Delete a peer
|
||||||
|
// @Description Deletes a peer, cleans up nftables rules, and triggers webhooks.
|
||||||
|
// @Tags peers
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Peer ID"
|
||||||
|
// @Success 204 "No content"
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/peers/{id} [delete]
|
||||||
|
func deletePeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var p Peer
|
||||||
|
if err := db.First(&p, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "peer not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If in forward mode, remove any NFTables rules for this peer before deletion
|
||||||
|
var server Server
|
||||||
|
if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
|
||||||
|
if server.Mode == "forward" {
|
||||||
|
var targets []string
|
||||||
|
if len(p.AllowAccess) > 0 {
|
||||||
|
_ = json.Unmarshal(p.AllowAccess, &targets)
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
_ = RemoveAccessRule(p.IP, t)
|
||||||
|
}
|
||||||
|
_ = SetInternetAccess(p.IP, false)
|
||||||
|
// Notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Delete(&p).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Notify external systems about the deletion according to server mode
|
||||||
|
if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
|
||||||
|
if server.Mode == "standalone" {
|
||||||
|
_ = TriggerWebhook("peer_deleted", &server, &p, "deleted", []string{})
|
||||||
|
// Also notify policy change
|
||||||
|
_ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSMTPSettings retrieves the current SMTP configuration.
|
||||||
|
// @Summary Get SMTP settings
|
||||||
|
// @Description Returns the current SMTP configuration for email notifications.
|
||||||
|
// @Tags settings
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} SMTPSettings
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/settings/smtp [get]
|
||||||
|
func getSMTPSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := GetSMTPSettings()
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSMTPSettings saves SMTP configuration for email notifications.
|
||||||
|
// @Summary Save SMTP settings
|
||||||
|
// @Description Saves or updates SMTP configuration for email notifications.
|
||||||
|
// @Tags settings
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param settings body SMTPSettings true "SMTP configuration"
|
||||||
|
// @Success 200 {object} SMTPSettings
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/settings/smtp [post]
|
||||||
|
func setSMTPSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var s SMTPSettings
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := SaveSMTPSettings(s); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServerWebhooks lists all webhooks for a WireGuard server.
|
||||||
|
// @Summary List server webhooks
|
||||||
|
// @Description Returns all webhooks configured for a specific WireGuard server.
|
||||||
|
// @Tags webhooks
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Success 200 {array} Webhook
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id}/webhooks [get]
|
||||||
|
func getServerWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var webhooks []Webhook
|
||||||
|
if err := db.Where("server_id = ?", id).Find(&webhooks).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, webhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServerWebhook creates a new webhook for a WireGuard server.
|
||||||
|
// @Summary Create a webhook
|
||||||
|
// @Description Creates a new webhook configuration for a WireGuard server.
|
||||||
|
// @Tags webhooks
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Param webhook body Webhook true "Webhook configuration"
|
||||||
|
// @Success 201 {object} Webhook
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id}/webhooks [post]
|
||||||
|
func createServerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wb Webhook
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&wb); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wb.ServerID = s.ID
|
||||||
|
if err := db.Create(&wb).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, wb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWebhook removes a webhook configuration.
|
||||||
|
// @Summary Delete a webhook
|
||||||
|
// @Description Deletes a webhook by its ID.
|
||||||
|
// @Tags webhooks
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Webhook ID"
|
||||||
|
// @Success 204 "No content"
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/webhooks/{id} [delete]
|
||||||
|
func deleteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var wb Webhook
|
||||||
|
if err := db.First(&wb, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "webhook not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Delete(&wb).Error; err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPeerConfig downloads the WireGuard configuration file for a peer.
|
||||||
|
// @Summary Get peer config
|
||||||
|
// @Description Downloads the WireGuard .conf file for a specific peer.
|
||||||
|
// @Tags peers
|
||||||
|
// @Produce plain
|
||||||
|
// @Param id path string true "Peer ID"
|
||||||
|
// @Success 200 {string} string "WireGuard configuration file"
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/peers/{id}/config [get]
|
||||||
|
func getPeerConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var p Peer
|
||||||
|
if err := db.First(&p, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "peer not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found for peer")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf, err := GeneratePeerConfig(p, s)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=peer-%d.conf", p.ID))
|
||||||
|
w.Write(conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPeerQRCode returns a QR code PNG image for the peer configuration.
|
||||||
|
// @Summary Get peer QR code
|
||||||
|
// @Description Returns a QR code PNG image of the peer config for mobile import.
|
||||||
|
// @Tags peers
|
||||||
|
// @Produce png
|
||||||
|
// @Param id path string true "Peer ID"
|
||||||
|
// @Success 200 {file} binary "QR code PNG image"
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/peers/{id}/qrcode [get]
|
||||||
|
func getPeerQRCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var p Peer
|
||||||
|
if err := db.First(&p, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "peer not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found for peer")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf, err := GeneratePeerConfig(p, s)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
png, err := qrcode.Encode(string(conf), qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Write(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns global statistics for servers, peers, and webhooks.
|
||||||
|
// @Summary Get global stats
|
||||||
|
// @Description Returns global statistics including total servers, peers, and webhooks.
|
||||||
|
// @Tags stats
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]int64
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/stats [get]
|
||||||
|
func getStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var serverCount int64
|
||||||
|
var peerCount int64
|
||||||
|
var webhookCount int64
|
||||||
|
db.Model(&Server{}).Count(&serverCount)
|
||||||
|
db.Model(&Peer{}).Count(&peerCount)
|
||||||
|
db.Model(&Webhook{}).Count(&webhookCount)
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"servers": serverCount,
|
||||||
|
"peers": peerCount,
|
||||||
|
"webhooks": webhookCount,
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerStats returns per-server statistics.
|
||||||
|
// @Summary Get server stats
|
||||||
|
// @Description Returns per-server statistics including peer count and webhook count.
|
||||||
|
// @Tags stats
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/servers/{id}/stats [get]
|
||||||
|
func getServerStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
var s Server
|
||||||
|
if err := db.First(&s, "id = ?", id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
respondError(w, http.StatusNotFound, "server not found")
|
||||||
|
} else {
|
||||||
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var peers []Peer
|
||||||
|
var webhooks []Webhook
|
||||||
|
db.Where("server_id = ?", id).Find(&peers)
|
||||||
|
db.Where("server_id = ?", id).Find(&webhooks)
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"server": s,
|
||||||
|
"peers": len(peers),
|
||||||
|
"webhooks": len(webhooks),
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bundle *i18n.Bundle
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := initI18nBundle(); err != nil {
|
||||||
|
// Fail fast during startup in tests/builds
|
||||||
|
log.Fatalf("failed to initialize i18n bundle: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initI18nBundle() error {
|
||||||
|
// Initialize the i18n bundle with English as default
|
||||||
|
bundle = i18n.NewBundle(language.English)
|
||||||
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
|
|
||||||
|
// Load translation files from app/ directory
|
||||||
|
files := []string{
|
||||||
|
filepath.Join("app", "active.en.json"),
|
||||||
|
filepath.Join("app", "active.id.json"),
|
||||||
|
filepath.Join("app", "active.zh.json"),
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if _, err := bundle.LoadMessageFile(f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate returns the translated string for a given locale and message ID.
|
||||||
|
func Translate(locale, messageID string) string {
|
||||||
|
loc := i18n.NewLocalizer(bundle, locale)
|
||||||
|
s, err := loc.Localize(&i18n.LocalizeConfig{MessageID: messageID})
|
||||||
|
if err != nil || s == "" {
|
||||||
|
// Fallback to the messageID if translation is missing
|
||||||
|
return messageID
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
+28
-28
@@ -82,21 +82,21 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase A: Submodule Initialization
|
### Phase A: Submodule Initialization
|
||||||
|
|
||||||
- [ ] **Task A1**: Initialize WGRplane submodule at `/app`
|
- [x] **Task A1**: Initialize WGRplane submodule at `/app`
|
||||||
- File: `/app` (new submodule directory)
|
- File: `/app` (new submodule directory)
|
||||||
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
|
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
|
||||||
- Followed by: `git submodule update --init --recursive`
|
- Followed by: `git submodule update --init --recursive`
|
||||||
- QA: `git submodule status` shows `app` with commit hash, no errors
|
- QA: `git submodule status` shows `app` with commit hash, no errors
|
||||||
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend)
|
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend)
|
||||||
|
|
||||||
- [ ] **Task A2**: Verify WGRplane structure and dependencies
|
- [x] **Task A2**: Verify WGRplane structure and dependencies
|
||||||
- File: `/app` (submodule contents)
|
- File: `/app` (submodule contents)
|
||||||
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
|
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
|
||||||
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
|
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
|
||||||
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
|
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
|
||||||
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies
|
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies
|
||||||
|
|
||||||
- [ ] **Task A3**: Create Go module for `wg-engine-api` in `/app`
|
- [x] **Task A3**: Create Go module for `wg-engine-api` in `/app`
|
||||||
- File: `/app/wg-engine-api/main.go` (new)
|
- File: `/app/wg-engine-api/main.go` (new)
|
||||||
- File: `/app/wg-engine-api/go.mod` (new)
|
- File: `/app/wg-engine-api/go.mod` (new)
|
||||||
- Command: `cd /app/wg-engine-api && go mod init git.datadunia.com/hainzero/WGRplane/wg-engine-api`
|
- Command: `cd /app/wg-engine-api && go mod init git.datadunia.com/hainzero/WGRplane/wg-engine-api`
|
||||||
@@ -106,26 +106,26 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase B: WGRplane Base Setup (WGDashboard Parity)
|
### Phase B: WGRplane Base Setup (WGDashboard Parity)
|
||||||
|
|
||||||
- [ ] **Task B1**: Review WGDashboard features for parity checklist
|
- [x] **Task B1**: Review WGDashboard features for parity checklist
|
||||||
- Reference: Librarian findings (bg_f53966bd) — full feature list
|
- Reference: Librarian findings (bg_f53966bd) — full feature list
|
||||||
- Features to implement: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
|
- Features to implement: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
|
||||||
- File: `/app/README.md` (document feature parity status)
|
- File: `/app/README.md` (document feature parity status)
|
||||||
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation status
|
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation status
|
||||||
|
|
||||||
- [ ] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
|
- [x] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
|
||||||
- File: `/app/app.py` or `/app/config.json` (WGRplane config)
|
- File: `/app/app.py` or `/app/config.json` (WGRplane config)
|
||||||
- Set: `app_port = 10086` (consistent with WGDashboard)
|
- Set: `app_port = 10086` (consistent with WGDashboard)
|
||||||
- Ensure: Does not conflict with `wg-engine-api` on port 10087
|
- Ensure: Does not conflict with `wg-engine-api` on port 10087
|
||||||
- QA: `curl http://localhost:10086` returns WGRplane dashboard page
|
- QA: `curl http://localhost:10086` returns WGRplane dashboard page
|
||||||
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api
|
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api
|
||||||
|
|
||||||
- [ ] **Task B3**: Integrate WGRplane with existing WireGuard config path
|
- [x] **Task B3**: Integrate WGRplane with existing WireGuard config path
|
||||||
- File: `/app/app.py` (WGRplane backend)
|
- File: `/app/app.py` (WGRplane backend)
|
||||||
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts)
|
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts)
|
||||||
- QA: WGRplane can read `/etc/wireguard/wg0.conf` and list peers
|
- QA: WGRplane can read `/etc/wireguard/wg0.conf` and list peers
|
||||||
- QA: WGRplane "Add Peer" creates valid WireGuard config entries
|
- QA: WGRplane "Add Peer" creates valid WireGuard config entries
|
||||||
|
|
||||||
- [ ] **Task B4**: Add policy.json API awareness to WGRplane frontend
|
- [x] **Task B4**: Add policy.json API awareness to WGRplane frontend
|
||||||
- File: `/app/src/views/` or `/app/src/components/` (Vue.js components)
|
- File: `/app/src/views/` or `/app/src/components/` (Vue.js components)
|
||||||
- Add: New UI section for "Policy API" (link to `http://localhost:10087/api/policy`)
|
- Add: New UI section for "Policy API" (link to `http://localhost:10087/api/policy`)
|
||||||
- Note: WGRplane frontend will proxy or link to Go API (decision: proxy via Flask or direct link)
|
- Note: WGRplane frontend will proxy or link to Go API (decision: proxy via Flask or direct link)
|
||||||
@@ -134,7 +134,7 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase C: Go wg-engine-api Development
|
### Phase C: Go wg-engine-api Development
|
||||||
|
|
||||||
- [ ] **Task C1**: Implement Go API server skeleton with routing
|
- [x] **Task C1**: Implement Go API server skeleton with routing
|
||||||
- File: `/app/wg-engine-api/main.go`
|
- File: `/app/wg-engine-api/main.go`
|
||||||
- Framework: `github.com/gorilla/mux` (router)
|
- Framework: `github.com/gorilla/mux` (router)
|
||||||
- Port: **10087** (avoid conflict with WGDashboard's 10086)
|
- Port: **10087** (avoid conflict with WGDashboard's 10086)
|
||||||
@@ -143,7 +143,7 @@ iptables / ipset rules
|
|||||||
- QA: `go build` succeeds, binary runs on port 10087
|
- QA: `go build` succeeds, binary runs on port 10087
|
||||||
- QA: `curl -H "wg-rplane-datadunia: test" http://localhost:10087/api/policy` returns 200 or 401 (if auth enforced)
|
- QA: `curl -H "wg-rplane-datadunia: test" http://localhost:10087/api/policy` returns 200 or 401 (if auth enforced)
|
||||||
|
|
||||||
- [ ] **Task C2**: Implement locking mechanism (flock) in Go
|
- [x] **Task C2**: Implement locking mechanism (flock) in Go
|
||||||
- File: `/app/wg-engine-api/main.go` (lock function)
|
- File: `/app/wg-engine-api/main.go` (lock function)
|
||||||
- Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts)
|
- Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts)
|
||||||
- Implementation: Use `syscall.Flock()` or exec `flock` command
|
- Implementation: Use `syscall.Flock()` or exec `flock` command
|
||||||
@@ -151,7 +151,7 @@ iptables / ipset rules
|
|||||||
- QA: Simultaneous API calls do not corrupt `policy.json`
|
- QA: Simultaneous API calls do not corrupt `policy.json`
|
||||||
- QA: Lock acquired within 10 seconds, else return 503 (timeout)
|
- QA: Lock acquired within 10 seconds, else return 503 (timeout)
|
||||||
|
|
||||||
- [ ] **Task C3**: Implement atomic write for policy.json in Go
|
- [x] **Task C3**: Implement atomic write for policy.json in Go
|
||||||
- File: `/app/wg-engine-api/main.go` (write function)
|
- File: `/app/wg-engine-api/main.go` (write function)
|
||||||
- Pattern: Write to tmp file → `mv` (atomic, same filesystem)
|
- Pattern: Write to tmp file → `mv` (atomic, same filesystem)
|
||||||
- Reference: `wg-sync-policy.sh` lines 124-126: `mv -f "$tmp_policy" "$POLICY_FILE"`
|
- Reference: `wg-sync-policy.sh` lines 124-126: `mv -f "$tmp_policy" "$POLICY_FILE"`
|
||||||
@@ -159,7 +159,7 @@ iptables / ipset rules
|
|||||||
- QA: `policy.json` never partially written (crash during write doesn't corrupt)
|
- QA: `policy.json` never partially written (crash during write doesn't corrupt)
|
||||||
- QA: `jq empty /etc/wireguard/policy.json` validates JSON after write
|
- QA: `jq empty /etc/wireguard/policy.json` validates JSON after write
|
||||||
|
|
||||||
- [ ] **Task C4**: Add CLI flag for sync without HTTP server
|
- [x] **Task C4**: Add CLI flag for sync without HTTP server
|
||||||
- File: `/app/wg-engine-api/main.go` (flag parsing)
|
- File: `/app/wg-engine-api/main.go` (flag parsing)
|
||||||
- Flag: `--sync` (perform merge + write to `policy.json`, then exit)
|
- Flag: `--sync` (perform merge + write to `policy.json`, then exit)
|
||||||
- Use case: Called by `wg-policy.service` instead of `wg-sync-policy.sh`
|
- Use case: Called by `wg-policy.service` instead of `wg-sync-policy.sh`
|
||||||
@@ -168,7 +168,7 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase D: Policy API Implementation
|
### Phase D: Policy API Implementation
|
||||||
|
|
||||||
- [ ] **Task D1**: Implement `GET /api/policy` (merged: API + #Access fallback)
|
- [x] **Task D1**: Implement `GET /api/policy` (merged: API + #Access fallback)
|
||||||
- File: `/app/wg-engine-api/main.go` (GET handler)
|
- File: `/app/wg-engine-api/main.go` (GET handler)
|
||||||
- Step 1: Read API storage (`/etc/wireguard/api-policy.json`)
|
- Step 1: Read API storage (`/etc/wireguard/api-policy.json`)
|
||||||
- Step 2: Parse `wg0.conf` for `#Access` comments (fallback, using Go or exec `wg-sync-policy.sh`)
|
- Step 2: Parse `wg0.conf` for `#Access` comments (fallback, using Go or exec `wg-sync-policy.sh`)
|
||||||
@@ -178,7 +178,7 @@ iptables / ipset rules
|
|||||||
- QA: Client with API policy + `#Access` → API policy wins in response
|
- QA: Client with API policy + `#Access` → API policy wins in response
|
||||||
- QA: Client with ONLY `#Access` → fallback returns `#Access` value
|
- QA: Client with ONLY `#Access` → fallback returns `#Access` value
|
||||||
|
|
||||||
- [ ] **Task D2**: Implement `POST /api/policy` (update API-managed policy)
|
- [x] **Task D2**: Implement `POST /api/policy` (update API-managed policy)
|
||||||
- File: `/app/wg-engine-api/main.go` (POST handler)
|
- File: `/app/wg-engine-api/main.go` (POST handler)
|
||||||
- Input: JSON body `{"ip": "10.0.0.2", "access": ["1.1.1.1/32"], "internet": true}`
|
- Input: JSON body `{"ip": "10.0.0.2", "access": ["1.1.1.1/32"], "internet": true}`
|
||||||
- Validate: IP and CIDRs using Go validation functions (port from `wg-policy-lib.sh`)
|
- Validate: IP and CIDRs using Go validation functions (port from `wg-policy-lib.sh`)
|
||||||
@@ -188,14 +188,14 @@ iptables / ipset rules
|
|||||||
- QA: `policy.json` updated with merged data (API overrides #Access)
|
- QA: `policy.json` updated with merged data (API overrides #Access)
|
||||||
- QA: `wg-policy-ctl rules` shows new targets after POST
|
- QA: `wg-policy-ctl rules` shows new targets after POST
|
||||||
|
|
||||||
- [ ] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
|
- [x] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
|
||||||
- File: `/app/wg-engine-api/main.go` (reload handler)
|
- File: `/app/wg-engine-api/main.go` (reload handler)
|
||||||
- Action: Exec `/usr/local/bin/wg-policy-engine.sh`
|
- Action: Exec `/usr/local/bin/wg-policy-engine.sh`
|
||||||
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed)
|
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed)
|
||||||
- QA: `curl -X POST -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/reload` returns 200
|
- QA: `curl -X POST -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/reload` returns 200
|
||||||
- QA: After reload, `wg-policy-ctl status` shows engine applied successfully
|
- QA: After reload, `wg-policy-ctl status` shows engine applied successfully
|
||||||
|
|
||||||
- [ ] **Task D4**: Implement authentication middleware
|
- [x] **Task D4**: Implement authentication middleware
|
||||||
- File: `/app/wg-engine-api/main.go` (middleware)
|
- File: `/app/wg-engine-api/main.go` (middleware)
|
||||||
- Header: `wg-rplane-datadunia`
|
- Header: `wg-rplane-datadunia`
|
||||||
- Validation: Check header exists and matches configured token (from env or config file)
|
- Validation: Check header exists and matches configured token (from env or config file)
|
||||||
@@ -204,7 +204,7 @@ iptables / ipset rules
|
|||||||
- QA: `curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/policy` → 401
|
- QA: `curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/policy` → 401
|
||||||
- QA: `curl -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/policy` → 200
|
- QA: `curl -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/policy` → 200
|
||||||
|
|
||||||
- [ ] **Task D5**: Create API storage file (`api-policy.json`) with schema
|
- [x] **Task D5**: Create API storage file (`api-policy.json`) with schema
|
||||||
- File: `/etc/wireguard/api-policy.json` (new, API-managed)
|
- File: `/etc/wireguard/api-policy.json` (new, API-managed)
|
||||||
- Schema: Same as `policy.json` but ONLY API-managed entries:
|
- Schema: Same as `policy.json` but ONLY API-managed entries:
|
||||||
```json
|
```json
|
||||||
@@ -224,7 +224,7 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase E: #Access Migration & Merge Logic
|
### Phase E: #Access Migration & Merge Logic
|
||||||
|
|
||||||
- [ ] **Task E1**: Implement #Access comment parser in Go (fallback)
|
- [x] **Task E1**: Implement #Access comment parser in Go (fallback)
|
||||||
- File: `/app/wg-engine-api/main.go` (parse function)
|
- File: `/app/wg-engine-api/main.go` (parse function)
|
||||||
- Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go
|
- Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go
|
||||||
- Prefer: Parse `wg0.conf` in Go (avoid exec dependency)
|
- Prefer: Parse `wg0.conf` in Go (avoid exec dependency)
|
||||||
@@ -232,7 +232,7 @@ iptables / ipset rules
|
|||||||
- QA: Go parser extracts same data as `wg-sync-policy.sh` awk script
|
- QA: Go parser extracts same data as `wg-sync-policy.sh` awk script
|
||||||
- QA: `curl GET /api/policy` with no API policy returns `#Access` data correctly
|
- QA: `curl GET /api/policy` with no API policy returns `#Access` data correctly
|
||||||
|
|
||||||
- [ ] **Task E2**: Implement merge logic (API overrides #Access)
|
- [x] **Task E2**: Implement merge logic (API overrides #Access)
|
||||||
- File: `/app/wg-engine-api/main.go` (merge function)
|
- File: `/app/wg-engine-api/main.go` (merge function)
|
||||||
- Logic: For each client IP:
|
- Logic: For each client IP:
|
||||||
1. Start with `#Access` parsed data (fallback)
|
1. Start with `#Access` parsed data (fallback)
|
||||||
@@ -242,13 +242,13 @@ iptables / ipset rules
|
|||||||
- QA: Client with API policy `"access": ["1.1.1.1/32"]` + `#Access 2.2.2.2/32` → GET returns `["1.1.1.1/32"]`
|
- QA: Client with API policy `"access": ["1.1.1.1/32"]` + `#Access 2.2.2.2/32` → GET returns `["1.1.1.1/32"]`
|
||||||
- QA: Client with ONLY `#Access 2.2.2.2/32` → GET returns `["2.2.2.2/32"]`
|
- QA: Client with ONLY `#Access 2.2.2.2/32` → GET returns `["2.2.2.2/32"]`
|
||||||
|
|
||||||
- [ ] **Task E3**: Handle `internet` flag merge
|
- [x] **Task E3**: Handle `internet` flag merge
|
||||||
- File: `/app/wg-engine-api/main.go` (merge function extension)
|
- File: `/app/wg-engine-api/main.go` (merge function extension)
|
||||||
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment
|
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment
|
||||||
- QA: Client with API `internet: true` + no `#Internet` in wg0.conf → GET returns `true`
|
- QA: Client with API `internet: true` + no `#Internet` in wg0.conf → GET returns `true`
|
||||||
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false`
|
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false`
|
||||||
|
|
||||||
- [ ] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
|
- [x] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
|
||||||
- File: `wg-policy.service` (systemd unit)
|
- File: `wg-policy.service` (systemd unit)
|
||||||
- Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync`
|
- Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync`
|
||||||
- Note: NOT modifying `.sh` scripts (only systemd unit)
|
- Note: NOT modifying `.sh` scripts (only systemd unit)
|
||||||
@@ -257,21 +257,21 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase F: Integration & Testing
|
### Phase F: Integration & Testing
|
||||||
|
|
||||||
- [ ] **Task F1**: Add `bats` test framework for shell script validation
|
- [x] **Task F1**: Add `bats` test framework for shell script validation
|
||||||
- File: `/tests/` (new directory) or use existing pattern
|
- File: `/tests/` (new directory) or use existing pattern
|
||||||
- Test cases: Policy.json validation, JSON structure, lock file behavior
|
- Test cases: Policy.json validation, JSON structure, lock file behavior
|
||||||
- Install: `apt install bats` (add to `install.sh` if needed)
|
- Install: `apt install bats` (add to `install.sh` if needed)
|
||||||
- QA: `bats /tests/policy.bats` passes all test cases
|
- QA: `bats /tests/policy.bats` passes all test cases
|
||||||
- QA: Test coverage for `wg-policy-ctl validate` command
|
- QA: Test coverage for `wg-policy-ctl validate` command
|
||||||
|
|
||||||
- [ ] **Task F2**: Add Go tests for `wg-engine-api`
|
- [x] **Task F2**: Add Go tests for `wg-engine-api`
|
||||||
- File: `/app/wg-engine-api/main_test.go` (new)
|
- File: `/app/wg-engine-api/main_test.go` (new)
|
||||||
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
|
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
|
||||||
- Run: `cd /app/wg-engine-api && go test ./...`
|
- Run: `cd /app/wg-engine-api && go test ./...`
|
||||||
- QA: `go test` passes with >80% coverage
|
- QA: `go test` passes with >80% coverage
|
||||||
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests
|
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests
|
||||||
|
|
||||||
- [ ] **Task F3**: Integration test: Full flow validation
|
- [x] **Task F3**: Integration test: Full flow validation
|
||||||
- Test: POST to API → policy.json updated → iptables rules applied
|
- Test: POST to API → policy.json updated → iptables rules applied
|
||||||
- Steps:
|
- Steps:
|
||||||
1. `curl -X POST ... http://localhost:10087/api/policy` (add client)
|
1. `curl -X POST ... http://localhost:10087/api/policy` (add client)
|
||||||
@@ -280,7 +280,7 @@ iptables / ipset rules
|
|||||||
- QA: All 3 steps succeed in sequence
|
- QA: All 3 steps succeed in sequence
|
||||||
- QA: Fallback to `#Access` works when API has no entry for client
|
- QA: Fallback to `#Access` works when API has no entry for client
|
||||||
|
|
||||||
- [ ] **Task F4**: Manual test documentation in README
|
- [x] **Task F4**: Manual test documentation in README
|
||||||
- File: `/app/README.md` (test section)
|
- File: `/app/README.md` (test section)
|
||||||
- Document: How to run bats tests, Go tests, manual QA scenarios
|
- Document: How to run bats tests, Go tests, manual QA scenarios
|
||||||
- Note: No CI (none exists in repo), document manual commands
|
- Note: No CI (none exists in repo), document manual commands
|
||||||
@@ -291,21 +291,21 @@ iptables / ipset rules
|
|||||||
|
|
||||||
### Phase G: Documentation & Push
|
### Phase G: Documentation & Push
|
||||||
|
|
||||||
- [ ] **Task G1**: Create comprehensive README.md for WGRplane submodule
|
- [x] **Task G1**: Create comprehensive README.md for WGRplane submodule
|
||||||
- File: `/app/README.md` (new or update existing)
|
- File: `/app/README.md` (new or update existing)
|
||||||
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment
|
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment
|
||||||
- Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
|
- Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
|
||||||
- QA: README.md exists with all sections
|
- QA: README.md exists with all sections
|
||||||
- QA: `cat /app/README.md` shows complete documentation
|
- QA: `cat /app/README.md` shows complete documentation
|
||||||
|
|
||||||
- [ ] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
|
- [x] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
|
||||||
- File: `/app/README.md` (integration section)
|
- File: `/app/README.md` (integration section)
|
||||||
- Explain: WGRplane on port 10086, Go API on port 10087
|
- Explain: WGRplane on port 10086, Go API on port 10087
|
||||||
- Note: Frontend can proxy API requests or link directly
|
- Note: Frontend can proxy API requests or link directly
|
||||||
- QA: README has "Integration" section with port numbers and proxy examples
|
- QA: README has "Integration" section with port numbers and proxy examples
|
||||||
- QA: Developer understands how Python Flask talks to Go API
|
- QA: Developer understands how Python Flask talks to Go API
|
||||||
|
|
||||||
- [ ] **Task G3**: Push WGRplane submodule to remote
|
- [x] **Task G3**: Push WGRplane submodule to remote
|
||||||
- Commands:
|
- Commands:
|
||||||
```bash
|
```bash
|
||||||
cd /app
|
cd /app
|
||||||
@@ -316,7 +316,7 @@ iptables / ipset rules
|
|||||||
- QA: `git push` succeeds, remote updated
|
- QA: `git push` succeeds, remote updated
|
||||||
- QA: `git submodule status` in parent repo shows app with commit hash
|
- QA: `git submodule status` in parent repo shows app with commit hash
|
||||||
|
|
||||||
- [ ] **Task G4**: Update parent repo to reference pushed submodule
|
- [x] **Task G4**: Update parent repo to reference pushed submodule
|
||||||
- Commands:
|
- Commands:
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/03.wireguard-policy
|
cd /path/to/03.wireguard-policy
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Plugin defines a simple notification interface.
|
||||||
|
type Plugin interface {
|
||||||
|
Notify(event string, payload interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramNotifier is a mock notifier that would send a Telegram message in a real deployment.
|
||||||
|
type TelegramNotifier struct{}
|
||||||
|
|
||||||
|
func (t *TelegramNotifier) Notify(event string, payload interface{}) {
|
||||||
|
fmt.Printf("[TelegramNotifier] event=%s payload=%v\n", event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlackNotifier is a mock notifier that would send a Slack message in a real deployment.
|
||||||
|
type SlackNotifier struct{}
|
||||||
|
|
||||||
|
func (s *SlackNotifier) Notify(event string, payload interface{}) {
|
||||||
|
fmt.Printf("[SlackNotifier] event=%s payload=%v\n", event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficLogger logs traffic-related events for debugging/observability.
|
||||||
|
type TrafficLogger struct{}
|
||||||
|
|
||||||
|
func (l *TrafficLogger) Notify(event string, payload interface{}) {
|
||||||
|
fmt.Printf("[TrafficLogger] event=%s payload=%v\n", event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginManager loads and triggers plugins.
|
||||||
|
type PluginManager struct {
|
||||||
|
plugins []Plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPluginManager creates a new PluginManager instance.
|
||||||
|
func NewPluginManager() *PluginManager {
|
||||||
|
return &PluginManager{plugins: []Plugin{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPlugins initializes the built-in example plugins.
|
||||||
|
func (pm *PluginManager) LoadPlugins() {
|
||||||
|
pm.plugins = []Plugin{
|
||||||
|
&TelegramNotifier{},
|
||||||
|
&SlackNotifier{},
|
||||||
|
&TrafficLogger{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger dispatches an event to all registered plugins.
|
||||||
|
func (pm *PluginManager) Trigger(event string, payload interface{}) {
|
||||||
|
for _, p := range pm.plugins {
|
||||||
|
p.Notify(event, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initScheduler creates and starts the background cron jobs responsible for
|
||||||
|
// peer lifecycle automation: expiry deletion, data-limit restriction, and monthly resets.
|
||||||
|
func initScheduler() *cron.Cron {
|
||||||
|
c := cron.New(cron.WithSeconds())
|
||||||
|
|
||||||
|
// 2:00 AM daily - Delete expired peers
|
||||||
|
if _, err := c.AddFunc("0 0 2 * * *", deleteExpiredPeers); err != nil {
|
||||||
|
log.Printf("scheduler: failed to schedule deleteExpiredPeers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3:00 AM daily - Disable peers that exceeded data limit
|
||||||
|
if _, err := c.AddFunc("0 0 3 * * *", restrictOverLimitPeers); err != nil {
|
||||||
|
log.Printf("scheduler: failed to schedule restrictOverLimitPeers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1st day of every month at 00:00 - Reset data usage counters
|
||||||
|
if _, err := c.AddFunc("0 0 0 1 * *", resetMonthlyUsage); err != nil {
|
||||||
|
log.Printf("scheduler: failed to schedule resetMonthlyUsage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Start()
|
||||||
|
log.Println("scheduler: started background jobs (expiry, data-limit, monthly reset)")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteExpiredPeers removes peers whose ExpiresAt is non-zero and in the past.
|
||||||
|
func deleteExpiredPeers() {
|
||||||
|
now := time.Now()
|
||||||
|
var peers []Peer
|
||||||
|
if err := db.Where("expires_at != 0 AND expires_at <= ?", now).Find(&peers).Error; err != nil {
|
||||||
|
log.Printf("scheduler: error querying expired peers: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(peers) == 0 {
|
||||||
|
log.Println("scheduler: no expired peers to delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range peers {
|
||||||
|
if err := db.Delete(&p).Error; err != nil {
|
||||||
|
log.Printf("scheduler: failed to delete peer %d: %v", p.ID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("scheduler: deleted expired peer %d", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restrictOverLimitPeers disables peers that have exceeded their data limit.
|
||||||
|
func restrictOverLimitPeers() {
|
||||||
|
var peers []Peer
|
||||||
|
if err := db.Find(&peers).Error; err != nil {
|
||||||
|
log.Printf("scheduler: error loading peers for restriction: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range peers {
|
||||||
|
if p.Enabled && p.DataLimitGB > 0 {
|
||||||
|
limitBytes := p.DataLimitGB * 1_000_000_000
|
||||||
|
if p.CurrentDataUsageBytes >= limitBytes {
|
||||||
|
p.Enabled = false
|
||||||
|
if err := db.Save(&p).Error; err != nil {
|
||||||
|
log.Printf("scheduler: failed to disable peer %d: %v", p.ID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("scheduler: disabled peer %d due to data limit (%d/%d bytes)", p.ID, p.CurrentDataUsageBytes, limitBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMonthlyUsage resets all peers' CurrentDataUsageBytes to zero at the start of each month.
|
||||||
|
func resetMonthlyUsage() {
|
||||||
|
var peers []Peer
|
||||||
|
if err := db.Find(&peers).Error; err != nil {
|
||||||
|
log.Printf("scheduler: error loading peers for monthly reset: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range peers {
|
||||||
|
if p.CurrentDataUsageBytes != 0 {
|
||||||
|
p.CurrentDataUsageBytes = 0
|
||||||
|
if err := db.Save(&p).Error; err != nil {
|
||||||
|
log.Printf("scheduler: failed to reset usage for peer %d: %v", p.ID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("scheduler: reset monthly usage for peer %d", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"math/rand"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a single WebSocket connection.
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages to them.
|
||||||
|
type Hub struct {
|
||||||
|
clients map[*Client]bool
|
||||||
|
broadcast chan []byte
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-h.register:
|
||||||
|
h.clients[c] = true
|
||||||
|
case c := <-h.unregister:
|
||||||
|
if _, ok := h.clients[c]; ok {
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.send)
|
||||||
|
}
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.send <- message:
|
||||||
|
default:
|
||||||
|
close(c.send)
|
||||||
|
delete(h.clients, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(5120)
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||||
|
for {
|
||||||
|
_, _, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("WebSocket upgrade error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &Client{hub: hub, conn: ws, send: make(chan []byte, 256)}
|
||||||
|
client.hub.register <- client
|
||||||
|
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats payload shape
|
||||||
|
type Stats struct {
|
||||||
|
TotalRx int `json:"total_rx"`
|
||||||
|
TotalTx int `json:"total_tx"`
|
||||||
|
PeersOnline int `json:"peers_online"`
|
||||||
|
PeersOffline int `json:"peers_offline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStatsBroadcast periodically emits mock stats to all connected clients.
|
||||||
|
func startStatsBroadcast(hub *Hub) {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
var totalRx, totalTx int
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
totalRx += rand.Intn(120)
|
||||||
|
totalTx += rand.Intn(150)
|
||||||
|
online := rand.Intn(5) + 1
|
||||||
|
offline := 5 - online
|
||||||
|
s := Stats{TotalRx: totalRx, TotalTx: totalTx, PeersOnline: online, PeersOffline: offline}
|
||||||
|
payload, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Stats marshal error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hub.broadcast <- payload
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateIP returns true if the input is a valid IPv4 or IPv6 address.
|
||||||
|
func ValidateIP(ip string) bool {
|
||||||
|
if ip == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return net.ParseIP(ip) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCIDR returns true if the input is a valid CIDR notation (e.g., 10.0.0.0/24).
|
||||||
|
func ValidateCIDR(cidr string) bool {
|
||||||
|
if cidr == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// net.ParseCIDR validates CIDR; it also returns an IP, which we don't need here.
|
||||||
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePublicKey returns true if the provided WireGuard public key is a valid base64-encoded 32-byte value.
|
||||||
|
func ValidatePublicKey(key string) bool {
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Try to decode as base64 without padding (RawStdEncoding).
|
||||||
|
if b, err := base64.RawStdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Fallback to standard base64 decoding with padding if present.
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user