diff --git a/Release.md b/Release.md index 6cba5645..69032dcc 100644 --- a/Release.md +++ b/Release.md @@ -1,7 +1,7 @@ ## Features * frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments. -* Redesigned the frps web dashboard with a modern UI, dark mode support, and improved navigation. +* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation. ## Fixes diff --git a/pkg/metrics/aggregate/server.go b/pkg/metrics/aggregate/server.go index 354d85c2..9ef70ebd 100644 --- a/pkg/metrics/aggregate/server.go +++ b/pkg/metrics/aggregate/server.go @@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() { } } -func (m *serverMetrics) NewProxy(name string, proxyType string) { +func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) { for _, v := range m.ms { - v.NewProxy(name, proxyType) + v.NewProxy(name, proxyType, user, clientID) } } diff --git a/pkg/metrics/mem/server.go b/pkg/metrics/mem/server.go index 70cfc1c1..677788d3 100644 --- a/pkg/metrics/mem/server.go +++ b/pkg/metrics/mem/server.go @@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() { m.info.ClientCounts.Dec(1) } -func (m *serverMetrics) NewProxy(name string, proxyType string) { +func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) { m.mu.Lock() defer m.mu.Unlock() counter, ok := m.info.ProxyTypeCounts[proxyType] @@ -119,6 +119,8 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) { } m.info.ProxyStatistics[name] = proxyStats } + proxyStats.User = user + proxyStats.ClientID = clientID proxyStats.LastStartTime = time.Now() } @@ -214,6 +216,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats { ps := &ProxyStats{ Name: name, Type: proxyStats.ProxyType, + User: proxyStats.User, + ClientID: proxyStats.ClientID, TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), CurConns: int64(proxyStats.CurConns.Count()), @@ -245,6 +249,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri res = &ProxyStats{ Name: name, Type: proxyStats.ProxyType, + User: proxyStats.User, + ClientID: proxyStats.ClientID, TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), CurConns: int64(proxyStats.CurConns.Count()), @@ -260,6 +266,31 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri return } +func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) { + m.mu.Lock() + defer m.mu.Unlock() + + proxyStats, ok := m.info.ProxyStatistics[proxyName] + if ok { + res = &ProxyStats{ + Name: proxyName, + Type: proxyStats.ProxyType, + User: proxyStats.User, + ClientID: proxyStats.ClientID, + TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), + TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), + CurConns: int64(proxyStats.CurConns.Count()), + } + if !proxyStats.LastStartTime.IsZero() { + res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05") + } + if !proxyStats.LastCloseTime.IsZero() { + res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05") + } + } + return +} + func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/metrics/mem/types.go b/pkg/metrics/mem/types.go index a6f276ce..b7661ba8 100644 --- a/pkg/metrics/mem/types.go +++ b/pkg/metrics/mem/types.go @@ -35,6 +35,8 @@ type ServerStats struct { type ProxyStats struct { Name string Type string + User string + ClientID string TodayTrafficIn int64 TodayTrafficOut int64 LastStartTime string @@ -51,6 +53,8 @@ type ProxyTrafficInfo struct { type ProxyStatistics struct { Name string ProxyType string + User string + ClientID string TrafficIn metric.DateCounter TrafficOut metric.DateCounter CurConns metric.Counter @@ -78,6 +82,7 @@ type Collector interface { GetServer() *ServerStats GetProxiesByType(proxyType string) []*ProxyStats GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats + GetProxyByName(proxyName string) *ProxyStats GetProxyTraffic(name string) *ProxyTrafficInfo ClearOfflineProxies() (int, int) } diff --git a/pkg/metrics/prometheus/server.go b/pkg/metrics/prometheus/server.go index a99bb1d5..6a300ffe 100644 --- a/pkg/metrics/prometheus/server.go +++ b/pkg/metrics/prometheus/server.go @@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() { m.clientCount.Dec() } -func (m *serverMetrics) NewProxy(name string, proxyType string) { +func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) { m.proxyCount.WithLabelValues(proxyType).Inc() m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc() } diff --git a/server/api/controller.go b/server/api/controller.go index ead224d4..8c9827d8 100644 --- a/server/api/controller.go +++ b/server/api/controller.go @@ -117,7 +117,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) { if userFilter != "" && info.User != userFilter { continue } - if clientIDFilter != "" && info.ClientID != clientIDFilter { + if clientIDFilter != "" && info.ClientID() != clientIDFilter { continue } if runIDFilter != "" && info.RunID != runIDFilter { @@ -204,6 +204,48 @@ func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) { return trafficResp, nil } +// /api/proxies/:name +func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + + ps := mem.StatsCollector.GetProxyByName(name) + if ps == nil { + return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") + } + + proxyInfo := GetProxyStatsResp{ + Name: ps.Name, + User: ps.User, + ClientID: ps.ClientID, + TodayTrafficIn: ps.TodayTrafficIn, + TodayTrafficOut: ps.TodayTrafficOut, + CurConns: ps.CurConns, + LastStartTime: ps.LastStartTime, + LastCloseTime: ps.LastCloseTime, + } + + if pxy, ok := c.pxyManager.GetByName(name); ok { + content, err := json.Marshal(pxy.GetConfigurer()) + if err != nil { + log.Warnf("marshal proxy [%s] conf info error: %v", name, err) + return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error") + } + proxyInfo.Conf = getConfByType(ps.Type) + if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil { + log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err) + return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error") + } + proxyInfo.Status = "online" + c.fillProxyClientInfo(&proxyClientInfo{ + clientVersion: &proxyInfo.ClientVersion, + }, pxy) + } else { + proxyInfo.Status = "offline" + } + + return proxyInfo, nil +} + // DELETE /api/proxies?status=offline func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { status := ctx.Query("status") @@ -219,7 +261,10 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS proxyStats := mem.StatsCollector.GetProxiesByType(proxyType) proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats)) for _, ps := range proxyStats { - proxyInfo := &ProxyStatsInfo{} + proxyInfo := &ProxyStatsInfo{ + User: ps.User, + ClientID: ps.ClientID, + } if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { content, err := json.Marshal(pxy.GetConfigurer()) if err != nil { @@ -232,9 +277,9 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS continue } proxyInfo.Status = "online" - if pxy.GetLoginMsg() != nil { - proxyInfo.ClientVersion = pxy.GetLoginMsg().Version - } + c.fillProxyClientInfo(&proxyClientInfo{ + clientVersion: &proxyInfo.ClientVersion, + }, pxy) } else { proxyInfo.Status = "offline" } @@ -256,6 +301,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri code = 404 msg = "no proxy info found" } else { + proxyInfo.User = ps.User + proxyInfo.ClientID = ps.ClientID if pxy, ok := c.pxyManager.GetByName(proxyName); ok { content, err := json.Marshal(pxy.GetConfigurer()) if err != nil { @@ -290,7 +337,7 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { resp := ClientInfoResp{ Key: info.Key, User: info.User, - ClientID: info.ClientID, + ClientID: info.ClientID(), RunID: info.RunID, Hostname: info.Hostname, ClientIP: info.IP, @@ -304,6 +351,37 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp { return resp } +type proxyClientInfo struct { + user *string + clientID *string + clientVersion *string +} + +func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) { + loginMsg := pxy.GetLoginMsg() + if loginMsg == nil { + return + } + if proxyInfo.user != nil { + *proxyInfo.user = loginMsg.User + } + if proxyInfo.clientVersion != nil { + *proxyInfo.clientVersion = loginMsg.Version + } + if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok { + if proxyInfo.clientID != nil { + *proxyInfo.clientID = info.ClientID() + } + return + } + if proxyInfo.clientID != nil { + *proxyInfo.clientID = loginMsg.ClientID + if *proxyInfo.clientID == "" { + *proxyInfo.clientID = loginMsg.RunID + } + } +} + func toUnix(t time.Time) int64 { if t.IsZero() { return 0 diff --git a/server/api/types.go b/server/api/types.go index 86e68da0..b91422be 100644 --- a/server/api/types.go +++ b/server/api/types.go @@ -98,6 +98,8 @@ type XTCPOutConf struct { type ProxyStatsInfo struct { Name string `json:"name"` Conf any `json:"conf"` + User string `json:"user,omitempty"` + ClientID string `json:"clientID,omitempty"` ClientVersion string `json:"clientVersion,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` @@ -115,6 +117,9 @@ type GetProxyInfoResp struct { type GetProxyStatsResp struct { Name string `json:"name"` Conf any `json:"conf"` + User string `json:"user,omitempty"` + ClientID string `json:"clientID,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` CurConns int64 `json:"curConns"` diff --git a/server/control.go b/server/control.go index 23e98c9e..863104f3 100644 --- a/server/control.go +++ b/server/control.go @@ -405,7 +405,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) { } else { resp.RemoteAddr = remoteAddr xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType) - metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType) + clientID := ctl.loginMsg.ClientID + if clientID == "" { + clientID = ctl.loginMsg.RunID + } + metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID) } _ = ctl.msgDispatcher.Send(resp) } diff --git a/server/metrics/metrics.go b/server/metrics/metrics.go index 5064a238..68be2677 100644 --- a/server/metrics/metrics.go +++ b/server/metrics/metrics.go @@ -7,7 +7,7 @@ import ( type ServerMetrics interface { NewClient() CloseClient() - NewProxy(name string, proxyType string) + NewProxy(name string, proxyType string, user string, clientID string) CloseProxy(name string, proxyType string) OpenConnection(name string, proxyType string) CloseConnection(name string, proxyType string) @@ -27,11 +27,11 @@ func Register(m ServerMetrics) { type noopServerMetrics struct{} -func (noopServerMetrics) NewClient() {} -func (noopServerMetrics) CloseClient() {} -func (noopServerMetrics) NewProxy(string, string) {} -func (noopServerMetrics) CloseProxy(string, string) {} -func (noopServerMetrics) OpenConnection(string, string) {} -func (noopServerMetrics) CloseConnection(string, string) {} -func (noopServerMetrics) AddTrafficIn(string, string, int64) {} -func (noopServerMetrics) AddTrafficOut(string, string, int64) {} +func (noopServerMetrics) NewClient() {} +func (noopServerMetrics) CloseClient() {} +func (noopServerMetrics) NewProxy(string, string, string, string) {} +func (noopServerMetrics) CloseProxy(string, string) {} +func (noopServerMetrics) OpenConnection(string, string) {} +func (noopServerMetrics) CloseConnection(string, string) {} +func (noopServerMetrics) AddTrafficIn(string, string, int64) {} +func (noopServerMetrics) AddTrafficOut(string, string, int64) {} diff --git a/server/registry/registry.go b/server/registry/registry.go index 88bf90e3..24751bf6 100644 --- a/server/registry/registry.go +++ b/server/registry/registry.go @@ -24,7 +24,7 @@ import ( type ClientInfo struct { Key string User string - ClientID string + RawClientID string RunID string Hostname string IP string @@ -34,8 +34,8 @@ type ClientInfo struct { Online bool } -// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (or runID if clientID is empty). -// Entries without an explicit clientID are removed on disconnect to avoid stale offline records. +// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty). +// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records. type ClientRegistry struct { mu sync.RWMutex clients map[string]*ClientInfo @@ -50,17 +50,17 @@ func NewClientRegistry() *ClientRegistry { } // Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client. -func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr string) (key string, conflict bool) { +func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) { if runID == "" { return "", false } - effectiveID := clientID + effectiveID := rawClientID if effectiveID == "" { effectiveID = runID } key = cr.composeClientKey(user, effectiveID) - enforceUnique := clientID != "" + enforceUnique := rawClientID != "" now := time.Now() cr.mu.Lock() @@ -75,7 +75,6 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s info = &ClientInfo{ Key: key, User: user, - ClientID: clientID, FirstConnectedAt: now, } cr.clients[key] = info @@ -83,6 +82,7 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s delete(cr.runIndex, info.RunID) } + info.RawClientID = rawClientID info.RunID = runID info.Hostname = hostname info.IP = remoteAddr @@ -107,7 +107,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) { return } if info, ok := cr.clients[key]; ok && info.RunID == runID { - if info.ClientID == "" { + if info.RawClientID == "" { delete(cr.clients, key) } else { info.RunID = "" @@ -131,7 +131,7 @@ func (cr *ClientRegistry) List() []ClientInfo { return result } -// GetByKey retrieves a client by its composite key ({user}.{clientID} or runID fallback). +// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback). func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) { cr.mu.RLock() defer cr.mu.RUnlock() @@ -143,6 +143,30 @@ func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) { return *info, true } +// ClientID returns the resolved client identifier for external use. +func (info ClientInfo) ClientID() string { + if info.RawClientID != "" { + return info.RawClientID + } + return info.RunID +} + +// GetByRunID retrieves a client by its run ID. +func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) { + cr.mu.RLock() + defer cr.mu.RUnlock() + + key, ok := cr.runIndex[runID] + if !ok { + return ClientInfo{}, false + } + info, ok := cr.clients[key] + if !ok { + return ClientInfo{}, false + } + return *info, true +} + func (cr *ClientRegistry) composeClientKey(user, id string) string { switch { case user == "": diff --git a/server/service.go b/server/service.go index decf1cef..6106dad6 100644 --- a/server/service.go +++ b/server/service.go @@ -99,7 +99,7 @@ type Service struct { // Manage all controllers ctlManager *ControlManager - // Track logical clients keyed by user.clientID. + // Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty). clientRegistry *registry.ClientRegistry // Manage all proxies @@ -709,6 +709,7 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET") + subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET") subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET") subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET") subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") diff --git a/web/frps/components.d.ts b/web/frps/components.d.ts index eacf7e56..95fe89dd 100644 --- a/web/frps/components.d.ts +++ b/web/frps/components.d.ts @@ -11,6 +11,8 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDivider: typeof import('element-plus/es')['ElDivider'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] @@ -18,21 +20,15 @@ declare module 'vue' { ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] - ElMenu: typeof import('element-plus/es')['ElMenu'] - ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] - ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] - ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] - ElTable: typeof import('element-plus/es')['ElTable'] - ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElText: typeof import('element-plus/es')['ElText'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] - ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.vue')['default'] + ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] StatCard: typeof import('./src/components/StatCard.vue')['default'] diff --git a/web/frps/package-lock.json b/web/frps/package-lock.json index 7986c726..07c3ad48 100644 --- a/web/frps/package-lock.json +++ b/web/frps/package-lock.json @@ -621,19 +621,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint/eslintrc/node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -653,9 +640,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -678,13 +665,14 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -707,9 +695,10 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, @@ -1582,9 +1571,9 @@ } }, "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, @@ -1596,17 +1585,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.20.0.tgz", - "integrity": "sha512-fTwGQUnjhoYHeSF6m5pWNkzmDDdsKELYrOBxhjMrofPqCkoC2k3B2wvGHFxa1CTIqkEn88nlW1HVMztjo2K8Hg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.20.0", - "@typescript-eslint/type-utils": "6.20.0", - "@typescript-eslint/utils": "6.20.0", - "@typescript-eslint/visitor-keys": "6.20.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1632,16 +1621,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz", - "integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "6.20.0", - "@typescript-eslint/types": "6.20.0", - "@typescript-eslint/typescript-estree": "6.20.0", - "@typescript-eslint/visitor-keys": "6.20.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { @@ -1661,14 +1650,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz", - "integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.20.0", - "@typescript-eslint/visitor-keys": "6.20.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1679,14 +1668,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.20.0.tgz", - "integrity": "sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.20.0", - "@typescript-eslint/utils": "6.20.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1707,9 +1696,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz", - "integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { @@ -1721,14 +1710,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz", - "integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "6.20.0", - "@typescript-eslint/visitor-keys": "6.20.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1766,18 +1755,18 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.20.0.tgz", - "integrity": "sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.20.0", - "@typescript-eslint/types": "6.20.0", - "@typescript-eslint/typescript-estree": "6.20.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", "semver": "^7.5.4" }, "engines": { @@ -1792,13 +1781,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz", - "integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.20.0", + "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -1809,19 +1798,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -2495,9 +2471,9 @@ } }, "node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2512,9 +2488,9 @@ } }, "node_modules/cross-spawn/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { @@ -3057,17 +3033,18 @@ } }, "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -3235,13 +3212,16 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/acorn-jsx": { @@ -3308,9 +3288,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3352,19 +3332,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4433,9 +4400,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4543,15 +4510,15 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash-unified": { @@ -4654,9 +4621,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -4771,9 +4738,9 @@ } }, "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { diff --git a/web/frps/src/App.vue b/web/frps/src/App.vue index b478b58d..f8dc0a7d 100644 --- a/web/frps/src/App.vue +++ b/web/frps/src/App.vue @@ -1,43 +1,55 @@ diff --git a/web/frps/src/api/http.ts b/web/frps/src/api/http.ts index 4d4e41bd..d6291e9e 100644 --- a/web/frps/src/api/http.ts +++ b/web/frps/src/api/http.ts @@ -19,7 +19,11 @@ async function request(url: string, options: RequestInit = {}): Promise { const response = await fetch(url, { ...defaultOptions, ...options }) if (!response.ok) { - throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`) + throw new HTTPError( + response.status, + response.statusText, + `HTTP ${response.status}`, + ) } // Handle empty response (e.g. 204 No Content) @@ -31,20 +35,22 @@ async function request(url: string, options: RequestInit = {}): Promise { } export const http = { - get: (url: string, options?: RequestInit) => request(url, { ...options, method: 'GET' }), - post: (url: string, body?: any, options?: RequestInit) => - request(url, { - ...options, - method: 'POST', + get: (url: string, options?: RequestInit) => + request(url, { ...options, method: 'GET' }), + post: (url: string, body?: any, options?: RequestInit) => + request(url, { + ...options, + method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(body) + body: JSON.stringify(body), }), - put: (url: string, body?: any, options?: RequestInit) => - request(url, { - ...options, - method: 'PUT', + put: (url: string, body?: any, options?: RequestInit) => + request(url, { + ...options, + method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(body) + body: JSON.stringify(body), }), - delete: (url: string, options?: RequestInit) => request(url, { ...options, method: 'DELETE' }), + delete: (url: string, options?: RequestInit) => + request(url, { ...options, method: 'DELETE' }), } diff --git a/web/frps/src/api/proxy.ts b/web/frps/src/api/proxy.ts index 4a70f0a0..b323a588 100644 --- a/web/frps/src/api/proxy.ts +++ b/web/frps/src/api/proxy.ts @@ -1,5 +1,9 @@ import { http } from './http' -import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy' +import type { + GetProxyResponse, + ProxyStatsInfo, + TrafficResponse, +} from '../types/proxy' export const getProxiesByType = (type: string) => { return http.get(`../api/proxy/${type}`) @@ -9,6 +13,10 @@ export const getProxy = (type: string, name: string) => { return http.get(`../api/proxy/${type}/${name}`) } +export const getProxyByName = (name: string) => { + return http.get(`../api/proxies/${name}`) +} + export const getProxyTraffic = (name: string) => { return http.get(`../api/traffic/${name}`) } diff --git a/web/frps/src/assets/css/custom.css b/web/frps/src/assets/css/custom.css index 6ff997a5..d482e8fd 100644 --- a/web/frps/src/assets/css/custom.css +++ b/web/frps/src/assets/css/custom.css @@ -67,7 +67,7 @@ .el-page-header__title { font-size: 20px; - font-weight: 600; + font-weight: 500; } /* Better form layouts */ diff --git a/web/frps/src/assets/icons/logo.svg b/web/frps/src/assets/icons/logo.svg new file mode 100644 index 00000000..fee4a82a --- /dev/null +++ b/web/frps/src/assets/icons/logo.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/web/frps/src/components/ClientCard.vue b/web/frps/src/components/ClientCard.vue index fd85d7f9..d44ef36a 100644 --- a/web/frps/src/components/ClientCard.vue +++ b/web/frps/src/components/ClientCard.vue @@ -1,65 +1,48 @@ diff --git a/web/frps/src/components/ProxyCard.vue b/web/frps/src/components/ProxyCard.vue new file mode 100644 index 00000000..850ebb18 --- /dev/null +++ b/web/frps/src/components/ProxyCard.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/web/frps/src/components/ProxyViewExpand.vue b/web/frps/src/components/ProxyViewExpand.vue deleted file mode 100644 index 7efd5948..00000000 --- a/web/frps/src/components/ProxyViewExpand.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/web/frps/src/components/StatCard.vue b/web/frps/src/components/StatCard.vue index 8ed5afd5..c62c1235 100644 --- a/web/frps/src/components/StatCard.vue +++ b/web/frps/src/components/StatCard.vue @@ -167,7 +167,7 @@ html.dark .icon-traffic { .stat-value { font-size: 28px; - font-weight: 600; + font-weight: 500; line-height: 1.2; color: #303133; margin-bottom: 4px; diff --git a/web/frps/src/components/Traffic.vue b/web/frps/src/components/Traffic.vue index b4a0e368..04fffa6c 100644 --- a/web/frps/src/components/Traffic.vue +++ b/web/frps/src/components/Traffic.vue @@ -6,7 +6,7 @@
{{ formatFileSize(maxVal / 2) }}
0
- +
@@ -15,15 +15,21 @@
- -
+
- -
+
@@ -32,15 +38,11 @@
- +
-
- Traffic In -
-
- Traffic Out -
+
Traffic In
+
Traffic Out
@@ -58,24 +60,26 @@ const props = defineProps<{ }>() const loading = ref(false) -const chartData = ref>([]) +const chartData = ref< + Array<{ + date: string + in: number + out: number + inPercent: number + outPercent: number + }> +>([]) const maxVal = ref(0) const processData = (trafficIn: number[], trafficOut: number[]) => { // Ensure we have arrays and reverse them (server returns newest first) const inArr = [...(trafficIn || [])].reverse() const outArr = [...(trafficOut || [])].reverse() - + // Pad with zeros if less than 7 days while (inArr.length < 7) inArr.unshift(0) while (outArr.length < 7) outArr.unshift(0) - + // Slice to last 7 entries just in case const finalIn = inArr.slice(-7) const finalOut = outArr.slice(-7) @@ -84,7 +88,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => { const dates: string[] = [] let d = new Date() d.setDate(d.getDate() - 6) - + for (let i = 0; i < 7; i++) { dates.push(`${d.getMonth() + 1}-${d.getDate()}`) d.setDate(d.getDate() + 1) @@ -179,9 +183,16 @@ html.dark .grid-line { background-color: #3a3d5c; } -.grid-line.top { top: 0; } -.grid-line.middle { top: 50%; transform: translateY(-50%); } -.grid-line.bottom { bottom: 24px; } /* Align with bottom of bars */ +.grid-line.top { + top: 0; +} +.grid-line.middle { + top: 50%; + transform: translateY(-50%); +} +.grid-line.bottom { + bottom: 24px; +} /* Align with bottom of bars */ .day-column { flex: 1; @@ -255,6 +266,10 @@ html.dark .legend-item { border-radius: 50%; } -.dot.in { background-color: #5470c6; } -.dot.out { background-color: #91cc75; } - \ No newline at end of file +.dot.in { + background-color: #5470c6; +} +.dot.out { + background-color: #91cc75; +} + diff --git a/web/frps/src/router/index.ts b/web/frps/src/router/index.ts index b42a4630..7689e886 100644 --- a/web/frps/src/router/index.ts +++ b/web/frps/src/router/index.ts @@ -1,10 +1,15 @@ import { createRouter, createWebHashHistory } from 'vue-router' import ServerOverview from '../views/ServerOverview.vue' import Clients from '../views/Clients.vue' +import ClientDetail from '../views/ClientDetail.vue' import Proxies from '../views/Proxies.vue' +import ProxyDetail from '../views/ProxyDetail.vue' const router = createRouter({ history: createWebHashHistory(), + scrollBehavior() { + return { top: 0 } + }, routes: [ { path: '/', @@ -16,11 +21,21 @@ const router = createRouter({ name: 'Clients', component: Clients, }, + { + path: '/clients/:key', + name: 'ClientDetail', + component: ClientDetail, + }, { path: '/proxies/:type?', name: 'Proxies', component: Proxies, }, + { + path: '/proxy/:name', + name: 'ProxyDetail', + component: ProxyDetail, + }, ], }) diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts index b2204a22..eec1cb43 100644 --- a/web/frps/src/types/proxy.ts +++ b/web/frps/src/types/proxy.ts @@ -1,6 +1,8 @@ export interface ProxyStatsInfo { name: string conf: any + user: string + clientID: string clientVersion: string todayTrafficIn: number todayTrafficOut: number diff --git a/web/frps/src/types/server.ts b/web/frps/src/types/server.ts index 837cb4f8..ada31cc5 100644 --- a/web/frps/src/types/server.ts +++ b/web/frps/src/types/server.ts @@ -12,7 +12,7 @@ export interface ServerInfo { heartbeatTimeout: number allowPortsStr: string tlsForce: boolean - + // Stats totalTrafficIn: number totalTrafficOut: number diff --git a/web/frps/src/utils/format.ts b/web/frps/src/utils/format.ts index e7e72fcf..11cd398f 100644 --- a/web/frps/src/utils/format.ts +++ b/web/frps/src/utils/format.ts @@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string { // Prevent index out of bounds for extremely large numbers const unit = sizes[i] || sizes[sizes.length - 1] const val = bytes / Math.pow(k, i) - + return parseFloat(val.toFixed(2)) + ' ' + unit } diff --git a/web/frps/src/utils/proxy.ts b/web/frps/src/utils/proxy.ts index 7e1a02ae..a2285c62 100644 --- a/web/frps/src/utils/proxy.ts +++ b/web/frps/src/utils/proxy.ts @@ -10,6 +10,8 @@ class BaseProxy { lastStartTime: string lastCloseTime: string status: string + user: string + clientID: string clientVersion: string addr: string port: number @@ -19,6 +21,10 @@ class BaseProxy { locations: string subdomain: string + // TCPMux specific + multiplexer: string + routeByHTTPUser: string + constructor(proxyStats: any) { this.name = proxyStats.name this.type = '' @@ -41,6 +47,8 @@ class BaseProxy { this.lastStartTime = proxyStats.lastStartTime this.lastCloseTime = proxyStats.lastCloseTime this.status = proxyStats.status + this.user = proxyStats.user || '' + this.clientID = proxyStats.clientID || '' this.clientVersion = proxyStats.clientVersion this.addr = '' @@ -49,6 +57,8 @@ class BaseProxy { this.hostHeaderRewrite = '' this.locations = '' this.subdomain = '' + this.multiplexer = '' + this.routeByHTTPUser = '' } } @@ -111,20 +121,15 @@ class HTTPSProxy extends BaseProxy { } class TCPMuxProxy extends BaseProxy { - multiplexer: string - routeByHTTPUser: string - constructor(proxyStats: any, port: number, subdomainHost: string) { super(proxyStats) this.type = 'tcpmux' this.port = port - this.multiplexer = '' - this.routeByHTTPUser = '' if (proxyStats.conf) { this.customDomains = proxyStats.conf.customDomains || this.customDomains - this.multiplexer = proxyStats.conf.multiplexer - this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser + this.multiplexer = proxyStats.conf.multiplexer || '' + this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || '' if (proxyStats.conf.subdomain) { this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}` } diff --git a/web/frps/src/views/ClientDetail.vue b/web/frps/src/views/ClientDetail.vue new file mode 100644 index 00000000..aa064579 --- /dev/null +++ b/web/frps/src/views/ClientDetail.vue @@ -0,0 +1,522 @@ + + + + + diff --git a/web/frps/src/views/Clients.vue b/web/frps/src/views/Clients.vue index a9ccd5a1..7afaa481 100644 --- a/web/frps/src/views/Clients.vue +++ b/web/frps/src/views/Clients.vue @@ -1,34 +1,48 @@ @@ -55,6 +69,12 @@ const stats = computed(() => { return { total, online, offline } }) +const statusTabs = computed(() => [ + { value: 'all' as const, label: 'All', count: stats.value.total }, + { value: 'online' as const, label: 'Online', count: stats.value.online }, + { value: 'offline' as const, label: 'Offline', count: stats.value.offline }, +]) + const filteredClients = computed(() => { let result = clients.value @@ -87,7 +107,6 @@ const fetchData = async () => { const json = await getClients() clients.value = json.map((data) => new Client(data)) } catch (error: any) { - console.error('Failed to fetch clients:', error) ElMessage({ showClose: true, message: 'Failed to fetch clients: ' + error.message, @@ -99,7 +118,6 @@ const fetchData = async () => { } const startAutoRefresh = () => { - // Auto refresh every 5 seconds refreshTimer = window.setInterval(() => { fetchData() }, 5000) @@ -124,46 +142,161 @@ onUnmounted(() => { diff --git a/web/frps/src/views/Proxies.vue b/web/frps/src/views/Proxies.vue index 4a0ba1f0..18782bed 100644 --- a/web/frps/src/views/Proxies.vue +++ b/web/frps/src/views/Proxies.vue @@ -1,34 +1,26 @@ diff --git a/web/frps/src/views/ProxyDetail.vue b/web/frps/src/views/ProxyDetail.vue new file mode 100644 index 00000000..f322545f --- /dev/null +++ b/web/frps/src/views/ProxyDetail.vue @@ -0,0 +1,985 @@ + + + + + diff --git a/web/frps/src/views/ServerOverview.vue b/web/frps/src/views/ServerOverview.vue index 47baa9dd..9d1493ad 100644 --- a/web/frps/src/views/ServerOverview.vue +++ b/web/frps/src/views/ServerOverview.vue @@ -53,7 +53,9 @@
Inbound
-
{{ formatFileSize(data.totalTrafficIn) }}
+
+ {{ formatFileSize(data.totalTrafficIn) }} +
@@ -63,7 +65,9 @@
Outbound
-
{{ formatFileSize(data.totalTrafficOut) }}
+
+ {{ formatFileSize(data.totalTrafficOut) }} +
@@ -78,9 +82,9 @@
-
@@ -187,7 +191,7 @@ const data = ref({ }) const hasActiveProxies = computed(() => { - return Object.values(data.value.proxyTypeCounts).some(c => c > 0) + return Object.values(data.value.proxyTypeCounts).some((c) => c > 0) }) const formatTrafficTotal = () => { @@ -223,7 +227,7 @@ const fetchData = async () => { data.value.proxyCounts = 0 if (json.proxyTypeCount != null) { Object.values(json.proxyTypeCount).forEach((count: any) => { - data.value.proxyCounts += (count || 0) + data.value.proxyCounts += count || 0 }) } } catch (err) { @@ -283,7 +287,7 @@ html.dark .config-card { .card-title { font-size: 16px; - font-weight: 600; + font-weight: 500; color: #303133; } @@ -337,7 +341,7 @@ html.dark .card-title { .traffic-info .value { font-size: 24px; - font-weight: 600; + font-weight: 500; color: #303133; } @@ -386,7 +390,7 @@ html.dark .proxy-type-item { .proxy-type-count { font-size: 20px; - font-weight: 600; + font-weight: 500; color: #303133; } @@ -437,7 +441,7 @@ html.dark .config-label { .config-value { font-size: 14px; color: #303133; - font-weight: 600; + font-weight: 500; word-break: break-all; } diff --git a/web/frps/vite.config.mts b/web/frps/vite.config.mts index eb1761ef..812cd10b 100644 --- a/web/frps/vite.config.mts +++ b/web/frps/vite.config.mts @@ -39,7 +39,9 @@ export default defineConfig({ }, }, server: { - allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [], + allowedHosts: process.env.ALLOWED_HOSTS + ? process.env.ALLOWED_HOSTS.split(',') + : [], proxy: { '/api': { target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',