feat: mutate proxy form default value

feat: support select log pkg for stream
This commit is contained in:
VaalaCat
2025-05-06 14:48:06 +00:00
parent 90f8884d1b
commit 31db046e68
25 changed files with 611 additions and 259 deletions

View File

@@ -39,6 +39,11 @@ func PullWorkers(appInstance app.Application, clientID, clientSecret string) err
ctrl := ctx.GetApp().GetWorkersManager()
for _, worker := range resp.GetWorkers() {
_, err := ctrl.GetWorkerStatus(ctx, worker.GetWorkerId())
if err == nil {
logger.Logger(ctx).Infof("worker [%s] already running", worker.GetWorkerId())
continue
}
ctrl.RunWorker(ctx, worker.GetWorkerId(), workerd.NewWorkerdController(worker, ctx.GetApp().GetConfig().Client.Worker.WorkerdWorkDir))
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/VaalaCat/frp-panel/utils/logger"
)
func StartSteamLogHandler(ctx *app.Context, req *pb.CommonRequest) (*pb.CommonResponse, error) {
func StartSteamLogHandler(ctx *app.Context, req *pb.StartSteamLogRequest) (*pb.CommonResponse, error) {
return common.StartSteamLogHandler(ctx, req, initStreamLog)
}

View File

@@ -12,6 +12,7 @@ import (
type HookMgr struct {
*sync.Mutex
hook *logger.StreamLogHook
pkgs []string
}
func (h *HookMgr) Close() {
@@ -33,23 +34,37 @@ func (h *HookMgr) AddStream(send func(msg string), closeSend func()) {
}
h.Lock()
defer h.Unlock()
h.hook = logger.NewStreamLogHook(send, closeSend)
if h.pkgs == nil {
h.pkgs = make([]string, 0)
}
h.hook = logger.NewStreamLogHook(send, closeSend, h.pkgs...)
logger.Instance().AddHook(h.hook)
go h.hook.Send()
}
func StartSteamLogHandler(ctx *app.Context, req *pb.CommonRequest, initStreamLogFunc func(*app.Context, app.StreamLogHookMgr)) (*pb.CommonResponse, error) {
logger.Logger(ctx).Infof("get a start stream log request, origin is: [%+v]", req)
func (h *HookMgr) SetPkgs(pkgs []string) {
if h.Mutex == nil {
h.Mutex = &sync.Mutex{}
}
h.Lock()
defer h.Unlock()
h.pkgs = pkgs
}
StopSteamLogHandler(ctx, req)
func StartSteamLogHandler(ctx *app.Context, req *pb.StartSteamLogRequest, initStreamLogFunc func(*app.Context, app.StreamLogHookMgr)) (*pb.CommonResponse, error) {
logger.Logger(ctx).Infof("get a start stream log request, origin is: [%s]", req.String())
StopSteamLogHandler(ctx, &pb.CommonRequest{})
hookMgr := ctx.GetApp().GetStreamLogHookMgr()
hookMgr.SetPkgs(req.GetPkgs())
initStreamLogFunc(ctx, hookMgr)
return &pb.CommonResponse{}, nil
}
func StopSteamLogHandler(ctx *app.Context, req *pb.CommonRequest) (*pb.CommonResponse, error) {
logger.Logger(ctx).Infof("get a stop stream log request, origin is: [%+v]", req)
logger.Logger(ctx).Infof("get a stop stream log request, origin is: [%s]", req.String())
h := ctx.GetApp().GetStreamLogHookMgr()
h.Close()
return &pb.CommonResponse{}, nil

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/VaalaCat/frp-panel/common"
"github.com/VaalaCat/frp-panel/pb"
@@ -22,13 +23,21 @@ func GetLogHandler(appInstance app.Application) func(*gin.Context) {
func getLogHander(c *gin.Context, appInstance app.Application) {
id := c.Query("id")
logger.Logger(c).Infof("user try to get stream log, id: [%s]", id)
pkgsQuery := c.Query("pkgs")
pkgs := strings.Split(pkgsQuery, ",")
logger.Logger(c).Infof("user try to get stream log, id: [%s], pkgs: [%s]", id, pkgsQuery)
if id == "" {
c.JSON(http.StatusBadRequest, common.Err("id is empty"))
return
}
if len(pkgs) != 0 {
if pkgs[0] == "all" {
pkgs = make([]string, 0)
}
}
appInstance.GetClientLogManager().GetClientLock(id).Lock()
defer appInstance.GetClientLogManager().GetClientLock(id).Unlock()
@@ -38,7 +47,7 @@ func getLogHander(c *gin.Context, appInstance app.Application) {
}
appInstance.GetClientLogManager().Store(id, ch)
_, err := rpc.CallClient(app.NewContext(c, appInstance), id, pb.Event_EVENT_START_STREAM_LOG, &pb.CommonRequest{})
_, err := rpc.CallClient(app.NewContext(c, appInstance), id, pb.Event_EVENT_START_STREAM_LOG, &pb.StartSteamLogRequest{Pkgs: pkgs})
if err != nil {
c.JSON(http.StatusInternalServerError, common.Err(err.Error()))
return

View File

@@ -10,7 +10,7 @@ import (
"github.com/VaalaCat/frp-panel/utils/logger"
)
func StartSteamLogHandler(ctx *app.Context, req *pb.CommonRequest) (*pb.CommonResponse, error) {
func StartSteamLogHandler(ctx *app.Context, req *pb.StartSteamLogRequest) (*pb.CommonResponse, error) {
return common.StartSteamLogHandler(ctx, req, initStreamLog)
}

View File

@@ -28,7 +28,8 @@ type ReqType interface {
pb.StartProxyRequest | pb.StopProxyRequest |
pb.CreateWorkerRequest | pb.RemoveWorkerRequest | pb.RunWorkerRequest | pb.StopWorkerRequest | pb.UpdateWorkerRequest | pb.GetWorkerRequest |
pb.ListWorkersRequest | pb.CreateWorkerIngressRequest | pb.GetWorkerIngressRequest |
pb.GetWorkerStatusRequest | pb.InstallWorkerdRequest
pb.GetWorkerStatusRequest | pb.InstallWorkerdRequest |
pb.StartSteamLogRequest
}
func GetProtoRequest[T ReqType](c *gin.Context) (r *T, err error) {

View File

@@ -29,7 +29,8 @@ type RespType interface {
pb.StartProxyResponse | pb.StopProxyResponse |
pb.CreateWorkerResponse | pb.RemoveWorkerResponse | pb.RunWorkerResponse | pb.StopWorkerResponse | pb.UpdateWorkerResponse | pb.GetWorkerResponse |
pb.ListWorkersResponse | pb.CreateWorkerIngressResponse | pb.GetWorkerIngressResponse |
pb.GetWorkerStatusResponse | pb.InstallWorkerdResponse
pb.GetWorkerStatusResponse | pb.InstallWorkerdResponse |
pb.StartSteamLogResponse
}
func OKResp[T RespType](c *gin.Context, origin *T) {

View File

@@ -49,4 +49,12 @@ message GetClientCertRequest {
message GetClientCertResponse {
optional common.Status status = 1;
bytes cert = 2;
}
message StartSteamLogRequest {
repeated string pkgs = 1; // 需要获取哪些包的日志
}
message StartSteamLogResponse {
optional common.Status status = 1;
}

View File

@@ -473,6 +473,94 @@ func (x *GetClientCertResponse) GetCert() []byte {
return nil
}
type StartSteamLogRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Pkgs []string `protobuf:"bytes,1,rep,name=pkgs,proto3" json:"pkgs,omitempty"` // 需要获取哪些包的日志
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartSteamLogRequest) Reset() {
*x = StartSteamLogRequest{}
mi := &file_api_master_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartSteamLogRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartSteamLogRequest) ProtoMessage() {}
func (x *StartSteamLogRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_master_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartSteamLogRequest.ProtoReflect.Descriptor instead.
func (*StartSteamLogRequest) Descriptor() ([]byte, []int) {
return file_api_master_proto_rawDescGZIP(), []int{6}
}
func (x *StartSteamLogRequest) GetPkgs() []string {
if x != nil {
return x.Pkgs
}
return nil
}
type StartSteamLogResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status *Status `protobuf:"bytes,1,opt,name=status,proto3,oneof" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartSteamLogResponse) Reset() {
*x = StartSteamLogResponse{}
mi := &file_api_master_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartSteamLogResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartSteamLogResponse) ProtoMessage() {}
func (x *StartSteamLogResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_master_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartSteamLogResponse.ProtoReflect.Descriptor instead.
func (*StartSteamLogResponse) Descriptor() ([]byte, []int) {
return file_api_master_proto_rawDescGZIP(), []int{7}
}
func (x *StartSteamLogResponse) GetStatus() *Status {
if x != nil {
return x.Status
}
return nil
}
var File_api_master_proto protoreflect.FileDescriptor
const file_api_master_proto_rawDesc = "" +
@@ -527,6 +615,11 @@ const file_api_master_proto_rawDesc = "" +
"\x15GetClientCertResponse\x12+\n" +
"\x06status\x18\x01 \x01(\v2\x0e.common.StatusH\x00R\x06status\x88\x01\x01\x12\x12\n" +
"\x04cert\x18\x02 \x01(\fR\x04certB\t\n" +
"\a_status\"*\n" +
"\x14StartSteamLogRequest\x12\x12\n" +
"\x04pkgs\x18\x01 \x03(\tR\x04pkgs\"O\n" +
"\x15StartSteamLogResponse\x12+\n" +
"\x06status\x18\x01 \x01(\v2\x0e.common.StatusH\x00R\x06status\x88\x01\x01B\t\n" +
"\a_statusB\aZ\x05../pbb\x06proto3"
var (
@@ -542,7 +635,7 @@ func file_api_master_proto_rawDescGZIP() []byte {
}
var file_api_master_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_api_master_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_api_master_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_api_master_proto_goTypes = []any{
(ClientStatus_Status)(0), // 0: api_master.ClientStatus.Status
(*ClientStatus)(nil), // 1: api_master.ClientStatus
@@ -551,25 +644,28 @@ var file_api_master_proto_goTypes = []any{
(*GetClientsStatusResponse)(nil), // 4: api_master.GetClientsStatusResponse
(*GetClientCertRequest)(nil), // 5: api_master.GetClientCertRequest
(*GetClientCertResponse)(nil), // 6: api_master.GetClientCertResponse
nil, // 7: api_master.GetClientsStatusResponse.ClientsEntry
(ClientType)(0), // 8: common.ClientType
(*Status)(nil), // 9: common.Status
(*StartSteamLogRequest)(nil), // 7: api_master.StartSteamLogRequest
(*StartSteamLogResponse)(nil), // 8: api_master.StartSteamLogResponse
nil, // 9: api_master.GetClientsStatusResponse.ClientsEntry
(ClientType)(0), // 10: common.ClientType
(*Status)(nil), // 11: common.Status
}
var file_api_master_proto_depIdxs = []int32{
8, // 0: api_master.ClientStatus.client_type:type_name -> common.ClientType
0, // 1: api_master.ClientStatus.status:type_name -> api_master.ClientStatus.Status
2, // 2: api_master.ClientStatus.version:type_name -> api_master.ClientVersion
8, // 3: api_master.GetClientsStatusRequest.client_type:type_name -> common.ClientType
9, // 4: api_master.GetClientsStatusResponse.status:type_name -> common.Status
7, // 5: api_master.GetClientsStatusResponse.clients:type_name -> api_master.GetClientsStatusResponse.ClientsEntry
8, // 6: api_master.GetClientCertRequest.client_type:type_name -> common.ClientType
9, // 7: api_master.GetClientCertResponse.status:type_name -> common.Status
1, // 8: api_master.GetClientsStatusResponse.ClientsEntry.value:type_name -> api_master.ClientStatus
9, // [9:9] is the sub-list for method output_type
9, // [9:9] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
10, // 0: api_master.ClientStatus.client_type:type_name -> common.ClientType
0, // 1: api_master.ClientStatus.status:type_name -> api_master.ClientStatus.Status
2, // 2: api_master.ClientStatus.version:type_name -> api_master.ClientVersion
10, // 3: api_master.GetClientsStatusRequest.client_type:type_name -> common.ClientType
11, // 4: api_master.GetClientsStatusResponse.status:type_name -> common.Status
9, // 5: api_master.GetClientsStatusResponse.clients:type_name -> api_master.GetClientsStatusResponse.ClientsEntry
10, // 6: api_master.GetClientCertRequest.client_type:type_name -> common.ClientType
11, // 7: api_master.GetClientCertResponse.status:type_name -> common.Status
11, // 8: api_master.StartSteamLogResponse.status:type_name -> common.Status
1, // 9: api_master.GetClientsStatusResponse.ClientsEntry.value:type_name -> api_master.ClientStatus
10, // [10:10] is the sub-list for method output_type
10, // [10:10] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
}
func init() { file_api_master_proto_init() }
@@ -581,13 +677,14 @@ func file_api_master_proto_init() {
file_api_master_proto_msgTypes[0].OneofWrappers = []any{}
file_api_master_proto_msgTypes[3].OneofWrappers = []any{}
file_api_master_proto_msgTypes[5].OneofWrappers = []any{}
file_api_master_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_master_proto_rawDesc), len(file_api_master_proto_rawDesc)),
NumEnums: 1,
NumMessages: 7,
NumMessages: 9,
NumExtensions: 0,
NumServices: 0,
},

View File

@@ -18,6 +18,7 @@ import (
// biz/common/stream_log.go
type StreamLogHookMgr interface {
AddStream(send func(msg string), closeSend func())
SetPkgs(pkgs []string)
Close()
Lock()
TryLock() bool

View File

@@ -4,7 +4,6 @@ package workerd
import (
"context"
"os"
"os/exec"
"syscall"
"time"
@@ -12,6 +11,7 @@ import (
"github.com/VaalaCat/frp-panel/services/app"
"github.com/VaalaCat/frp-panel/utils"
"github.com/VaalaCat/frp-panel/utils/logger"
"github.com/sirupsen/logrus"
)
type workerExecManager struct {
@@ -70,8 +70,8 @@ func (m *workerExecManager) RunCmd(uid string, cwd string, argv []string) {
cmd := exec.CommandContext(ctx, m.binaryPath, args...)
cmd.Dir = cwd
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: false}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = logger.LoggerWriter("workerd", logrus.InfoLevel)
cmd.Stderr = logger.LoggerWriter("workerd", logrus.ErrorLevel)
if err := cmd.Run(); err != nil {
logger.Logger(ctx).WithError(err).Errorf("command id: [%s] run failed, binary path: [%s], args: %s", uid, m.binaryPath, utils.MarshalForJson(args))
}

View File

@@ -61,6 +61,8 @@ func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) {
}
isFrpPkg := entry.Data["pkg"] == "frp"
isWorkerdPkg := entry.Data["pkg"] == "workerd"
resetCode := f.getReset()
var levelColorCode string
@@ -100,12 +102,15 @@ func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) {
levelColor := f.getColor(levelColorCode)
fmt.Fprintf(b, " [%s%s%s]", levelColor, entry.Level.String(), resetCode)
if entry.HasCaller() {
fileName := filepath.Base(entry.Caller.File)
fatherDir := filepath.Base(filepath.Dir(entry.Caller.File))
callerColor := f.getColor(colorGray)
fmt.Fprintf(b, " [%s%s:%d%s]", callerColor, filepath.Join(fatherDir, fileName), entry.Caller.Line, resetCode)
if !isWorkerdPkg {
if entry.HasCaller() {
fileName := filepath.Base(entry.Caller.File)
fatherDir := filepath.Base(filepath.Dir(entry.Caller.File))
callerColor := f.getColor(colorGray)
fmt.Fprintf(b, " [%s%s:%d%s]", callerColor, filepath.Join(fatherDir, fileName), entry.Caller.Line, resetCode)
}
}
b.WriteString(" ")
}

View File

@@ -8,6 +8,7 @@ import (
"runtime/debug"
"sync"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
@@ -18,9 +19,11 @@ type StreamLogHook struct {
streamEnabled bool
stdio io.Writer
lock *sync.Mutex
pkgs map[string]bool // 只传输指定包的日志
}
func NewStreamLogHook(handler func(msg string), stopFunc func()) *StreamLogHook {
func NewStreamLogHook(handler func(msg string), stopFunc func(), pkgs ...string) *StreamLogHook {
pkgs = lo.FilterMap(pkgs, func(v string, _ int) (string, bool) { return v, len(v) > 0 })
return &StreamLogHook{
ch: make(chan string, 4096),
handler: handler,
@@ -28,6 +31,7 @@ func NewStreamLogHook(handler func(msg string), stopFunc func()) *StreamLogHook
stdio: bufio.NewWriter(os.Stdout),
stopFunc: stopFunc,
lock: &sync.Mutex{},
pkgs: lo.SliceToMap(pkgs, func(v string) (string, bool) { return v, true }),
}
}
@@ -35,6 +39,18 @@ func (s *StreamLogHook) Fire(entry *logrus.Entry) error {
if !s.streamEnabled {
return nil
}
// 有过滤时需要过滤
if len(s.pkgs) > 0 {
pkgName, ok := entry.Data["pkg"].(string)
if !ok {
return nil
}
if _, ok := s.pkgs[pkgName]; !ok {
return nil
}
}
str, _ := entry.String()
s.ch <- str
return nil

View File

@@ -20,7 +20,7 @@ func initFrpLogger(frpLogLevel log.Level) {
log.WithCaller(true),
log.AddCallerSkip(1),
log.WithLevel(frpLogLevel),
log.WithOutput(logger))
log.WithOutput(LoggerWriter("frp", logrus.InfoLevel)))
}
func InitLogger() {

View File

@@ -9,36 +9,38 @@ import (
type LogrusWriter struct {
Logger *logrus.Logger
Level logrus.Level
Pkg string
}
func (w *LogrusWriter) Write(p []byte) (n int, err error) {
msg := string(p)
switch w.Level {
case logrus.DebugLevel:
w.Logger.WithField("pkg", "frp").Debug(msg)
w.Logger.WithField("pkg", w.Pkg).Debug(msg)
case logrus.InfoLevel:
w.Logger.WithField("pkg", "frp").Info(msg)
w.Logger.WithField("pkg", w.Pkg).Info(msg)
case logrus.WarnLevel:
w.Logger.WithField("pkg", "frp").Warn(msg)
w.Logger.WithField("pkg", w.Pkg).Warn(msg)
case logrus.ErrorLevel:
w.Logger.WithField("pkg", "frp").Error(msg)
w.Logger.WithField("pkg", w.Pkg).Error(msg)
default:
w.Logger.WithField("pkg", "frp").Info(msg)
w.Logger.WithField("pkg", w.Pkg).Info(msg)
}
return len(p), nil
}
var (
logger = &LogrusWriter{
Logger: logrus.New(),
Level: logrus.InfoLevel,
}
LoggerInstance = logrus.New()
)
func Instance() *logrus.Logger {
return logger.Logger
return LoggerInstance
}
func LoggerWriter(pkg string, level logrus.Level) *LogrusWriter {
return &LogrusWriter{Logger: LoggerInstance, Level: level, Pkg: pkg}
}
func Logger(c context.Context) *logrus.Entry {
return logger.Logger.WithContext(c)
return LoggerInstance.WithContext(c)
}

View File

@@ -1,74 +1,56 @@
import { Server } from "@/lib/pb/common";
import { TypedClientPluginOptions } from "@/types/plugin";
import { HTTPProxyConfig, TypedProxyConfig } from "@/types/proxy";
import { ServerConfig } from "@/types/server";
import { ArrowRight, Globe, Monitor } from 'lucide-react';
export function VisitPreview({ server, typedProxyConfig }: { server: Server; typedProxyConfig: TypedProxyConfig }) {
export function VisitPreview({ server, typedProxyConfig, direction, withIcon = true }:
{
server: Server;
typedProxyConfig: TypedProxyConfig;
direction?: "row" | "column";
withIcon?: boolean
}) {
return (
<div className={"flex items-start sm:items-center justify-start p-2 text-xs font-mono text-nowrap " + (
!direction ? "flex-wrap" : direction == "row" ? "flex-row" : "flex-col"
)}>
<ServerSideVisitPreview server={server} typedProxyConfig={typedProxyConfig} withIcon={withIcon} />
<ArrowRight className="hidden sm:block w-4 h-4 text-gray-400 mx-2 flex-shrink-0" />
<ClientSideVisitPreview typedProxyConfig={typedProxyConfig} withIcon={withIcon} />
</div>
);
}
export function ServerSideVisitPreview({ server, typedProxyConfig, withIcon = true }: { server: Server; typedProxyConfig: TypedProxyConfig; withIcon?: boolean }) {
const serverCfg = JSON.parse(server?.config || '{}') as ServerConfig;
const serverAddress = server.ip || serverCfg.bindAddr || 'Unknown';
const serverPort = getServerPort(typedProxyConfig, serverCfg);
return <div className="flex items-center mb-2 sm:mb-0">
{withIcon && <Globe className="w-4 h-4 text-blue-500 mr-2 flex-shrink-0" />}
<span className="text-nowrap">{typedProxyConfig.type == "http" ? "http://" : ""}{
typedProxyConfig.type == "http" ? (
getServerAuth(typedProxyConfig as HTTPProxyConfig) + getServerHost(typedProxyConfig as HTTPProxyConfig, serverCfg, serverAddress)
) : serverAddress
}:{serverPort || "?"}{
typedProxyConfig.type == "http" ?
getServerPath(typedProxyConfig as HTTPProxyConfig) : ""
}</span>
</div>
}
export function ClientSideVisitPreview({ typedProxyConfig, withIcon = true }: { typedProxyConfig: TypedProxyConfig, withIcon?: boolean }) {
const localAddress = typedProxyConfig.localIP || '127.0.0.1';
const localPort = typedProxyConfig.localPort;
const clientPlugin = typedProxyConfig.plugin;
function getServerPath(httpProxyConfig: HTTPProxyConfig) {
if (!httpProxyConfig.locations) {
return "";
}
if (httpProxyConfig.locations.length == 0) {
return "";
}
if (httpProxyConfig.locations.length == 1) {
return httpProxyConfig.locations[0];
}
return `[${httpProxyConfig.locations.join(", ")}]`;
}
function getServerHost(httpProxyConfig: HTTPProxyConfig) {
let allHosts = []
if (httpProxyConfig.subdomain) {
allHosts.push(`${httpProxyConfig.subdomain}.${serverCfg.subDomainHost}`);
}
allHosts.push(...(httpProxyConfig.customDomains || []));
if (allHosts.length == 0) {
return serverAddress;
}
if (allHosts.length == 1) {
return allHosts[0];
}
return `[${allHosts.join(", ")}]`;
}
function getServerAuth(httpProxyConfig: HTTPProxyConfig) {
if (!httpProxyConfig.httpUser || !httpProxyConfig.httpPassword) {
return "";
}
return `${httpProxyConfig.httpUser}:${httpProxyConfig.httpPassword}@`
}
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-start p-2 text-sm font-mono text-nowrap">
<div className="flex items-center mb-2 sm:mb-0">
<Globe className="w-4 h-4 text-blue-500 mr-2 flex-shrink-0" />
<span className="text-nowrap">{typedProxyConfig.type == "http" ? "http://" : ""}{
typedProxyConfig.type == "http" ? (
getServerAuth(typedProxyConfig as HTTPProxyConfig) + getServerHost(typedProxyConfig as HTTPProxyConfig)
) : serverAddress
}:{serverPort || "?"}{
typedProxyConfig.type == "http" ?
getServerPath(typedProxyConfig as HTTPProxyConfig) : ""
}</span>
</div>
<ArrowRight className="hidden sm:block w-4 h-4 text-gray-400 mx-2 flex-shrink-0" />
<div className="flex items-center mb-2 sm:mb-0">
<Monitor className="w-4 h-4 text-green-500 mr-2 flex-shrink-0" />
<span className="text-nowrap">{localAddress}:{localPort}</span>
</div>
</div>
);
return <div className="flex items-center mb-2 sm:mb-0">
{withIcon && <Monitor className="w-4 h-4 text-green-500 mr-2 flex-shrink-0" />}
{clientPlugin && clientPlugin.type.length > 0 ?
<PluginLocalDist plugins={clientPlugin} /> :
<span className="text-nowrap">{localAddress}:{localPort}</span>}
</div>
}
function getServerPort(proxyConfig: TypedProxyConfig, serverConfig: ServerConfig): number | undefined {
@@ -86,3 +68,56 @@ function getServerPort(proxyConfig: TypedProxyConfig, serverConfig: ServerConfig
}
}
function getServerAuth(httpProxyConfig: HTTPProxyConfig) {
if (!httpProxyConfig.httpUser || !httpProxyConfig.httpPassword) {
return "";
}
return `${httpProxyConfig.httpUser}:${httpProxyConfig.httpPassword}@`
}
function getServerPath(httpProxyConfig: HTTPProxyConfig) {
if (!httpProxyConfig.locations) {
return "";
}
if (httpProxyConfig.locations.length == 0) {
return "";
}
if (httpProxyConfig.locations.length == 1) {
return httpProxyConfig.locations[0];
}
return `[${httpProxyConfig.locations.join(", ")}]`;
}
function getServerHost(httpProxyConfig: HTTPProxyConfig, serverCfg: ServerConfig, serverAddress: string) {
let allHosts = []
if (httpProxyConfig.subdomain) {
allHosts.push(`${httpProxyConfig.subdomain}.${serverCfg.subDomainHost}`);
}
allHosts.push(...(httpProxyConfig.customDomains || []));
if (allHosts.length == 0) {
return serverAddress;
}
if (allHosts.length == 1) {
return allHosts[0];
}
return `[${allHosts.join(", ")}]`;
}
function PluginLocalDist({ plugins }: { plugins: TypedClientPluginOptions }) {
return (<>
{
plugins.type === "unix_domain_socket" ? (
<span className="text-nowrap">{plugins.unixPath}</span>
) : plugins.type === "static_file" ? (
<span className="text-nowrap">{plugins.localPath}</span>
) : plugins.type === "http2https" || plugins.type === "https2http" || plugins.type === "https2https" ? (
<span className="text-nowrap">{plugins.localAddr}</span>
) : (
<span className="text-nowrap">{JSON.stringify(plugins)}</span>
)
}</>)
}

View File

@@ -99,7 +99,7 @@ export const ProxyConfigMutateForm = ({
useEffect(() => {
if (proxyName && proxyType) {
setProxyConfigs([{ name: proxyName, type: proxyType }])
setProxyConfigs([{...defaultProxyConfig, name: proxyName, type: proxyType }])
}
}, [proxyName, proxyType])

View File

@@ -28,6 +28,8 @@ import { useStore } from '@nanostores/react'
import { $proxyTableRefetchTrigger } from '@/store/refetch-trigger'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { ServerSideVisitPreview, VisitPreview } from '../base/visit-preview'
import { getServer } from '@/api/server'
interface WorkerIngressProps {
workerId: string
@@ -240,14 +242,20 @@ export function WorkerIngress({ workerId, refetchWorker, clients }: WorkerIngres
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<span className="text-xs font-medium text-muted-foreground mr-1">Server:</span>
<span className="font-mono text-xs truncate max-w-[150px] md:max-w-[200px]">
{ingress.serverId}
<div className="flex items-center text-center">
<span className="text-xs font-medium text-muted-foreground mr-1 font-mono">Server:</span>
<span className="font-mono text-xs items-center hide-scroll-bar">
<WorkerIngressPreview
serverId={ingress.serverId}
proxyCfg={JSON.parse(ingress.config || '{}') as TypedProxyConfig} />
</span>
</div>
</TooltipTrigger>
<TooltipContent>{ingress.serverId}</TooltipContent>
<TooltipContent>
<div>
ServerID: {ingress.serverId}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -255,7 +263,7 @@ export function WorkerIngress({ workerId, refetchWorker, clients }: WorkerIngres
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<span className="text-xs font-medium text-muted-foreground mr-1">Client:</span>
<span className="text-xs font-medium text-muted-foreground mr-1 font-mono">Client:</span>
<span className="font-mono text-xs truncate max-w-[150px] md:max-w-[200px]">
{ingress.originClientId}
</span>
@@ -361,3 +369,16 @@ export const IngressDeleteForm = ({
</DialogFooter>
)
}
const WorkerIngressPreview = ({ serverId, proxyCfg }: { serverId?: string; proxyCfg: TypedProxyConfig }) => {
const { data: getServerResp } = useQuery({
queryKey: ['getServer', serverId],
queryFn: () => {
return getServer({ serverId: serverId })
},
})
return (<div className='font-mono text-xs'>
<ServerSideVisitPreview server={getServerResp?.server || { frpsUrls: [] }} typedProxyConfig={proxyCfg} withIcon={false} />
</div>)
}

View File

@@ -186,7 +186,8 @@
"cancel": "Cancel",
"clientType": "Client Type",
"disconnect": "Disconnect",
"connect": "Connect"
"connect": "Connect",
"stream_log_pkgs_select": "Stream Log From"
},
"frpc": {
"client_plugins": {

View File

@@ -186,7 +186,8 @@
"cancel": "取消",
"clientType": "客户端类型",
"disconnect": "断开连接",
"connect": "连接"
"connect": "连接",
"stream_log_pkgs_select": "捕获日志的模块"
},
"frpc": {
"client_plugins": {

View File

@@ -157,6 +157,24 @@ export interface GetClientCertResponse {
*/
cert: Uint8Array;
}
/**
* @generated from protobuf message api_master.StartSteamLogRequest
*/
export interface StartSteamLogRequest {
/**
* @generated from protobuf field: repeated string pkgs = 1;
*/
pkgs: string[]; // 需要获取哪些包的日志
}
/**
* @generated from protobuf message api_master.StartSteamLogResponse
*/
export interface StartSteamLogResponse {
/**
* @generated from protobuf field: optional common.Status status = 1;
*/
status?: Status;
}
// @generated message type with reflection information, may provide speed optimized methods
class ClientStatus$Type extends MessageType<ClientStatus> {
constructor() {
@@ -590,3 +608,96 @@ class GetClientCertResponse$Type extends MessageType<GetClientCertResponse> {
* @generated MessageType for protobuf message api_master.GetClientCertResponse
*/
export const GetClientCertResponse = new GetClientCertResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class StartSteamLogRequest$Type extends MessageType<StartSteamLogRequest> {
constructor() {
super("api_master.StartSteamLogRequest", [
{ no: 1, name: "pkgs", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<StartSteamLogRequest>): StartSteamLogRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.pkgs = [];
if (value !== undefined)
reflectionMergePartial<StartSteamLogRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: StartSteamLogRequest): StartSteamLogRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* repeated string pkgs */ 1:
message.pkgs.push(reader.string());
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: StartSteamLogRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* repeated string pkgs = 1; */
for (let i = 0; i < message.pkgs.length; i++)
writer.tag(1, WireType.LengthDelimited).string(message.pkgs[i]);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message api_master.StartSteamLogRequest
*/
export const StartSteamLogRequest = new StartSteamLogRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class StartSteamLogResponse$Type extends MessageType<StartSteamLogResponse> {
constructor() {
super("api_master.StartSteamLogResponse", [
{ no: 1, name: "status", kind: "message", T: () => Status }
]);
}
create(value?: PartialMessage<StartSteamLogResponse>): StartSteamLogResponse {
const message = globalThis.Object.create((this.messagePrototype!));
if (value !== undefined)
reflectionMergePartial<StartSteamLogResponse>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: StartSteamLogResponse): StartSteamLogResponse {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* optional common.Status status */ 1:
message.status = Status.internalBinaryRead(reader, reader.uint32(), options, message.status);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: StartSteamLogResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* optional common.Status status = 1; */
if (message.status)
Status.internalBinaryWrite(message.status, writer.tag(1, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message api_master.StartSteamLogResponse
*/
export const StartSteamLogResponse = new StartSteamLogResponse$Type();

View File

@@ -1,87 +1,96 @@
import { API_PATH } from "./consts";
import { API_PATH } from './consts'
export const parseStreaming = async (
controller: AbortController,
id: string,
onLog: (value: string) => void,
onError?: (status: number) => void,
onDone?: () => void
controller: AbortController,
id: string,
pkgs: string[],
onLog: (value: string) => void,
onError?: (status: number) => void,
onDone?: () => void,
) => {
const decoder = new TextDecoder();
let uint8Array = new Uint8Array();
let chunks = "";
const response = await fetch(`${API_PATH}/log?id=${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "*./*",
},
signal: controller.signal,
});
if (response.status !== 200) {
onError?.(response.status);
return;
} else {
onError?.(200);
}
function decodeLog(chunk: string): string {
const lines = chunk.split("\n");
const newLines = lines.map((line) => {
try {
return JSON.parse(line)
} catch (error) {
return line;
}
})
const decodedLines = newLines.map((line) => {
return Buffer.from(line, 'base64').toString('utf-8');
})
const splittedLines = decodedLines.map((line) => {
return line.split("\n");
}).flat();
const trimmedLines = splittedLines.map((line) => {
return line.trim().replaceAll("\r", "").replaceAll("\n", "").replaceAll("\t", "");
})
return trimmedLines.join("\n");
const decoder = new TextDecoder()
let uint8Array = new Uint8Array()
let chunks = ''
let param: Record<string, string> = {
id: id,
}
if (pkgs.length > 0) {
param['pkgs'] = pkgs.join(',')
}
}
fetchStream(
response,
(chunk) => {
//@ts-ignore
uint8Array = new Uint8Array([...uint8Array, ...chunk]);
chunks = decoder.decode(uint8Array, { stream: true });
onLog(decodeLog(chunks));
},
() => {
onDone && onDone();
},
);
};
const response = await fetch(`${API_PATH}/log?${new URLSearchParams(param).toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: '*./*',
},
signal: controller.signal,
})
if (response.status !== 200) {
onError?.(response.status)
return
} else {
onError?.(200)
}
function decodeLog(chunk: string): string {
const lines = chunk.split('\n')
const newLines = lines.map((line) => {
try {
return JSON.parse(line)
} catch (error) {
return line
}
})
const decodedLines = newLines.map((line) => {
return Buffer.from(line, 'base64').toString('utf-8')
})
const splittedLines = decodedLines
.map((line) => {
return line.split('\n')
})
.flat()
const trimmedLines = splittedLines.map((line) => {
return line.trim().replaceAll('\r', '').replaceAll('\n', '').replaceAll('\t', '')
})
return trimmedLines.join('\n')
}
fetchStream(
response,
(chunk) => {
//@ts-ignore
uint8Array = new Uint8Array([...uint8Array, ...chunk])
chunks = decoder.decode(uint8Array, { stream: true })
onLog(decodeLog(chunks))
},
() => {
onDone && onDone()
},
)
}
async function pump(
reader: ReadableStreamDefaultReader<Uint8Array>,
controller: ReadableStreamDefaultController,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
reader: ReadableStreamDefaultReader<Uint8Array>,
controller: ReadableStreamDefaultController,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
): Promise<ReadableStreamReadResult<Uint8Array> | undefined> {
const { done, value } = await reader.read();
if (done) {
onDone && onDone();
controller.close();
return;
}
onChunk && onChunk(value);
controller.enqueue(value);
return pump(reader, controller, onChunk, onDone);
const { done, value } = await reader.read()
if (done) {
onDone && onDone()
controller.close()
return
}
onChunk && onChunk(value)
controller.enqueue(value)
return pump(reader, controller, onChunk, onDone)
}
export const fetchStream = (
response: Response,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
response: Response,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
): ReadableStream<string> => {
const reader = response.body!.getReader();
return new ReadableStream({
start: (controller) => pump(reader, controller, onChunk, onDone),
});
};
const reader = response.body!.getReader()
return new ReadableStream({
start: (controller) => pump(reader, controller, onChunk, onDone),
})
}

View File

@@ -1,4 +1,4 @@
"use client"
'use client'
import { Providers } from '@/components/providers'
import { RootLayout } from '@/components/layout'
@@ -21,23 +21,24 @@ import { PlayCircle, StopCircle, RefreshCcw, Eraser } from 'lucide-react'
import { cn } from '@/lib/utils'
const LogTerminalComponent = dynamic(() => import('@/components/base/readonly-xterm'), {
ssr: false
ssr: false,
})
export default function StreamLogPage() {
const { t } = useTranslation();
const { t } = useTranslation()
const [clientID, setClientID] = useState<string | undefined>(undefined)
const [log, setLog] = useState<string | undefined>(undefined)
const [clear, setClear] = useState<number>(0)
const [enabled, setEnabled] = useState<boolean>(false)
const [timeoutID, setTimeoutID] = useState<NodeJS.Timeout | null>(null);
const [timeoutID, setTimeoutID] = useState<NodeJS.Timeout | null>(null)
const [clientType, setClientType] = useState<ClientType>(ClientType.FRPS)
const [status, setStatus] = useState<"loading" | "success" | "error" | undefined>()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | undefined>()
const [pkgs, setPkgs] = useState<string[]>([])
const searchParams = useSearchParams()
const paramClientID = searchParams.get('clientID')
const paramClientType = searchParams.get('clientType')
useEffect(() => {
if (paramClientID) {
setClientID(paramClientID)
@@ -58,47 +59,52 @@ export default function StreamLogPage() {
setClear(Math.random())
setStatus(undefined)
if (!clientID || !enabled) {
return;
return
}
const abortController = new AbortController();
setStatus("loading");
const abortController = new AbortController()
setStatus('loading')
void parseStreaming(
parseStreaming(
abortController,
clientID,
pkgs,
setLog,
(status: number) => {
if (status === 200) {
setStatus("success")
setStatus('success')
} else {
setStatus("error")
setStatus('error')
}
},
() => {
console.log("parseStreaming success")
setStatus("success")
}
);
console.log('parseStreaming success')
setStatus('success')
},
)
return () => {
abortController.abort("unmount");
setEnabled(false);
};
}, [clientID, enabled]);
abortController.abort('unmount')
setEnabled(false)
}
}, [clientID, enabled, pkgs])
const handleConnect = () => {
if (enabled) {
setEnabled(false)
if (enabled) {
setEnabled(false)
}
if (timeoutID) {
clearTimeout(timeoutID)
if (timeoutID) {
clearTimeout(timeoutID)
}
setTimeoutID(setTimeout(() => { setEnabled(true) }, 10))
setTimeoutID(
setTimeout(() => {
setEnabled(true)
}, 10),
)
}
const handleRefresh = () => {
setClear(Math.random());
setClear(Math.random())
if (clientID) {
getClientsStatus({ clientIds: [clientID], clientType: clientType })
}
@@ -106,7 +112,7 @@ export default function StreamLogPage() {
const handleDisconnect = () => {
setEnabled(false)
setClear(Math.random());
setClear(Math.random())
}
return (
@@ -114,12 +120,12 @@ export default function StreamLogPage() {
<RootLayout mainHeader={<Header />}>
<Card className="w-full h-[calc(100dvh_-_80px)] flex flex-col">
<CardContent className="p-3 flex-1 flex flex-col gap-2 first-letter:">
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5">
<LoadingCircle status={status} />
<LoadingCircle status={status} />
<Button
disabled={!clientID}
variant={enabled ? "destructive" : "default"}
variant={enabled ? 'destructive' : 'default'}
className="h-8 px-2 text-sm gap-1.5"
onClick={enabled ? handleDisconnect : handleConnect}
>
@@ -135,21 +141,12 @@ export default function StreamLogPage() {
</>
)}
</Button>
<Button
disabled={!clientID}
variant="outline"
className="h-8 w-8 p-0"
onClick={handleRefresh}
>
<Button disabled={!clientID} variant="outline" className="h-8 w-8 p-0" onClick={handleRefresh}>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setClear(Math.random())}
>
<Button variant="outline" className="h-8 w-8 p-0" onClick={() => setClear(Math.random())}>
<Eraser className="h-3.5 w-3.5" />
</Button>
</div>
@@ -157,8 +154,8 @@ export default function StreamLogPage() {
<div className="flex items-center gap-1.5">
<BaseSelector
dataList={[
{ value: ClientType.FRPC.toString(), label: "frpc" },
{ value: ClientType.FRPS.toString(), label: "frps" }
{ value: ClientType.FRPC.toString(), label: 'frpc' },
{ value: ClientType.FRPS.toString(), label: 'frps' },
]}
setValue={(value) => {
setClientType(value === ClientType.FRPC.toString() ? ClientType.FRPC : ClientType.FRPS)
@@ -168,20 +165,27 @@ export default function StreamLogPage() {
className="h-8"
/>
</div>
<div className="flex items-center gap-1.5">
<BaseSelector
dataList={[
{ value: 'all', label: 'all' },
{ value: 'frp', label: 'frp' },
{ value: 'workerd', label: 'workerd' },
]}
setValue={(value) => {
setPkgs([value])
}}
label={t('common.stream_log_pkgs_select')}
className="h-8"
/>
</div>
</div>
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
{clientType === ClientType.FRPC && (
<ClientSelector clientID={clientID} setClientID={setClientID} />
)}
{clientType === ClientType.FRPS && (
<ServerSelector serverID={clientID} setServerID={setClientID} />
)}
<div className={cn(
'flex-1 min-h-0 overflow-hidden',
'border rounded-lg overflow-hidden'
)}>
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
{clientType === ClientType.FRPC && <ClientSelector clientID={clientID} setClientID={setClientID} />}
{clientType === ClientType.FRPS && <ServerSelector serverID={clientID} setServerID={setClientID} />}
<div className={cn('flex-1 min-h-0 overflow-hidden', 'border rounded-lg overflow-hidden')}>
<LogTerminalComponent logs={log || ''} reset={clear} />
</div>
</div>

View File

@@ -110,4 +110,12 @@
width: var(--radix-popover-trigger-width);
max-height: var(--radix-popover-content-available-height);
}
}
.hide-scroll-bar {
overflow: auto;
}
.hide-scroll-bar::-webkit-scrollbar {
width: 0;
height: 0;
}

View File

@@ -1,7 +1,5 @@
import { HeaderOperations } from './common'
export interface ClientPluginOptions {}
export type ClientPluginType =
| 'http_proxy'
| 'http2https'
@@ -11,10 +9,14 @@ export type ClientPluginType =
| 'static_file'
| 'unix_domain_socket'
export interface TypedClientPluginOptions {
type: ClientPluginType
clientPluginOptions?: ClientPluginOptions
}
export type TypedClientPluginOptions =
| HTTP2HTTPSPluginOptions
| HTTPProxyPluginOptions
| HTTPS2HTTPPluginOptions
| HTTPS2HTTPSPluginOptions
| Socks5PluginOptions
| StaticFilePluginOptions
| UnixDomainSocketPluginOptions
export interface HTTP2HTTPSPluginOptions {
type: 'http2https'