feat: M2 HTTP/HTTPS 完善

- store: Local 增加 customDomains/subdomain/locations/hostHeaderRewrite/httpHeaders/basicAuth;Remote 增加 vhostHttpPort;migrate() 补列;CRUD 读写(JSOm 编解码)

- render: http/https proxy 输出 customDomains/subdomain/locations/hostHeaderRewrite/httpHeaders/basicAuth

- main: renderRemote 透传 M2 字段(customDomains 逗号拆分 splitCSV)

- web: LocalNode 折叠 HTTP 路由高级区;RemoteNode vhostHTTPPort;StatusView 显示 HTTP 访问端口

- 测试: TestRenderHTTPAdvanced / TestRenderHTTPSUsesCustomDomains;端到端 PUT canvas→config 验证通过
This commit is contained in:
2026-08-17 12:59:12 +08:00
parent 07f25ea067
commit 58906c5a81
11 changed files with 323 additions and 116 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-D5XN_p_5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B38pSFA7.css">
<script type="module" crossorigin src="/assets/index-DHaXL5Wt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-m13EHiyb.css">
</head>
<body>
<div id="app"></div>

View File

@ -32,6 +32,13 @@ type Proxy struct {
PoolCount int
Metadatas map[string]string
Annotations map[string]string
// M2 HTTP/HTTPS routing (http/https only).
Locations []string // path routing
HostHeaderRewrite string // rewrite Host header
HTTPHeaders map[string]string // additional request headers
BasicAuthUser string
BasicAuthPassword string
}
// frpcAuth mirrors frpc's auth section.
@ -53,6 +60,12 @@ type frpcTLS struct {
ServerName string `json:"serverName,omitempty"`
}
// frpcBasicAuth mirrors frpc's HTTP basicAuth section.
type frpcBasicAuth struct {
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// frpcProxy is the serialized proxy object. Different types use different
// fields; empty ones are omitted so the JSON is accepted by frpc.
type frpcProxy struct {
@ -71,6 +84,12 @@ type frpcProxy struct {
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing.
Locations []string `json:"locations,omitempty"`
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
HTTPBasicAuth *frpcBasicAuth `json:"basicAuth,omitempty"`
}
// Config is a complete frpc configuration document.
@ -120,6 +139,12 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
PoolCount: p.PoolCount,
Metadatas: p.Metadatas,
Annotations: p.Annotations,
Locations: p.Locations,
HostHeaderRewrite: p.HostHeaderRewrite,
HTTPHeaders: p.HTTPHeaders,
}
if p.BasicAuthUser != "" || p.BasicAuthPassword != "" {
frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword}
}
// http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header.

View File

