Compare commits

..

4 Commits

Author SHA1 Message Date
fatedier
84be1938e4 api: expose v2 proxy timestamps as unix seconds (#5402) 2026-07-09 14:47:19 +08:00
fatedier
becee40715 docs: update API v2 release notes (#5400) 2026-07-08 15:54:16 +08:00
fatedier
17e788d43b refactor: clean up frps v2 frontend models (#5399) 2026-07-08 13:10:55 +08:00
fatedier
68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
12 changed files with 46 additions and 49 deletions

View File

@@ -1,7 +1,6 @@
## Features ## Features
* Added dashboard API v2 pagination endpoints for users, clients, and proxies. * Expanded the frps dashboard API v2 migration across Clients, Proxies, Server Overview, Client Detail, and Proxy Detail, covering paginated users/clients/proxies, detail data, proxy traffic history, server system info, offline proxy statistics pruning, server-side pagination, search, and proxy type filtering.
* The frps dashboard Clients and Proxies pages now use API v2 pagination and server-side search, including proxy type filtering and searchable proxy spec fields such as remote ports, custom domains, and subdomains.
## Fixes ## Fixes

View File

@@ -239,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
} }
if !proxyStats.LastStartTime.IsZero() { if !proxyStats.LastStartTime.IsZero() {
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05") ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
ps.LastStartAt = proxyStats.LastStartTime.Unix()
} }
if !proxyStats.LastCloseTime.IsZero() { if !proxyStats.LastCloseTime.IsZero() {
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05") ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
} }
return ps return ps
} }

View File

@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
clk.SetTime(closedAt) clk.SetTime(closedAt)
metrics.CloseProxy("proxy", "tcp") metrics.CloseProxy("proxy", "tcp")
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime) require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
stats := metrics.GetProxyByName("proxy")
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
require.Equal(start.Unix(), stats.LastStartAt)
require.Equal(closedAt.Unix(), stats.LastCloseAt)
} }
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) { func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {

View File

@@ -41,6 +41,8 @@ type ProxyStats struct {
TodayTrafficOut int64 TodayTrafficOut int64
LastStartTime string LastStartTime string
LastCloseTime string LastCloseTime string
LastStartAt int64
LastCloseAt int64
CurConns int64 CurConns int64
} }

View File

@@ -545,8 +545,8 @@ func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
TodayTrafficIn: ps.TodayTrafficIn, TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut, TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns, CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime, LastStartAt: ps.LastStartAt,
LastCloseTime: ps.LastCloseTime, LastCloseAt: ps.LastCloseAt,
}, },
} }
} }

View File

