19 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie be821d46f6 chore: bump version to v2.8.0
Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-08-20 00:39:29 -07:00
GL.iNet-Yongping.Xie 8648908793 fix(cmd): fix execute-command execution and result display
- address the live device by ddns and call /cmd via bare axios
- write the response body once on timeout (was written twice)
- group results by outcome and show stderr in the detail dialog
- default user to root, minimum wait 1s, warn on long/streaming commands

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-08-20 00:39:17 -07:00
pengyu.lu afe4cb9370 fix(ui): fix execute command feature 2026-08-20 11:40:13 +08:00
GL.iNet-Yongping.Xie ce05341dae fix(coturn): run as root and remove invalid allowed-peer-ip
Coturn image defaults to nobody and cannot read the bind-mounted
entrypoint on stricter hosts (e.g. NAS), causing Permission denied.
The allowed-peer-ip=0.0.0.0/0 CIDR is rejected by coturn as well.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-08-18 02:41:19 -07:00
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
GL.iNet-Yongping.Xie cbe724f425 fix: keep i18n messages reactive after language switch
Form validation messages and imperative t() toasts (e.g. delete-user
success) no longer stay stuck in the previous language.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-23 00:05:19 -07:00
GL.iNet-Yongping.Xie 53b7ee4e9e fix: render modal Confirm/Cancel buttons in all locales
Route button labels through the project's vue-i18n; the upstream
fallback only knew en/zh and crashed the footer in de/es/fr/ja/ko.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-22 21:01:27 -07:00
58 changed files with 1858 additions and 378 deletions
+1
View File
@@ -80,6 +80,7 @@ services:
image: ${COTURN_IMAGE:-coturn/coturn:edge-alpine}
container_name: glkvm_coturn
restart: always
user: "0:0" # run as root so it can read the bind-mounted entrypoint
environment:
# Same semantics as above: prefer explicit value, else auto-detect
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
@@ -3,5 +3,4 @@ lt-cred-mech
user={{TURN_USER}}:{{TURN_PASS}}
realm=glkvm
no-multicast-peers
allowed-peer-ip=0.0.0.0/0
external-ip={{GLKVM_ACCESS_IP}}
+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")
+13 -3
View File
@@ -30,6 +30,7 @@ import (
"net/http"
"rttys/utils"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -41,6 +42,11 @@ type CommandReq struct {
cancel context.CancelFunc
acked bool
c *gin.Context
// respondOnce guarantees the HTTP response body is written exactly once.
// Both the device-response path (handleCmdMsg) and the timeout/offline paths
// here race to respond; without this a timeout used to write the error twice
// (timeout + offline), producing a malformed, concatenated JSON body.
respondOnce sync.Once
}
type CommandReqInfo struct {
@@ -122,15 +128,19 @@ func (dev *Device) handleCmdReq(c *gin.Context, info *CommandReqInfo) {
select {
case <-tmr.C:
cmdErrResp(c, rttyCmdErrTimeout)
req.respondOnce.Do(func() {
cmdErrResp(c, rttyCmdErrTimeout)
})
case <-ctx.Done():
}
dev.commands.Delete(token)
if !req.acked {
// Only writes when neither the device response nor the timeout has responded
// yet (i.e. the device/connection went away before acking).
req.respondOnce.Do(func() {
cmdErrResp(c, rttyCmdErrOffline)
}
})
log.Debug().Msgf("handle cmd request for device '%s', token '%s' done", dev.id, token)
}
+53 -18
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 {
@@ -728,10 +761,12 @@ func handleCmdMsg(dev *Device, data []byte) error {
attrs["devid"] = dev.id
if val, ok := dev.commands.Load(info.Token); ok {
if val, ok := dev.commands.LoadAndDelete(info.Token); ok {
req := val.(*CommandReq)
req.acked = true
req.c.JSON(http.StatusOK, attrs)
req.respondOnce.Do(func() {
req.acked = true
req.c.JSON(http.StatusOK, attrs)
})
req.cancel()
}
+1 -1
View File
@@ -1,7 +1,7 @@
package server
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v2.5.0"
const KVMCloudVersion = "v2.8.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
}
+22 -5
View File
@@ -7,11 +7,21 @@
* @Description: 设备相关API
*/
import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
import request, { httpService } from './request'
import request from './request'
import axios from 'axios'
/** 获取设备列表 */
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,
})
@@ -25,8 +35,15 @@ export const getAddDeviceScriptInfoApi = () => {
}
/** 执行命令 */
// The /cmd endpoint is served directly by rttys and does NOT use the cloud
// `{code:"OK",...}` envelope: success is {code:0,stdout,stderr,devid}, an rtty error is
// {err:1xxx,msg}, and wait=0 returns an empty body. Routing it through httpService's
// interceptor would (a) reject even a successful command (code 0 !== "OK") and (b) pop a
// "Server Error Message" toast via showErrorMessage. Use a bare axios call to bypass the
// interceptor entirely; it is same-origin, so the `sid` auth cookie is still sent, and the
// caller reads the native body from res.data.
export const reqExecuteCommand = (data: ExecuteCommandParams) => {
return httpService.post(`/cmd/${data.id}?group=${data.group}&wait=${data.wait}`, data)
return axios.post(`/cmd/${data.id}?group=${data.group}&wait=${data.wait}`, data)
}
/** 修改描述 */
+31
View File
@@ -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>
+2 -1
View File
@@ -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
+3
View File
@@ -12,6 +12,7 @@
"success": "Erfolgreich",
"failed": "Fehlgeschlagen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"ok": "OK",
"close": "Schließen",
"about": "Über",
@@ -116,6 +117,8 @@
"commandResponseDetail": "Details zur Befehlsantwort",
"standardOutput": "Standardausgabe",
"standardErrorOutput": "Standardfehlerausgabe",
"commandResponseError": "Ungültige Antwort vom Gerät erhalten",
"commandUsageWarning": "Nur für einfache, schnell antwortende Befehle. Lang laufende, Echtzeit-/Streaming- oder interaktive Befehle führen innerhalb der Wartezeit (max. 30 Sek.) zu einer Zeitüberschreitung ohne Ergebnis; nutzen Sie dafür das Remote-Terminal.",
"status": "Status",
"online": "Online",
"offline": "Offline",
+3
View File
@@ -12,6 +12,7 @@
"success": "Success",
"failed": "Failed",
"cancel": "Cancel",
"confirm": "Confirm",
"ok": "OK",
"close": "Close",
"about": "About",
@@ -116,6 +117,8 @@
"commandResponseDetail": "Command Response Detail",
"standardOutput": "Standard output",
"standardErrorOutput": "Standard error output",
"commandResponseError": "Invalid response returned by the device",
"commandUsageWarning": "Only for simple, quick-returning commands. Long-running, real-time/streaming, or interactive commands will time out within the wait time (up to 30s) and return no result; use the remote terminal for those.",
"status": "Status",
"online": "Online",
"offline": "Offline",
+3
View File
@@ -12,6 +12,7 @@
"success": "Éxito",
"failed": "Error",
"cancel": "Cancelar",
"confirm": "Confirmar",
"ok": "Aceptar",
"close": "Cerrar",
"about": "Acerca de",
@@ -116,6 +117,8 @@
"commandResponseDetail": "Detalle de respuesta del comando",
"standardOutput": "Salida estándar",
"standardErrorOutput": "Salida de error estándar",
"commandResponseError": "Respuesta no válida devuelta por el dispositivo",
"commandUsageWarning": "Solo para comandos simples de respuesta rápida. Los comandos de larga duración, en tiempo real/streaming o interactivos agotarán el tiempo de espera (máx. 30 s) sin devolver resultado; use el terminal remoto para ellos.",
"status": "Estado",
"online": "En línea",
"offline": "Fuera de línea",
+3
View File
@@ -12,6 +12,7 @@
"success": "Succès",
"failed": "Échec",
"cancel": "Annuler",
"confirm": "Confirmer",
"ok": "OK",
"close": "Fermer",
"about": "À propos",
@@ -116,6 +117,8 @@
"commandResponseDetail": "Détail de la réponse de la commande",
"standardOutput": "Sortie standard",
"standardErrorOutput": "Sortie d'erreur standard",
"commandResponseError": "Réponse non valide renvoyée par l'appareil",
"commandUsageWarning": "Uniquement pour des commandes simples à réponse rapide. Les commandes longues, en temps réel/streaming ou interactives expireront dans le délai d'attente (max. 30 s) sans renvoyer de résultat ; utilisez le terminal distant pour cela.",
"status": "Statut",
"online": "En ligne",
"offline": "Hors ligne",
+3
View File
@@ -12,6 +12,7 @@
"success": "成功",
"failed": "失敗",
"cancel": "キャンセル",
"confirm": "確認",
"ok": "OK",
"close": "閉じる",
"about": "このサービスについて",
@@ -116,6 +117,8 @@
"commandResponseDetail": "コマンド応答の詳細",
"standardOutput": "標準出力",
"standardErrorOutput": "標準エラー出力",
"commandResponseError": "デバイスから無効な応答が返されました",
"commandUsageWarning": "すぐに応答する簡単なコマンド専用です。長時間実行、リアルタイム/ストリーミング、または対話型のコマンドは待機時間(最大30秒)内にタイムアウトし、結果を取得できません。その場合はリモートターミナルをご利用ください。",
"status": "ステータス",
"online": "オンライン",
"offline": "オフライン",
+3
View File
@@ -12,6 +12,7 @@
"success": "성공",
"failed": "실패",
"cancel": "취소",
"confirm": "확인",
"ok": "확인",
"close": "닫기",
"about": "정보",
@@ -116,6 +117,8 @@
"commandResponseDetail": "명령 응답 상세",
"standardOutput": "표준 출력",
"standardErrorOutput": "표준 오류 출력",
"commandResponseError": "기기에서 잘못된 응답이 반환되었습니다",
"commandUsageWarning": "빠르게 응답하는 간단한 명령에만 사용하세요. 장시간 실행, 실시간/스트리밍 또는 대화형 명령은 대기 시간(최대 30초) 내에 시간 초과되어 결과를 가져올 수 없습니다. 이러한 작업에는 원격 터미널을 사용하세요.",
"status": "상태",
"online": "온라인",
"offline": "오프라인",
+3
View File
@@ -12,6 +12,7 @@
"success": "成功",
"failed": "失败",
"cancel": "取消",
"confirm": "确认",
"ok": "确定",
"close": "关闭",
"about": "关于",
@@ -116,6 +117,8 @@
"commandResponseDetail": "命令响应详情",
"standardOutput": "标准输出",
"standardErrorOutput": "标准错误输出",
"commandResponseError": "设备返回数据异常",
"commandUsageWarning": "仅适用于快速返回的简单命令。长时间运行、实时/流式输出或交互式命令会在等待时间(最长 30 秒)内超时且无法获取结果;此类操作请使用远程终端。",
"status": "状态",
"online": "在线",
"offline": "离线",
+2 -1
View File
@@ -26,13 +26,14 @@ export interface DeviceQuery {
searchText: string
deviceGroupId: number
onlyShowUnassigned: boolean
status?: 'online' | 'offline'
sortBy?: string
order?: 'asc' | 'desc'
}
/** 执行命令参数 */
export interface ExecuteCommandParams {
id: number
id: string
group: string
wait: number
cmd: string
@@ -28,6 +28,7 @@ import {
Popover,
Popconfirm,
DatePicker,
Alert,
} from 'ant-design-vue'
export default function (app: any) {
@@ -52,4 +53,5 @@ export default function (app: any) {
app.use(Popover)
app.use(Popconfirm)
app.use(DatePicker)
app.use(Alert)
}
+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,
@@ -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) => {
@@ -20,13 +20,27 @@
<BaseText>{{ props.res.id }}</BaseText>
</div>
<BaseText style="margin: 20px 0 6px;">{{ props.type === 'success' ? $t('device.standardOutput') : $t('device.standardErrorOutput') }}</BaseText>
<ATextarea :value="props.type === 'success' ? props.res.stdout : props.res.stderr" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
<!-- 成功页:命令已执行,展示标准输出(以及有内容时的标准错误输出) -->
<template v-if="props.type === 'success'">
<BaseText style="margin: 20px 0 6px;">{{ $t('device.standardOutput') }}</BaseText>
<ATextarea :value="props.res.stdout" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
<template v-if="props.res.stderr">
<BaseText style="margin: 20px 0 6px;">{{ $t('device.standardErrorOutput') }}</BaseText>
<ATextarea :value="props.res.stderr" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
</template>
</template>
<!-- 失败页:平台层未执行成功(离线/超时/格式错),只展示错误信息 -->
<template v-else>
<BaseText style="margin: 20px 0 6px;">{{ $t('device.errorMessage') }}</BaseText>
<ATextarea :value="props.res.msg" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
</template>
</BaseModal>
</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 }>()
@@ -2,8 +2,8 @@
* @Author: LPY
* @Date: 2025-08-26 11:15:08
* @LastEditors: LPY
* @LastEditTime: 2025-08-26 16:51:00
* @FilePath: \glkvm-cloud\web-ui\src\views\device\components\commandResponseDialog.vue
* @LastEditTime: 2026-08-20 11:32:44
* @FilePath: \glkvm-cloud-open-code\ui\src\views\device\components\commandResponseDialog.vue
* @Description:
-->
<template>
@@ -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'
@@ -149,11 +149,11 @@ const handleOpenDetail = (record: any) => {
const init = () => {
// 调用接口进行下发配置
const selections = props.selection.filter(item => item.proto > 4)
const selections = props.selection
state.total = selections.length
selections.map(device => {
const data = {
id: device.id,
id: device.ddns,
group: '',
wait: props.formData.wait,
cmd: props.formData.cmd,
@@ -166,13 +166,30 @@ const init = () => {
state.responses.push({
err: 0,
msg: '',
id: device.id,
id: device.ddns,
code: 0,
stdout: '',
stderr: '',
})
} else {
const resp = res.info
const resp = res.data
// Defensive: a well-formed /cmd response is always a JSON object. If the
// body was malformed (e.g. a corrupted/concatenated payload that axios could
// not parse), res.data is a raw string — treat it as a failure instead of
// throwing "Cannot create property 'stdout' on string".
if (!resp || typeof resp !== 'object') {
state.fail++
state.responses.push({
err: -1,
msg: t('device.commandResponseError'),
id: device.ddns,
code: 0,
stdout: '',
stderr: '',
})
return
}
if (resp.err && resp.err !== 0) {
state.fail++
@@ -183,9 +200,21 @@ const init = () => {
resp.stderr = window.atob(resp.stderr || '')
}
resp.id = device.id
resp.id = device.ddns
state.responses.push(resp)
}
}).catch(error => {
const response = error?.response?.data || error
state.fail++
state.responses.push({
err: response?.err || response?.code || -1,
msg: response?.msg || response?.message || '',
id: device.ddns,
code: response?.code || 0,
stdout: '',
stderr: '',
})
})
})
}
@@ -202,4 +231,4 @@ init()
margin: 0;
}
}
</style>
</style>
@@ -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) => {
@@ -16,6 +16,12 @@
:beforeOk="handleApply"
@close="emits('update:open', false)"
>
<AAlert
class="usage-tip"
type="warning"
show-icon
:message="$t('device.commandUsageWarning')"
/>
<AForm
class="dense-form"
:colon="false"
@@ -53,7 +59,7 @@
name="wait"
:addonAfter="$t('common.s')"
:placeholder="$t('device.inputWaitTime')"
:min="0"
:min="1"
:max="30"
style="width: 200px;" />
</div>
@@ -63,8 +69,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'
@@ -83,7 +89,7 @@ const formRef = ref<FormInstance>()
const state = reactive<{formData: ExecuteCommandFormData}>({
formData: {
username: '',
username: 'root',
cmd: '',
params: [],
wait: 30,
@@ -91,10 +97,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) => {
@@ -114,7 +120,7 @@ watch(() => props.open, (newVal) => {
})
const init = () => {
state.formData.username = ''
state.formData.username = 'root'
state.formData.cmd = ''
state.formData.params = []
state.formData.wait = 30
@@ -126,5 +132,8 @@ const init = () => {
.base-modal-content {
overflow: hidden !important;
}
.usage-tip {
margin-bottom: 16px;
}
}
</style>
@@ -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()
+1 -1
View File
@@ -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'
+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">
@@ -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) => {
File diff suppressed because one or more lines are too long
+58 -93
View File
@@ -236,16 +236,16 @@
resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz"
integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==
"@esbuild/android-arm@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz"
integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==
"@esbuild/android-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz"
integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==
"@esbuild/android-arm@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz"
integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==
"@esbuild/android-x64@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz"
@@ -271,16 +271,16 @@
resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz"
integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==
"@esbuild/linux-arm@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz"
integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==
"@esbuild/linux-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz"
integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==
"@esbuild/linux-arm@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz"
integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==
"@esbuild/linux-ia32@0.25.5":
version "0.25.5"
resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz"
@@ -469,7 +469,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -711,7 +711,7 @@
resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@*", "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^22.15.24":
"@types/node@*", "@types/node@^22.15.24":
version "22.15.32"
resolved "https://registry.npmmirror.com/@types/node/-/node-22.15.32.tgz"
integrity sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==
@@ -747,7 +747,7 @@
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/parser@^6.0.0 || ^6.0.0-alpha", "@typescript-eslint/parser@^6.7.0":
"@typescript-eslint/parser@^6.7.0":
version "6.21.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-6.21.0.tgz"
integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==
@@ -821,7 +821,7 @@
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@~2.4.11", "@volar/language-core@2.4.14":
"@volar/language-core@2.4.14", "@volar/language-core@~2.4.11":
version "2.4.14"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.14.tgz"
integrity sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==
@@ -853,7 +853,7 @@
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@^3.5.0", "@vue/compiler-dom@3.5.16":
"@vue/compiler-dom@3.5.16", "@vue/compiler-dom@^3.5.0":
version "3.5.16"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz"
integrity sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==
@@ -892,12 +892,7 @@
de-indent "^1.0.2"
he "^1.2.0"
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3":
version "6.6.4"
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/devtools-api@^6.6.4":
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3", "@vue/devtools-api@^6.6.4":
version "6.6.4"
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
@@ -985,7 +980,7 @@
"@vue/compiler-ssr" "3.5.16"
"@vue/shared" "3.5.16"
"@vue/shared@^3.5.0", "@vue/shared@3.5.16":
"@vue/shared@3.5.16", "@vue/shared@^3.5.0":
version "3.5.16"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.16.tgz"
integrity sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==
@@ -1005,17 +1000,25 @@
resolved "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz"
integrity sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==
"@xterm/xterm@^5.0.0", "@xterm/xterm@5.5.0":
"@xterm/xterm@5.5.0":
version "5.5.0"
resolved "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz"
integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==
JSONStream@^1.3.5:
version "1.3.5"
resolved "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz"
integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
dependencies:
jsonparse "^1.2.0"
through ">=2.2.7 <3"
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0:
acorn@^8.9.0:
version "8.15.0"
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -1062,14 +1065,7 @@ ansi-regex@^6.0.1:
resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz"
integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
ansi-styles@^4.0.0:
version "4.3.0"
resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
dependencies:
color-convert "^2.0.1"
ansi-styles@^4.1.0:
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
@@ -1368,8 +1364,8 @@ conventional-commits-parser@^5.0.0:
resolved "https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz"
integrity sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==
dependencies:
is-text-path "^2.0.0"
JSONStream "^1.3.5"
is-text-path "^2.0.0"
meow "^12.0.1"
split2 "^4.0.0"
@@ -1392,7 +1388,7 @@ cosmiconfig-typescript-loader@^6.1.0:
dependencies:
jiti "^2.4.1"
cosmiconfig@^9.0.0, cosmiconfig@>=9:
cosmiconfig@^9.0.0:
version "9.0.0"
resolved "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz"
integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==
@@ -1630,7 +1626,7 @@ escape-string-regexp@^4.0.0:
resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-plugin-vue@^9.0.0, eslint-plugin-vue@9.17.0:
eslint-plugin-vue@9.17.0:
version "9.17.0"
resolved "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.17.0.tgz"
integrity sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==
@@ -1656,7 +1652,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0", "eslint@^7.0.0 || ^8.0.0", eslint@>=6.0.0, eslint@8.49.0:
eslint@8.49.0:
version "8.49.0"
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.49.0.tgz"
integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==
@@ -2194,7 +2190,7 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jiti@^2.4.1, jiti@>=1.21.0:
jiti@^2.4.1:
version "2.4.2"
resolved "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
@@ -2251,14 +2247,6 @@ jsonparse@^1.2.0:
resolved "https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz"
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
JSONStream@^1.3.5:
version "1.3.5"
resolved "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz"
integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
dependencies:
jsonparse "^1.2.0"
through ">=2.2.7 <3"
keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz"
@@ -2463,34 +2451,20 @@ mimic-function@^5.0.0:
resolved "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz"
integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==
minimatch@^3.0.5:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimatch@^9.0.3, minimatch@9.0.3:
minimatch@9.0.3, minimatch@^9.0.3:
version "9.0.3"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.3.tgz"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"
minimist@^1.2.8:
version "1.2.8"
resolved "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz"
@@ -2692,7 +2666,7 @@ picomatch@^2.3.1:
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
"picomatch@^3 || ^4", picomatch@^4.0.2:
picomatch@^4.0.2:
version "4.0.2"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
@@ -2864,7 +2838,7 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
sass@*, sass@^1.69.7, sass@^1.89.0:
sass@^1.69.7, sass@^1.89.0:
version "1.89.2"
resolved "https://registry.npmmirror.com/sass/-/sass-1.89.2.tgz"
integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==
@@ -2950,7 +2924,7 @@ sortablejs@^1.15.2, sortablejs@^1.15.7:
resolved "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz"
integrity sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==
source-map-js@^1.0.2, source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0":
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -2970,16 +2944,7 @@ string-argv@~0.3.2:
resolved "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz"
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.2.3:
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -3107,7 +3072,7 @@ type-fest@^0.20.2:
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@*, typescript@>=4.2.0, typescript@>=4.4.4, typescript@>=4.9.5, typescript@>=5, typescript@>=5.0.0, typescript@5.3, typescript@5.x:
typescript@5.3:
version "5.3.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
@@ -3139,7 +3104,7 @@ vite-plugin-remove-console@^2.2.0:
resolved "https://registry.npmmirror.com/vite-plugin-remove-console/-/vite-plugin-remove-console-2.2.0.tgz"
integrity sha512-qgjh5pz75MdE9Kzs8J0kBwaCfifHV0ezRbB9rpGsIOxam+ilcGV7WOk91vFJXquzRmiKrFh3Hxlh0JJWAmXTbQ==
"vite@^5.0.0 || ^6.0.0", vite@^6.3.5:
vite@^6.3.5:
version "6.3.5"
resolved "https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz"
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
@@ -3183,15 +3148,6 @@ vue-eslint-parser@^9.3.1:
lodash "^4.17.21"
semver "^7.3.6"
vue-i18n@^11.1.5:
version "11.1.6"
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.6.tgz"
integrity sha512-+IbsW/sTZHj7U1w0rPOYJbuSB0/7DeO1nvUo3BxvO20OQgHs+ukJ3QeLqvoUA6DiLk+8SA9+djRmKC9+FC6cAg==
dependencies:
"@intlify/core-base" "11.1.6"
"@intlify/shared" "11.1.6"
"@vue/devtools-api" "^6.5.0"
vue-i18n@9:
version "9.14.5"
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz"
@@ -3201,6 +3157,15 @@ vue-i18n@9:
"@intlify/shared" "9.14.5"
"@vue/devtools-api" "^6.5.0"
vue-i18n@^11.1.5:
version "11.1.6"
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.6.tgz"
integrity sha512-+IbsW/sTZHj7U1w0rPOYJbuSB0/7DeO1nvUo3BxvO20OQgHs+ukJ3QeLqvoUA6DiLk+8SA9+djRmKC9+FC6cAg==
dependencies:
"@intlify/core-base" "11.1.6"
"@intlify/shared" "11.1.6"
"@vue/devtools-api" "^6.5.0"
vue-router@^4.2.5, vue-router@^4.5.1:
version "4.5.1"
resolved "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz"
@@ -3223,7 +3188,7 @@ vue-types@^3.0.0:
dependencies:
is-plain-object "3.0.1"
"vue@^2.7.0 || ^3.5.11", vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.2.0, vue@^3.2.25, vue@^3.3.11, vue@^3.4.0, vue@^3.5.13, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.16:
vue@^3.3.11, vue@^3.5.13:
version "3.5.16"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.16.tgz"
integrity sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==
@@ -3313,7 +3278,7 @@ y18n@^5.0.5:
resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yaml@^2.4.2, yaml@~2.5.0:
yaml@~2.5.0:
version "2.5.1"
resolved "https://registry.npmmirror.com/yaml/-/yaml-2.5.1.tgz"
integrity sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==