@ -101,3 +101,58 @@ func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0])
}
}
func TestRenderHTTPAdvanced(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080,
CustomDomains: []string{"app.example.com"},
Locations: []string{"/api", "/admin"},
HostHeaderRewrite: "backend.internal",
HTTPHeaders: map[string]string{"X-Custom": "val"},
BasicAuthUser: "user", BasicAuthPassword: "pass"},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
p := cfg.Proxies[0]
if p.CustomDomains[0] != "app.example.com" {
t.Fatalf("customDomains = %+v", p.CustomDomains)
}
if len(p.Locations) != 2 || p.Locations[0] != "/api" {
t.Fatalf("locations = %+v", p.Locations)
}
if p.HostHeaderRewrite != "backend.internal" {
t.Fatalf("hostHeaderRewrite = %q", p.HostHeaderRewrite)
}
if p.HTTPHeaders["X-Custom"] != "val" {
t.Fatalf("httpHeaders = %+v", p.HTTPHeaders)
}
if p.HTTPBasicAuth == nil || p.HTTPBasicAuth.User != "user" || p.HTTPBasicAuth.Password != "pass" {
t.Fatalf("basicAuth = %+v", p.HTTPBasicAuth)
}
}
func TestRenderHTTPSUsesCustomDomains(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, []Proxy{
{Name: "secure", Type: "https", LocalIP: "127.0.0.1", LocalPort: 8443,
CustomDomains: []string{"secure.example.com"}},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
p := cfg.Proxies[0]
if p.RemotePort != 0 {
t.Fatalf("https proxy should not set remotePort, got %d", p.RemotePort)
}
if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "secure.example.com" {
t.Fatalf("customDomains = %+v", p.CustomDomains)
}
}

View File

@ -27,6 +27,15 @@ type Local struct {
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing (http/https only).
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
SubDomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"` // path routing
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
BasicAuthUser string `json:"basicAuthUser,omitempty"`
BasicAuthPassword string `json:"basicAuthPassword,omitempty"`
}
// Remote is a remote server node on the canvas.
@ -43,6 +52,10 @@ type Remote struct {
TransportTLS bool `json:"transportTls,omitempty"`
TransportPool int `json:"transportPool,omitempty"`
TransportTLSServerName string `json:"transportTlsServerName,omitempty"`
// M2: frps vhostHTTPPort (optional, displayed on the status page as the
// HTTP access port for http/https services).
VhostHTTPPort int `json:"vhostHttpPort,omitempty"`
}
// Link connects one local to one remote.
@ -148,12 +161,20 @@ func (s *Store) migrate() error {
"pool_count INTEGER NOT NULL DEFAULT 0",
"metadatas TEXT NOT NULL DEFAULT ''",
"annotations TEXT NOT NULL DEFAULT ''",
"custom_domains TEXT NOT NULL DEFAULT ''",
"subdomain TEXT NOT NULL DEFAULT ''",
"locations TEXT NOT NULL DEFAULT ''",
"host_header_rewrite TEXT NOT NULL DEFAULT ''",
"http_headers TEXT NOT NULL DEFAULT ''",
"basic_auth_user TEXT NOT NULL DEFAULT ''",
"basic_auth_password TEXT NOT NULL DEFAULT ''",
},
"remotes": {
"transport_protocol TEXT NOT NULL DEFAULT ''",
"transport_tls INTEGER NOT NULL DEFAULT 0",
"transport_pool INTEGER NOT NULL DEFAULT 0",
"transport_tls_server_name TEXT NOT NULL DEFAULT ''",
"vhost_http_port INTEGER NOT NULL DEFAULT 0",
},
}
for table, cols := range tables {
@ -191,7 +212,7 @@ func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) {
rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations FROM locals ORDER BY name")
rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals ORDER BY name")
if err != nil {
return nil, err
}
@ -200,14 +221,16 @@ func (s *Store) ListLocals() ([]Local, error) {
for rows.Next() {
var l Local
var enc, comp int
var metaRaw, annoRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw); err != nil {
var metaRaw, annoRaw, hdrRaw, locRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil {
return nil, err
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
out = append(out, l)
}
return out, rows.Err()
@ -216,27 +239,53 @@ func (s *Store) ListLocals() ([]Local, error) {
func (s *Store) GetLocal(name string) (Local, bool) {
var l Local
var enc, comp int
var metaRaw, annoRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw); err != nil {
var metaRaw, annoRaw, hdrRaw, locRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil {
return Local{}, false
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
return l, true
}
func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations) VALUES(?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations),
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword,
)
return err
}
// encodeSlice serializes an optional []string as JSON (empty -> "").
func encodeSlice(s []string) string {
if len(s) == 0 {
return ""
}
b, err := json.Marshal(s)
if err != nil {
return ""
}
return string(b)
}
// decodeSlice reads a JSON []string column; empty/invalid -> nil.
func decodeSlice(raw string) []string {
if raw == "" {
return nil
}
var s []string
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil
}
return s
}
// encodeMap serializes an optional JSON map so nil stays an empty string.
func encodeMap(m map[string]string) string {
if len(m) == 0 {
@ -269,7 +318,7 @@ func (s *Store) DeleteLocal(name string) error {
// ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) {
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name FROM remotes ORDER BY name")
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes ORDER BY name")
if err != nil {
return nil, err
}
@ -278,7 +327,7 @@ func (s *Store) ListRemotes() ([]Remote, error) {
for rows.Next() {
var r Remote
var en, tls int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName); err != nil {
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil {
return nil, err
}
r.Enabled = en != 0
@ -291,8 +340,8 @@ func (s *Store) ListRemotes() ([]Remote, error) {
func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote
var en, tls int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName); err != nil {
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil {
return Remote{}, false
}
r.Enabled = en != 0
@ -302,9 +351,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) {
func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name) VALUES(?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName,
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port) VALUES(?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort,
)
return err
}

View File

@ -48,10 +48,10 @@
### M2 HTTP/HTTPS 完善
- [ ] local http/https 增加可编辑域名字段customDomains / subdomain
- [ ] locations路径路由多配置
- [ ] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证
- [ ] frps vhostHTTPPort 的显示与状态映射(状态页展示)
- [x] local http/https 增加可编辑域名字段customDomains / subdomain
- [x] locations路径路由多配置
- [x] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证
- [x] frps vhostHTTPPort 的显示与状态映射(状态页展示)
### M3 负载均衡与健康检查

View File

@ -46,6 +46,15 @@
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
<div class="adv-sep">HTTP/HTTPS 路由</div>
<label>域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
<label>子域 <input v-model="subdomainField" placeholder="app (需 frps subdomain_host)" /></label>
<label>路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
<label>Host 改写 <input v-model="hostHeaderRewriteField" placeholder="backend.internal" /></label>
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
</div>
</div>
</div>
@ -114,6 +123,44 @@ const annotationsField = computed({
emit('update:data', { ...props.data, annotations: parsed })
},
})
const customDomainsField = field('customDomains')
const subdomainField = field('subdomain')
const hostHeaderRewriteField = field('hostHeaderRewrite')
const basicAuthUserField = field('basicAuthUser')
const basicAuthPasswordField = field('basicAuthPassword')
// locations is an array shown as JSON in the UI
const locationsField = computed({
get: () => JSON.stringify(props.data.locations ?? []),
set: (v: string) => {
let parsed: string[] = []
if (v.trim()) {
try {
const arr = JSON.parse(v)
if (Array.isArray(arr)) parsed = arr.map(String)
else return
} catch {
return
}
}
emit('update:data', { ...props.data, locations: parsed })
},
})
// httpHeaders is a map shown as JSON
const httpHeadersField = computed({
get: () => JSON.stringify(props.data.httpHeaders ?? {}),
set: (v: string) => {
let parsed: Record<string, string> = {}
if (v.trim()) {
try {
parsed = JSON.parse(v)
} catch {
return
}
}
emit('update:data', { ...props.data, httpHeaders: parsed })
},
})
</script>
<style scoped lang="scss">

View File

@ -45,6 +45,8 @@
</label>
<label>TLS SNI <input v-model="transportTlsServerNameField" placeholder="frps.local" /></label>
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
</div>
</div>
</div>
@ -84,6 +86,7 @@ const transportProtocolField = field('transportProtocol')
const transportTlsField = field('transportTls')
const transportTlsServerNameField = field('transportTlsServerName')
const transportPoolField = field('transportPool')
const vhostHttpPortField = field('vhostHttpPort')
const showAdv = ref(false)
</script>

View File

@ -2,109 +2,121 @@
// CanvasLocal / CanvasRemote are aliases kept for node components migrated
// from the original prototype; they equal Local / Remote.
export type CanvasLocal = Local
export type CanvasRemote = Remote
export type CanvasLocal = Local;
export type CanvasRemote = Remote;
// CanvasLink mirrors the store.Link JSON shape used by the canvas editor.
export type CanvasLink = Link
export type CanvasLink = Link;
export interface Local {
name: string
ip: string
port: number
protocol: string // tcp | udp | http | https
name: string;
ip: string;
port: number;
protocol: string; // tcp | udp | http | https
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
useEncryption?: boolean
useCompression?: boolean
bandwidthLimit?: string
poolCount?: number
metadatas?: Record<string, string>
annotations?: Record<string, string>
useEncryption?: boolean;
useCompression?: boolean;
bandwidthLimit?: string;
poolCount?: number;
metadatas?: Record<string, string>;
annotations?: Record<string, string>;
// M2 HTTP/HTTPS routing (http/https only)
customDomains?: string; // comma-separated
subdomain?: string;
locations?: string[];
hostHeaderRewrite?: string;
httpHeaders?: Record<string, string>;
basicAuthUser?: string;
basicAuthPassword?: string;
}
export interface Remote {
name: string
ip: string
port: number // frps connect port
token?: string
url?: string
enabled: boolean
name: string;
ip: string;
port: number; // frps connect port
token?: string;
url?: string;
enabled: boolean;
// M1 transport section
transportProtocol?: string // tcp | quic | kcp | websocket
transportTls?: boolean
transportPool?: number
transportTlsServerName?: string
transportProtocol?: string; // tcp | quic | kcp | websocket
transportTls?: boolean;
transportPool?: number;
transportTlsServerName?: string;
// M2: frps vhostHTTPPort
vhostHttpPort?: number;
}
export interface Link {
id?: number
local: string
remote: string
remotePort: number
offsetX?: number
offsetY?: number
id?: number;
local: string;
remote: string;
remotePort: number;
offsetX?: number;
offsetY?: number;
}
export interface CanvasData {
locals: Local[]
remotes: Remote[]
links: Link[]
locals: Local[];
remotes: Remote[];
links: Link[];
}
export interface Settings {
autoStartProfiles: boolean
restartOnExit: boolean
restartIntervalSeconds: number
binaryPath?: string
autoStartProfiles: boolean;
restartOnExit: boolean;
restartIntervalSeconds: number;
binaryPath?: string;
}
export interface ProcessStatus {
state: string
pid?: number
startTime?: number
restartCount: number
exitCode?: number
err?: string
state: string;
pid?: number;
startTime?: number;
restartCount: number;
exitCode?: number;
err?: string;
}
export interface StatusProfile {
name: string
enabled: boolean
process: ProcessStatus
hasProcess: boolean
forwards: { service: string; remotePort: number; localPort?: number }[]
name: string;
enabled: boolean;
process: ProcessStatus;
hasProcess: boolean;
forwards: { service: string; remotePort: number; localPort?: number }[];
}
export interface StatusResp {
version: string
workDir: string
settings: Settings
services: Local[]
remotes: Remote[]
binaryPath: string
profiles: StatusProfile[]
localStatus: LocalStatus[]
version: string;
workDir: string;
settings: Settings;
services: Local[];
remotes: Remote[];
binaryPath: string;
profiles: StatusProfile[];
localStatus: LocalStatus[];
}
export interface LocalTargetStatus {
remote: string
remotePort: number
workerState: string
remote: string;
remotePort: number;
workerState: string;
}
export interface LocalStatus {
local: Local
targets: LocalTargetStatus[]
local: Local;
targets: LocalTargetStatus[];
}
export interface BinaryStatus {
binaryPath: string
version?: string
binaryPath: string;
version?: string;
}
export interface InstallResult {
path: string
version: string
path: string;
version: string;
}

View File

@ -33,6 +33,9 @@
<div class="ns-meta">
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
<span v-if="remoteVhostPort(p.name)" class="ns-vhost">
HTTP 访问端口 :{{ remoteVhostPort(p.name) }}
</span>
</div>
<div class="ns-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
@ -218,6 +221,10 @@ const workerStateLabel = (s?: string) => {
const workerHealthy = (s?: string) => s === 'running'
// remoteVhostPort returns the frps vhostHTTPPort for a remote (0 if unset).
const remoteVhostPort = (name: string): number =>
status.value?.remotes.find((r) => r.name === name)?.vhostHttpPort || 0
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 5000)
@ -402,6 +409,15 @@ onBeforeUnmount(() => {
color: $color-danger;
}
.ns-vhost {
margin-left: 8px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 8px;
font-size: 12px;
}
.ns-forwards {
display: flex;
flex-wrap: wrap;