mirror of
https://github.com/239573049/FastGateway.git
synced 2026-10-16 21:10:58 +08:00
支持容器化及性能优化,改进证书与UI
增加对 Docker 的支持,优化了 `docker-compose` 和 `Dockerfile` 配置,支持多阶段构建。改进了 QPS 计算逻辑,采用无锁滑动窗口机制,提升性能并添加历史记录功能。增强了证书管理,支持缓存和失效逻辑,避免重复加载。更新了仪表盘 UI,替换折线图为柱状图,优化布局和交互。删除冗余代码,提升代码可读性和维护性。
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk">
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectVersion>2.1</ProjectVersion>
|
||||
<DockerTargetOS>Linux</DockerTargetOS>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Include="docker-compose.override.yml">
|
||||
<DependentUpon>docker-compose.yml</DependentUpon>
|
||||
</None>
|
||||
<None Include="docker-compose.yml" />
|
||||
<None Include=".dockerignore" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+11
-10
@@ -1,4 +1,7 @@
|
||||
FROM mcr.microsoft.com/dotnet/nightly/aspnet:10.0-preview AS base
|
||||
# 请参阅 https://aka.ms/customizecontainer 以了解如何自定义调试容器,以及 Visual Studio 如何使用此 Dockerfile 生成映像以更快地进行调试。
|
||||
|
||||
# 此阶段用于在快速模式(默认为调试配置)下从 VS 运行时
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
|
||||
USER root
|
||||
RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo Asia/Shanghai > /etc/timezone
|
||||
RUN apt update \
|
||||
@@ -11,30 +14,28 @@ RUN apt update \
|
||||
&& apt-get purge -y --auto-remove wget && apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM node:alpine AS builder
|
||||
WORKDIR /src
|
||||
COPY web .
|
||||
RUN npm i
|
||||
RUN npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/nightly/sdk:10.0-preview AS build
|
||||
# 此阶段用于生成服务项目
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Directory.Build.props", "."]
|
||||
COPY ["src/FastGateway/FastGateway.csproj", "src/FastGateway/"]
|
||||
COPY ["src/Core/Core.csproj", "src/Core/"]
|
||||
RUN dotnet restore "src/FastGateway/FastGateway.csproj"
|
||||
RUN dotnet restore "./src/FastGateway/FastGateway.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/src/FastGateway"
|
||||
RUN dotnet build "./FastGateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
# 此阶段用于发布要复制到最终阶段的服务项目
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./FastGateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# 此阶段在生产中使用,或在常规模式下从 VS 运行时使用(在不使用调试配置时为默认值)
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
COPY --from=builder /src/dist ./wwwroot
|
||||
ENTRYPOINT ["dotnet", "FastGateway.dll"]
|
||||
ENTRYPOINT ["dotnet", "FastGateway.dll"]
|
||||
@@ -11,16 +11,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0"/>
|
||||
<PackageReference Include="Certes" Version="3.0.4"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0"/>
|
||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
||||
<PackageReference Include="Certes" Version="3.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\Core.csproj"/>
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -33,13 +34,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace FastGateway.Gateway
|
||||
{
|
||||
public class FastGatewayForwarderHttpClientFactory : IForwarderHttpClientFactory
|
||||
{
|
||||
public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
UseProxy = false,
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None | DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||
UseCookies = false,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
ActivityHeadersPropagator = new ReverseProxyPropagator(DistributedContextPropagator.Current),
|
||||
ConnectTimeout = TimeSpan.FromSeconds(600),
|
||||
};
|
||||
|
||||
return new HttpMessageInvoker(handler, disposeHandler: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,25 @@ public static class Gateway
|
||||
/// </summary>
|
||||
private static readonly DefaultContentTypeProvider DefaultContentTypeProvider = new();
|
||||
|
||||
/// <summary>
|
||||
/// 证书缓存(按 SNI 名称及默认证书缓存 X509Certificate2 实例)
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, X509Certificate2> CertificateCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// 证书缓存失效(根据域名)
|
||||
/// </summary>
|
||||
/// <param name="domain">SNI 域名</param>
|
||||
public static void InvalidateCertificate(string domain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(domain)) return;
|
||||
var key = $"sni:{domain.Trim().ToLowerInvariant()}";
|
||||
if (CertificateCache.TryRemove(key, out var cert))
|
||||
{
|
||||
try { cert.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否存在80端口服务
|
||||
/// </summary>
|
||||
@@ -98,6 +117,9 @@ public static class Gateway
|
||||
if (GatewayWebApplications.TryRemove(serverId, out var webApplication))
|
||||
{
|
||||
await webApplication.StopAsync();
|
||||
|
||||
await webApplication.DisposeAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -105,24 +127,50 @@ public static class Gateway
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 找到证书
|
||||
/// 找到证书(带缓存,避免每次握手都创建新实例)
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="name">SNI 主机名</param>
|
||||
/// <returns></returns>
|
||||
private static X509Certificate2 ServerCertificateSelector(ConnectionContext? context, string name)
|
||||
{
|
||||
// 规范化缓存键(可能为空)
|
||||
var sni = string.IsNullOrWhiteSpace(name) ? "__default__" : name.Trim().ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
var cert = CertService.GetCert(name);
|
||||
if (cert != null) return new X509Certificate2(cert.Certs.File, cert.Certs.Password);
|
||||
// 优先按 SNI 查找业务证书
|
||||
var certInfo = CertService.GetCert(name);
|
||||
if (certInfo != null)
|
||||
{
|
||||
return CertificateCache.GetOrAdd($"sni:{sni}", _ =>
|
||||
new X509Certificate2(
|
||||
certInfo.Certs.File,
|
||||
certInfo.Certs.Password,
|
||||
X509KeyStorageFlags.EphemeralKeySet));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
|
||||
return new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "gateway.pfx"), "010426");
|
||||
// 回退到默认证书
|
||||
var defaultKey = "default";
|
||||
try
|
||||
{
|
||||
return CertificateCache.GetOrAdd(defaultKey, _ =>
|
||||
new X509Certificate2(
|
||||
Path.Combine(AppContext.BaseDirectory, "gateway.pfx"),
|
||||
"010426",
|
||||
X509KeyStorageFlags.EphemeralKeySet));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
// 发生异常时,尽量最后再尝试无 flags 的构造,保持与旧实现兼容
|
||||
return new X509Certificate2(Path.Combine(AppContext.BaseDirectory, "gateway.pfx"), "010426");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -179,11 +227,6 @@ public static class Gateway
|
||||
options.AllowedOrigins.Add("*");
|
||||
});
|
||||
|
||||
builder.Services.AddRequestTimeouts(options =>
|
||||
{
|
||||
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddCors(options =>
|
||||
{
|
||||
@@ -208,14 +251,65 @@ public static class Gateway
|
||||
builder.Services
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(routes, clusters)
|
||||
.AddGateway(server);
|
||||
.AddTransforms(context =>
|
||||
{
|
||||
var prefix = context.Route.Match.Path?
|
||||
.Replace("/{**catch-all}", "")
|
||||
.Replace("{**catch-all}", "");
|
||||
|
||||
// 如果存在泛域名则需要保留原始Host
|
||||
if (context.Route.Match.Hosts?.Any(x => x.Contains('*')) == true)
|
||||
context.AddOriginalHost();
|
||||
else if (server.CopyRequestHost) context.AddOriginalHost();
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix)) context.AddPathRemovePrefix(prefix);
|
||||
|
||||
#region 静态站点
|
||||
|
||||
if (context.Route.Metadata!.TryGetValue(Root, out var root))
|
||||
{
|
||||
var tryFiles = context.Cluster!.Metadata!.Select(p => p.Key).ToArray();
|
||||
context.AddRequestTransform(async transformContext =>
|
||||
{
|
||||
var response = transformContext.HttpContext.Response;
|
||||
var path = Path.Combine(root, transformContext.Path.Value![1..]);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
DefaultContentTypeProvider.TryGetContentType(path, out var contentType);
|
||||
response.Headers.ContentType = contentType;
|
||||
|
||||
await response.SendFileAsync(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索 try_files
|
||||
foreach (var tryFile in tryFiles)
|
||||
{
|
||||
var tryPath = Path.Combine(root, tryFile);
|
||||
|
||||
if (!File.Exists(tryPath)) continue;
|
||||
|
||||
DefaultContentTypeProvider.TryGetContentType(tryPath, out var contentType);
|
||||
response.Headers.ContentType = contentType;
|
||||
|
||||
await response.SendFileAsync(tryPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
response.StatusCode = 404;
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}); // 删除 ConfigureHttpClient 避免与自定义工厂冲突
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
|
||||
app.UseWebSockets();
|
||||
app.UseRequestTimeouts();
|
||||
|
||||
if (server.StaticCompress)
|
||||
app.UseResponseCompression();
|
||||
@@ -292,86 +386,18 @@ public static class Gateway
|
||||
app.Lifetime.ApplicationStopping.Register(() => { GatewayWebApplications.Remove(server.Id, out _); });
|
||||
|
||||
await app.RunAsync();
|
||||
}catch (Exception e)
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine("网关启动错误:"+e);
|
||||
Console.WriteLine("网关启动错误:" + e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
GatewayWebApplications.Remove(server.Id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReverseProxyBuilder AddGateway(this IReverseProxyBuilder builder, Server server)
|
||||
{
|
||||
builder.AddTransforms(context =>
|
||||
{
|
||||
var prefix = context.Route.Match.Path?
|
||||
.Replace("/{**catch-all}", "")
|
||||
.Replace("{**catch-all}", "");
|
||||
|
||||
// 如果存在泛域名则需要保留原始Host
|
||||
if (context.Route.Match.Hosts?.Any(x => x.Contains('*')) == true)
|
||||
context.AddOriginalHost();
|
||||
else if (server.CopyRequestHost) context.AddOriginalHost();
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix)) context.AddPathRemovePrefix(prefix);
|
||||
|
||||
#region 静态站点
|
||||
|
||||
if (context.Route.Metadata!.TryGetValue(Root, out var root))
|
||||
{
|
||||
var tryFiles = context.Cluster!.Metadata!.Select(p => p.Key).ToArray();
|
||||
context.AddRequestTransform(async transformContext =>
|
||||
{
|
||||
var response = transformContext.HttpContext.Response;
|
||||
var path = Path.Combine(root, transformContext.Path.Value![1..]);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
DefaultContentTypeProvider.TryGetContentType(path, out var contentType);
|
||||
response.Headers.ContentType = contentType;
|
||||
|
||||
await response.SendFileAsync(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索 try_files
|
||||
foreach (var tryFile in tryFiles)
|
||||
{
|
||||
var tryPath = Path.Combine(root, tryFile);
|
||||
|
||||
if (!File.Exists(tryPath)) continue;
|
||||
|
||||
DefaultContentTypeProvider.TryGetContentType(tryPath, out var contentType);
|
||||
response.Headers.ContentType = contentType;
|
||||
|
||||
await response.SendFileAsync(tryPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
response.StatusCode = 404;
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
});
|
||||
|
||||
builder.ConfigureHttpClient((client, socket) =>
|
||||
{
|
||||
socket.ConnectTimeout = TimeSpan.FromSeconds(server.Timeout);
|
||||
socket.Expect100ContinueTimeout = TimeSpan.FromSeconds(server.Timeout);
|
||||
socket.ResponseDrainTimeout = TimeSpan.FromSeconds(server.Timeout);
|
||||
socket.ActivityHeadersPropagator = new ReverseProxyPropagator(DistributedContextPropagator.Current);
|
||||
socket.AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
socket.EnableMultipleHttp2Connections = true;
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static WebApplication UseInitGatewayMiddleware(this WebApplication app)
|
||||
{
|
||||
app.Use(async (context, next) =>
|
||||
@@ -402,9 +428,7 @@ public static class Gateway
|
||||
foreach (var domainName in domainNames)
|
||||
{
|
||||
var path = domainName.Path ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = "/{**catch-all}";
|
||||
else if (path == "/")
|
||||
if (string.IsNullOrWhiteSpace(path) || path == "/")
|
||||
path = "/{**catch-all}";
|
||||
else
|
||||
path = $"/{path.TrimStart('/')}/{{**catch-all}}";
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
"ASPNETCORE_URLS": "http://localhost:5202"
|
||||
},
|
||||
"distributionName": ""
|
||||
},
|
||||
"Container (Dockerfile)": {
|
||||
"commandName": "Docker",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/weatherforecast",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": false
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
|
||||
@@ -91,6 +91,8 @@ public static class CertService
|
||||
{
|
||||
configService.UpdateCert(cert);
|
||||
InitCert(configService.GetCerts().Where(x => !x.Expired).ToArray());
|
||||
// 清除网关证书缓存以便新证书生效
|
||||
Gateway.Gateway.InvalidateCertificate(cert.Domain);
|
||||
}
|
||||
|
||||
return ResultDto.CreateSuccess();
|
||||
@@ -219,6 +221,8 @@ public static class CertService
|
||||
{
|
||||
configService.DeleteCert(id);
|
||||
CertWebApplications.TryRemove(certItem.Domain, out _);
|
||||
// 删除证书后移除网关缓存
|
||||
Gateway.Gateway.InvalidateCertificate(certItem.Domain);
|
||||
}
|
||||
}).WithDescription("删除证书").WithDisplayName("删除证书").WithTags("证书");
|
||||
|
||||
@@ -231,6 +235,8 @@ public static class CertService
|
||||
|
||||
cert.Id = id;
|
||||
configService.UpdateCert(cert);
|
||||
// 更新证书后移除网关缓存
|
||||
Gateway.Gateway.InvalidateCertificate(cert.Domain);
|
||||
}).WithDescription("更新证书").WithDisplayName("更新证书").WithTags("证书");
|
||||
|
||||
|
||||
|
||||
@@ -8,18 +8,61 @@ namespace FastGateway.Services;
|
||||
|
||||
public sealed class QpsItem
|
||||
{
|
||||
private int _lastQps;
|
||||
private int _lastQps; // last calculated 3s average QPS
|
||||
private long _totalRequests;
|
||||
private long _successRequests;
|
||||
private long _failedRequests;
|
||||
private readonly ConcurrentQueue<long> _responseTimes = new();
|
||||
private readonly object _lockObject = new();
|
||||
|
||||
// 3s window tracking (lock-free)
|
||||
private long _windowRequestCount; // requests accumulated in current window
|
||||
private long _windowStartTicks = Stopwatch.GetTimestamp();
|
||||
|
||||
// 缓存最近 50 次 QPS 数据
|
||||
private readonly ConcurrentQueue<QpsHistoryItem> _qpsHistory = new();
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void IncrementRequests()
|
||||
{
|
||||
Interlocked.Increment(ref _lastQps);
|
||||
Interlocked.Increment(ref _totalRequests);
|
||||
Interlocked.Increment(ref _windowRequestCount);
|
||||
TryCalculateWindowQps();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void TryCalculateWindowQps()
|
||||
{
|
||||
// Read start ticks
|
||||
var start = Volatile.Read(ref _windowStartTicks);
|
||||
double elapsedSeconds = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency;
|
||||
if (elapsedSeconds < 3.0) return;
|
||||
|
||||
// Re-evaluate with fresh timestamp
|
||||
long nowTicks = Stopwatch.GetTimestamp();
|
||||
start = Volatile.Read(ref _windowStartTicks);
|
||||
elapsedSeconds = (nowTicks - start) / (double)Stopwatch.Frequency;
|
||||
if (elapsedSeconds < 3.0) return;
|
||||
|
||||
// Attempt to rotate window atomically
|
||||
if (Interlocked.CompareExchange(ref _windowStartTicks, nowTicks, start) == start)
|
||||
{
|
||||
long windowCount = Interlocked.Exchange(ref _windowRequestCount, 0);
|
||||
var calculatedQps = elapsedSeconds > 0 ? (int)(windowCount / elapsedSeconds) : 0;
|
||||
_lastQps = calculatedQps;
|
||||
|
||||
// 添加到历史记录
|
||||
_qpsHistory.Enqueue(new QpsHistoryItem
|
||||
{
|
||||
Time = DateTime.Now.ToString("HH:mm:ss"),
|
||||
Qps = calculatedQps
|
||||
});
|
||||
|
||||
// 保持最近 50 条记录
|
||||
while (_qpsHistory.Count > 50)
|
||||
{
|
||||
_qpsHistory.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
@@ -38,23 +81,25 @@ public sealed class QpsItem
|
||||
public void RecordResponseTime(long milliseconds)
|
||||
{
|
||||
_responseTimes.Enqueue(milliseconds);
|
||||
|
||||
// 保持队列大小不超过1000
|
||||
// Keep queue size <= 1000
|
||||
while (_responseTimes.Count > 1000)
|
||||
{
|
||||
_responseTimes.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual trigger (e.g., timer) to ensure QPS updates even during idle periods
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void CalculateQps()
|
||||
{
|
||||
Interlocked.Exchange(ref _lastQps, 0);
|
||||
TryCalculateWindowQps();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public int GetQps()
|
||||
{
|
||||
// Ensure we refresh if window elapsed without new requests
|
||||
TryCalculateWindowQps();
|
||||
return _lastQps;
|
||||
}
|
||||
|
||||
@@ -72,7 +117,6 @@ public sealed class QpsItem
|
||||
}
|
||||
|
||||
Array.Sort(times);
|
||||
|
||||
var avg = (long)times.Average();
|
||||
var min = times[0];
|
||||
var max = times[^1];
|
||||
@@ -83,27 +127,37 @@ public sealed class QpsItem
|
||||
|
||||
return new ResponseTimeStats(avg, p95, p99, min, max);
|
||||
}
|
||||
|
||||
public QpsHistoryItem[] GetQpsHistory()
|
||||
{
|
||||
return _qpsHistory.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public record QpsHistoryItem
|
||||
{
|
||||
public string Time { get; init; } = string.Empty;
|
||||
public int Qps { get; init; }
|
||||
}
|
||||
|
||||
public record ResponseTimeStats(long Avg, long P95, long P99, long Min, long Max);
|
||||
|
||||
public static class SystemMonitorService
|
||||
{
|
||||
private static readonly Lazy<PerformanceCounter> _cpuCounter = new(() =>
|
||||
private static readonly Lazy<PerformanceCounter> _cpuCounter = new(() =>
|
||||
new PerformanceCounter("Processor", "% Processor Time", "_Total"));
|
||||
private static readonly Lazy<PerformanceCounter> _memoryCounter = new(() =>
|
||||
private static readonly Lazy<PerformanceCounter> _memoryCounter = new(() =>
|
||||
new PerformanceCounter("Memory", "Available MBytes"));
|
||||
private static readonly Lazy<PerformanceCounter> _diskReadCounter = new(() =>
|
||||
private static readonly Lazy<PerformanceCounter> _diskReadCounter = new(() =>
|
||||
new PerformanceCounter("PhysicalDisk", "Disk Read Bytes/sec", "_Total"));
|
||||
private static readonly Lazy<PerformanceCounter> _diskWriteCounter = new(() =>
|
||||
private static readonly Lazy<PerformanceCounter> _diskWriteCounter = new(() =>
|
||||
new PerformanceCounter("PhysicalDisk", "Disk Write Bytes/sec", "_Total"));
|
||||
|
||||
|
||||
private static bool _performanceCountersAvailable = true;
|
||||
private static bool _diskCountersAvailable = true;
|
||||
|
||||
|
||||
static SystemMonitorService()
|
||||
{
|
||||
// 初始化时读取一次以确保计数器准备就绪
|
||||
try
|
||||
{
|
||||
if (_performanceCountersAvailable)
|
||||
@@ -116,7 +170,7 @@ public static class SystemMonitorService
|
||||
{
|
||||
_performanceCountersAvailable = false;
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
if (_diskCountersAvailable)
|
||||
@@ -139,7 +193,7 @@ public static class SystemMonitorService
|
||||
float availableMemoryMB = 0;
|
||||
long diskReadBytesPerSec = 0;
|
||||
long diskWriteBytesPerSec = 0;
|
||||
|
||||
|
||||
if (_performanceCountersAvailable)
|
||||
{
|
||||
try
|
||||
@@ -152,7 +206,7 @@ public static class SystemMonitorService
|
||||
_performanceCountersAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (_diskCountersAvailable)
|
||||
{
|
||||
try
|
||||
@@ -165,20 +219,18 @@ public static class SystemMonitorService
|
||||
_diskCountersAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var totalMemoryBytes = GC.GetTotalMemory(false);
|
||||
var workingSetBytes = Environment.WorkingSet;
|
||||
|
||||
// 如果性能计数器不可用,使用近似计算
|
||||
|
||||
if (!_performanceCountersAvailable)
|
||||
{
|
||||
// 使用进程工作集作为已用内存的粗略估计
|
||||
var totalSystemMemoryBytes = workingSetBytes * 4; // 粗略估计系统总内存
|
||||
var totalSystemMemoryBytes = workingSetBytes * 4;
|
||||
var usedMemoryBytes = workingSetBytes;
|
||||
var memoryUsagePercent = (double)usedMemoryBytes / totalSystemMemoryBytes * 100;
|
||||
|
||||
|
||||
return new SystemStats(
|
||||
CpuUsage: 0, // 无法获取CPU使用率
|
||||
CpuUsage: 0,
|
||||
MemoryUsagePercent: Math.Min(memoryUsagePercent, 100),
|
||||
TotalMemoryBytes: totalSystemMemoryBytes,
|
||||
UsedMemoryBytes: usedMemoryBytes,
|
||||
@@ -190,8 +242,7 @@ public static class SystemMonitorService
|
||||
DiskWriteBytesPerSec: diskWriteBytesPerSec
|
||||
);
|
||||
}
|
||||
|
||||
// 使用性能计数器的准确数据
|
||||
|
||||
var totalSystemMemoryBytesAccurate = (long)(availableMemoryMB * 1024 * 1024) + workingSetBytes;
|
||||
var usedMemoryBytesAccurate = totalSystemMemoryBytesAccurate - (long)(availableMemoryMB * 1024 * 1024);
|
||||
var memoryUsagePercentAccurate = (double)usedMemoryBytesAccurate / totalSystemMemoryBytesAccurate * 100;
|
||||
@@ -211,10 +262,9 @@ public static class SystemMonitorService
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果获取系统信息失败,返回基本信息
|
||||
var workingSetBytes = Environment.WorkingSet;
|
||||
var gcMemoryBytes = GC.GetTotalMemory(false);
|
||||
|
||||
|
||||
return new SystemStats(
|
||||
CpuUsage: 0,
|
||||
MemoryUsagePercent: 0,
|
||||
@@ -233,7 +283,7 @@ public static class SystemMonitorService
|
||||
|
||||
public record SystemStats(
|
||||
float CpuUsage,
|
||||
double MemoryUsagePercent,
|
||||
double MemoryUsagePercent,
|
||||
long TotalMemoryBytes,
|
||||
long UsedMemoryBytes,
|
||||
long AvailableMemoryBytes,
|
||||
@@ -247,20 +297,17 @@ public record SystemStats(
|
||||
public static class QpsService
|
||||
{
|
||||
private static readonly QpsItem QpsItem = new();
|
||||
private static bool _enable;
|
||||
private static readonly DateTime _startTime = DateTime.UtcNow;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void IncrementServiceRequests()
|
||||
{
|
||||
if (!_enable) return;
|
||||
QpsItem.IncrementRequests();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void RecordSuccessRequest(long responseTimeMs)
|
||||
{
|
||||
if (!_enable) return;
|
||||
QpsItem.IncrementSuccessRequests();
|
||||
QpsItem.RecordResponseTime(responseTimeMs);
|
||||
}
|
||||
@@ -268,7 +315,6 @@ public static class QpsService
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void RecordFailedRequest(long responseTimeMs = 0)
|
||||
{
|
||||
if (!_enable) return;
|
||||
QpsItem.IncrementFailedRequests();
|
||||
if (responseTimeMs > 0)
|
||||
{
|
||||
@@ -278,15 +324,12 @@ public static class QpsService
|
||||
|
||||
public static void CalculateServiceQps()
|
||||
{
|
||||
if (!_enable) return;
|
||||
QpsItem.CalculateQps();
|
||||
}
|
||||
|
||||
public static int GetServiceQps()
|
||||
{
|
||||
var qps = QpsItem.GetQps();
|
||||
QpsItem.CalculateQps();
|
||||
return qps;
|
||||
return QpsItem.GetQps();
|
||||
}
|
||||
|
||||
public static (long total, long success, long failed) GetRequestCounts()
|
||||
@@ -299,18 +342,15 @@ public static class QpsService
|
||||
return QpsItem.GetResponseTimeStats();
|
||||
}
|
||||
|
||||
public static QpsHistoryItem[] GetQpsHistory()
|
||||
{
|
||||
return QpsItem.GetQpsHistory();
|
||||
}
|
||||
|
||||
public static TimeSpan GetUptime()
|
||||
{
|
||||
return DateTime.UtcNow - _startTime;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void EnableQps(bool enable)
|
||||
{
|
||||
_enable = enable;
|
||||
}
|
||||
|
||||
public static bool IsEnabled => _enable;
|
||||
}
|
||||
|
||||
public static class ApiQpsService
|
||||
@@ -333,18 +373,12 @@ public static class ApiQpsService
|
||||
|
||||
public static async Task GetAsync(HttpContext context)
|
||||
{
|
||||
QpsService.EnableQps(true);
|
||||
|
||||
try
|
||||
{
|
||||
// 获取所有网络接口
|
||||
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
|
||||
// 存储每个接口的初始值
|
||||
long initialBytesSent = 0;
|
||||
long initialBytesReceived = 0;
|
||||
|
||||
// 只考虑活动的和支持IPv4的网络接口
|
||||
foreach (var ni in networkInterfaces)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up ||
|
||||
@@ -355,19 +389,14 @@ public static class ApiQpsService
|
||||
initialBytesSent += interfaceStats.BytesSent;
|
||||
initialBytesReceived += interfaceStats.BytesReceived;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略获取统计信息失败的接口
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
// 存储每个接口1秒后的值
|
||||
long bytesSentAfter1Sec = 0;
|
||||
long bytesReceivedAfter1Sec = 0;
|
||||
|
||||
// 再次遍历网络接口
|
||||
foreach (var ni in networkInterfaces)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up ||
|
||||
@@ -378,31 +407,25 @@ public static class ApiQpsService
|
||||
bytesSentAfter1Sec += interfaceStats.BytesSent;
|
||||
bytesReceivedAfter1Sec += interfaceStats.BytesReceived;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略获取统计信息失败的接口
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// 计算1秒内发送和接收的总字节
|
||||
var upload = Math.Max(0, bytesSentAfter1Sec - initialBytesSent);
|
||||
var download = Math.Max(0, bytesReceivedAfter1Sec - initialBytesReceived);
|
||||
|
||||
// 获取系统统计信息
|
||||
var systemStats = SystemMonitorService.GetSystemStats();
|
||||
var requestCounts = QpsService.GetRequestCounts();
|
||||
var responseTimeStats = QpsService.GetResponseTimeStats();
|
||||
var qpsHistory = QpsService.GetQpsHistory();
|
||||
var uptime = QpsService.GetUptime();
|
||||
|
||||
var data = new
|
||||
{
|
||||
// 基础QPS数据
|
||||
qps = QpsService.GetServiceQps(),
|
||||
qps = QpsService.GetServiceQps(), // 3s average QPS
|
||||
qpsHistory = qpsHistory.Select(h => new { time = h.Time, qps = h.Qps }).ToArray(),
|
||||
now = DateTime.Now.ToString("HH:mm:ss"),
|
||||
upload,
|
||||
download,
|
||||
|
||||
// 请求统计
|
||||
requests = new
|
||||
{
|
||||
total = requestCounts.total,
|
||||
@@ -410,8 +433,6 @@ public static class ApiQpsService
|
||||
failed = requestCounts.failed,
|
||||
successRate = requestCounts.total > 0 ? Math.Round((double)requestCounts.success / requestCounts.total * 100, 2) : 0.0
|
||||
},
|
||||
|
||||
// 响应时间统计
|
||||
responseTime = new
|
||||
{
|
||||
avg = responseTimeStats.Avg,
|
||||
@@ -420,8 +441,6 @@ public static class ApiQpsService
|
||||
min = responseTimeStats.Min,
|
||||
max = responseTimeStats.Max
|
||||
},
|
||||
|
||||
// 系统资源
|
||||
system = new
|
||||
{
|
||||
cpu = new
|
||||
@@ -444,8 +463,6 @@ public static class ApiQpsService
|
||||
writeBytesPerSec = systemStats.DiskWriteBytesPerSec
|
||||
}
|
||||
},
|
||||
|
||||
// 服务状态
|
||||
service = new
|
||||
{
|
||||
isOnline = true,
|
||||
@@ -463,9 +480,6 @@ public static class ApiQpsService
|
||||
|
||||
await context.Response.WriteAsJsonAsync(data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QpsService.EnableQps(false);
|
||||
}
|
||||
finally { }
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,20 @@ internal class TunnelClientFactory(
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback =
|
||||
(sender, certificate, chain, errors) => true;
|
||||
|
||||
handler.MaxConnectionsPerServer = 100;
|
||||
handler.PooledConnectionLifetime = TimeSpan.FromMinutes(5);
|
||||
handler.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1);
|
||||
handler.KeepAlivePingDelay = TimeSpan.FromSeconds(30);
|
||||
handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
|
||||
handler.KeepAlivePingDelay = TimeSpan.FromMinutes(5);
|
||||
handler.KeepAlivePingTimeout = TimeSpan.FromMinutes(5);
|
||||
handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always;
|
||||
|
||||
// 新增:从 Gateway.ConfigureHttpClient 移植的参数
|
||||
handler.ConnectTimeout = TimeSpan.FromMinutes(5);
|
||||
handler.ResponseDrainTimeout = TimeSpan.FromMinutes(5);
|
||||
handler.EnableMultipleHttp2Connections = true;
|
||||
handler.EnableMultipleHttp3Connections = true;
|
||||
|
||||
|
||||
base.ConfigureHandler(httpClientContext, handler);
|
||||
|
||||
var previous = handler.ConnectCallback ?? DefaultConnectCallback;
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface BarChartProps {
|
||||
showXAxis?: boolean
|
||||
showYAxis?: boolean
|
||||
barRadius?: number
|
||||
barSize?: number
|
||||
barGap?: number | string
|
||||
}
|
||||
|
||||
const defaultColors = [
|
||||
@@ -36,6 +38,8 @@ export function BarChart({
|
||||
showXAxis = false,
|
||||
showYAxis = true,
|
||||
barRadius = 3,
|
||||
barSize,
|
||||
barGap = 4,
|
||||
}: BarChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
@@ -47,15 +51,17 @@ export function BarChart({
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<ChartContainer config={chartConfig} className="h-[200px]">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: showYAxis ? 0 : 5,
|
||||
bottom: 5,
|
||||
}}
|
||||
barGap={barGap}
|
||||
barCategoryGap="10%"
|
||||
>
|
||||
{showGrid && (
|
||||
<CartesianGrid
|
||||
@@ -98,6 +104,7 @@ export function BarChart({
|
||||
fill={colors[index % colors.length]}
|
||||
radius={[barRadius, barRadius, barRadius, barRadius]}
|
||||
minPointSize={5}
|
||||
maxBarSize={barSize}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar } from '@/components/ui/progress-bar';
|
||||
import { LineChart } from '@/components/ui/line-chart';
|
||||
import { BarChart } from '@/components/ui/bar-chart';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { bytesToSize } from '@/utils/byte';
|
||||
@@ -30,7 +29,18 @@ const Dashboard = () => {
|
||||
const [network, setNetwork] = useState({ upload: 0, download: 0 });
|
||||
const [networkHistory, setNetworkHistory] = useState<
|
||||
Array<{ time: string; upload: number; download: number; total: number }>
|
||||
>([]);
|
||||
>(() => {
|
||||
const now = new Date();
|
||||
return Array.from({ length: 50 }, (_, i) => {
|
||||
const time = new Date(now.getTime() - (49 - i) * 3000);
|
||||
return {
|
||||
time: time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||
upload: 0,
|
||||
download: 0,
|
||||
total: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
const [data, setData] = useState({
|
||||
total: 0,
|
||||
success: 0,
|
||||
@@ -57,13 +67,27 @@ const Dashboard = () => {
|
||||
cores: 0,
|
||||
temperature: 0,
|
||||
});
|
||||
const [diskChart, setDiskChart] = useState<Array<{ date: string; read: number; write: number }>>([]);
|
||||
const [qpsChart, setQpsChart] = useState<Array<{ time: string; QPS: number }>>(() =>
|
||||
Array.from({ length: 30 }, () => ({
|
||||
time: '00:00',
|
||||
QPS: 0,
|
||||
}))
|
||||
);
|
||||
const [diskChart, setDiskChart] = useState<Array<{ date: string; read: number; write: number }>>(() => {
|
||||
const now = new Date();
|
||||
return Array.from({ length: 25 }, (_, i) => {
|
||||
const time = new Date(now.getTime() - (24 - i) * 3000);
|
||||
return {
|
||||
date: time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||
read: 0,
|
||||
write: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
const [qpsChart, setQpsChart] = useState<Array<{ time: string; QPS: number }>>(() => {
|
||||
const now = new Date();
|
||||
return Array.from({ length: 50 }, (_, i) => {
|
||||
const time = new Date(now.getTime() - (49 - i) * 3000);
|
||||
return {
|
||||
time: time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||
QPS: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200);
|
||||
@@ -96,15 +120,24 @@ const Dashboard = () => {
|
||||
const currentTime = fetchedData.now;
|
||||
const currentQps = fetchedData.qps;
|
||||
|
||||
setQpsChart(prev => {
|
||||
const newData = [...prev];
|
||||
newData.shift();
|
||||
newData.push({
|
||||
time: currentTime,
|
||||
QPS: currentQps,
|
||||
// 如果后端返回了历史数据(首次加载),使用历史数据初始化
|
||||
if (fetchedData.qpsHistory && fetchedData.qpsHistory.length > 0) {
|
||||
setQpsChart(fetchedData.qpsHistory.map((item: any) => ({
|
||||
time: item.time,
|
||||
QPS: item.qps
|
||||
})));
|
||||
} else {
|
||||
// 后续更新,添加新数据点
|
||||
setQpsChart(prev => {
|
||||
const newData = [...prev];
|
||||
newData.shift();
|
||||
newData.push({
|
||||
time: currentTime,
|
||||
QPS: currentQps,
|
||||
});
|
||||
return newData;
|
||||
});
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
setQps(currentQps);
|
||||
calculateQpsMetrics(currentQps);
|
||||
@@ -124,7 +157,7 @@ const Dashboard = () => {
|
||||
download: newNetwork.download,
|
||||
total: newNetwork.upload + newNetwork.download,
|
||||
},
|
||||
].slice(-30);
|
||||
].slice(-50);
|
||||
return newData;
|
||||
});
|
||||
|
||||
@@ -169,7 +202,7 @@ const Dashboard = () => {
|
||||
read: fetchedData.system.disk.readBytesPerSec,
|
||||
write: fetchedData.system.disk.writeBytesPerSec,
|
||||
},
|
||||
].slice(-80);
|
||||
].slice(-25);
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
@@ -286,7 +319,7 @@ const Dashboard = () => {
|
||||
</span>
|
||||
<Badge variant={qpsBadgeVariant}>{qpsBadgeLabel}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[0.85rem] text-muted-foreground">最近 30s 滑动窗口</CardDescription>
|
||||
<CardDescription className="text-[0.85rem] text-muted-foreground">最近 3s 滑动窗口</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-3xl font-semibold text-yellow-600">{qps.toLocaleString()}</p>
|
||||
@@ -321,23 +354,28 @@ const Dashboard = () => {
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 border border-border">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckCircle className="h-4 w-4 text-blue-500" />
|
||||
成功率
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-500" />
|
||||
请求概况
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[0.85rem] text-muted-foreground">请求成功/失败的比例</CardDescription>
|
||||
<CardDescription>全量请求与今日增量</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-semibold text-blue-600">{successRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">成功 {data.success.toLocaleString()} / 失败 {data.fail.toLocaleString()}</p>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{requestStats.map(stat => (
|
||||
<div key={stat.label} className="space-y-1 text-center">
|
||||
<div className={`mx-auto flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground`}>
|
||||
<stat.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||
<p className={`text-lg font-semibold ${stat.tone}`}>{stat.label === '成功率' ? `${stat.value}%` : stat.value.toLocaleString()}</p>
|
||||
{stat.change !== undefined && stat.label !== '成功率' && (
|
||||
<p className="text-xs text-muted-foreground">+{stat.change.toLocaleString()} 今日</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={successRate}
|
||||
className="h-3"
|
||||
color={successRate > 90 ? 'green' : successRate > 70 ? 'yellow' : 'red'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -374,10 +412,10 @@ const Dashboard = () => {
|
||||
<Activity className="h-5 w-5 text-yellow-500" />
|
||||
QPS 趋势
|
||||
</CardTitle>
|
||||
<CardDescription>滚动的 30 次请求曲线</CardDescription>
|
||||
<CardDescription>滚动的 50 次请求曲线</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
<LineChart
|
||||
<BarChart
|
||||
key={`qps-chart-${windowWidth}`}
|
||||
data={qpsChart}
|
||||
categories={['QPS']}
|
||||
@@ -385,6 +423,7 @@ const Dashboard = () => {
|
||||
valueFormatter={(value: number) => `${value} req/s`}
|
||||
index="time"
|
||||
showXAxis={false}
|
||||
showYAxis={false}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -399,21 +438,17 @@ const Dashboard = () => {
|
||||
<CardDescription>上/下行速度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
{networkHistory.length ? (
|
||||
<LineChart
|
||||
key={`network-chart-${windowWidth}`}
|
||||
data={networkHistory}
|
||||
categories={['upload', 'download']}
|
||||
colors={['blue', 'green']}
|
||||
valueFormatter={(value: number) => `${bytesToSize(value)}/s`}
|
||||
index="time"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
等待网络数据…
|
||||
</div>
|
||||
)}
|
||||
<BarChart
|
||||
key={`network-chart-${windowWidth}`}
|
||||
data={networkHistory}
|
||||
categories={['upload', 'download']}
|
||||
colors={['blue', 'green']}
|
||||
valueFormatter={(value: number) => `${bytesToSize(value)}/s`}
|
||||
index="time"
|
||||
showXAxis={false}
|
||||
showYAxis={false}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -471,47 +506,38 @@ const Dashboard = () => {
|
||||
<CardDescription>读取 / 写入 每秒</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[260px]">
|
||||
{diskChart.length ? (
|
||||
<BarChart
|
||||
data={diskChart}
|
||||
categories={['read', 'write']}
|
||||
colors={['cyan', 'indigo']}
|
||||
index="date"
|
||||
valueFormatter={(value: number) => bytesToSize(value)}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
等待磁盘指标…
|
||||
</div>
|
||||
)}
|
||||
<BarChart
|
||||
data={diskChart}
|
||||
categories={['read', 'write']}
|
||||
colors={['#10b981', '#f59e0b']}
|
||||
index="date"
|
||||
valueFormatter={(value: number) => bytesToSize(value)}
|
||||
showXAxis={false}
|
||||
showYAxis={false}
|
||||
barRadius={4}
|
||||
className="h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 border border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-500" />
|
||||
请求概况
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckCircle className="h-4 w-4 text-blue-500" />
|
||||
成功率
|
||||
</CardTitle>
|
||||
<CardDescription>全量请求与今日增量</CardDescription>
|
||||
<CardDescription className="text-[0.85rem] text-muted-foreground">请求成功/失败的比例</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{requestStats.map(stat => (
|
||||
<div key={stat.label} className="space-y-1 text-center">
|
||||
<div className={`mx-auto flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground`}>
|
||||
<stat.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
|
||||
<p className={`text-lg font-semibold ${stat.tone}`}>{stat.label === '成功率' ? `${stat.value}%` : stat.value.toLocaleString()}</p>
|
||||
{stat.change !== undefined && stat.label !== '成功率' && (
|
||||
<p className="text-xs text-muted-foreground">+{stat.change.toLocaleString()} 今日</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-semibold text-blue-600">{successRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">成功 {data.success.toLocaleString()} / 失败 {data.fail.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
value={successRate}
|
||||
className="h-3"
|
||||
color={successRate > 90 ? 'green' : successRate > 70 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<div className="grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
||||
{latencyMetrics.map(metric => (
|
||||
<div key={metric.label} className="rounded-lg border border-border p-3">
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Square,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import UpdateServer from "./UpdateServer";
|
||||
@@ -219,16 +218,6 @@ const ProxyList = memo(() => {
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-sm">
|
||||
开始创建您的第一个代理服务器配置
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button>
|
||||
<ServerIcon className="mr-2 h-4 w-4" />
|
||||
创建服务器
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
查看文档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user