Merge pull request #282 from LLLLLLiulei/feat/ip-management-enhancements
feat: enhance proxy management
This commit is contained in:
@@ -67,7 +67,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
userHandler := handler.NewUserHandler(userService)
|
userHandler := handler.NewUserHandler(userService)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||||
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
|
||||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||||
@@ -76,15 +75,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||||
|
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
||||||
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||||
|
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
|
||||||
timingWheelService := service.ProvideTimingWheelService()
|
timingWheelService := service.ProvideTimingWheelService()
|
||||||
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
|
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
|
||||||
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
|
|
||||||
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
||||||
accountRepository := repository.NewAccountRepository(client, db)
|
accountRepository := repository.NewAccountRepository(client, db)
|
||||||
proxyRepository := repository.NewProxyRepository(client, db)
|
proxyRepository := repository.NewProxyRepository(client, db)
|
||||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, apiKeyAuthCacheInvalidator)
|
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||||
|
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator)
|
||||||
adminUserHandler := admin.NewUserHandler(adminService)
|
adminUserHandler := admin.NewUserHandler(adminService)
|
||||||
groupHandler := admin.NewGroupHandler(adminService)
|
groupHandler := admin.NewGroupHandler(adminService)
|
||||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||||
@@ -112,9 +113,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
|
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
|
||||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||||
schedulerCache := repository.NewSchedulerCache(redisClient)
|
|
||||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
|
||||||
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
|
||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
@@ -125,6 +123,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||||
promoHandler := admin.NewPromoHandler(promoService)
|
promoHandler := admin.NewPromoHandler(promoService)
|
||||||
opsRepository := repository.NewOpsRepository(db)
|
opsRepository := repository.NewOpsRepository(db)
|
||||||
|
schedulerCache := repository.NewSchedulerCache(redisClient)
|
||||||
|
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||||
|
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
||||||
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
|
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
|
||||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -196,6 +196,28 @@ func (h *ProxyHandler) Delete(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"message": "Proxy deleted successfully"})
|
response.Success(c, gin.H{"message": "Proxy deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDelete handles batch deleting proxies
|
||||||
|
// POST /api/v1/admin/proxies/batch-delete
|
||||||
|
func (h *ProxyHandler) BatchDelete(c *gin.Context) {
|
||||||
|
type BatchDeleteRequest struct {
|
||||||
|
IDs []int64 `json:"ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BatchDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.adminService.BatchDeleteProxies(c.Request.Context(), req.IDs)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Test handles testing proxy connectivity
|
// Test handles testing proxy connectivity
|
||||||
// POST /api/v1/admin/proxies/:id/test
|
// POST /api/v1/admin/proxies/:id/test
|
||||||
func (h *ProxyHandler) Test(c *gin.Context) {
|
func (h *ProxyHandler) Test(c *gin.Context) {
|
||||||
@@ -243,19 +265,17 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page, pageSize := response.ParsePagination(c)
|
accounts, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID)
|
||||||
|
|
||||||
accounts, total, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID, page, pageSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]dto.Account, 0, len(accounts))
|
out := make([]dto.ProxyAccountSummary, 0, len(accounts))
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
out = append(out, *dto.AccountFromService(&accounts[i]))
|
out = append(out, *dto.ProxyAccountSummaryFromService(&accounts[i]))
|
||||||
}
|
}
|
||||||
response.Paginated(c, out, total, page, pageSize)
|
response.Success(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchCreateProxyItem represents a single proxy in batch create request
|
// BatchCreateProxyItem represents a single proxy in batch create request
|
||||||
|
|||||||
@@ -213,8 +213,24 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &ProxyWithAccountCount{
|
return &ProxyWithAccountCount{
|
||||||
Proxy: *ProxyFromService(&p.Proxy),
|
Proxy: *ProxyFromService(&p.Proxy),
|
||||||
AccountCount: p.AccountCount,
|
AccountCount: p.AccountCount,
|
||||||
|
LatencyMs: p.LatencyMs,
|
||||||
|
LatencyStatus: p.LatencyStatus,
|
||||||
|
LatencyMessage: p.LatencyMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProxyAccountSummaryFromService(a *service.ProxyAccountSummary) *ProxyAccountSummary {
|
||||||
|
if a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ProxyAccountSummary{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
Platform: a.Platform,
|
||||||
|
Type: a.Type,
|
||||||
|
Notes: a.Notes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,18 @@ type Proxy struct {
|
|||||||
|
|
||||||
type ProxyWithAccountCount struct {
|
type ProxyWithAccountCount struct {
|
||||||
Proxy
|
Proxy
|
||||||
AccountCount int64 `json:"account_count"`
|
AccountCount int64 `json:"account_count"`
|
||||||
|
LatencyMs *int64 `json:"latency_ms,omitempty"`
|
||||||
|
LatencyStatus string `json:"latency_status,omitempty"`
|
||||||
|
LatencyMessage string `json:"latency_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyAccountSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedeemCode struct {
|
type RedeemCode struct {
|
||||||
|
|||||||
74
backend/internal/repository/proxy_latency_cache.go
Normal file
74
backend/internal/repository/proxy_latency_cache.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const proxyLatencyKeyPrefix = "proxy:latency:"
|
||||||
|
|
||||||
|
func proxyLatencyKey(proxyID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d", proxyLatencyKeyPrefix, proxyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type proxyLatencyCache struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyLatencyCache(rdb *redis.Client) service.ProxyLatencyCache {
|
||||||
|
return &proxyLatencyCache{rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *proxyLatencyCache) GetProxyLatencies(ctx context.Context, proxyIDs []int64) (map[int64]*service.ProxyLatencyInfo, error) {
|
||||||
|
results := make(map[int64]*service.ProxyLatencyInfo)
|
||||||
|
if len(proxyIDs) == 0 {
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(proxyIDs))
|
||||||
|
for _, id := range proxyIDs {
|
||||||
|
keys = append(keys, proxyLatencyKey(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := c.rdb.MGet(ctx, keys...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, raw := range values {
|
||||||
|
if raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload []byte
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
payload = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
payload = v
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var info service.ProxyLatencyInfo
|
||||||
|
if err := json.Unmarshal(payload, &info); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[proxyIDs[i]] = &info
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *proxyLatencyCache) SetProxyLatency(ctx context.Context, proxyID int64, info *service.ProxyLatencyInfo) error {
|
||||||
|
if info == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.rdb.Set(ctx, proxyLatencyKey(proxyID), payload, 0).Err()
|
||||||
|
}
|
||||||
@@ -34,7 +34,10 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIPInfoURL = "https://ipinfo.io/json"
|
const (
|
||||||
|
defaultIPInfoURL = "https://ipinfo.io/json"
|
||||||
|
defaultProxyProbeTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type proxyProbeService struct {
|
type proxyProbeService struct {
|
||||||
ipInfoURL string
|
ipInfoURL string
|
||||||
@@ -46,7 +49,7 @@ type proxyProbeService struct {
|
|||||||
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
||||||
client, err := httpclient.GetClient(httpclient.Options{
|
client, err := httpclient.GetClient(httpclient.Options{
|
||||||
ProxyURL: proxyURL,
|
ProxyURL: proxyURL,
|
||||||
Timeout: 15 * time.Second,
|
Timeout: defaultProxyProbeTimeout,
|
||||||
InsecureSkipVerify: s.insecureSkipVerify,
|
InsecureSkipVerify: s.insecureSkipVerify,
|
||||||
ProxyStrict: true,
|
ProxyStrict: true,
|
||||||
ValidateResolvedIP: s.validateResolvedIP,
|
ValidateResolvedIP: s.validateResolvedIP,
|
||||||
|
|||||||
@@ -219,12 +219,54 @@ func (r *proxyRepository) ExistsByHostPortAuth(ctx context.Context, host string,
|
|||||||
// CountAccountsByProxyID returns the number of accounts using a specific proxy
|
// CountAccountsByProxyID returns the number of accounts using a specific proxy
|
||||||
func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM accounts WHERE proxy_id = $1", []any{proxyID}, &count); err != nil {
|
if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM accounts WHERE proxy_id = $1 AND deleted_at IS NULL", []any{proxyID}, &count); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *proxyRepository) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]service.ProxyAccountSummary, error) {
|
||||||
|
rows, err := r.sql.QueryContext(ctx, `
|
||||||
|
SELECT id, name, platform, type, notes
|
||||||
|
FROM accounts
|
||||||
|
WHERE proxy_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
`, proxyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make([]service.ProxyAccountSummary, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
platform string
|
||||||
|
accType string
|
||||||
|
notes sql.NullString
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &name, &platform, &accType, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var notesPtr *string
|
||||||
|
if notes.Valid {
|
||||||
|
notesPtr = ¬es.String
|
||||||
|
}
|
||||||
|
out = append(out, service.ProxyAccountSummary{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Platform: platform,
|
||||||
|
Type: accType,
|
||||||
|
Notes: notesPtr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccountCountsForProxies returns a map of proxy ID to account count for all proxies
|
// GetAccountCountsForProxies returns a map of proxy ID to account count for all proxies
|
||||||
func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (counts map[int64]int64, err error) {
|
func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (counts map[int64]int64, err error) {
|
||||||
rows, err := r.sql.QueryContext(ctx, "SELECT proxy_id, COUNT(*) AS count FROM accounts WHERE proxy_id IS NOT NULL AND deleted_at IS NULL GROUP BY proxy_id")
|
rows, err := r.sql.QueryContext(ctx, "SELECT proxy_id, COUNT(*) AS count FROM accounts WHERE proxy_id IS NOT NULL AND deleted_at IS NULL GROUP BY proxy_id")
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewGeminiTokenCache,
|
NewGeminiTokenCache,
|
||||||
NewSchedulerCache,
|
NewSchedulerCache,
|
||||||
NewSchedulerOutboxRepository,
|
NewSchedulerOutboxRepository,
|
||||||
|
NewProxyLatencyCache,
|
||||||
|
|
||||||
// HTTP service ports (DI Strategy A: return interface directly)
|
// HTTP service ports (DI Strategy A: return interface directly)
|
||||||
NewTurnstileVerifier,
|
NewTurnstileVerifier,
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
settingRepo := newStubSettingRepo()
|
settingRepo := newStubSettingRepo()
|
||||||
settingService := service.NewSettingService(settingRepo, cfg)
|
settingService := service.NewSettingService(settingRepo, cfg)
|
||||||
|
|
||||||
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil)
|
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil)
|
||||||
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil)
|
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
@@ -859,6 +859,10 @@ func (stubProxyRepo) CountAccountsByProxyID(ctx context.Context, proxyID int64)
|
|||||||
return 0, errors.New("not implemented")
|
return 0, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (stubProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]service.ProxyAccountSummary, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
type stubRedeemCodeRepo struct{}
|
type stubRedeemCodeRepo struct{}
|
||||||
|
|
||||||
func (stubRedeemCodeRepo) Create(ctx context.Context, code *service.RedeemCode) error {
|
func (stubRedeemCodeRepo) Create(ctx context.Context, code *service.RedeemCode) error {
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
proxies.POST("/:id/test", h.Admin.Proxy.Test)
|
proxies.POST("/:id/test", h.Admin.Proxy.Test)
|
||||||
proxies.GET("/:id/stats", h.Admin.Proxy.GetStats)
|
proxies.GET("/:id/stats", h.Admin.Proxy.GetStats)
|
||||||
proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts)
|
proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts)
|
||||||
|
proxies.POST("/batch-delete", h.Admin.Proxy.BatchDelete)
|
||||||
proxies.POST("/batch", h.Admin.Proxy.BatchCreate)
|
proxies.POST("/batch", h.Admin.Proxy.BatchCreate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ type AdminService interface {
|
|||||||
CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error)
|
CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error)
|
||||||
UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error)
|
UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error)
|
||||||
DeleteProxy(ctx context.Context, id int64) error
|
DeleteProxy(ctx context.Context, id int64) error
|
||||||
GetProxyAccounts(ctx context.Context, proxyID int64, page, pageSize int) ([]Account, int64, error)
|
BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error)
|
||||||
|
GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error)
|
||||||
CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error)
|
CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error)
|
||||||
TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error)
|
TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error)
|
||||||
|
|
||||||
@@ -223,6 +224,16 @@ type GenerateRedeemCodesInput struct {
|
|||||||
ValidityDays int // 订阅类型专用:有效天数
|
ValidityDays int // 订阅类型专用:有效天数
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProxyBatchDeleteResult struct {
|
||||||
|
DeletedIDs []int64 `json:"deleted_ids"`
|
||||||
|
Skipped []ProxyBatchDeleteSkipped `json:"skipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyBatchDeleteSkipped struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyTestResult represents the result of testing a proxy
|
// ProxyTestResult represents the result of testing a proxy
|
||||||
type ProxyTestResult struct {
|
type ProxyTestResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -257,6 +268,7 @@ type adminServiceImpl struct {
|
|||||||
redeemCodeRepo RedeemCodeRepository
|
redeemCodeRepo RedeemCodeRepository
|
||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
proxyProber ProxyExitInfoProber
|
proxyProber ProxyExitInfoProber
|
||||||
|
proxyLatencyCache ProxyLatencyCache
|
||||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +282,7 @@ func NewAdminService(
|
|||||||
redeemCodeRepo RedeemCodeRepository,
|
redeemCodeRepo RedeemCodeRepository,
|
||||||
billingCacheService *BillingCacheService,
|
billingCacheService *BillingCacheService,
|
||||||
proxyProber ProxyExitInfoProber,
|
proxyProber ProxyExitInfoProber,
|
||||||
|
proxyLatencyCache ProxyLatencyCache,
|
||||||
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
||||||
) AdminService {
|
) AdminService {
|
||||||
return &adminServiceImpl{
|
return &adminServiceImpl{
|
||||||
@@ -281,6 +294,7 @@ func NewAdminService(
|
|||||||
redeemCodeRepo: redeemCodeRepo,
|
redeemCodeRepo: redeemCodeRepo,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
proxyProber: proxyProber,
|
proxyProber: proxyProber,
|
||||||
|
proxyLatencyCache: proxyLatencyCache,
|
||||||
authCacheInvalidator: authCacheInvalidator,
|
authCacheInvalidator: authCacheInvalidator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1107,7 @@ func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
s.attachProxyLatency(ctx, proxies)
|
||||||
return proxies, result.Total, nil
|
return proxies, result.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1116,12 @@ func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||||
return s.proxyRepo.ListActiveWithAccountCount(ctx)
|
proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.attachProxyLatency(ctx, proxies)
|
||||||
|
return proxies, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) {
|
func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) {
|
||||||
@@ -1121,6 +1141,8 @@ func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyIn
|
|||||||
if err := s.proxyRepo.Create(ctx, proxy); err != nil {
|
if err := s.proxyRepo.Create(ctx, proxy); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Probe latency asynchronously so creation isn't blocked by network timeout.
|
||||||
|
go s.probeProxyLatency(context.Background(), proxy)
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,12 +1181,53 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error {
|
func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error {
|
||||||
|
count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return ErrProxyInUse
|
||||||
|
}
|
||||||
return s.proxyRepo.Delete(ctx, id)
|
return s.proxyRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64, page, pageSize int) ([]Account, int64, error) {
|
func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) {
|
||||||
// Return mock data for now - would need a dedicated repository method
|
result := &ProxyBatchDeleteResult{}
|
||||||
return []Account{}, 0, nil
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||||
|
ID: id,
|
||||||
|
Reason: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||||
|
ID: id,
|
||||||
|
Reason: ErrProxyInUse.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.proxyRepo.Delete(ctx, id); err != nil {
|
||||||
|
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||||
|
ID: id,
|
||||||
|
Reason: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.DeletedIDs = append(result.DeletedIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||||
|
return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||||
@@ -1264,12 +1327,24 @@ func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestR
|
|||||||
proxyURL := proxy.URL()
|
proxyURL := proxy.URL()
|
||||||
exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL)
|
exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{
|
||||||
|
Success: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
return &ProxyTestResult{
|
return &ProxyTestResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latency := latencyMs
|
||||||
|
s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{
|
||||||
|
Success: true,
|
||||||
|
LatencyMs: &latency,
|
||||||
|
Message: "Proxy is accessible",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
return &ProxyTestResult{
|
return &ProxyTestResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Proxy is accessible",
|
Message: "Proxy is accessible",
|
||||||
@@ -1281,6 +1356,29 @@ func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestR
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) {
|
||||||
|
if s.proxyProber == nil || proxy == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL())
|
||||||
|
if err != nil {
|
||||||
|
s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{
|
||||||
|
Success: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := latencyMs
|
||||||
|
s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{
|
||||||
|
Success: true,
|
||||||
|
LatencyMs: &latency,
|
||||||
|
Message: "Proxy is accessible",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic)
|
// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic)
|
||||||
// 如果存在混合,返回错误提示用户确认
|
// 如果存在混合,返回错误提示用户确认
|
||||||
func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
||||||
@@ -1330,6 +1428,46 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) {
|
||||||
|
if s.proxyLatencyCache == nil || len(proxies) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(proxies))
|
||||||
|
for i := range proxies {
|
||||||
|
ids = append(ids, proxies[i].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: load proxy latency cache failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range proxies {
|
||||||
|
info := latencies[proxies[i].ID]
|
||||||
|
if info == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Success {
|
||||||
|
proxies[i].LatencyStatus = "success"
|
||||||
|
proxies[i].LatencyMs = info.LatencyMs
|
||||||
|
} else {
|
||||||
|
proxies[i].LatencyStatus = "failed"
|
||||||
|
}
|
||||||
|
proxies[i].LatencyMessage = info.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) {
|
||||||
|
if s.proxyLatencyCache == nil || info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, info); err != nil {
|
||||||
|
log.Printf("Warning: store proxy latency cache failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
|
// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
|
||||||
func getAccountPlatform(accountPlatform string) string {
|
func getAccountPlatform(accountPlatform string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
|
switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
|
||||||
|
|||||||
@@ -153,8 +153,10 @@ func (s *groupRepoStub) DeleteAccountGroupsByGroupID(ctx context.Context, groupI
|
|||||||
}
|
}
|
||||||
|
|
||||||
type proxyRepoStub struct {
|
type proxyRepoStub struct {
|
||||||
deleteErr error
|
deleteErr error
|
||||||
deletedIDs []int64
|
countErr error
|
||||||
|
accountCount int64
|
||||||
|
deletedIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *proxyRepoStub) Create(ctx context.Context, proxy *Proxy) error {
|
func (s *proxyRepoStub) Create(ctx context.Context, proxy *Proxy) error {
|
||||||
@@ -199,7 +201,14 @@ func (s *proxyRepoStub) ExistsByHostPortAuth(ctx context.Context, host string, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *proxyRepoStub) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
func (s *proxyRepoStub) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||||
panic("unexpected CountAccountsByProxyID call")
|
if s.countErr != nil {
|
||||||
|
return 0, s.countErr
|
||||||
|
}
|
||||||
|
return s.accountCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||||
|
panic("unexpected ListAccountSummariesByProxyID call")
|
||||||
}
|
}
|
||||||
|
|
||||||
type redeemRepoStub struct {
|
type redeemRepoStub struct {
|
||||||
@@ -409,6 +418,15 @@ func TestAdminService_DeleteProxy_Idempotent(t *testing.T) {
|
|||||||
require.Equal(t, []int64{404}, repo.deletedIDs)
|
require.Equal(t, []int64{404}, repo.deletedIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminService_DeleteProxy_InUse(t *testing.T) {
|
||||||
|
repo := &proxyRepoStub{accountCount: 2}
|
||||||
|
svc := &adminServiceImpl{proxyRepo: repo}
|
||||||
|
|
||||||
|
err := svc.DeleteProxy(context.Background(), 77)
|
||||||
|
require.ErrorIs(t, err, ErrProxyInUse)
|
||||||
|
require.Empty(t, repo.deletedIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminService_DeleteProxy_Error(t *testing.T) {
|
func TestAdminService_DeleteProxy_Error(t *testing.T) {
|
||||||
deleteErr := errors.New("delete failed")
|
deleteErr := errors.New("delete failed")
|
||||||
repo := &proxyRepoStub{deleteErr: deleteErr}
|
repo := &proxyRepoStub{deleteErr: deleteErr}
|
||||||
|
|||||||
@@ -31,5 +31,16 @@ func (p *Proxy) URL() string {
|
|||||||
|
|
||||||
type ProxyWithAccountCount struct {
|
type ProxyWithAccountCount struct {
|
||||||
Proxy
|
Proxy
|
||||||
AccountCount int64
|
AccountCount int64
|
||||||
|
LatencyMs *int64
|
||||||
|
LatencyStatus string
|
||||||
|
LatencyMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyAccountSummary struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Platform string
|
||||||
|
Type string
|
||||||
|
Notes *string
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/internal/service/proxy_latency_cache.go
Normal file
18
backend/internal/service/proxy_latency_cache.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProxyLatencyInfo struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
LatencyMs *int64 `json:"latency_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyLatencyCache interface {
|
||||||
|
GetProxyLatencies(ctx context.Context, proxyIDs []int64) (map[int64]*ProxyLatencyInfo, error)
|
||||||
|
SetProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) error
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProxyNotFound = infraerrors.NotFound("PROXY_NOT_FOUND", "proxy not found")
|
ErrProxyNotFound = infraerrors.NotFound("PROXY_NOT_FOUND", "proxy not found")
|
||||||
|
ErrProxyInUse = infraerrors.Conflict("PROXY_IN_USE", "proxy is in use by accounts")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxyRepository interface {
|
type ProxyRepository interface {
|
||||||
@@ -26,6 +27,7 @@ type ProxyRepository interface {
|
|||||||
|
|
||||||
ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error)
|
ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error)
|
||||||
CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error)
|
CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error)
|
||||||
|
ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProxyRequest 创建代理请求
|
// CreateProxyRequest 创建代理请求
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types'
|
import type {
|
||||||
|
Proxy,
|
||||||
|
ProxyAccountSummary,
|
||||||
|
CreateProxyRequest,
|
||||||
|
UpdateProxyRequest,
|
||||||
|
PaginatedResponse
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all proxies with pagination
|
* List all proxies with pagination
|
||||||
@@ -160,8 +166,8 @@ export async function getStats(id: number): Promise<{
|
|||||||
* @param id - Proxy ID
|
* @param id - Proxy ID
|
||||||
* @returns List of accounts using the proxy
|
* @returns List of accounts using the proxy
|
||||||
*/
|
*/
|
||||||
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
|
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
|
||||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`)
|
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +195,17 @@ export async function batchCreate(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchDelete(ids: number[]): Promise<{
|
||||||
|
deleted_ids: number[]
|
||||||
|
skipped: Array<{ id: number; reason: string }>
|
||||||
|
}> {
|
||||||
|
const { data } = await apiClient.post<{
|
||||||
|
deleted_ids: number[]
|
||||||
|
skipped: Array<{ id: number; reason: string }>
|
||||||
|
}>('/admin/proxies/batch-delete', { ids })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const proxiesAPI = {
|
export const proxiesAPI = {
|
||||||
list,
|
list,
|
||||||
getAll,
|
getAll,
|
||||||
@@ -201,7 +218,8 @@ export const proxiesAPI = {
|
|||||||
testProxy,
|
testProxy,
|
||||||
getStats,
|
getStats,
|
||||||
getProxyAccounts,
|
getProxyAccounts,
|
||||||
batchCreate
|
batchCreate,
|
||||||
|
batchDelete
|
||||||
}
|
}
|
||||||
|
|
||||||
export default proxiesAPI
|
export default proxiesAPI
|
||||||
|
|||||||
@@ -22,29 +22,36 @@
|
|||||||
]"
|
]"
|
||||||
@click="column.sortable && handleSort(column.key)"
|
@click="column.sortable && handleSort(column.key)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-1">
|
<slot
|
||||||
<span>{{ column.label }}</span>
|
:name="`header-${column.key}`"
|
||||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
:column="column"
|
||||||
<svg
|
:sort-key="sortKey"
|
||||||
v-if="sortKey === column.key"
|
:sort-order="sortOrder"
|
||||||
class="h-4 w-4"
|
>
|
||||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
<div class="flex items-center space-x-1">
|
||||||
fill="currentColor"
|
<span>{{ column.label }}</span>
|
||||||
viewBox="0 0 20 20"
|
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||||
>
|
<svg
|
||||||
<path
|
v-if="sortKey === column.key"
|
||||||
fill-rule="evenodd"
|
class="h-4 w-4"
|
||||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||||
clip-rule="evenodd"
|
fill="currentColor"
|
||||||
/>
|
viewBox="0 0 20 20"
|
||||||
</svg>
|
>
|
||||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<path
|
||||||
<path
|
fill-rule="evenodd"
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||||
/>
|
clip-rule="evenodd"
|
||||||
</svg>
|
/>
|
||||||
</span>
|
</svg>
|
||||||
</div>
|
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1633,11 +1633,29 @@ export default {
|
|||||||
address: 'Address',
|
address: 'Address',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
accounts: 'Accounts',
|
accounts: 'Accounts',
|
||||||
|
latency: 'Latency',
|
||||||
actions: 'Actions'
|
actions: 'Actions'
|
||||||
},
|
},
|
||||||
testConnection: 'Test Connection',
|
testConnection: 'Test Connection',
|
||||||
batchTest: 'Test All Proxies',
|
batchTest: 'Test All Proxies',
|
||||||
testFailed: 'Failed',
|
testFailed: 'Failed',
|
||||||
|
latencyFailed: 'Connection failed',
|
||||||
|
batchTestEmpty: 'No proxies available for testing',
|
||||||
|
batchTestDone: 'Batch test completed for {count} proxies',
|
||||||
|
batchTestFailed: 'Batch test failed',
|
||||||
|
batchDeleteAction: 'Delete',
|
||||||
|
batchDelete: 'Batch delete',
|
||||||
|
batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.',
|
||||||
|
batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}',
|
||||||
|
batchDeleteSkipped: 'Skipped {skipped} proxies',
|
||||||
|
batchDeleteFailed: 'Batch delete failed',
|
||||||
|
deleteBlockedInUse: 'This proxy is in use and cannot be deleted',
|
||||||
|
accountsTitle: 'Accounts using this IP',
|
||||||
|
accountsEmpty: 'No accounts are using this proxy',
|
||||||
|
accountsFailed: 'Failed to load accounts list',
|
||||||
|
accountName: 'Account',
|
||||||
|
accountPlatform: 'Platform',
|
||||||
|
accountNotes: 'Notes',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
protocol: 'Protocol',
|
protocol: 'Protocol',
|
||||||
host: 'Host',
|
host: 'Host',
|
||||||
|
|||||||
@@ -1719,6 +1719,7 @@ export default {
|
|||||||
address: '地址',
|
address: '地址',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
accounts: '账号数',
|
accounts: '账号数',
|
||||||
|
latency: '延迟',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
nameLabel: '名称',
|
nameLabel: '名称',
|
||||||
namePlaceholder: '请输入代理名称',
|
namePlaceholder: '请输入代理名称',
|
||||||
@@ -1755,11 +1756,32 @@ export default {
|
|||||||
enterProxyName: '请输入代理名称',
|
enterProxyName: '请输入代理名称',
|
||||||
optionalAuth: '可选认证信息',
|
optionalAuth: '可选认证信息',
|
||||||
leaveEmptyToKeep: '留空保持不变',
|
leaveEmptyToKeep: '留空保持不变',
|
||||||
|
form: {
|
||||||
|
hostPlaceholder: '请输入主机地址',
|
||||||
|
portPlaceholder: '请输入端口'
|
||||||
|
},
|
||||||
noProxiesYet: '暂无代理',
|
noProxiesYet: '暂无代理',
|
||||||
createFirstProxy: '添加您的第一个代理以开始使用。',
|
createFirstProxy: '添加您的第一个代理以开始使用。',
|
||||||
testConnection: '测试连接',
|
testConnection: '测试连接',
|
||||||
batchTest: '批量测试',
|
batchTest: '批量测试',
|
||||||
testFailed: '失败',
|
testFailed: '失败',
|
||||||
|
latencyFailed: '链接失败',
|
||||||
|
batchTestEmpty: '暂无可测试的代理',
|
||||||
|
batchTestDone: '批量测试完成,共测试 {count} 个代理',
|
||||||
|
batchTestFailed: '批量测试失败',
|
||||||
|
batchDeleteAction: '删除',
|
||||||
|
batchDelete: '批量删除',
|
||||||
|
batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。',
|
||||||
|
batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个',
|
||||||
|
batchDeleteSkipped: '已跳过 {skipped} 个代理',
|
||||||
|
batchDeleteFailed: '批量删除失败',
|
||||||
|
deleteBlockedInUse: '该代理已有账号使用,无法删除',
|
||||||
|
accountsTitle: '使用该IP的账号',
|
||||||
|
accountsEmpty: '暂无账号使用此代理',
|
||||||
|
accountsFailed: '获取账号列表失败',
|
||||||
|
accountName: '账号名称',
|
||||||
|
accountPlatform: '所属平台',
|
||||||
|
accountNotes: '备注',
|
||||||
// Batch import
|
// Batch import
|
||||||
standardAdd: '标准添加',
|
standardAdd: '标准添加',
|
||||||
batchAdd: '快捷添加',
|
batchAdd: '快捷添加',
|
||||||
|
|||||||
@@ -364,10 +364,21 @@ export interface Proxy {
|
|||||||
password?: string | null
|
password?: string | null
|
||||||
status: 'active' | 'inactive'
|
status: 'active' | 'inactive'
|
||||||
account_count?: number // Number of accounts using this proxy
|
account_count?: number // Number of accounts using this proxy
|
||||||
|
latency_ms?: number
|
||||||
|
latency_status?: 'success' | 'failed'
|
||||||
|
latency_message?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyAccountSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
platform: AccountPlatform
|
||||||
|
type: AccountType
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// Gemini credentials structure for OAuth and API Key authentication
|
// Gemini credentials structure for OAuth and API Key authentication
|
||||||
export interface GeminiCredentials {
|
export interface GeminiCredentials {
|
||||||
// API Key authentication
|
// API Key authentication
|
||||||
|
|||||||
@@ -51,6 +51,24 @@
|
|||||||
>
|
>
|
||||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleBatchTest"
|
||||||
|
:disabled="batchTesting || loading"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:title="t('admin.proxies.testConnection')"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="md" class="mr-2" />
|
||||||
|
{{ t('admin.proxies.testConnection') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="openBatchDelete"
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
class="btn btn-danger"
|
||||||
|
:title="t('admin.proxies.batchDeleteAction')"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="md" class="mr-2" />
|
||||||
|
{{ t('admin.proxies.batchDeleteAction') }}
|
||||||
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||||
<Icon name="plus" size="md" class="mr-2" />
|
<Icon name="plus" size="md" class="mr-2" />
|
||||||
{{ t('admin.proxies.createProxy') }}
|
{{ t('admin.proxies.createProxy') }}
|
||||||
@@ -61,6 +79,26 @@
|
|||||||
|
|
||||||
<template #table>
|
<template #table>
|
||||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||||
|
<template #header-select>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
:checked="allVisibleSelected"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleSelectAllVisible($event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-select="{ row }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
:checked="selectedProxyIds.has(row.id)"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleSelectRow(row.id, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,17 +117,43 @@
|
|||||||
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-status="{ value }">
|
<template #cell-account_count="{ row, value }">
|
||||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
<button
|
||||||
{{ t('admin.accounts.status.' + value) }}
|
v-if="(value || 0) > 0"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-primary-700 hover:bg-gray-200 dark:bg-dark-600 dark:text-primary-300 dark:hover:bg-dark-500"
|
||||||
|
@click="openAccountsModal(row)"
|
||||||
|
>
|
||||||
|
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ t('admin.groups.accountsCount', { count: 0 }) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-account_count="{ value }">
|
<template #cell-latency="{ row }">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
v-if="row.latency_status === 'failed'"
|
||||||
|
class="badge badge-danger"
|
||||||
|
:title="row.latency_message || undefined"
|
||||||
>
|
>
|
||||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
{{ t('admin.proxies.latencyFailed') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="typeof row.latency_ms === 'number'"
|
||||||
|
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
|
||||||
|
>
|
||||||
|
{{ row.latency_ms }}ms
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-status="{ value }">
|
||||||
|
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||||
|
{{ t('admin.accounts.status.' + value) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -515,6 +579,63 @@
|
|||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
@cancel="showDeleteDialog = false"
|
@cancel="showDeleteDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Batch Delete Confirmation Dialog -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showBatchDeleteDialog"
|
||||||
|
:title="t('admin.proxies.batchDelete')"
|
||||||
|
:message="t('admin.proxies.batchDeleteConfirm', { count: selectedCount })"
|
||||||
|
:confirm-text="t('common.delete')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
:danger="true"
|
||||||
|
@confirm="confirmBatchDelete"
|
||||||
|
@cancel="showBatchDeleteDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Proxy Accounts Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
:show="showAccountsModal"
|
||||||
|
:title="t('admin.proxies.accountsTitle', { name: accountsProxy?.name || '' })"
|
||||||
|
width="normal"
|
||||||
|
@close="closeAccountsModal"
|
||||||
|
>
|
||||||
|
<div v-if="accountsLoading" class="flex items-center justify-center py-8 text-sm text-gray-500">
|
||||||
|
<Icon name="refresh" size="md" class="mr-2 animate-spin" />
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="proxyAccounts.length === 0" class="py-6 text-center text-sm text-gray-500">
|
||||||
|
{{ t('admin.proxies.accountsEmpty') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-h-80 overflow-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
|
||||||
|
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountName') }}</th>
|
||||||
|
<th class="px-4 py-2 text-left">{{ t('admin.accounts.columns.platformType') }}</th>
|
||||||
|
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountNotes') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||||
|
<tr v-for="account in proxyAccounts" :key="account.id">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{{ account.name }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<PlatformTypeBadge :platform="account.platform" :type="account.type" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ account.notes || '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button @click="closeAccountsModal" class="btn btn-secondary">
|
||||||
|
{{ t('common.close') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -523,7 +644,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Proxy, ProxyProtocol } from '@/types'
|
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
@@ -534,15 +655,18 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
|
{ key: 'select', label: '', sortable: false },
|
||||||
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
|
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
|
||||||
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
|
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
|
||||||
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
|
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
|
||||||
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
|
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
|
||||||
|
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
|
||||||
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
||||||
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
||||||
])
|
])
|
||||||
@@ -592,11 +716,24 @@ const pagination = reactive({
|
|||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
|
const showBatchDeleteDialog = ref(false)
|
||||||
|
const showAccountsModal = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const testingProxyIds = ref<Set<number>>(new Set())
|
const testingProxyIds = ref<Set<number>>(new Set())
|
||||||
|
const batchTesting = ref(false)
|
||||||
|
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||||
|
const accountsProxy = ref<Proxy | null>(null)
|
||||||
|
const proxyAccounts = ref<ProxyAccountSummary[]>([])
|
||||||
|
const accountsLoading = ref(false)
|
||||||
const editingProxy = ref<Proxy | null>(null)
|
const editingProxy = ref<Proxy | null>(null)
|
||||||
const deletingProxy = ref<Proxy | null>(null)
|
const deletingProxy = ref<Proxy | null>(null)
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedProxyIds.value.size)
|
||||||
|
const allVisibleSelected = computed(() => {
|
||||||
|
if (proxies.value.length === 0) return false
|
||||||
|
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
|
||||||
|
})
|
||||||
|
|
||||||
// Batch import state
|
// Batch import state
|
||||||
const createMode = ref<'standard' | 'batch'>('standard')
|
const createMode = ref<'standard' | 'batch'>('standard')
|
||||||
const batchInput = ref('')
|
const batchInput = ref('')
|
||||||
@@ -641,6 +778,30 @@ const isAbortError = (error: unknown) => {
|
|||||||
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
|
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSelectRow = (id: number, event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const next = new Set(selectedProxyIds.value)
|
||||||
|
if (target.checked) {
|
||||||
|
next.add(id)
|
||||||
|
} else {
|
||||||
|
next.delete(id)
|
||||||
|
}
|
||||||
|
selectedProxyIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectAllVisible = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const next = new Set(selectedProxyIds.value)
|
||||||
|
for (const proxy of proxies.value) {
|
||||||
|
if (target.checked) {
|
||||||
|
next.add(proxy.id)
|
||||||
|
} else {
|
||||||
|
next.delete(proxy.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedProxyIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
const loadProxies = async () => {
|
const loadProxies = async () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
@@ -895,35 +1056,151 @@ const handleUpdateProxy = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestConnection = async (proxy: Proxy) => {
|
const applyLatencyResult = (
|
||||||
// Create new Set to trigger reactivity
|
proxyId: number,
|
||||||
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
|
result: { success: boolean; latency_ms?: number; message?: string }
|
||||||
|
) => {
|
||||||
|
const target = proxies.value.find((proxy) => proxy.id === proxyId)
|
||||||
|
if (!target) return
|
||||||
|
if (result.success) {
|
||||||
|
target.latency_status = 'success'
|
||||||
|
target.latency_ms = result.latency_ms
|
||||||
|
} else {
|
||||||
|
target.latency_status = 'failed'
|
||||||
|
target.latency_ms = undefined
|
||||||
|
}
|
||||||
|
target.latency_message = result.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTestingProxy = (proxyId: number) => {
|
||||||
|
testingProxyIds.value = new Set([...testingProxyIds.value, proxyId])
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopTestingProxy = (proxyId: number) => {
|
||||||
|
const next = new Set(testingProxyIds.value)
|
||||||
|
next.delete(proxyId)
|
||||||
|
testingProxyIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const runProxyTest = async (proxyId: number, notify: boolean) => {
|
||||||
|
startTestingProxy(proxyId)
|
||||||
try {
|
try {
|
||||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
const result = await adminAPI.proxies.testProxy(proxyId)
|
||||||
if (result.success) {
|
applyLatencyResult(proxyId, result)
|
||||||
const message = result.latency_ms
|
if (notify) {
|
||||||
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
if (result.success) {
|
||||||
: t('admin.proxies.proxyWorking')
|
const message = result.latency_ms
|
||||||
appStore.showSuccess(message)
|
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
||||||
} else {
|
: t('admin.proxies.proxyWorking')
|
||||||
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
appStore.showSuccess(message)
|
||||||
|
} else {
|
||||||
|
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
|
const message = error.response?.data?.detail || t('admin.proxies.failedToTest')
|
||||||
|
applyLatencyResult(proxyId, { success: false, message })
|
||||||
|
if (notify) {
|
||||||
|
appStore.showError(message)
|
||||||
|
}
|
||||||
console.error('Error testing proxy:', error)
|
console.error('Error testing proxy:', error)
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
// Create new Set without this proxy id to trigger reactivity
|
stopTestingProxy(proxyId)
|
||||||
const newSet = new Set(testingProxyIds.value)
|
}
|
||||||
newSet.delete(proxy.id)
|
}
|
||||||
testingProxyIds.value = newSet
|
|
||||||
|
const handleTestConnection = async (proxy: Proxy) => {
|
||||||
|
await runProxyTest(proxy.id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
|
||||||
|
const pageSize = 200
|
||||||
|
const result: Proxy[] = []
|
||||||
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
|
||||||
|
while (page <= totalPages) {
|
||||||
|
const response = await adminAPI.proxies.list(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
{
|
||||||
|
protocol: filters.protocol || undefined,
|
||||||
|
status: filters.status as any,
|
||||||
|
search: searchQuery.value || undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.push(...response.items)
|
||||||
|
totalPages = response.pages || 1
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBatchProxyTests = async (ids: number[]) => {
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const concurrency = 5
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (index < ids.length) {
|
||||||
|
const current = ids[index]
|
||||||
|
index++
|
||||||
|
await runProxyTest(current, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchTest = async () => {
|
||||||
|
if (batchTesting.value) return
|
||||||
|
|
||||||
|
batchTesting.value = true
|
||||||
|
try {
|
||||||
|
let ids: number[] = []
|
||||||
|
if (selectedCount.value > 0) {
|
||||||
|
ids = Array.from(selectedProxyIds.value)
|
||||||
|
} else {
|
||||||
|
const allProxies = await fetchAllProxiesForBatch()
|
||||||
|
ids = allProxies.map((proxy) => proxy.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
appStore.showInfo(t('admin.proxies.batchTestEmpty'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runBatchProxyTests(ids)
|
||||||
|
appStore.showSuccess(t('admin.proxies.batchTestDone', { count: ids.length }))
|
||||||
|
loadProxies()
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchTestFailed'))
|
||||||
|
console.error('Error batch testing proxies:', error)
|
||||||
|
} finally {
|
||||||
|
batchTesting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (proxy: Proxy) => {
|
const handleDelete = (proxy: Proxy) => {
|
||||||
|
if ((proxy.account_count || 0) > 0) {
|
||||||
|
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
||||||
|
return
|
||||||
|
}
|
||||||
deletingProxy.value = proxy
|
deletingProxy.value = proxy
|
||||||
showDeleteDialog.value = true
|
showDeleteDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openBatchDelete = () => {
|
||||||
|
if (selectedCount.value === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showBatchDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!deletingProxy.value) return
|
if (!deletingProxy.value) return
|
||||||
|
|
||||||
@@ -931,6 +1208,11 @@ const confirmDelete = async () => {
|
|||||||
await adminAPI.proxies.delete(deletingProxy.value.id)
|
await adminAPI.proxies.delete(deletingProxy.value.id)
|
||||||
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
|
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
|
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
|
||||||
|
const next = new Set(selectedProxyIds.value)
|
||||||
|
next.delete(deletingProxy.value.id)
|
||||||
|
selectedProxyIds.value = next
|
||||||
|
}
|
||||||
deletingProxy.value = null
|
deletingProxy.value = null
|
||||||
loadProxies()
|
loadProxies()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -939,6 +1221,55 @@ const confirmDelete = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmBatchDelete = async () => {
|
||||||
|
const ids = Array.from(selectedProxyIds.value)
|
||||||
|
if (ids.length === 0) {
|
||||||
|
showBatchDeleteDialog.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.proxies.batchDelete(ids)
|
||||||
|
const deleted = result.deleted_ids?.length || 0
|
||||||
|
const skipped = result.skipped?.length || 0
|
||||||
|
|
||||||
|
if (deleted > 0) {
|
||||||
|
appStore.showSuccess(t('admin.proxies.batchDeleteDone', { deleted, skipped }))
|
||||||
|
} else if (skipped > 0) {
|
||||||
|
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedProxyIds.value = new Set()
|
||||||
|
showBatchDeleteDialog.value = false
|
||||||
|
loadProxies()
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchDeleteFailed'))
|
||||||
|
console.error('Error batch deleting proxies:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAccountsModal = async (proxy: Proxy) => {
|
||||||
|
accountsProxy.value = proxy
|
||||||
|
proxyAccounts.value = []
|
||||||
|
accountsLoading.value = true
|
||||||
|
showAccountsModal.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
proxyAccounts.value = await adminAPI.proxies.getProxyAccounts(proxy.id)
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.detail || t('admin.proxies.accountsFailed'))
|
||||||
|
console.error('Error loading proxy accounts:', error)
|
||||||
|
} finally {
|
||||||
|
accountsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAccountsModal = () => {
|
||||||
|
showAccountsModal.value = false
|
||||||
|
accountsProxy.value = null
|
||||||
|
proxyAccounts.value = []
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProxies()
|
loadProxies()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user