13 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 57da780653 test(qa): add group/RBAC visibility, event-log, and device CRUD e2e cases
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 02:57:44 -07:00
GL.iNet-Yongping.Xie 07609164ef test(qa): add repeatable live e2e/regression suite (API + device protocol + real device)
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 02:48:14 -07:00
GL.iNet-Yongping.Xie c6c57e450c feat(devices): add online/offline status filter to device list
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 02:05:28 -07:00
GL.iNet-Yongping.Xie 428d296881 fix(devices): show total device count in header, not current page
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 01:42:53 -07:00
GL.iNet-Yongping.Xie 34c3bb4e68 perf(devices): drop unused status/last_seen_at indexes (write churn)
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 00:07:50 -07:00
GL.iNet-Yongping.Xie 3b41b0b41d perf(devices): minimal write on reconnect, skip unchanged client
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-24 00:07:50 -07:00
GL.iNet-Yongping.Xie bf96173777 perf(devices): push list filter/sort/pagination to SQL
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-23 23:22:54 -07:00
GL.iNet-Yongping.Xie 2abb1cff9a feat(devices): server-side pagination & search for device list (50/page)
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-23 23:22:54 -07:00
GL.iNet-Yongping.Xie dce404e593 perf(sqlite): enable WAL and widen connection pool
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-23 23:22:54 -07:00
GL.iNet-Yongping.Xie f291671d9d chore: bump version to v2.7.0
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-23 23:22:54 -07:00
GL.iNet-Yongping.Xie e98d46850b fix(devicelog): pair online/offline events and damp flapping
Only emit offline for devices that came online, debounce online/offline
per device within 30s, and drop the INFO log that printed the server token.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-22 21:32:00 -07:00
GL.iNet-Yongping.Xie b871dda40c fix(device): guard against empty token/MAC on registration
Quote $TOKEN in the rtty watchdog so an empty token no longer consumes -d
(empty MAC), and reject empty-MAC registrations before the devices upsert
to avoid the UNIQUE constraint failure.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-22 21:31:33 -07:00
GL.iNet-Yongping.Xie 8f21589239 fix(script-info): let GLKVM_ACCESS_IP take priority over the web host
Use the configured IP whenever it is set, regardless of how the UI was
reached, so the device script no longer falls back to HOSTNAME="localhost".

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-06-22 21:30:55 -07:00
23 changed files with 1575 additions and 194 deletions
+26
View File
@@ -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)
}
+3
View File
@@ -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)
}
+6
View File
@@ -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)
}
+40 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+5
View File
@@ -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)
}
+880
View File
@@ -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")
}
})
}
+7 -4
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
package server
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v2.6.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)
}
}
+103
View File
@@ -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)
}
}
+18 -3
View File
@@ -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
}
+4 -2
View File
@@ -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
+20 -1
View File
@@ -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
View File
@@ -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,
})
+1
View File
@@ -26,6 +26,7 @@ export interface DeviceQuery {
searchText: string
deviceGroupId: number
onlyShowUnassigned: boolean
status?: 'online' | 'offline'
sortBy?: string
order?: 'asc' | 'desc'
}
+31 -29
View File
@@ -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,
@@ -149,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
@@ -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">
+1 -1
View File
@@ -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">