@@ -421,10 +421,22 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
} }
resp = performRequest(router, "/api/v2/proxies/tcp-alice") resp = performRequest(router, "/api/v2/proxies/tcp-alice")
rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
"curConns",
"lastCloseAt",
"lastStartAt",
"phase",
"todayTrafficIn",
"todayTrafficOut",
)
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp) proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" {
t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data)
} }
if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 {
t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status)
}
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50") resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp) userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
@@ -744,6 +756,10 @@ func newV2TestController(t *testing.T) *Controller {
TodayTrafficIn: 30, TodayTrafficIn: 30,
TodayTrafficOut: 40, TodayTrafficOut: 40,
CurConns: 2, CurConns: 2,
LastStartTime: "07-08 12:30:00",
LastCloseTime: "07-08 12:31:40",
LastStartAt: 1783504200,
LastCloseAt: 1783504300,
}, },
"http-alice": { "http-alice": {
Name: "http-alice", Name: "http-alice",

View File

@@ -87,8 +87,8 @@ type V2ProxyStatusResp struct {
TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"` TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"` CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"` LastStartAt int64 `json:"lastStartAt,omitempty"`
LastCloseTime string `json:"lastCloseTime"` LastCloseAt int64 `json:"lastCloseAt,omitempty"`
} }
type V2ProxyTrafficResp struct { type V2ProxyTrafficResp struct {

View File

@@ -1,4 +1,5 @@
import { buildQueryString, http } from './http' import { buildQueryString, http } from './http'
import { formatUnixSeconds } from '../utils/format'
import type { V2Page } from './http' import type { V2Page } from './http'
import type { import type {
GetProxyResponse, GetProxyResponse,
@@ -47,9 +48,9 @@ export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({
todayTrafficIn: proxy.status.todayTrafficIn, todayTrafficIn: proxy.status.todayTrafficIn,
todayTrafficOut: proxy.status.todayTrafficOut, todayTrafficOut: proxy.status.todayTrafficOut,
curConns: proxy.status.curConns, curConns: proxy.status.curConns,
lastStartTime: proxy.status.lastStartTime, lastStartTime: formatUnixSeconds(proxy.status.lastStartAt),
lastCloseTime: proxy.status.lastCloseTime, lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt),
status: proxy.status.state || proxy.status.phase || '', status: proxy.status.phase,
}) })
export const getProxy = (type: string, name: string) => { export const getProxy = (type: string, name: string) => {

View File

@@ -7,7 +7,6 @@ export interface ClientInfoData {
wireProtocol?: string wireProtocol?: string
hostname: string hostname: string
clientIP?: string clientIP?: string
metas?: Record<string, string>
firstConnectedAt: number firstConnectedAt: number
lastConnectedAt: number lastConnectedAt: number
disconnectedAt?: number disconnectedAt?: number

View File

@@ -36,13 +36,12 @@ export interface ProxyV2Info {
} }
export interface ProxyV2Status { export interface ProxyV2Status {
state?: string phase: 'online' | 'offline'
phase?: string
todayTrafficIn: number todayTrafficIn: number
todayTrafficOut: number todayTrafficOut: number
curConns: number curConns: number
lastStartTime: string lastStartAt?: number
lastCloseTime: string lastCloseAt?: number
} }
export interface TrafficResponse { export interface TrafficResponse {

View File

@@ -10,7 +10,6 @@ export class Client {
wireProtocol: string wireProtocol: string
hostname: string hostname: string
ip: string ip: string
metas: Map<string, string>
firstConnectedAt: Date firstConnectedAt: Date
lastConnectedAt: Date lastConnectedAt: Date
disconnectedAt?: Date disconnectedAt?: Date
@@ -26,12 +25,6 @@ export class Client {
this.wireProtocol = data.wireProtocol || '' this.wireProtocol = data.wireProtocol || ''
this.hostname = data.hostname this.hostname = data.hostname
this.ip = data.clientIP || '' this.ip = data.clientIP || ''
this.metas = new Map<string, string>()
if (data.metas) {
for (const [key, value] of Object.entries(data.metas)) {
this.metas.set(key, value)
}
}
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000) this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000) this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) { if (data.disconnectedAt && data.disconnectedAt > 0) {
@@ -52,10 +45,6 @@ export class Client {
return this.runID return this.runID
} }
get shortRunId(): string {
return this.runID.substring(0, 8)
}
get wireProtocolLabel(): string { get wireProtocolLabel(): string {
if (!this.wireProtocol) return '' if (!this.wireProtocol) return ''
return `Protocol ${this.wireProtocol}` return `Protocol ${this.wireProtocol}`
@@ -73,28 +62,4 @@ export class Client {
if (!this.disconnectedAt) return '' if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt) return formatDistanceToNow(this.disconnectedAt)
} }
get statusColor(): string {
return this.online ? 'success' : 'danger'
}
get metasArray(): Array<{ key: string; value: string }> {
const arr: Array<{ key: string; value: string }> = []
this.metas.forEach((value, key) => {
arr.push({ key, value })
})
return arr
}
matchesFilter(searchText: string): boolean {
const search = searchText.toLowerCase()
return (
this.key.toLowerCase().includes(search) ||
this.user.toLowerCase().includes(search) ||
this.clientID.toLowerCase().includes(search) ||
this.runID.toLowerCase().includes(search) ||
this.wireProtocol.toLowerCase().includes(search) ||
this.hostname.toLowerCase().includes(search)
)
}
} }

View File

@@ -19,6 +19,14 @@ export function formatDistanceToNow(date: Date): string {
return Math.floor(seconds) + ' seconds ago' return Math.floor(seconds) + ' seconds ago'
} }
export function formatUnixSeconds(seconds?: number): string {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return ''
const date = new Date(seconds * 1000)
const pad = (value: number) => value.toString().padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B' if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'