mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 20:54:47 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57da780653 | |||
| 07609164ef | |||
| c6c57e450c | |||
| 428d296881 | |||
| 34c3bb4e68 | |||
| 3b41b0b41d | |||
| bf96173777 | |||
| 2abb1cff9a | |||
| dce404e593 | |||
| f291671d9d | |||
| e98d46850b | |||
| b871dda40c | |||
| 8f21589239 | |||
| cbe724f425 | |||
| 53b7ee4e9e |
@@ -20,3 +20,29 @@ type Device struct {
|
||||
Status Status
|
||||
LastSeenAt *int64
|
||||
}
|
||||
|
||||
// ListItem is a device plus its joined device-group name, returned by the
|
||||
// server-side paginated list query so callers don't need a second lookup.
|
||||
type ListItem struct {
|
||||
Device
|
||||
GroupName string
|
||||
}
|
||||
|
||||
// ListQuery describes a server-side filtered/sorted/paginated device listing.
|
||||
// Filtering, ordering (incl. online-first) and pagination all happen in SQL so
|
||||
// large fleets don't require loading every row into memory per request.
|
||||
type ListQuery struct {
|
||||
// RestrictGroups limits results to devices whose device_group_id is in this
|
||||
// set. nil means no restriction (admin: all devices, including ungrouped).
|
||||
RestrictGroups []int64
|
||||
// Search matches ddns/mac/ip/description as a case-insensitive substring.
|
||||
// Callers pass it already lowercased and with ':' stripped (to match the
|
||||
// colon-less MAC stored in the DB).
|
||||
Search string
|
||||
Unassigned bool // only devices with no device group
|
||||
Status string // "" = any; otherwise online|offline|disabled
|
||||
SortBy string // id|ip|mac|ddns|description|connectedTime|deviceGroupName
|
||||
Order string // "asc" (default) or "desc"
|
||||
Page int // 1-based
|
||||
PageSize int // 0 => no limit (return all matching rows)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ import "context"
|
||||
type Repository interface {
|
||||
ListAll(ctx context.Context) ([]Device, error)
|
||||
ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error)
|
||||
// ListPaged returns one page of devices (with joined group name) plus the
|
||||
// total count of matching rows, doing all filtering/sorting/pagination in SQL.
|
||||
ListPaged(ctx context.Context, q ListQuery) ([]ListItem, int64, error)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,9 @@ func (s *Service) ListVisible(ctx context.Context, role identity.Role, userID in
|
||||
func (s *Service) ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error) {
|
||||
return s.repo.ListByDeviceGroupIDs(ctx, groupIDs)
|
||||
}
|
||||
|
||||
// ListPaged returns one filtered/sorted page plus the total matching count,
|
||||
// pushing all the work to SQL (see Repository.ListPaged).
|
||||
func (s *Service) ListPaged(ctx context.Context, q ListQuery) ([]ListItem, int64, error) {
|
||||
return s.repo.ListPaged(ctx, q)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -21,16 +22,48 @@ import (
|
||||
// noise. SSH/Web events are user-initiated and never suppressed.
|
||||
const startupGraceWindow = 60 * time.Second
|
||||
|
||||
// onOffDebounceWindow coalesces rapid online/offline flapping in the audit
|
||||
// log: a second event of the same kind for the same device within this window
|
||||
// is dropped. Only the historical event log is affected; the live device
|
||||
// status (devices.status) is updated on a separate path and stays accurate.
|
||||
const onOffDebounceWindow = 30 * time.Second
|
||||
|
||||
// Detail field length cap to keep rows bounded against malicious input.
|
||||
const maxDetailLen = 2000
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
startupTime time.Time
|
||||
|
||||
// lastOnOff tracks the last time an online/offline event was accepted for
|
||||
// a given device+type, keyed by deviceID+"|"+eventType, for flap damping.
|
||||
onOffMu sync.Mutex
|
||||
lastOnOff map[string]int64
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo, startupTime: time.Now()}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
startupTime: time.Now(),
|
||||
lastOnOff: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// allowOnOff reports whether an online/offline event for the device should be
|
||||
// recorded now, updating the last-seen timestamp when it returns true. It
|
||||
// drops a same-kind event that arrives within onOffDebounceWindow.
|
||||
func (s *Service) allowOnOff(deviceID string, evt EventType) bool {
|
||||
key := deviceID + "|" + string(evt)
|
||||
now := time.Now().Unix()
|
||||
window := int64(onOffDebounceWindow / time.Second)
|
||||
|
||||
s.onOffMu.Lock()
|
||||
defer s.onOffMu.Unlock()
|
||||
if last, ok := s.lastOnOff[key]; ok && now-last < window {
|
||||
return false
|
||||
}
|
||||
s.lastOnOff[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeMac strips colons and lowercases, matching the format in the
|
||||
@@ -54,6 +87,9 @@ func (s *Service) RecordDeviceOnline(ctx context.Context, deviceID, mac, ip stri
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
if !s.allowOnOff(deviceID, EventDeviceOnline) {
|
||||
return
|
||||
}
|
||||
if _, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
@@ -74,6 +110,9 @@ func (s *Service) RecordDeviceOffline(ctx context.Context, deviceID, mac, ip str
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
if !s.allowOnOff(deviceID, EventDeviceOffline) {
|
||||
return
|
||||
}
|
||||
if _, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
|
||||
+52
-115
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -53,17 +52,19 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
|
||||
isAdmin := p.Role == identity.RoleAdmin
|
||||
var items []device.Device
|
||||
|
||||
emptyResp := func() {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{}, Page: 1, PageSize: 0, Total: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// Resolve visibility into a group-id restriction. nil = no restriction
|
||||
// (admin sees all devices, including ungrouped).
|
||||
var restrictGroups []int64
|
||||
if isAdmin {
|
||||
var err error
|
||||
if filterGroupID != nil {
|
||||
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), []int64{*filterGroupID})
|
||||
} else {
|
||||
items, err = h.devSvc.ListVisible(c.Request.Context(), p.Role, p.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
restrictGroups = []int64{*filterGroupID}
|
||||
}
|
||||
} else {
|
||||
if h.groupRepo == nil {
|
||||
@@ -72,12 +73,7 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
dgIDs, err := h.groupRepo.ListDeviceGroupIDsByUser(c.Request.Context(), p.UserID)
|
||||
if err != nil || len(dgIDs) == 0 {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{},
|
||||
Page: 1,
|
||||
PageSize: 0,
|
||||
Total: 0,
|
||||
}))
|
||||
emptyResp()
|
||||
return
|
||||
}
|
||||
if filterGroupID != nil {
|
||||
@@ -89,119 +85,56 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{},
|
||||
Page: 1,
|
||||
PageSize: 0,
|
||||
Total: 0,
|
||||
}))
|
||||
emptyResp()
|
||||
return
|
||||
}
|
||||
dgIDs = []int64{*filterGroupID}
|
||||
}
|
||||
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), dgIDs)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
restrictGroups = dgIDs
|
||||
}
|
||||
|
||||
groupNameByID := map[int64]string{}
|
||||
if h.groupRepo != nil {
|
||||
groups, err := h.groupRepo.ListDeviceGroupsVisibleToUser(c.Request.Context(), p.UserID, isAdmin)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
for _, g := range groups {
|
||||
groupNameByID[g.ID] = g.Name
|
||||
}
|
||||
// Pagination params: pageSize omitted => return all (backward compatible).
|
||||
page := 1
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(c.Query("page"))); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
pageSize := 0
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(c.Query("pageSize"))); err == nil && v > 0 {
|
||||
pageSize = v
|
||||
}
|
||||
|
||||
// Parse sort parameters: sortBy and order
|
||||
sortBy := strings.TrimSpace(c.Query("sortBy")) // id, ip, mac, connectedTime, description, ddns
|
||||
sortOrder := strings.TrimSpace(c.Query("order")) // asc, desc (default: asc)
|
||||
|
||||
ascending := true
|
||||
if strings.EqualFold(sortOrder, "desc") {
|
||||
ascending = false
|
||||
// Status filter: only accept known values, ignore anything else.
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
switch status {
|
||||
case "online", "offline", "disabled":
|
||||
default:
|
||||
status = ""
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
// Online devices always come first regardless of sort field/order
|
||||
oi := items[i].Status == device.StatusOnline
|
||||
oj := items[j].Status == device.StatusOnline
|
||||
if oi != oj {
|
||||
return oi
|
||||
}
|
||||
|
||||
// Secondary sort by the requested field
|
||||
// cmp: -1 means i<j, 0 means equal, 1 means i>j
|
||||
var cmp int
|
||||
switch sortBy {
|
||||
case "id":
|
||||
switch {
|
||||
case items[i].ID < items[j].ID:
|
||||
cmp = -1
|
||||
case items[i].ID > items[j].ID:
|
||||
cmp = 1
|
||||
}
|
||||
case "ip":
|
||||
cmp = strings.Compare(items[i].IP, items[j].IP)
|
||||
case "mac":
|
||||
cmp = strings.Compare(items[i].Mac, items[j].Mac)
|
||||
case "connectedTime":
|
||||
var ti, tj int64
|
||||
if items[i].LastSeenAt != nil {
|
||||
ti = *items[i].LastSeenAt
|
||||
}
|
||||
if items[j].LastSeenAt != nil {
|
||||
tj = *items[j].LastSeenAt
|
||||
}
|
||||
switch {
|
||||
case ti < tj:
|
||||
cmp = -1
|
||||
case ti > tj:
|
||||
cmp = 1
|
||||
}
|
||||
case "description":
|
||||
cmp = strings.Compare(items[i].Description, items[j].Description)
|
||||
case "ddns":
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
case "deviceGroupName":
|
||||
var gi, gj string
|
||||
if items[i].DeviceGroupID != nil {
|
||||
gi = groupNameByID[*items[i].DeviceGroupID]
|
||||
}
|
||||
if items[j].DeviceGroupID != nil {
|
||||
gj = groupNameByID[*items[j].DeviceGroupID]
|
||||
}
|
||||
cmp = strings.Compare(gi, gj)
|
||||
default:
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
}
|
||||
|
||||
if cmp == 0 {
|
||||
return false // equal, preserve original order
|
||||
}
|
||||
if ascending {
|
||||
return cmp < 0
|
||||
}
|
||||
return cmp > 0
|
||||
// All filtering/sorting/pagination is pushed to SQL. The search ':' is
|
||||
// stripped to match the colon-less MAC stored in the DB, mirroring the
|
||||
// previous client-side search behavior.
|
||||
items, total, err := h.devSvc.ListPaged(c.Request.Context(), device.ListQuery{
|
||||
RestrictGroups: restrictGroups,
|
||||
Search: strings.ToLower(strings.ReplaceAll(strings.TrimSpace(c.Query("q")), ":", "")),
|
||||
Unassigned: strings.EqualFold(strings.TrimSpace(c.Query("unassigned")), "true"),
|
||||
Status: status,
|
||||
SortBy: strings.TrimSpace(c.Query("sortBy")),
|
||||
Order: strings.TrimSpace(c.Query("order")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Device, 0, len(items))
|
||||
for _, d := range items {
|
||||
var groupName string
|
||||
if d.DeviceGroupID != nil {
|
||||
groupName = groupNameByID[*d.DeviceGroupID]
|
||||
}
|
||||
|
||||
var connectedTime int64
|
||||
if d.LastSeenAt != nil {
|
||||
connectedTime = *d.LastSeenAt
|
||||
}
|
||||
|
||||
out = append(out, dto.Device{
|
||||
ID: d.ID,
|
||||
Ddns: d.Ddns,
|
||||
@@ -212,15 +145,19 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
Description: d.Description,
|
||||
Client: d.Client,
|
||||
DeviceGroupID: d.DeviceGroupID,
|
||||
DeviceGroupName: groupName,
|
||||
DeviceGroupName: d.GroupName,
|
||||
})
|
||||
}
|
||||
|
||||
respPageSize := int(total)
|
||||
if pageSize > 0 {
|
||||
respPageSize = pageSize
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: out,
|
||||
Page: 1,
|
||||
PageSize: len(out),
|
||||
Total: len(out),
|
||||
Page: page,
|
||||
PageSize: respPageSize,
|
||||
Total: int(total),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+6
-17
@@ -83,19 +83,12 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
}
|
||||
|
||||
chosen := hostname
|
||||
// -------- Reverse proxy mode: force IP ----------
|
||||
if cfg.ReverseProxyEnabled {
|
||||
// Reverse proxy mode: always use configured WebRTC IP
|
||||
if strings.TrimSpace(cfg.WebrtcIP) != "" {
|
||||
chosen = strings.TrimSpace(cfg.WebrtcIP)
|
||||
}
|
||||
} else {
|
||||
// -------- 3) Original behavior (unchanged) ----------
|
||||
// 1) If hostname is domain, keep it
|
||||
// 2) If hostname is IP and cfg.WebrtcIP is set, use cfg.WebrtcIP
|
||||
if isIP(hostname) && cfg.WebrtcIP != "" {
|
||||
chosen = cfg.WebrtcIP
|
||||
}
|
||||
// GLKVM_ACCESS_IP (cfg.WebrtcIP) takes priority whenever it is set,
|
||||
// regardless of how the web UI was reached (IP or domain/localhost)
|
||||
// and regardless of reverse-proxy mode. Only fall back to the current
|
||||
// web host when it is left empty (auto-detect).
|
||||
if v := strings.TrimSpace(cfg.WebrtcIP); v != "" {
|
||||
chosen = v
|
||||
}
|
||||
|
||||
// Determine selfhost WebUI URL
|
||||
@@ -205,7 +198,3 @@ type scriptInfoResp struct {
|
||||
WebrtcPassword string `json:"webrtcPassword"`
|
||||
WebUIURL string `json:"webUIURL"`
|
||||
}
|
||||
|
||||
func isIP(addr string) bool {
|
||||
return net.ParseIP(addr) != nil
|
||||
}
|
||||
|
||||
@@ -35,3 +35,8 @@ func MarkDeviceOffline(deviceID string) error {
|
||||
repo := sqlite.MustContainer().DeviceMeta
|
||||
return repo.MarkOffline(context.Background(), deviceID)
|
||||
}
|
||||
|
||||
func MarkDeviceOnline(deviceID string) error {
|
||||
repo := sqlite.MustContainer().DeviceMeta
|
||||
return repo.MarkOnline(context.Background(), deviceID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
// Package qa contains repeatable end-to-end / API regression tests that run
|
||||
// against a LIVE glkvm-cloud deployment (not unit tests). They are gated by the
|
||||
// QA_TARGET env var, so a normal `go test ./...` skips them.
|
||||
//
|
||||
// Run (example, against the CN box):
|
||||
//
|
||||
// QA_TARGET=https://106.55.158.199 \
|
||||
// QA_USER=admin QA_PASS='<password>' \
|
||||
// QA_DEV_ADDR=106.55.158.199:5912 QA_DEV_TOKEN='<rtty token>' \
|
||||
// QA_REAL_DEVID=zh71fb1 QA_REAL_MAC=9483c4b71fb1 \
|
||||
// go test ./internal/qa -run TestE2E -v
|
||||
//
|
||||
// Assertions are invariant-based (don't depend on exact fixture counts), so the
|
||||
// suite can be re-run against any environment. L2 device-protocol tests register
|
||||
// their own throwaway devices (ddns prefix "qae2e") and clean them up via the API.
|
||||
package qa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- config from env ----------
|
||||
|
||||
type config struct {
|
||||
target string // user API base, e.g. https://106.55.158.199
|
||||
user string
|
||||
pass string
|
||||
devAddr string // device port host:port (TLS), e.g. 106.55.158.199:5912
|
||||
devToken string // rtty registration token
|
||||
realID string // an already-online real device id (optional)
|
||||
realMAC string // its colon-less MAC (optional)
|
||||
}
|
||||
|
||||
func loadConfig(t *testing.T) config {
|
||||
c := config{
|
||||
target: os.Getenv("QA_TARGET"),
|
||||
user: os.Getenv("QA_USER"),
|
||||
pass: os.Getenv("QA_PASS"),
|
||||
devAddr: os.Getenv("QA_DEV_ADDR"),
|
||||
devToken: os.Getenv("QA_DEV_TOKEN"),
|
||||
realID: os.Getenv("QA_REAL_DEVID"),
|
||||
realMAC: os.Getenv("QA_REAL_MAC"),
|
||||
}
|
||||
if c.target == "" {
|
||||
t.Skip("QA_TARGET not set; skipping live e2e suite")
|
||||
}
|
||||
if c.user == "" {
|
||||
c.user = "admin"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---------- HTTP client ----------
|
||||
|
||||
type client struct {
|
||||
cfg config
|
||||
http *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func newClient(cfg config) *client {
|
||||
return &client{
|
||||
cfg: cfg,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type apiEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, auth bool) (int, apiEnvelope, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.cfg.target+path, rdr)
|
||||
if err != nil {
|
||||
return 0, apiEnvelope{}, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if auth && c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, apiEnvelope{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var env apiEnvelope
|
||||
_ = json.Unmarshal(raw, &env)
|
||||
return resp.StatusCode, env, nil
|
||||
}
|
||||
|
||||
func (c *client) login(t *testing.T) {
|
||||
t.Helper()
|
||||
_, env, err := c.do("POST", "/api/login", map[string]string{
|
||||
"username": c.cfg.user, "password": c.cfg.pass,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("login request failed: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("login failed: code=%s msg=%s", env.Code, env.Message)
|
||||
}
|
||||
var d struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &d); err != nil || d.Token == "" {
|
||||
t.Fatalf("login returned no token: %s", string(env.Data))
|
||||
}
|
||||
c.token = d.Token
|
||||
}
|
||||
|
||||
// ---------- device-list helpers ----------
|
||||
|
||||
type devItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Ddns string `json:"ddns"`
|
||||
Mac string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
ConnectedTime int64 `json:"connectedTime"`
|
||||
DeviceGroupID *int64 `json:"deviceGroupId"`
|
||||
DeviceGroupName string `json:"deviceGroupName"`
|
||||
}
|
||||
|
||||
type devList struct {
|
||||
Items []devItem `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (c *client) listDevices(t *testing.T, query string) devList {
|
||||
t.Helper()
|
||||
path := "/api/devices"
|
||||
if query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
st, env, err := c.do("GET", path, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("list devices not ok: http=%d code=%s", st, env.Code)
|
||||
}
|
||||
var d devList
|
||||
if err := json.Unmarshal(env.Data, &d); err != nil {
|
||||
t.Fatalf("decode device list: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ---------- rtty register helper (device protocol over TLS :5912) ----------
|
||||
|
||||
const (
|
||||
regAttrDevid = 1
|
||||
regAttrDesc = 2 // MAC carried here
|
||||
regAttrToken = 3
|
||||
)
|
||||
|
||||
func tlv(typ byte, v []byte) []byte {
|
||||
b := []byte{typ, 0, 0}
|
||||
binary.BigEndian.PutUint16(b[1:], uint16(len(v)))
|
||||
return append(b, v...)
|
||||
}
|
||||
func frame(typ byte, payload []byte) []byte {
|
||||
b := []byte{typ, 0, 0}
|
||||
binary.BigEndian.PutUint16(b[1:], uint16(len(payload)))
|
||||
return append(b, payload...)
|
||||
}
|
||||
|
||||
// registerDevice opens a TLS connection to the device port and performs an rtty
|
||||
// registration. Returns the live connection (caller must Close) and the server's
|
||||
// register response code (0 = accepted, non-zero = rejected).
|
||||
func registerDevice(addr, devid, mac, token string) (net.Conn, byte, error) {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
payload := []byte{5} // proto 5 (TLV)
|
||||
payload = append(payload, tlv(regAttrDevid, []byte(devid))...)
|
||||
payload = append(payload, tlv(regAttrDesc, []byte(mac))...)
|
||||
if token != "" {
|
||||
payload = append(payload, tlv(regAttrToken, []byte(token))...)
|
||||
}
|
||||
if _, err := conn.Write(frame(0 /*register*/, payload)); err != nil {
|
||||
conn.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(8 * time.Second))
|
||||
head := make([]byte, 3)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
conn.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
blen := binary.BigEndian.Uint16(head[1:])
|
||||
body := make([]byte, blen)
|
||||
io.ReadFull(conn, body)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
var code byte
|
||||
if len(body) > 0 {
|
||||
code = body[0]
|
||||
}
|
||||
return conn, code, nil
|
||||
}
|
||||
|
||||
// cleanupQADevices deletes every device whose ddns starts with the qa prefix.
|
||||
func (c *client) cleanupQADevices(t *testing.T) {
|
||||
t.Helper()
|
||||
list := c.listDevices(t, "q=qae2e&pageSize=500")
|
||||
for _, it := range list.Items {
|
||||
if strings.HasPrefix(it.Ddns, "qae2e") {
|
||||
c.do("DELETE", fmt.Sprintf("/api/devices/%d", it.ID), nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the suite ----------
|
||||
|
||||
func TestE2E(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
c := newClient(cfg)
|
||||
|
||||
// ===== A. Auth =====
|
||||
t.Run("A_auth", func(t *testing.T) {
|
||||
// A5 public auth-config
|
||||
st, env, err := c.do("GET", "/auth-config", nil, false)
|
||||
if err != nil || !env.OK {
|
||||
t.Fatalf("A5 auth-config not ok: http=%d err=%v", st, err)
|
||||
}
|
||||
// A3 unauthenticated device list rejected
|
||||
c.token = ""
|
||||
_, env, _ = c.do("GET", "/api/devices?pageSize=1", nil, true)
|
||||
if env.OK {
|
||||
t.Errorf("A3 expected auth required without token, got ok")
|
||||
}
|
||||
// A2 wrong password rejected
|
||||
_, env, _ = c.do("POST", "/api/login", map[string]string{"username": cfg.user, "password": "definitely-wrong-xyz"}, false)
|
||||
if env.OK {
|
||||
t.Errorf("A2 expected login failure with wrong password")
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.pass == "" {
|
||||
t.Skip("QA_PASS not set; skipping authenticated tests")
|
||||
}
|
||||
c.login(t)
|
||||
|
||||
// ===== B. Pagination (invariants) =====
|
||||
t.Run("B_pagination", func(t *testing.T) {
|
||||
p1 := c.listDevices(t, "page=1&pageSize=10")
|
||||
if len(p1.Items) > 10 {
|
||||
t.Errorf("B1 page items %d > pageSize 10", len(p1.Items))
|
||||
}
|
||||
if p1.Total < len(p1.Items) {
|
||||
t.Errorf("B1 total %d < page items %d", p1.Total, len(p1.Items))
|
||||
}
|
||||
if p1.Total <= 10 {
|
||||
t.Skip("fewer than 11 devices; pagination cross-page checks skipped")
|
||||
}
|
||||
p2 := c.listDevices(t, "page=2&pageSize=10")
|
||||
if len(p2.Items) > 0 && len(p1.Items) > 0 && p2.Items[0].ID == p1.Items[0].ID {
|
||||
t.Errorf("B2 page2 first item equals page1 first item")
|
||||
}
|
||||
// B3 last page size = remainder
|
||||
size := 10
|
||||
pages := (p1.Total + size - 1) / size
|
||||
last := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages, size))
|
||||
wantLast := p1.Total - (pages-1)*size
|
||||
if last.Total == p1.Total && len(last.Items) != wantLast {
|
||||
t.Errorf("B3 last page items=%d want=%d (total=%d)", len(last.Items), wantLast, p1.Total)
|
||||
}
|
||||
// B4 out-of-range page → empty, total unchanged
|
||||
oob := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages+50, size))
|
||||
if len(oob.Items) != 0 {
|
||||
t.Errorf("B4 out-of-range page returned %d items", len(oob.Items))
|
||||
}
|
||||
if oob.Total != p1.Total {
|
||||
t.Errorf("B4 total changed on oob page: %d vs %d", oob.Total, p1.Total)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== C. Search (invariants) =====
|
||||
t.Run("C_search", func(t *testing.T) {
|
||||
// C5 no-match → 0
|
||||
none := c.listDevices(t, "q=zzz_no_such_device_zzz")
|
||||
if none.Total != 0 || len(none.Items) != 0 {
|
||||
t.Errorf("C5 non-matching search returned total=%d", none.Total)
|
||||
}
|
||||
// pick a real device to search for
|
||||
base := c.listDevices(t, "page=1&pageSize=1")
|
||||
if len(base.Items) == 0 {
|
||||
t.Skip("no devices to exercise search")
|
||||
}
|
||||
d := base.Items[0]
|
||||
// C1 search by ddns substring → contains it
|
||||
got := c.listDevices(t, "q="+d.Ddns)
|
||||
if !containsDdns(got.Items, d.Ddns) {
|
||||
t.Errorf("C1 search by ddns %q did not return it", d.Ddns)
|
||||
}
|
||||
// C2 search by MAC with colons → still matches (colon-strip logic)
|
||||
if d.Mac != "" {
|
||||
withColons := insertColons(d.Mac)
|
||||
gotMac := c.listDevices(t, "q="+withColons)
|
||||
if !containsDdns(gotMac.Items, d.Ddns) {
|
||||
t.Errorf("C2 search by colon-MAC %q did not return device %q", withColons, d.Ddns)
|
||||
}
|
||||
}
|
||||
// C4 case-insensitive
|
||||
gotUpper := c.listDevices(t, "q="+strings.ToUpper(d.Ddns))
|
||||
if !containsDdns(gotUpper.Items, d.Ddns) {
|
||||
t.Errorf("C4 uppercase search did not match")
|
||||
}
|
||||
})
|
||||
|
||||
// ===== D. Sort (invariants) =====
|
||||
t.Run("D_sort", func(t *testing.T) {
|
||||
for _, field := range []string{"ddns", "mac", "ip"} {
|
||||
asc := c.listDevices(t, "sortBy="+field+"&order=asc&pageSize=50")
|
||||
// online-first must always hold
|
||||
if !onlineFirst(asc.Items) {
|
||||
t.Errorf("D7 online-first violated when sorting by %s", field)
|
||||
}
|
||||
// within the same online-bucket, the field is ordered
|
||||
if !fieldOrderedWithinBucket(asc.Items, field, true) {
|
||||
t.Errorf("D sort by %s asc not ordered within status bucket", field)
|
||||
}
|
||||
desc := c.listDevices(t, "sortBy="+field+"&order=desc&pageSize=50")
|
||||
if !fieldOrderedWithinBucket(desc.Items, field, false) {
|
||||
t.Errorf("D sort by %s desc not ordered within status bucket", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ===== E. Status filter (new feature) =====
|
||||
t.Run("E_status_filter", func(t *testing.T) {
|
||||
on := c.listDevices(t, "status=online&pageSize=50")
|
||||
for _, it := range on.Items {
|
||||
if it.Status != "online" {
|
||||
t.Errorf("E1 status=online returned a %s device (%s)", it.Status, it.Ddns)
|
||||
}
|
||||
}
|
||||
off := c.listDevices(t, "status=offline&pageSize=50")
|
||||
for _, it := range off.Items {
|
||||
if it.Status != "offline" {
|
||||
t.Errorf("E2 status=offline returned a %s device (%s)", it.Status, it.Ddns)
|
||||
}
|
||||
}
|
||||
all := c.listDevices(t, "pageSize=1")
|
||||
// E4 invalid status ignored → behaves like all
|
||||
bad := c.listDevices(t, "status=foobar&pageSize=1")
|
||||
if bad.Total != all.Total {
|
||||
t.Errorf("E4 invalid status changed total: %d vs %d", bad.Total, all.Total)
|
||||
}
|
||||
// E6 online+offline totals reconcile with all (allowing live drift)
|
||||
if on.Total+off.Total > all.Total {
|
||||
t.Errorf("E6 online(%d)+offline(%d) > all(%d)", on.Total, off.Total, all.Total)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== G. Device protocol (L2) =====
|
||||
if cfg.devAddr != "" {
|
||||
t.Run("G_device_protocol", func(t *testing.T) {
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
devid := "qae2e_ok"
|
||||
mac := "02ffqae20001"
|
||||
// G2 empty MAC → rejected
|
||||
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_nomac", "", cfg.devToken); err == nil {
|
||||
conn.Close()
|
||||
if code == 0 {
|
||||
t.Errorf("G2 empty-MAC registration was accepted (code 0)")
|
||||
}
|
||||
}
|
||||
// G3 bad token → rejected (only meaningful if server enforces a token)
|
||||
if cfg.devToken != "" {
|
||||
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_badtok", "02ffqae29999", "wrong-token-xyz"); err == nil {
|
||||
conn.Close()
|
||||
if code == 0 {
|
||||
t.Errorf("G3 bad-token registration was accepted (code 0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 valid registration → device appears online with correct MAC
|
||||
conn, code, err := registerDevice(cfg.devAddr, devid, mac, cfg.devToken)
|
||||
if err != nil {
|
||||
t.Fatalf("G1 register failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if code != 0 {
|
||||
t.Fatalf("G1 valid registration rejected, code=%d", code)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
got := c.listDevices(t, "q="+devid)
|
||||
var found *devItem
|
||||
for i := range got.Items {
|
||||
if got.Items[i].Ddns == devid {
|
||||
found = &got.Items[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("G1 registered device %q not in list", devid)
|
||||
}
|
||||
if found.Status != "online" {
|
||||
t.Errorf("G1 device status=%q want online", found.Status)
|
||||
}
|
||||
if found.Mac != mac {
|
||||
t.Errorf("G1 device mac=%q want %q", found.Mac, mac)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== L. Real device E2E (L3) =====
|
||||
if cfg.realID != "" {
|
||||
t.Run("L_real_device", func(t *testing.T) {
|
||||
got := c.listDevices(t, "q="+cfg.realID)
|
||||
var found *devItem
|
||||
for i := range got.Items {
|
||||
if got.Items[i].Ddns == cfg.realID {
|
||||
found = &got.Items[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("L1 real device %q not found in list", cfg.realID)
|
||||
}
|
||||
if found.Status != "online" {
|
||||
t.Errorf("L1 real device %q status=%q want online (is the KVM connected?)", cfg.realID, found.Status)
|
||||
}
|
||||
if cfg.realMAC != "" && found.Mac != cfg.realMAC {
|
||||
t.Errorf("L1 real device mac=%q want %q", found.Mac, cfg.realMAC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- assertion helpers ----------
|
||||
|
||||
func containsDdns(items []devItem, ddns string) bool {
|
||||
for _, it := range items {
|
||||
if it.Ddns == ddns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func insertColons(mac string) string {
|
||||
if len(mac) != 12 {
|
||||
return mac
|
||||
}
|
||||
var p []string
|
||||
for i := 0; i < 12; i += 2 {
|
||||
p = append(p, mac[i:i+2])
|
||||
}
|
||||
return strings.Join(p, ":")
|
||||
}
|
||||
|
||||
func onlineFirst(items []devItem) bool {
|
||||
seenOffline := false
|
||||
for _, it := range items {
|
||||
if it.Status == "online" && seenOffline {
|
||||
return false
|
||||
}
|
||||
if it.Status != "online" {
|
||||
seenOffline = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fieldVal(it devItem, field string) string {
|
||||
switch field {
|
||||
case "ddns":
|
||||
return it.Ddns
|
||||
case "mac":
|
||||
return it.Mac
|
||||
case "ip":
|
||||
return it.IP
|
||||
case "description":
|
||||
return it.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fieldOrderedWithinBucket checks the field is sorted within each status bucket
|
||||
// (online block, then offline block), matching the server's online-first rule.
|
||||
func fieldOrderedWithinBucket(items []devItem, field string, asc bool) bool {
|
||||
buckets := map[string][]string{}
|
||||
order := []string{}
|
||||
for _, it := range items {
|
||||
if _, ok := buckets[it.Status]; !ok {
|
||||
order = append(order, it.Status)
|
||||
}
|
||||
buckets[it.Status] = append(buckets[it.Status], fieldVal(it, field))
|
||||
}
|
||||
for _, st := range order {
|
||||
vals := buckets[st]
|
||||
sorted := make([]string, len(vals))
|
||||
copy(sorted, vals)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if asc {
|
||||
return sorted[i] < sorted[j]
|
||||
}
|
||||
return sorted[i] > sorted[j]
|
||||
})
|
||||
for i := range vals {
|
||||
if vals[i] != sorted[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// F / H / I — groups & RBAC, event logs, CRUD. Modular, self-contained tests.
|
||||
// ============================================================================
|
||||
|
||||
type evtItem struct {
|
||||
DeviceMac string `json:"deviceMac"`
|
||||
EventType string `json:"eventType"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ---- generic request helpers ----
|
||||
|
||||
func (c *client) post(t *testing.T, path string, body any) apiEnvelope {
|
||||
t.Helper()
|
||||
_, env, err := c.do("POST", path, body, true)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (c *client) put(t *testing.T, path string, body any) apiEnvelope {
|
||||
t.Helper()
|
||||
_, env, err := c.do("PUT", path, body, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT %s: %v", path, err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (c *client) del(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
c.do("DELETE", path, nil, true)
|
||||
}
|
||||
|
||||
func loginClient(t *testing.T, cfg config, user, pass string) *client {
|
||||
c := newClient(cfg)
|
||||
c.cfg.user, c.cfg.pass = user, pass
|
||||
_, env, err := c.do("POST", "/api/login", map[string]string{"username": user, "password": pass}, false)
|
||||
if err != nil || !env.OK {
|
||||
return nil
|
||||
}
|
||||
var d struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
if d.Token == "" {
|
||||
return nil
|
||||
}
|
||||
c.token = d.Token
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- fixture helpers ----
|
||||
|
||||
func (c *client) createDeviceGroup(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/device-groups", map[string]any{"name": name})
|
||||
if !env.OK {
|
||||
t.Fatalf("create device-group %q: %s", name, env.Code)
|
||||
}
|
||||
var d struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
if d.ID == 0 {
|
||||
t.Fatalf("create device-group returned no id")
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
func (c *client) createUserGroup(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/user-groups", map[string]any{"name": name})
|
||||
if !env.OK {
|
||||
t.Fatalf("create user-group %q: %s", name, env.Code)
|
||||
}
|
||||
var d struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.ID
|
||||
}
|
||||
func (c *client) linkUGtoDG(t *testing.T, ugID int64, dgIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.put(t, fmt.Sprintf("/api/user-groups/%d/device-groups", ugID), map[string]any{"deviceGroupIds": dgIDs})
|
||||
if !env.OK {
|
||||
t.Fatalf("link ug %d -> dg: %s", ugID, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) createNormalUser(t *testing.T, username, pass string, ugIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/users", map[string]any{
|
||||
"role": "user", "username": username, "password": pass, "repassword": pass, "userGroupIds": ugIDs,
|
||||
})
|
||||
if !env.OK {
|
||||
t.Fatalf("create user %q: %s", username, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) findUserID(t *testing.T, username string) int64 {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", "/api/users", nil, true)
|
||||
var d struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
for _, u := range d.Items {
|
||||
if u.Username == username {
|
||||
return u.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (c *client) assignDevicesToGroup(t *testing.T, dgID int64, devIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.put(t, fmt.Sprintf("/api/device-groups/%d/devices", dgID), map[string]any{"deviceIds": devIDs})
|
||||
if !env.OK {
|
||||
t.Fatalf("assign devices to dg %d: %s", dgID, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) me(t *testing.T) []string {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", "/api/me", nil, true)
|
||||
var d struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.Permissions
|
||||
}
|
||||
func (c *client) listEventLogs(t *testing.T, mac, types string) []evtItem {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", fmt.Sprintf("/api/device-event-logs?mac=%s&types=%s&pageSize=50", mac, types), nil, true)
|
||||
var d struct {
|
||||
Items []evtItem `json:"items"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.Items
|
||||
}
|
||||
|
||||
// registerQAOnline registers a qa device and returns the live conn + its DB id.
|
||||
func (c *client) registerQAOnline(t *testing.T, devid, mac string) (net.Conn, int64) {
|
||||
t.Helper()
|
||||
conn, code, err := registerDevice(c.cfg.devAddr, devid, mac, c.cfg.devToken)
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", devid, err)
|
||||
}
|
||||
if code != 0 {
|
||||
conn.Close()
|
||||
t.Fatalf("register %s rejected, code=%d", devid, code)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
got := c.listDevices(t, "q="+devid)
|
||||
for _, it := range got.Items {
|
||||
if it.Ddns == devid {
|
||||
return conn, it.ID
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
t.Fatalf("registered device %s not found in list", devid)
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func findItem(items []devItem, ddns string) *devItem {
|
||||
for i := range items {
|
||||
if items[i].Ddns == ddns {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func anyEvent(items []evtItem, mac, typ string) bool {
|
||||
for _, e := range items {
|
||||
if e.DeviceMac == mac && e.EventType == typ {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ===== F. Group filter + RBAC visibility =====
|
||||
func TestE2E_GroupsRBAC(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" {
|
||||
t.Skip("QA_PASS required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
|
||||
t.Run("F2_unassigned", func(t *testing.T) {
|
||||
un := c.listDevices(t, "unassigned=true&pageSize=50")
|
||||
for _, it := range un.Items {
|
||||
if it.DeviceGroupID != nil {
|
||||
t.Errorf("F2 unassigned=true returned grouped device %s", it.Ddns)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.devAddr == "" {
|
||||
t.Skip("QA_DEV_ADDR required for RBAC fixture")
|
||||
}
|
||||
t.Run("F3_visibility_and_perm_enforcement", func(t *testing.T) {
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
dgID := c.createDeviceGroup(t, "qae2e_dg")
|
||||
ugID := c.createUserGroup(t, "qae2e_ug")
|
||||
c.linkUGtoDG(t, ugID, []int64{dgID})
|
||||
uname, upass := "qae2e_user", "Qae2ePass123!"
|
||||
c.createNormalUser(t, uname, upass, []int64{ugID})
|
||||
defer func() {
|
||||
if uid := c.findUserID(t, uname); uid > 0 {
|
||||
c.del(t, fmt.Sprintf("/api/users/%d", uid))
|
||||
}
|
||||
c.del(t, fmt.Sprintf("/api/user-groups/%d", ugID))
|
||||
c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
||||
}()
|
||||
|
||||
conn, devID := c.registerQAOnline(t, "qae2e_vis", "02ffqaevis01")
|
||||
defer conn.Close()
|
||||
c.assignDevicesToGroup(t, dgID, []int64{devID})
|
||||
|
||||
nu := loginClient(t, cfg, uname, upass)
|
||||
if nu == nil {
|
||||
t.Fatal("normal user login failed")
|
||||
}
|
||||
vl := nu.listDevices(t, "pageSize=100")
|
||||
// F3: the user sees their group device, and ONLY devices in their group.
|
||||
if findItem(vl.Items, "qae2e_vis") == nil {
|
||||
t.Errorf("F3 normal user cannot see their own group's device")
|
||||
}
|
||||
for _, it := range vl.Items {
|
||||
if it.DeviceGroupID == nil || *it.DeviceGroupID != dgID {
|
||||
t.Errorf("F3 normal user saw out-of-scope device %s (group=%v)", it.Ddns, it.DeviceGroupID)
|
||||
}
|
||||
}
|
||||
// Permission enforcement must be consistent with declared permissions.
|
||||
perms := nu.me(t)
|
||||
hasWrite := contains(perms, "device.write")
|
||||
_, env, _ := nu.do("DELETE", fmt.Sprintf("/api/devices/%d", devID), nil, true)
|
||||
denied := !env.OK
|
||||
if hasWrite && denied {
|
||||
t.Errorf("RBAC inconsistent: user HAS device.write but delete was denied")
|
||||
}
|
||||
if !hasWrite && !denied {
|
||||
t.Errorf("RBAC inconsistent: user LACKS device.write but delete succeeded")
|
||||
}
|
||||
t.Logf("normal user perms=%v device.write=%v deleteDenied=%v", perms, hasWrite, denied)
|
||||
})
|
||||
}
|
||||
|
||||
// ===== H. Event logs =====
|
||||
func TestE2E_EventLogs(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" || cfg.devAddr == "" {
|
||||
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
devid, mac := "qae2e_evt", "02ffqaeevt01"
|
||||
conn, _ := c.registerQAOnline(t, devid, mac)
|
||||
|
||||
t.Run("H1_online_event", func(t *testing.T) {
|
||||
// poll a few seconds for the online event
|
||||
for i := 0; i < 6; i++ {
|
||||
if anyEvent(c.listEventLogs(t, mac, "device_online"), mac, "device_online") {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
t.Errorf("H1 no device_online event recorded for %s (mac %s)", devid, mac)
|
||||
})
|
||||
|
||||
t.Run("H2_offline_event", func(t *testing.T) {
|
||||
conn.Close() // clean disconnect -> server should mark offline + log
|
||||
for i := 0; i < 12; i++ {
|
||||
if anyEvent(c.listEventLogs(t, mac, "device_offline"), mac, "device_offline") {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
t.Errorf("H2 no device_offline event within 12s after disconnect")
|
||||
})
|
||||
}
|
||||
|
||||
// ===== I. Device CRUD (admin) =====
|
||||
func TestE2E_CRUD(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" || cfg.devAddr == "" {
|
||||
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
dgID := c.createDeviceGroup(t, "qae2e_crud_dg")
|
||||
defer c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
||||
|
||||
conn, devID := c.registerQAOnline(t, "qae2e_crud", "02ffqaecrud1")
|
||||
|
||||
t.Run("I1_update_description", func(t *testing.T) {
|
||||
env := c.put(t, fmt.Sprintf("/api/devices/%d", devID), map[string]string{"description": "qa-updated-desc"})
|
||||
if !env.OK {
|
||||
t.Fatalf("I1 update failed: %s", env.Code)
|
||||
}
|
||||
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
||||
if d == nil || d.Description != "qa-updated-desc" {
|
||||
t.Errorf("I1 description not updated, got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("I3_move_to_group", func(t *testing.T) {
|
||||
env := c.post(t, "/api/devices/move-to-device-group", map[string]any{"groupId": dgID, "deviceIds": []int64{devID}})
|
||||
if !env.OK {
|
||||
t.Fatalf("I3 move failed: %s", env.Code)
|
||||
}
|
||||
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
||||
if d == nil || d.DeviceGroupID == nil || *d.DeviceGroupID != dgID {
|
||||
t.Errorf("I3 device not in group %d, got %+v", dgID, d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("I2_delete", func(t *testing.T) {
|
||||
conn.Close()
|
||||
c.del(t, fmt.Sprintf("/api/devices/%d", devID))
|
||||
if findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud") != nil {
|
||||
t.Errorf("I2 device still present after delete")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -71,10 +71,13 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
|
||||
cfg := xconfig.Must()
|
||||
// --- DB ---
|
||||
appDB, err := sqlite.Open(ctx, sqlite.Options{
|
||||
DSN: defaultDBPath,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
LogSQL: true,
|
||||
DSN: defaultDBPath,
|
||||
// WAL (set via DSN pragmas) lets reads run concurrently with the single
|
||||
// writer, so a wider pool serves API reads without blocking on device
|
||||
// registration writes.
|
||||
MaxOpenConns: 8,
|
||||
MaxIdleConns: 8,
|
||||
LogSQL: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("open sqlite failed")
|
||||
|
||||
+48
-15
@@ -38,6 +38,7 @@ import (
|
||||
"rttys/internal/store/sqlite"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rttys/utils"
|
||||
@@ -81,6 +82,11 @@ type Device struct {
|
||||
close sync.Once
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// registered becomes true only after the device passes registration
|
||||
// (token + MAC checks) and is added to the server. Close() consults it so
|
||||
// that failed registrations never emit an unpaired device-offline event.
|
||||
registered atomic.Bool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -127,6 +133,7 @@ const (
|
||||
devRegErrInvalidToken
|
||||
devRegErrHookFailed
|
||||
devRegErrIdConflicting
|
||||
devRegErrEmptyMac
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -144,6 +151,7 @@ var DevRegErrMsg = map[byte]string{
|
||||
devRegErrInvalidToken: "Invalid token",
|
||||
devRegErrHookFailed: "Hook failed",
|
||||
devRegErrIdConflicting: "ID conflict",
|
||||
devRegErrEmptyMac: "Empty MAC",
|
||||
}
|
||||
|
||||
var DeviceMsgHandlers = map[byte]func(*Device, []byte) error{
|
||||
@@ -248,19 +256,29 @@ func handleDeviceConnection(srv *RttyServer, conn net.Conn) {
|
||||
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v, remoteIP '%s'",
|
||||
dev.id, dev.group, dev.proto, dev.heartbeat, deviceRemoteIP)
|
||||
|
||||
// 2. Load existing metadata by device_id
|
||||
description := ""
|
||||
meta, err := legacy.GetDeviceMetaByDeviceID(dev.id)
|
||||
if err == nil && meta != nil {
|
||||
description = meta.Description
|
||||
}
|
||||
if err := legacy.SaveOrUpdateDeviceMeta(
|
||||
dev.id,
|
||||
dev.desc, // device register mac info with desc filed
|
||||
description,
|
||||
deviceRemoteIP,
|
||||
); err != nil {
|
||||
return
|
||||
// 2. Persist device metadata. A known device reconnecting with unchanged
|
||||
// identity (same MAC + IP) only needs to be flipped back online with a
|
||||
// fresh last_seen — avoid rewriting every metadata column. This removes most
|
||||
// of the per-reconnect write amplification that drives restart/reconnect
|
||||
// storms. New devices, or ones whose MAC/IP changed, get the full upsert.
|
||||
meta, _ := legacy.GetDeviceMetaByDeviceID(dev.id)
|
||||
if meta != nil && meta.Mac == utils.NormalizeMac(dev.desc) && meta.IP == deviceRemoteIP {
|
||||
if err := legacy.MarkDeviceOnline(dev.id); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
description := ""
|
||||
if meta != nil {
|
||||
description = meta.Description
|
||||
}
|
||||
if err := legacy.SaveOrUpdateDeviceMeta(
|
||||
dev.id,
|
||||
dev.desc, // device register mac info with desc filed
|
||||
description,
|
||||
deviceRemoteIP,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -377,7 +395,11 @@ func (dev *Device) Close(srv *RttyServer) {
|
||||
dev.close.Do(func() {
|
||||
log.Error().Msgf("device '%s' disconnected", dev.id)
|
||||
srv.DelDevice(dev)
|
||||
if dev.id != "" {
|
||||
// Only emit an offline event for devices that actually came online.
|
||||
// A connection that failed registration (bad token, empty MAC, proto
|
||||
// too low, hook failure, id conflict) never recorded an online event,
|
||||
// so recording offline here would produce orphan rows on every retry.
|
||||
if dev.id != "" && dev.registered.Load() {
|
||||
_ = legacy.MarkDeviceOffline(dev.id)
|
||||
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
|
||||
c.DeviceLogSvc.RecordDeviceOffline(context.Background(), dev.id, dev.desc, "")
|
||||
@@ -445,12 +467,20 @@ func (dev *Device) Register(srv *RttyServer) byte {
|
||||
return devRegErrHookFailed
|
||||
}
|
||||
|
||||
log.Info().Msgf("cfg.Token:%s,dev.token:%s", cfg.Token, dev.token)
|
||||
if cfg.Token != "" && dev.token != cfg.Token {
|
||||
log.Error().Msgf("invalid token for device '%s'", dev.id)
|
||||
return devRegErrInvalidToken
|
||||
}
|
||||
|
||||
// Reject an empty MAC (carried in the register description field). An empty
|
||||
// value would collide with the devices.mac UNIQUE constraint as soon as a
|
||||
// second device registers the same way, so refuse it here instead of
|
||||
// letting the DB upsert fail repeatedly on every reconnect.
|
||||
if strings.TrimSpace(dev.desc) == "" {
|
||||
log.Error().Msgf("empty MAC for device '%s'", dev.id)
|
||||
return devRegErrEmptyMac
|
||||
}
|
||||
|
||||
devHookUrl := cfg.DevHookUrl
|
||||
if devHookUrl != "" {
|
||||
cli := &http.Client{
|
||||
@@ -476,6 +506,9 @@ func (dev *Device) Register(srv *RttyServer) byte {
|
||||
return devRegErrIdConflicting
|
||||
}
|
||||
|
||||
// Mark as fully registered so Close() will emit a paired offline event.
|
||||
dev.registered.Store(true)
|
||||
|
||||
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
|
||||
c.DeviceLogSvc.RecordDeviceOnline(context.Background(), dev.id, dev.desc, "")
|
||||
if c.NotificationSvc != nil {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v2.5.0"
|
||||
const KVMCloudVersion = "v2.7.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rttys/internal/domain/device"
|
||||
)
|
||||
|
||||
// Mirrors the OLD in-memory ordering logic from the device list handler, used
|
||||
// as the oracle to prove the SQL ListPaged produces identical ordering.
|
||||
func oracleOrder(items []device.Device, groupName map[int64]string, sortBy, order string) []string {
|
||||
asc := !strings.EqualFold(order, "desc")
|
||||
idx := make([]int, len(items))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
// items are assumed pre-ordered by id ASC (as the repo loaded them)
|
||||
sort.SliceStable(idx, func(a, b int) bool {
|
||||
i, j := items[idx[a]], items[idx[b]]
|
||||
oi := i.Status == device.StatusOnline
|
||||
oj := j.Status == device.StatusOnline
|
||||
if oi != oj {
|
||||
return oi
|
||||
}
|
||||
var cmp int
|
||||
switch sortBy {
|
||||
case "id":
|
||||
switch {
|
||||
case i.ID < j.ID:
|
||||
cmp = -1
|
||||
case i.ID > j.ID:
|
||||
cmp = 1
|
||||
}
|
||||
case "ip":
|
||||
cmp = strings.Compare(i.IP, j.IP)
|
||||
case "mac":
|
||||
cmp = strings.Compare(i.Mac, j.Mac)
|
||||
case "connectedTime":
|
||||
var ti, tj int64
|
||||
if i.LastSeenAt != nil {
|
||||
ti = *i.LastSeenAt
|
||||
}
|
||||
if j.LastSeenAt != nil {
|
||||
tj = *j.LastSeenAt
|
||||
}
|
||||
switch {
|
||||
case ti < tj:
|
||||
cmp = -1
|
||||
case ti > tj:
|
||||
cmp = 1
|
||||
}
|
||||
case "description":
|
||||
cmp = strings.Compare(i.Description, j.Description)
|
||||
case "ddns":
|
||||
cmp = strings.Compare(i.Ddns, j.Ddns)
|
||||
case "deviceGroupName":
|
||||
var gi, gj string
|
||||
if i.DeviceGroupID != nil {
|
||||
gi = groupName[*i.DeviceGroupID]
|
||||
}
|
||||
if j.DeviceGroupID != nil {
|
||||
gj = groupName[*j.DeviceGroupID]
|
||||
}
|
||||
cmp = strings.Compare(gi, gj)
|
||||
default:
|
||||
cmp = strings.Compare(i.Ddns, j.Ddns)
|
||||
}
|
||||
if cmp == 0 {
|
||||
return false
|
||||
}
|
||||
if asc {
|
||||
return cmp < 0
|
||||
}
|
||||
return cmp > 0
|
||||
})
|
||||
out := make([]string, len(idx))
|
||||
for k, ix := range idx {
|
||||
out[k] = items[ix].Ddns
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListPagedMatchesLegacyOrdering(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "lp.db")
|
||||
db, err := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
|
||||
// minimal tables (only what ListPaged reads)
|
||||
g.Exec(`CREATE TABLE device_groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`)
|
||||
g.Exec(`CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY, ddns TEXT, mac TEXT, name TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER, status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO device_groups(id,name) VALUES (1,'Beta'),(2,'alpha'),(3,'Zeta')`)
|
||||
|
||||
// Fixtures: mixed status, null/non-null group, null/value last_seen, ties,
|
||||
// case differences, ungrouped devices.
|
||||
rows := []struct {
|
||||
id int64
|
||||
ddns string
|
||||
mac string
|
||||
ip string
|
||||
desc string
|
||||
group interface{}
|
||||
status string
|
||||
lastSee interface{}
|
||||
}{
|
||||
{1, "bbb001", "aa11", "10.0.0.5", "Camera", 1, "online", 1000},
|
||||
{2, "aaa002", "bb22", "10.0.0.2", "router", nil, "offline", nil},
|
||||
{3, "ccc003", "cc33", "10.0.0.9", "camera", 2, "online", 2000},
|
||||
{4, "aaa001", "dd44", "10.0.0.2", "router", 3, "online", 1000}, // ties ip & lastseen with others
|
||||
{5, "ddd005", "ee55", "10.0.0.1", "", nil, "offline", 500},
|
||||
{6, "eee006", "ff66", "10.0.0.7", "Zebra", 1, "online", nil},
|
||||
{7, "fff007", "0011", "10.0.0.3", "alpha", 2, "offline", 3000},
|
||||
{8, "ggg008", "1122", "10.0.0.8", "Beta", nil, "online", 2000}, // ungrouped, online, ties lastseen
|
||||
}
|
||||
for _, r := range rows {
|
||||
g.Exec(`INSERT INTO devices(id,ddns,mac,ip,description,device_group_id,status,last_seen_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`, r.id, r.ddns, r.mac, r.ip, r.desc, r.group, r.status, r.lastSee)
|
||||
}
|
||||
|
||||
// Build the oracle input: all devices ordered by id ASC, plus group names.
|
||||
groupName := map[int64]string{1: "Beta", 2: "alpha", 3: "Zeta"}
|
||||
var legacyItems []device.Device
|
||||
for _, r := range rows {
|
||||
var gid *int64
|
||||
if r.group != nil {
|
||||
v := int64(r.group.(int))
|
||||
gid = &v
|
||||
}
|
||||
var ls *int64
|
||||
if r.lastSee != nil {
|
||||
v := int64(r.lastSee.(int))
|
||||
ls = &v
|
||||
}
|
||||
legacyItems = append(legacyItems, device.Device{
|
||||
ID: r.id, Ddns: r.ddns, Mac: r.mac, IP: r.ip, Description: r.desc,
|
||||
DeviceGroupID: gid, Status: device.Status(r.status), LastSeenAt: ls,
|
||||
})
|
||||
}
|
||||
|
||||
repo := NewDeviceRepo(g)
|
||||
fields := []string{"", "id", "ip", "mac", "ddns", "description", "connectedTime", "deviceGroupName"}
|
||||
orders := []string{"asc", "desc"}
|
||||
for _, f := range fields {
|
||||
for _, o := range orders {
|
||||
want := oracleOrder(legacyItems, groupName, f, o)
|
||||
got, total, err := repo.ListPaged(context.Background(), device.ListQuery{SortBy: f, Order: o})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged(%q,%q): %v", f, o, err)
|
||||
}
|
||||
if total != int64(len(rows)) {
|
||||
t.Errorf("sortBy=%q order=%q: total=%d want=%d", f, o, total, len(rows))
|
||||
}
|
||||
var gotDdns []string
|
||||
for _, it := range got {
|
||||
gotDdns = append(gotDdns, it.Ddns)
|
||||
}
|
||||
if fmt.Sprint(gotDdns) != fmt.Sprint(want) {
|
||||
t.Errorf("sortBy=%q order=%q ordering mismatch:\n SQL = %v\n legacy = %v", f, o, gotDdns, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// group-name join correctness
|
||||
got, _, _ := repo.ListPaged(context.Background(), device.ListQuery{SortBy: "id", Order: "asc"})
|
||||
for _, it := range got {
|
||||
var wantName string
|
||||
if it.DeviceGroupID != nil {
|
||||
wantName = groupName[*it.DeviceGroupID]
|
||||
}
|
||||
if it.GroupName != wantName {
|
||||
t.Errorf("device %s group name=%q want=%q", it.Ddns, it.GroupName, wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedSearchUnassignedPagination(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "lp2.db")
|
||||
db, _ := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
g.Exec(`CREATE TABLE device_groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`)
|
||||
g.Exec(`CREATE TABLE devices (id INTEGER PRIMARY KEY, ddns TEXT, mac TEXT, name TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER, status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO device_groups(id,name) VALUES (1,'G1')`)
|
||||
g.Exec(`INSERT INTO devices(id,ddns,mac,ip,description,device_group_id,status) VALUES
|
||||
(1,'zh71fb1','9483c4b71fb1','10.0.0.1','lab',1,'online'),
|
||||
(2,'pubacff','9483c4bbacff','10.0.0.2','',NULL,'offline'),
|
||||
(3,'aq71029','9483c4a71029','192.168.1.9','Office',NULL,'online'),
|
||||
(4,'xy00001','001122334455','10.0.0.4','SEARCHME',1,'offline')`)
|
||||
repo := NewDeviceRepo(g)
|
||||
ctx := context.Background()
|
||||
|
||||
// search by ddns fragment
|
||||
got, total, _ := repo.ListPaged(ctx, device.ListQuery{Search: "zh71"})
|
||||
if total != 1 || len(got) != 1 || got[0].Ddns != "zh71fb1" {
|
||||
t.Errorf("search zh71: got %d rows total=%d", len(got), total)
|
||||
}
|
||||
// search by MAC fragment (colon-less, as handler passes it)
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Search: "9483c4"})
|
||||
if total != 3 {
|
||||
t.Errorf("search 9483c4: total=%d want 3", total)
|
||||
}
|
||||
// case-insensitive description search
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Search: "searchme"})
|
||||
if total != 1 || got[0].Ddns != "xy00001" {
|
||||
t.Errorf("search searchme: total=%d", total)
|
||||
}
|
||||
// unassigned only
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Unassigned: true})
|
||||
if total != 2 {
|
||||
t.Errorf("unassigned: total=%d want 2", total)
|
||||
}
|
||||
for _, it := range got {
|
||||
if it.DeviceGroupID != nil {
|
||||
t.Errorf("unassigned returned grouped device %s", it.Ddns)
|
||||
}
|
||||
}
|
||||
// pagination: pageSize 2 over 4 rows, sorted by id asc (online-first)
|
||||
p1, total, _ := repo.ListPaged(ctx, device.ListQuery{SortBy: "id", Order: "asc", Page: 1, PageSize: 2})
|
||||
p2, _, _ := repo.ListPaged(ctx, device.ListQuery{SortBy: "id", Order: "asc", Page: 2, PageSize: 2})
|
||||
if total != 4 || len(p1) != 2 || len(p2) != 2 {
|
||||
t.Fatalf("pagination sizes: total=%d p1=%d p2=%d", total, len(p1), len(p2))
|
||||
}
|
||||
// online-first: page1 should be the two online devices (ids 1,3)
|
||||
if p1[0].Status != device.StatusOnline || p1[1].Status != device.StatusOnline {
|
||||
t.Errorf("online-first violated: p1=%v %v", p1[0].Status, p1[1].Status)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -86,3 +87,105 @@ func (r *DeviceRepo) ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deviceListRow carries the device columns plus the joined group name.
|
||||
type deviceListRow struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
Ddns string `gorm:"column:ddns"`
|
||||
Mac string `gorm:"column:mac"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description string `gorm:"column:description"`
|
||||
IP string `gorm:"column:ip"`
|
||||
Client string `gorm:"column:client"`
|
||||
DeviceGroupID *int64 `gorm:"column:device_group_id"`
|
||||
Status string `gorm:"column:status"`
|
||||
LastSeenAt *int64 `gorm:"column:last_seen_at"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
}
|
||||
|
||||
// sortColumns whitelists the user-supplied sortBy to a safe SQL expression so
|
||||
// the value is never interpolated as raw column input.
|
||||
var sortColumns = map[string]string{
|
||||
"id": "d.id",
|
||||
"ip": "d.ip",
|
||||
"mac": "d.mac",
|
||||
"ddns": "d.ddns",
|
||||
"description": "d.description",
|
||||
"connectedTime": "COALESCE(d.last_seen_at, 0)",
|
||||
"deviceGroupName": "COALESCE(dg.name, '')",
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) ListPaged(ctx context.Context, q device.ListQuery) ([]device.ListItem, int64, error) {
|
||||
base := r.db.WithContext(ctx).
|
||||
Table("devices AS d").
|
||||
Joins("LEFT JOIN device_groups AS dg ON dg.id = d.device_group_id")
|
||||
|
||||
if q.RestrictGroups != nil {
|
||||
base = base.Where("d.device_group_id IN ?", q.RestrictGroups)
|
||||
}
|
||||
if q.Unassigned {
|
||||
base = base.Where("d.device_group_id IS NULL")
|
||||
}
|
||||
if q.Status != "" {
|
||||
base = base.Where("d.status = ?", q.Status)
|
||||
}
|
||||
if q.Search != "" {
|
||||
like := "%" + q.Search + "%"
|
||||
base = base.Where(
|
||||
"lower(d.ddns) LIKE ? OR lower(d.mac) LIKE ? OR lower(d.ip) LIKE ? OR lower(d.description) LIKE ?",
|
||||
like, like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// ORDER BY: online devices first (always), then the requested field, then id
|
||||
// as a stable tie-breaker (mirrors the previous in-memory SliceStable order).
|
||||
expr, ok := sortColumns[q.SortBy]
|
||||
if !ok {
|
||||
expr = "d.ddns"
|
||||
}
|
||||
dir := "ASC"
|
||||
if strings.EqualFold(q.Order, "desc") {
|
||||
dir = "DESC"
|
||||
}
|
||||
orderBy := "(d.status = 'online') DESC, " + expr + " " + dir + ", d.id ASC"
|
||||
|
||||
tx := base.Session(&gorm.Session{}).
|
||||
Select("d.*, COALESCE(dg.name, '') AS group_name").
|
||||
Order(orderBy)
|
||||
if q.PageSize > 0 {
|
||||
page := q.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
tx = tx.Limit(q.PageSize).Offset((page - 1) * q.PageSize)
|
||||
}
|
||||
|
||||
var rows2 []deviceListRow
|
||||
if err := tx.Scan(&rows2).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
out2 := make([]device.ListItem, 0, len(rows2))
|
||||
for _, row := range rows2 {
|
||||
out2 = append(out2, device.ListItem{
|
||||
Device: device.Device{
|
||||
ID: row.ID,
|
||||
Ddns: row.Ddns,
|
||||
Mac: row.Mac,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
IP: row.IP,
|
||||
Client: row.Client,
|
||||
DeviceGroupID: row.DeviceGroupID,
|
||||
Status: device.Status(row.Status),
|
||||
LastSeenAt: row.LastSeenAt,
|
||||
},
|
||||
GroupName: row.GroupName,
|
||||
})
|
||||
}
|
||||
return out2, total, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verifies the reconnect minimal-write path: MarkOnline flips status + refreshes
|
||||
// last_seen_at without rewriting identity columns, and UpdateClient only writes
|
||||
// when the value actually changed.
|
||||
func TestMarkOnlineAndClientGuard(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "mw.db")
|
||||
db, err := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
g.Exec(`CREATE TABLE devices (id INTEGER PRIMARY KEY AUTOINCREMENT, ddns TEXT, mac TEXT, name TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '', ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER,
|
||||
status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO devices(ddns,mac,description,ip,client,status,last_seen_at)
|
||||
VALUES ('dev1','aabbcc','MacInfo','10.0.0.9','rtty-go','offline',100)`)
|
||||
|
||||
repo := NewDeviceMetaRepo(g)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.MarkOnline(ctx, "dev1"); err != nil {
|
||||
t.Fatalf("MarkOnline: %v", err)
|
||||
}
|
||||
var status, mac, desc, ip string
|
||||
var lastSeen int64
|
||||
g.Raw(`SELECT status,mac,description,ip,last_seen_at FROM devices WHERE ddns='dev1'`).
|
||||
Row().Scan(&status, &mac, &desc, &ip, &lastSeen)
|
||||
if status != "online" {
|
||||
t.Errorf("status=%q want online", status)
|
||||
}
|
||||
if lastSeen <= 100 {
|
||||
t.Errorf("last_seen_at not refreshed: %d", lastSeen)
|
||||
}
|
||||
// identity columns untouched
|
||||
if mac != "aabbcc" || desc != "MacInfo" || ip != "10.0.0.9" {
|
||||
t.Errorf("identity columns changed: mac=%q desc=%q ip=%q", mac, desc, ip)
|
||||
}
|
||||
|
||||
// UpdateClient with the SAME value => no row written
|
||||
res := g.Exec(`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
"rtty-go", "dev1", "rtty-go")
|
||||
if res.RowsAffected != 0 {
|
||||
t.Errorf("unchanged client should not write, RowsAffected=%d", res.RowsAffected)
|
||||
}
|
||||
if err := repo.UpdateClient(ctx, "dev1", "rtty-go"); err != nil {
|
||||
t.Fatalf("UpdateClient same: %v", err)
|
||||
}
|
||||
// changed value => writes
|
||||
res = g.Exec(`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
"rtty-go-2.0", "dev1", "rtty-go-2.0")
|
||||
if res.RowsAffected != 1 {
|
||||
t.Errorf("changed client should write once, RowsAffected=%d", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,28 @@ func (r *DeviceMetaRepo) SaveOrUpdate(ctx context.Context, deviceID, mac, descri
|
||||
).Error
|
||||
}
|
||||
|
||||
func (r *DeviceMetaRepo) UpdateClient(ctx context.Context, deviceID, client string) error {
|
||||
// MarkOnline flips a known device back online and refreshes last_seen without
|
||||
// rewriting its (unchanged) identity columns. Used on reconnect to avoid the
|
||||
// write amplification of a full upsert during reconnect storms.
|
||||
func (r *DeviceMetaRepo) MarkOnline(ctx context.Context, deviceID string) error {
|
||||
if r.db == nil {
|
||||
return fmt.Errorf("gorm db is nil")
|
||||
}
|
||||
return r.db.WithContext(ctx).Exec(
|
||||
`UPDATE devices SET client=? WHERE ddns=?`,
|
||||
client, deviceID,
|
||||
`UPDATE devices SET status='online', last_seen_at=unixepoch() WHERE ddns=?`,
|
||||
deviceID,
|
||||
).Error
|
||||
}
|
||||
|
||||
func (r *DeviceMetaRepo) UpdateClient(ctx context.Context, deviceID, client string) error {
|
||||
if r.db == nil {
|
||||
return fmt.Errorf("gorm db is nil")
|
||||
}
|
||||
// Only write when the value actually changed, so reconnects of a device
|
||||
// whose client is unchanged don't generate a redundant write.
|
||||
return r.db.WithContext(ctx).Exec(
|
||||
`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
client, deviceID, client,
|
||||
).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,10 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_group_id ON devices(device_group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at);
|
||||
-- No index on status/last_seen_at: both change on every connect/disconnect (high
|
||||
-- write churn during reconnect storms) but neither is used by any read query —
|
||||
-- the device-list sort uses expressions ((status='online'), COALESCE(last_seen_at,0))
|
||||
-- that can't use a plain column index, and nothing filters by these columns.
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||
AFTER UPDATE ON users
|
||||
|
||||
@@ -34,6 +34,25 @@ type Options struct {
|
||||
LogSQL bool
|
||||
}
|
||||
|
||||
// withPragmas appends connection pragmas to the DSN so every pooled connection
|
||||
// opens in WAL mode (readers never block on the single writer), waits instead
|
||||
// of erroring on contention (busy_timeout), and uses the faster-but-safe
|
||||
// synchronous=NORMAL. Without this, a device reconnect storm serializes all DB
|
||||
// access on one connection and blocks user-facing API reads for seconds.
|
||||
func withPragmas(dsn string) string {
|
||||
const pragmas = "_pragma=busy_timeout(5000)" +
|
||||
"&_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=synchronous(NORMAL)" +
|
||||
"&_pragma=foreign_keys(ON)"
|
||||
if strings.HasPrefix(dsn, "file:") {
|
||||
if strings.Contains(dsn, "?") {
|
||||
return dsn + "&" + pragmas
|
||||
}
|
||||
return dsn + "?" + pragmas
|
||||
}
|
||||
return "file:" + dsn + "?" + pragmas
|
||||
}
|
||||
|
||||
// Open opens sqlite via GORM (glebarez/sqlite) and exposes both *gorm.DB and *sql.DB.
|
||||
func Open(ctx context.Context, opt Options) (*AppDB, error) {
|
||||
if opt.DSN == "" {
|
||||
@@ -51,7 +70,7 @@ func Open(ctx context.Context, opt Options) (*AppDB, error) {
|
||||
gormCfg.Logger = logger.Default.LogMode(logger.Info)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(gormsqlite.Open(opt.DSN), gormCfg)
|
||||
gdb, err := gorm.Open(gormsqlite.Open(withPragmas(opt.DSN)), gormCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+12
-3
@@ -9,9 +9,18 @@
|
||||
import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
|
||||
import request, { httpService } from './request'
|
||||
|
||||
/** 获取设备列表 */
|
||||
export const getDeviceListApi = (params?: { groupId: number, sortBy?: string, order?: 'asc' | 'desc' }) => {
|
||||
return request<{ items: DeviceInfo[]}>({
|
||||
/** 获取设备列表(服务端分页:page/pageSize;q 为搜索词;unassigned 仅未分配) */
|
||||
export const getDeviceListApi = (params?: {
|
||||
groupId?: number
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
unassigned?: boolean
|
||||
status?: 'online' | 'offline'
|
||||
}) => {
|
||||
return request<{ items: DeviceInfo[], page: number, pageSize: number, total: number }>({
|
||||
url: '/api/devices',
|
||||
params,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
* @Description: Wraps gl-web-main's BaseModal with project-i18n defaults for
|
||||
* okText / cancelText. The upstream BaseModal falls back to its own internal
|
||||
* i18n table when these props are omitted, but that table only contains
|
||||
* zh/en — any other locale throws at render time and the entire footer slot
|
||||
* fails to mount, so Confirm/Cancel buttons disappear in de/es/fr/ja/ko.
|
||||
* Defaulting the props here routes labels through the project's vue-i18n,
|
||||
* which knows every supported locale.
|
||||
-->
|
||||
<template>
|
||||
<BaseModal
|
||||
:cancelText="cancelText"
|
||||
:okText="okText"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData || {}" />
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
}>(), {
|
||||
okText: 'common.confirm',
|
||||
cancelText: 'common.cancel',
|
||||
})
|
||||
</script>
|
||||
@@ -38,4 +38,5 @@ export default function useLanguage () {
|
||||
return {currentLang, setLanguage, isZh, t: i18n.global.t, currentLangLabel}
|
||||
}
|
||||
|
||||
export const t = i18n.global.t
|
||||
export const t = ((...args: any[]) =>
|
||||
(i18n.global.t as any)(...args)) as typeof i18n.global.t
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "Erfolgreich",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"about": "Über",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"about": "About",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "Éxito",
|
||||
"failed": "Error",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"about": "Acerca de",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "Succès",
|
||||
"failed": "Échec",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"about": "À propos",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"ok": "OK",
|
||||
"close": "閉じる",
|
||||
"about": "このサービスについて",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "성공",
|
||||
"failed": "실패",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"ok": "확인",
|
||||
"close": "닫기",
|
||||
"about": "정보",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"about": "关于",
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DeviceQuery {
|
||||
searchText: string
|
||||
deviceGroupId: number
|
||||
onlyShowUnassigned: boolean
|
||||
status?: 'online' | 'offline'
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const GET_DEVICE_POLLING_INTERVAL = 10 * 1000
|
||||
let getDeviceListTimer: number
|
||||
let pollingEnable = false
|
||||
|
||||
const DEVICE_VIEW_PAGE_SIZE = 20
|
||||
const DEVICE_VIEW_PAGE_SIZE = 50
|
||||
|
||||
export const useDeviceStore = defineStore('device', () => {
|
||||
const state = reactive({
|
||||
@@ -34,6 +34,8 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
deviceGroupId: undefined,
|
||||
/** 是否仅显示未分配项 */
|
||||
onlyShowUnassigned: false,
|
||||
/** 在线状态筛选:undefined=全部 / 'online' / 'offline' */
|
||||
status: undefined as 'online' | 'offline' | undefined,
|
||||
/** 这个字段存储是否有设备,因为UI上没有设备和没有筛选出来的设备是对应不同的展示画面的 */
|
||||
hasDevice: false,
|
||||
/** 排序字段 */
|
||||
@@ -53,47 +55,38 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
searchText: state.searchText?.replaceAll(':','').toLowerCase(),
|
||||
deviceGroupId: state.deviceGroupId,
|
||||
onlyShowUnassigned: state.onlyShowUnassigned,
|
||||
status: state.status,
|
||||
sortBy: state.sortBy,
|
||||
order: state.order,
|
||||
}
|
||||
return query
|
||||
})
|
||||
/** 设备列表的分页展示数据 */
|
||||
const deviceList= computed<DeviceInfo[]>(() => {
|
||||
/** 设备列表展示数据(服务端已分页,直接展示当前页) */
|
||||
const deviceList = computed<DeviceInfo[]>(() => state.deviceList)
|
||||
/** 获取设备列表(服务端分页 + 搜索/筛选下推后端) */
|
||||
const getDeviceList = async (isPolling = false, isGetAll = false) => {
|
||||
try {
|
||||
const { page, size } = pageLink.value
|
||||
return state.deviceList.slice((page - 1) * size, page * size)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
/** 获取设备列表 */
|
||||
const getDeviceList = async (isPolling = false, isGetAll = false) => {
|
||||
try {
|
||||
console.log('getDeviceList', computedDeviceQuery.value)
|
||||
!isPolling && (state.getDeviceLoading = true)
|
||||
const res = await getDeviceListApi({
|
||||
page: pageLink.value.page,
|
||||
pageSize: pageLink.value.size,
|
||||
q: computedDeviceQuery.value.searchText || undefined,
|
||||
groupId: computedDeviceQuery.value.deviceGroupId,
|
||||
unassigned: computedDeviceQuery.value.onlyShowUnassigned || undefined,
|
||||
status: computedDeviceQuery.value.status || undefined,
|
||||
sortBy: computedDeviceQuery.value.sortBy,
|
||||
order: computedDeviceQuery.value.order,
|
||||
})
|
||||
console.log(res)
|
||||
|
||||
const total = res.data.total ?? res.data.items.length
|
||||
pageLink.value.setTotal(total)
|
||||
// hasDevice 区分“账号一台设备都没有(引导页)”和“筛选无结果(空表格)”:
|
||||
// 只有无筛选的首次加载(isGetAll)可置 false,之后只升不降。
|
||||
if (isGetAll) {
|
||||
state.hasDevice = res.data.items.length > 0
|
||||
}
|
||||
if (res.data.items.length) {
|
||||
state.hasDevice = total > 0
|
||||
} else if (total > 0) {
|
||||
state.hasDevice = true
|
||||
}
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.deviceList = res.data.items.filter(d => {
|
||||
return (d?.ddns?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.mac?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.ip?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.description?.toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1) &&
|
||||
(computedDeviceQuery.value.deviceGroupId ? d.deviceGroupId === computedDeviceQuery.value.deviceGroupId : true) &&
|
||||
(!computedDeviceQuery.value.onlyShowUnassigned || (computedDeviceQuery.value.onlyShowUnassigned && !d.deviceGroupId))
|
||||
}) || []
|
||||
state.deviceList = res.data.items || []
|
||||
state.completeDeviceList = res.data.items || []
|
||||
!isPolling && (state.getDeviceLoading = false)
|
||||
} catch (error) {
|
||||
@@ -123,10 +116,19 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
pollingEnable && startPolling()
|
||||
}, GET_DEVICE_POLLING_INTERVAL)
|
||||
}
|
||||
/** 监听设备列表的查询条件变化 */
|
||||
watch(computedDeviceQuery, () => {
|
||||
/** 翻页(服务端分页):页码变化即拉取当前页 */
|
||||
watch(() => pageLink.value.page, () => {
|
||||
getDeviceList()
|
||||
})
|
||||
/** 查询条件变化(搜索/组/未分配/排序):重置到第 1 页。
|
||||
* 若已在第 1 页则直接拉取,否则改页码由上面的页码 watch 触发,避免重复请求。 */
|
||||
watch(computedDeviceQuery, () => {
|
||||
if (pageLink.value.page !== 1) {
|
||||
pageLink.value.changePage(1)
|
||||
} else {
|
||||
getDeviceList()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -65,7 +65,7 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
ip: [{
|
||||
trigger: 'blur',
|
||||
validator: async (_: any, value: string) => {
|
||||
@@ -86,7 +86,7 @@ const formRules: FormRules = {
|
||||
return Promise.reject(t('device.portNotCorrect'))
|
||||
},
|
||||
}],
|
||||
}
|
||||
}))
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
formRef.value.validate().then(() => {
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal, BaseRadioButtonsCompact } from 'gl-web-main/components'
|
||||
import { BaseInfo, BaseRadioButtonsCompact } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { getAddDeviceScriptInfoApi } from '@/api/device'
|
||||
import { copyText } from 'gl-web-main'
|
||||
import { message } from 'ant-design-vue'
|
||||
@@ -148,7 +149,7 @@ device_mac=$(cat /proc/gl-hw-info/device_mac)
|
||||
while true; do
|
||||
if ! pgrep -f "rtty.*-d $device_mac" > /dev/null; then
|
||||
echo "rtty not running, starting..."
|
||||
rtty -sx -T 2 -I "$device_id" -h $HOSTNAME$PORT -t $TOKEN -d "$device_mac" &
|
||||
rtty -sx -T 2 -I "$device_id" -h $HOSTNAME$PORT -t "$TOKEN" -d "$device_mac" &
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
@@ -191,10 +192,10 @@ esac
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
# 4. Add execution permissions
|
||||
# 5. Add execution permissions
|
||||
chmod +x "$SCRIPT_FILE"
|
||||
|
||||
# 5. Execute restart once
|
||||
# 6. Execute restart once
|
||||
"$SCRIPT_FILE" restart
|
||||
|
||||
`
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -62,11 +62,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
name: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
|
||||
const props = defineProps<{ open: boolean, type: string, res: any }>()
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ import BaseTable from '@/components/base/baseTable.vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
|
||||
import { useFakeUpgradeProgress } from 'gl-web-main'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { TableColumnType } from 'ant-design-vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import CmdResDetailDialog from './cmdResDetailDialog.vue'
|
||||
|
||||
@@ -17,13 +17,22 @@
|
||||
@search="deviceStore.handleSearch"
|
||||
/>
|
||||
|
||||
<ASelect
|
||||
<ASelect
|
||||
v-model:value="deviceStore.state.deviceGroupId"
|
||||
allowClear
|
||||
:placeholder="$t('device.allAssociatedDeviceGroup')"
|
||||
style="width: 224px;">
|
||||
<ASelectOption v-for="item in state.groupList" :key="item.groupId" :value="item.groupId">{{ item.name }}</ASelectOption>
|
||||
</ASelect>
|
||||
|
||||
<ASelect
|
||||
v-model:value="deviceStore.state.status"
|
||||
allowClear
|
||||
:placeholder="$t('device.status')"
|
||||
style="width: 140px; margin-left: 8px;">
|
||||
<ASelectOption value="online">{{ $t('device.online') }}</ASelectOption>
|
||||
<ASelectOption value="offline">{{ $t('device.offline') }}</ASelectOption>
|
||||
</ASelect>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk, useValidateInfo } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -55,12 +55,12 @@ const state = reactive<{formData: { description: string }}>({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
description: [
|
||||
{ required: true, message: t('device.requiredDescription'), trigger: 'change' },
|
||||
{ max: 256, message: t('common.maxLength', { length: 256 }), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk, useValidateInfo } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
|
||||
@@ -91,10 +91,10 @@ const state = reactive<{formData: ExecuteCommandFormData}>({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: [{ required: true, message: t('device.requiredUsername'), trigger: 'change' }],
|
||||
cmd: [{ required: true, message: t('device.requiredCommand'), trigger: 'change' }],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { BaseInfo } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -76,11 +77,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
groupId: [
|
||||
{ required: true, message: t('device.requiredDeviceGroup'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
const getDeviceGroupListOptions = async () => {
|
||||
const res = await reqDeviceGroupListOptions()
|
||||
|
||||
@@ -44,7 +44,7 @@ import '@xterm/xterm/css/xterm.css'
|
||||
import OverlayAddon from './components/xterm-addon-overlay'
|
||||
import ContextMenu from './components/contextMenu.vue'
|
||||
import RttyKeyboard from './components/rttyKeyboard.vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="device-list-container" v-else>
|
||||
<div class="out-device-list-header">
|
||||
<div class="left">
|
||||
<BaseText type="large-title-m">{{ $t('device.devices') + '(' + deviceStore.deviceList.length + ')' }}</BaseText>
|
||||
<BaseText type="large-title-m">{{ $t('device.devices') + '(' + deviceStore.pageLink.total + ')' }}</BaseText>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-list">
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal } from 'gl-web-main/components'
|
||||
import { BaseInfo } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, macAddressFormatter, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance, TableColumnType } from 'ant-design-vue'
|
||||
@@ -131,11 +132,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
name: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 获取用户组下拉选项 */
|
||||
const getUserGroupListOptions = async () => {
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal } from 'gl-web-main/components'
|
||||
import { BaseInfo } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { baseCustomModal, macAddressFormatter, OnBeforeOk } from 'gl-web-main'
|
||||
import BaseLoadingContainer from '@/components/base/baseLoadingContainer.vue'
|
||||
import { useDeviceStore } from '@/stores/modules/device'
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -75,11 +75,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
name: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 获取用户组下拉选项 */
|
||||
const getUserGroupListOptions = async () => {
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal } from 'gl-web-main/components'
|
||||
import { BaseInfo } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { baseCustomModal, macAddressFormatter, PageLink } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { message, TableColumnType } from 'ant-design-vue'
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
import { reqDisable2fa } from '@/api/personal'
|
||||
@@ -54,12 +54,12 @@ const state = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
code: [
|
||||
{ required: true, message: t('personalCenter.enterVerifyCode'), trigger: 'change' },
|
||||
{ len: 6, message: t('personalCenter.enterVerifyCode'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) state.formData.code = ''
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { FormInstance, message } from 'ant-design-vue'
|
||||
import { reqUpdateProfile } from '@/api/personal'
|
||||
@@ -54,11 +54,11 @@ const state = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
displayName: [
|
||||
{ max: 200, message: t('common.maxLength', { length: 200 }), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
import QRCode from 'qrcode'
|
||||
@@ -81,12 +81,12 @@ const state = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
code: [
|
||||
{ required: true, message: t('personalCenter.enterVerifyCode'), trigger: 'change' },
|
||||
{ len: 6, message: t('personalCenter.enterVerifyCode'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
const setup = async () => {
|
||||
state.loading = true
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -113,7 +113,7 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
@@ -130,7 +130,7 @@ const formRules: FormRules = {
|
||||
return Promise.reject(t('login.confirmPasswordValidateError'))
|
||||
},
|
||||
}],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 获取用户组下拉选项 */
|
||||
const getUserGroupListOptions = async () => {
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance, message } from 'ant-design-vue'
|
||||
@@ -65,11 +65,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
name: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
|
||||
@@ -40,8 +40,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseInfo, BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { BaseInfo } from 'gl-web-main/components'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
@@ -66,11 +67,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
deviceGroupIds: [
|
||||
{ required: true, message: t('device.requiredDeviceGroup'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
const getDeviceGroupListOptions = async () => {
|
||||
const res = await reqDeviceGroupListOptions()
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance, Tooltip } from 'ant-design-vue'
|
||||
@@ -131,7 +131,7 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
@@ -144,7 +144,7 @@ const formRules: FormRules = {
|
||||
return Promise.reject(t('login.confirmPasswordValidateError'))
|
||||
},
|
||||
}],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 获取用户组下拉选项 */
|
||||
const getUserGroupListOptions = async () => {
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/base/baseModalI18n.vue'
|
||||
import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance, message } from 'ant-design-vue'
|
||||
@@ -66,11 +66,11 @@ const state = reactive({
|
||||
})
|
||||
|
||||
/** 表单验证 */
|
||||
const formRules: FormRules = {
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
name: [
|
||||
{ required: true, message: t('device.requiredDeviceGroupName'), trigger: 'change' },
|
||||
],
|
||||
}
|
||||
}))
|
||||
|
||||
/** 提交 */
|
||||
const handleApply: OnBeforeOk = (done) => {
|
||||
|
||||
Reference in New Issue
Block a user