mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
feat(admin): redesign dashboard with pro analytics
This commit is contained in:
@@ -1301,6 +1301,143 @@ type ActivityPage struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AdminCoreStats defines model for AdminCoreStats.
|
||||
type AdminCoreStats struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Operations struct {
|
||||
FailedBackgroundJobs int `json:"failedBackgroundJobs"`
|
||||
OfflineDownloaders int `json:"offlineDownloaders"`
|
||||
PendingInvitations int `json:"pendingInvitations"`
|
||||
RunningDownloadTasks int `json:"runningDownloadTasks"`
|
||||
} `json:"operations"`
|
||||
Sharing struct {
|
||||
ActiveShares int `json:"activeShares"`
|
||||
Downloads int `json:"downloads"`
|
||||
TotalShares int `json:"totalShares"`
|
||||
Views int `json:"views"`
|
||||
} `json:"sharing"`
|
||||
Spaces struct {
|
||||
NewLast30Days int `json:"newLast30Days"`
|
||||
Personal int `json:"personal"`
|
||||
Team int `json:"team"`
|
||||
Total int `json:"total"`
|
||||
} `json:"spaces"`
|
||||
Storage struct {
|
||||
ActiveBackendCount int `json:"activeBackendCount"`
|
||||
BackendCount int `json:"backendCount"`
|
||||
CapacityBytes int `json:"capacityBytes"`
|
||||
QuotaBytes int `json:"quotaBytes"`
|
||||
QuotaUtilization float32 `json:"quotaUtilization"`
|
||||
UsedBytes int `json:"usedBytes"`
|
||||
} `json:"storage"`
|
||||
Traffic struct {
|
||||
Period string `json:"period"`
|
||||
QuotaBytes int `json:"quotaBytes"`
|
||||
UsedBytes int `json:"usedBytes"`
|
||||
Utilization float32 `json:"utilization"`
|
||||
} `json:"traffic"`
|
||||
Users struct {
|
||||
ActiveLast30Days int `json:"activeLast30Days"`
|
||||
Admins int `json:"admins"`
|
||||
NewLast7Days int `json:"newLast7Days"`
|
||||
Total int `json:"total"`
|
||||
} `json:"users"`
|
||||
}
|
||||
|
||||
// AdminDetailedStats defines model for AdminDetailedStats.
|
||||
type AdminDetailedStats struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
PeriodDays int `json:"periodDays"`
|
||||
Reliability struct {
|
||||
BackgroundJobs struct {
|
||||
ByStatus []struct {
|
||||
Count int `json:"count"`
|
||||
Status string `json:"status"`
|
||||
} `json:"byStatus"`
|
||||
Failed int `json:"failed"`
|
||||
FailureRate float32 `json:"failureRate"`
|
||||
Failures []struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ErrorMessage *string `json:"errorMessage"`
|
||||
Id string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"failures"`
|
||||
Total int `json:"total"`
|
||||
} `json:"backgroundJobs"`
|
||||
CloudTrafficReports struct {
|
||||
Failed int `json:"failed"`
|
||||
Pending int `json:"pending"`
|
||||
} `json:"cloudTrafficReports"`
|
||||
License struct {
|
||||
Active bool `json:"active"`
|
||||
Edition *string `json:"edition"`
|
||||
LastRefreshAt *string `json:"lastRefreshAt"`
|
||||
LastRefreshError *string `json:"lastRefreshError"`
|
||||
} `json:"license"`
|
||||
} `json:"reliability"`
|
||||
RemoteDownloads struct {
|
||||
ByDownloader []struct {
|
||||
DownloaderId string `json:"downloaderId"`
|
||||
FailedTasks int `json:"failedTasks"`
|
||||
LastHeartbeatAt *string `json:"lastHeartbeatAt"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Tasks int `json:"tasks"`
|
||||
} `json:"byDownloader"`
|
||||
ByStatus []struct {
|
||||
Count int `json:"count"`
|
||||
Status string `json:"status"`
|
||||
} `json:"byStatus"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
FailureReasons []struct {
|
||||
Count int `json:"count"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"failureReasons"`
|
||||
Running int `json:"running"`
|
||||
SuccessRate float32 `json:"successRate"`
|
||||
Total int `json:"total"`
|
||||
} `json:"remoteDownloads"`
|
||||
Sharing struct {
|
||||
ConversionRate float32 `json:"conversionRate"`
|
||||
DownloadLimitHitShares int `json:"downloadLimitHitShares"`
|
||||
ExpiredShares int `json:"expiredShares"`
|
||||
RevokedShares int `json:"revokedShares"`
|
||||
} `json:"sharing"`
|
||||
StorageByType []struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Files int `json:"files"`
|
||||
Type string `json:"type"`
|
||||
} `json:"storageByType"`
|
||||
TopShares []struct {
|
||||
CreatorId string `json:"creatorId"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
Downloads int `json:"downloads"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Token string `json:"token"`
|
||||
Views int `json:"views"`
|
||||
} `json:"topShares"`
|
||||
Trends []struct {
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
Date string `json:"date"`
|
||||
FailedJobs int `json:"failedJobs"`
|
||||
RemoteTasks int `json:"remoteTasks"`
|
||||
ShareDownloads int `json:"shareDownloads"`
|
||||
ShareViews int `json:"shareViews"`
|
||||
Signups int `json:"signups"`
|
||||
} `json:"trends"`
|
||||
UsageBySpace []struct {
|
||||
OrgId string `json:"orgId"`
|
||||
OrgName string `json:"orgName"`
|
||||
OrgType string `json:"orgType"`
|
||||
QuotaBytes int `json:"quotaBytes"`
|
||||
UsedBytes int `json:"usedBytes"`
|
||||
Utilization float32 `json:"utilization"`
|
||||
} `json:"usageBySpace"`
|
||||
}
|
||||
|
||||
// AdminUserQuota defines model for AdminUserQuota.
|
||||
type AdminUserQuota struct {
|
||||
HasPersonalOrg bool `json:"hasPersonalOrg"`
|
||||
@@ -2309,6 +2446,11 @@ type User struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
// GetAdminDetailedStatsParams defines parameters for GetAdminDetailedStats.
|
||||
type GetAdminDetailedStatsParams struct {
|
||||
PeriodDays *int `form:"periodDays,omitempty" json:"periodDays,omitempty"`
|
||||
}
|
||||
|
||||
// BanUserJSONBody defines parameters for BanUser.
|
||||
type BanUserJSONBody struct {
|
||||
// BanExpiresIn The number of seconds until the ban expires
|
||||
@@ -4291,6 +4433,12 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
|
||||
|
||||
// The interface specification for the client above.
|
||||
type ClientInterface interface {
|
||||
// GetAdminCoreStats request
|
||||
GetAdminCoreStats(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// GetAdminDetailedStats request
|
||||
GetAdminDetailedStats(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// GetApiAuthAccountInfo request
|
||||
GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -5137,6 +5285,30 @@ type ClientInterface interface {
|
||||
ListUserObjects(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (c *Client) GetAdminCoreStats(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetAdminCoreStatsRequest(c.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GetAdminDetailedStats(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetAdminDetailedStatsRequest(c.Server, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetApiAuthAccountInfoRequest(c.Server)
|
||||
if err != nil {
|
||||
@@ -8929,6 +9101,87 @@ func (c *Client) ListUserObjects(ctx context.Context, username string, reqEditor
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
// NewGetAdminCoreStatsRequest generates requests for GetAdminCoreStats
|
||||
func NewGetAdminCoreStatsRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/admin/stats/core")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGetAdminDetailedStatsRequest generates requests for GetAdminDetailedStats
|
||||
func NewGetAdminDetailedStatsRequest(server string, params *GetAdminDetailedStatsParams) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/admin/stats/details")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
// queryValues collects non-styled parameters (passthrough, JSON)
|
||||
// that are safe to round-trip through url.Values.Encode().
|
||||
queryValues := queryURL.Query()
|
||||
// rawQueryFragments collects pre-encoded query fragments from
|
||||
// styled parameters, preserving literal commas as delimiters
|
||||
// per the OpenAPI spec (e.g. "color=blue,black,brown").
|
||||
var rawQueryFragments []string
|
||||
|
||||
if params.PeriodDays != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "periodDays", *params.PeriodDays, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if encoded := queryValues.Encode(); encoded != "" {
|
||||
rawQueryFragments = append(rawQueryFragments, encoded)
|
||||
}
|
||||
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGetApiAuthAccountInfoRequest generates requests for GetApiAuthAccountInfo
|
||||
func NewGetApiAuthAccountInfoRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -18062,6 +18315,12 @@ func WithBaseURL(baseURL string) ClientOption {
|
||||
|
||||
// ClientWithResponsesInterface is the interface specification for the client with responses above.
|
||||
type ClientWithResponsesInterface interface {
|
||||
// GetAdminCoreStatsWithResponse request
|
||||
GetAdminCoreStatsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAdminCoreStatsResponse, error)
|
||||
|
||||
// GetAdminDetailedStatsWithResponse request
|
||||
GetAdminDetailedStatsWithResponse(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDetailedStatsResponse, error)
|
||||
|
||||
// GetApiAuthAccountInfoWithResponse request
|
||||
GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error)
|
||||
|
||||
@@ -18908,6 +19167,68 @@ type ClientWithResponsesInterface interface {
|
||||
ListUserObjectsWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*ListUserObjectsResponse, error)
|
||||
}
|
||||
|
||||
type GetAdminCoreStatsResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *AdminCoreStats
|
||||
JSON401 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r GetAdminCoreStatsResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r GetAdminCoreStatsResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r GetAdminCoreStatsResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetAdminDetailedStatsResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *AdminDetailedStats
|
||||
JSON402 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r GetAdminDetailedStatsResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r GetAdminDetailedStatsResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r GetAdminDetailedStatsResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetApiAuthAccountInfoResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -27643,6 +27964,24 @@ func (r ListUserObjectsResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAdminCoreStatsWithResponse request returning *GetAdminCoreStatsResponse
|
||||
func (c *ClientWithResponses) GetAdminCoreStatsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAdminCoreStatsResponse, error) {
|
||||
rsp, err := c.GetAdminCoreStats(ctx, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseGetAdminCoreStatsResponse(rsp)
|
||||
}
|
||||
|
||||
// GetAdminDetailedStatsWithResponse request returning *GetAdminDetailedStatsResponse
|
||||
func (c *ClientWithResponses) GetAdminDetailedStatsWithResponse(ctx context.Context, params *GetAdminDetailedStatsParams, reqEditors ...RequestEditorFn) (*GetAdminDetailedStatsResponse, error) {
|
||||
rsp, err := c.GetAdminDetailedStats(ctx, params, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseGetAdminDetailedStatsResponse(rsp)
|
||||
}
|
||||
|
||||
// GetApiAuthAccountInfoWithResponse request returning *GetApiAuthAccountInfoResponse
|
||||
func (c *ClientWithResponses) GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error) {
|
||||
rsp, err := c.GetApiAuthAccountInfo(ctx, reqEditors...)
|
||||
@@ -30384,6 +30723,72 @@ func (c *ClientWithResponses) ListUserObjectsWithResponse(ctx context.Context, u
|
||||
return ParseListUserObjectsResponse(rsp)
|
||||
}
|
||||
|
||||
// ParseGetAdminCoreStatsResponse parses an HTTP response from a GetAdminCoreStatsWithResponse call
|
||||
func ParseGetAdminCoreStatsResponse(rsp *http.Response) (*GetAdminCoreStatsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &GetAdminCoreStatsResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest AdminCoreStats
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseGetAdminDetailedStatsResponse parses an HTTP response from a GetAdminDetailedStatsWithResponse call
|
||||
func ParseGetAdminDetailedStatsResponse(rsp *http.Response) (*GetAdminDetailedStatsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &GetAdminDetailedStatsResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest AdminDetailedStats
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON402 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseGetApiAuthAccountInfoResponse parses an HTTP response from a GetApiAuthAccountInfoWithResponse call
|
||||
func ParseGetApiAuthAccountInfoResponse(rsp *http.Response) (*GetApiAuthAccountInfoResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"recharts": "3.8.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"simple-icons": "^16.18.0",
|
||||
|
||||
Generated
+278
@@ -152,6 +152,9 @@ importers:
|
||||
react-pdf:
|
||||
specifier: ^10.4.1
|
||||
version: 10.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
recharts:
|
||||
specifier: 3.8.0
|
||||
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@17.0.2)(react@19.2.5)(redux@5.0.1)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2413,6 +2416,17 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0':
|
||||
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
@@ -3244,6 +3258,33 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -3288,6 +3329,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -3704,6 +3748,50 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -3721,6 +3809,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -3903,6 +3994,9 @@ packages:
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
es-toolkit@1.49.0:
|
||||
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4140,6 +4234,12 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.11:
|
||||
resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -4153,6 +4253,10 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
interpret@3.1.1:
|
||||
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -4902,6 +5006,18 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-redux@9.3.0:
|
||||
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4952,10 +5068,26 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
rechoir@0.8.0:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
refractor@5.0.0:
|
||||
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
|
||||
|
||||
@@ -5028,6 +5160,9 @@ packages:
|
||||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@5.0.0:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5473,6 +5608,9 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite@7.3.5:
|
||||
resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -7761,6 +7899,18 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.11
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.5
|
||||
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.61.0':
|
||||
@@ -8482,6 +8632,30 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -8526,6 +8700,8 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.9.1
|
||||
@@ -8918,6 +9094,44 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-urls@7.0.0(@noble/hashes@2.2.0):
|
||||
@@ -8931,6 +9145,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
@@ -9025,6 +9241,8 @@ snapshots:
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-toolkit@1.49.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
@@ -9389,6 +9607,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.11: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
@@ -9397,6 +9619,8 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
interpret@3.1.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -10374,6 +10598,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
redux: 5.0.1
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
@@ -10423,10 +10656,36 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@17.0.2)(react@19.2.5)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.49.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
rechoir@0.8.0:
|
||||
dependencies:
|
||||
resolve: 1.22.12
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
refractor@5.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -10556,6 +10815,8 @@ snapshots:
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -11082,6 +11343,23 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { isPersonalOrgLike } from '@shared/org-slugs'
|
||||
import type { AdminStatsPoint } from '@shared/types'
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { and, count, desc, eq, gt, gte, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
|
||||
import { organization, user } from '../../db/auth-schema'
|
||||
import {
|
||||
activityEvents,
|
||||
backgroundJobs,
|
||||
cloudTrafficReports,
|
||||
downloaders,
|
||||
downloadTasks,
|
||||
matters,
|
||||
shares,
|
||||
siteInvitations,
|
||||
storages,
|
||||
webhookEvents,
|
||||
} from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { AdminCoreStatsBase, AdminDetailedStatsBase, AdminStatsRepo } from '../../usecases/ports'
|
||||
|
||||
const RUNNING_DOWNLOAD_STATUSES = ['queued', 'assigned', 'running', 'downloading', 'ingesting']
|
||||
|
||||
export function createAdminStatsRepo(db: Database): AdminStatsRepo {
|
||||
return {
|
||||
getCoreStatsBase: (now) => getCoreStatsBase(db, now),
|
||||
getDetailedStatsBase: (now, periodDays) => getDetailedStatsBase(db, now, periodDays),
|
||||
}
|
||||
}
|
||||
|
||||
async function getCoreStatsBase(db: Database, now: Date): Promise<AdminCoreStatsBase> {
|
||||
const last7Days = daysAgo(now, 7)
|
||||
const last30Days = daysAgo(now, 30)
|
||||
|
||||
const [
|
||||
users,
|
||||
admins,
|
||||
newUsers,
|
||||
activeUsers,
|
||||
orgs,
|
||||
storageBackends,
|
||||
sharing,
|
||||
pendingInvitations,
|
||||
failedBackgroundJobs,
|
||||
offlineDownloaders,
|
||||
runningDownloadTasks,
|
||||
] = await Promise.all([
|
||||
countRows(db, user),
|
||||
countRowsWhere(db, user, eq(user.role, 'admin')),
|
||||
countRowsWhere(db, user, gte(user.createdAt, last7Days)),
|
||||
distinctCount(db, activityEvents.userId, gte(activityEvents.createdAt, last30Days)),
|
||||
listSpaces(db, last30Days),
|
||||
getStorageBackends(db),
|
||||
getSharingStats(db),
|
||||
countRowsWhere(
|
||||
db,
|
||||
siteInvitations,
|
||||
and(isNull(siteInvitations.acceptedAt), isNull(siteInvitations.revokedAt), gt(siteInvitations.expiresAt, now)),
|
||||
),
|
||||
countRowsWhere(db, backgroundJobs, eq(backgroundJobs.status, 'failed')),
|
||||
countRowsWhere(db, downloaders, eq(downloaders.status, 'offline')),
|
||||
countRowsWhere(db, downloadTasks, inArray(downloadTasks.status, RUNNING_DOWNLOAD_STATUSES)),
|
||||
])
|
||||
|
||||
return {
|
||||
users: {
|
||||
total: users,
|
||||
admins,
|
||||
activeLast30Days: activeUsers,
|
||||
newLast7Days: newUsers,
|
||||
},
|
||||
spaces: orgs,
|
||||
storageBackends,
|
||||
sharing,
|
||||
operations: {
|
||||
pendingInvitations,
|
||||
failedBackgroundJobs,
|
||||
offlineDownloaders,
|
||||
runningDownloadTasks,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getDetailedStatsBase(db: Database, now: Date, periodDays: number): Promise<AdminDetailedStatsBase> {
|
||||
const start = startOfDay(daysAgo(now, periodDays - 1))
|
||||
const [
|
||||
trends,
|
||||
storageByType,
|
||||
topShares,
|
||||
sharing,
|
||||
downloadTotals,
|
||||
downloadStatus,
|
||||
failureReasons,
|
||||
byDownloader,
|
||||
jobTotals,
|
||||
jobStatus,
|
||||
jobFailures,
|
||||
cloudReports,
|
||||
] = await Promise.all([
|
||||
buildTrends(db, start, now, periodDays),
|
||||
getStorageByType(db),
|
||||
getTopShares(db),
|
||||
getDetailedSharing(db, now),
|
||||
getDownloadTotals(db, start),
|
||||
getDownloadStatus(db, start),
|
||||
getDownloadFailureReasons(db, start),
|
||||
getDownloaderHealth(db, start),
|
||||
getBackgroundJobTotals(db, start),
|
||||
getBackgroundJobStatus(db, start),
|
||||
getBackgroundJobFailures(db, start),
|
||||
getCloudReportReliability(db),
|
||||
])
|
||||
|
||||
return {
|
||||
trends,
|
||||
storageByType,
|
||||
topShares,
|
||||
sharing,
|
||||
remoteDownloads: {
|
||||
...downloadTotals,
|
||||
byStatus: downloadStatus,
|
||||
failureReasons,
|
||||
byDownloader,
|
||||
},
|
||||
backgroundJobs: {
|
||||
...jobTotals,
|
||||
byStatus: jobStatus,
|
||||
failures: jobFailures,
|
||||
},
|
||||
cloudTrafficReports: cloudReports,
|
||||
}
|
||||
}
|
||||
|
||||
async function countRows(db: Database, table: typeof user): Promise<number> {
|
||||
const rows = await db.select({ value: count() }).from(table)
|
||||
return toNumber(rows[0]?.value)
|
||||
}
|
||||
|
||||
async function countRowsWhere(db: Database, table: SQLiteTable, where: SQL | undefined): Promise<number> {
|
||||
const rows = await db.select({ value: count() }).from(table).where(where)
|
||||
return toNumber(rows[0]?.value)
|
||||
}
|
||||
|
||||
async function distinctCount(
|
||||
db: Database,
|
||||
column: typeof activityEvents.userId,
|
||||
where: ReturnType<typeof gte>,
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ value: sql<number>`COUNT(DISTINCT ${column})` })
|
||||
.from(activityEvents)
|
||||
.where(where)
|
||||
return toNumber(rows[0]?.value)
|
||||
}
|
||||
|
||||
async function listSpaces(db: Database, last30Days: Date): Promise<AdminCoreStatsBase['spaces']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
slug: organization.slug,
|
||||
metadata: organization.metadata,
|
||||
createdAt: organization.createdAt,
|
||||
})
|
||||
.from(organization)
|
||||
|
||||
let personal = 0
|
||||
let team = 0
|
||||
let newLast30Days = 0
|
||||
for (const row of rows) {
|
||||
if (isPersonalOrgLike(row)) personal += 1
|
||||
else team += 1
|
||||
if (row.createdAt && row.createdAt >= last30Days) newLast30Days += 1
|
||||
}
|
||||
|
||||
return { total: rows.length, personal, team, newLast30Days }
|
||||
}
|
||||
|
||||
async function getStorageBackends(db: Database): Promise<AdminCoreStatsBase['storageBackends']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
backendCount: count(),
|
||||
activeBackendCount: sql<number>`SUM(CASE WHEN ${storages.status} = 'active' THEN 1 ELSE 0 END)`,
|
||||
capacityBytes: sql<number>`COALESCE(SUM(${storages.capacity}), 0)`,
|
||||
})
|
||||
.from(storages)
|
||||
return {
|
||||
backendCount: toNumber(rows[0]?.backendCount),
|
||||
activeBackendCount: toNumber(rows[0]?.activeBackendCount),
|
||||
capacityBytes: toNumber(rows[0]?.capacityBytes),
|
||||
}
|
||||
}
|
||||
|
||||
async function getSharingStats(db: Database): Promise<AdminCoreStatsBase['sharing']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
totalShares: count(),
|
||||
activeShares: sql<number>`SUM(CASE WHEN ${shares.status} = 'active' THEN 1 ELSE 0 END)`,
|
||||
views: sql<number>`COALESCE(SUM(${shares.views}), 0)`,
|
||||
downloads: sql<number>`COALESCE(SUM(${shares.downloads}), 0)`,
|
||||
})
|
||||
.from(shares)
|
||||
return {
|
||||
totalShares: toNumber(rows[0]?.totalShares),
|
||||
activeShares: toNumber(rows[0]?.activeShares),
|
||||
views: toNumber(rows[0]?.views),
|
||||
downloads: toNumber(rows[0]?.downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async function buildTrends(db: Database, start: Date, now: Date, periodDays: number): Promise<AdminStatsPoint[]> {
|
||||
const buckets = createTrendBuckets(start, periodDays)
|
||||
const [usersRows, activityRows, shareRows, taskRows, jobRows] = await Promise.all([
|
||||
db.select({ createdAt: user.createdAt }).from(user).where(gte(user.createdAt, start)),
|
||||
db
|
||||
.select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt })
|
||||
.from(activityEvents)
|
||||
.where(gte(activityEvents.createdAt, start)),
|
||||
db
|
||||
.select({ createdAt: shares.createdAt, views: shares.views, downloads: shares.downloads })
|
||||
.from(shares)
|
||||
.where(gte(shares.createdAt, start)),
|
||||
db.select({ createdAt: downloadTasks.createdAt }).from(downloadTasks).where(gte(downloadTasks.createdAt, start)),
|
||||
db
|
||||
.select({ createdAt: backgroundJobs.createdAt, status: backgroundJobs.status })
|
||||
.from(backgroundJobs)
|
||||
.where(gte(backgroundJobs.createdAt, start)),
|
||||
])
|
||||
|
||||
for (const row of usersRows) {
|
||||
const bucket = buckets.get(dayKey(row.createdAt))
|
||||
if (bucket) bucket.signups += 1
|
||||
}
|
||||
|
||||
const activeUsersByDay = new Map<string, Set<string>>()
|
||||
for (const row of activityRows) {
|
||||
const key = dayKey(row.createdAt)
|
||||
if (!buckets.has(key)) continue
|
||||
const set = activeUsersByDay.get(key) ?? new Set<string>()
|
||||
set.add(row.userId)
|
||||
activeUsersByDay.set(key, set)
|
||||
}
|
||||
for (const [key, usersForDay] of activeUsersByDay.entries()) {
|
||||
const bucket = buckets.get(key)
|
||||
if (bucket) bucket.activeUsers = usersForDay.size
|
||||
}
|
||||
|
||||
for (const row of shareRows) {
|
||||
const bucket = buckets.get(dayKey(row.createdAt))
|
||||
if (!bucket) continue
|
||||
bucket.shareViews += row.views
|
||||
bucket.shareDownloads += row.downloads
|
||||
}
|
||||
|
||||
for (const row of taskRows) {
|
||||
const bucket = buckets.get(dayKey(row.createdAt))
|
||||
if (bucket) bucket.remoteTasks += 1
|
||||
}
|
||||
|
||||
for (const row of jobRows) {
|
||||
const bucket = buckets.get(dayKey(row.createdAt))
|
||||
if (bucket && row.status === 'failed') bucket.failedJobs += 1
|
||||
}
|
||||
|
||||
const todayKey = dayKey(now)
|
||||
return [...buckets.values()].filter((point) => point.date <= todayKey)
|
||||
}
|
||||
|
||||
async function getStorageByType(db: Database): Promise<AdminDetailedStatsBase['storageByType']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
type: matters.type,
|
||||
files: count(),
|
||||
bytes: sql<number>`COALESCE(SUM(${matters.size}), 0)`,
|
||||
})
|
||||
.from(matters)
|
||||
.where(and(eq(matters.status, 'active'), eq(matters.dirtype, 0)))
|
||||
.groupBy(matters.type)
|
||||
.orderBy(desc(sql`COALESCE(SUM(${matters.size}), 0)`))
|
||||
.limit(8)
|
||||
|
||||
return rows.map((row) => ({ type: row.type || 'unknown', files: toNumber(row.files), bytes: toNumber(row.bytes) }))
|
||||
}
|
||||
|
||||
async function getTopShares(db: Database): Promise<AdminDetailedStatsBase['topShares']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: shares.id,
|
||||
token: shares.token,
|
||||
name: matters.name,
|
||||
creatorId: shares.creatorId,
|
||||
creatorName: user.name,
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
})
|
||||
.from(shares)
|
||||
.leftJoin(matters, eq(matters.id, shares.matterId))
|
||||
.leftJoin(user, eq(user.id, shares.creatorId))
|
||||
.orderBy(desc(sql`${shares.views} + ${shares.downloads}`))
|
||||
.limit(8)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
token: row.token,
|
||||
name: row.name ?? row.token,
|
||||
creatorId: row.creatorId,
|
||||
creatorName: row.creatorName ?? row.creatorId,
|
||||
views: row.views,
|
||||
downloads: row.downloads,
|
||||
status: row.status,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getDetailedSharing(db: Database, now: Date): Promise<AdminDetailedStatsBase['sharing']> {
|
||||
const nowSec = unixSeconds(now)
|
||||
const rows = await db
|
||||
.select({
|
||||
expiredShares: sql<number>`SUM(CASE WHEN ${shares.expiresAt} IS NOT NULL AND ${shares.expiresAt} <= ${nowSec} THEN 1 ELSE 0 END)`,
|
||||
revokedShares: sql<number>`SUM(CASE WHEN ${shares.status} = 'revoked' THEN 1 ELSE 0 END)`,
|
||||
downloadLimitHitShares: sql<number>`SUM(CASE WHEN ${shares.downloadLimit} IS NOT NULL AND ${shares.downloads} >= ${shares.downloadLimit} THEN 1 ELSE 0 END)`,
|
||||
views: sql<number>`COALESCE(SUM(${shares.views}), 0)`,
|
||||
downloads: sql<number>`COALESCE(SUM(${shares.downloads}), 0)`,
|
||||
})
|
||||
.from(shares)
|
||||
const views = toNumber(rows[0]?.views)
|
||||
const downloads = toNumber(rows[0]?.downloads)
|
||||
return {
|
||||
expiredShares: toNumber(rows[0]?.expiredShares),
|
||||
revokedShares: toNumber(rows[0]?.revokedShares),
|
||||
downloadLimitHitShares: toNumber(rows[0]?.downloadLimitHitShares),
|
||||
conversionRate: percent(downloads, views),
|
||||
}
|
||||
}
|
||||
|
||||
async function getDownloadTotals(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<
|
||||
Omit<AdminDetailedStatsBase['remoteDownloads'], 'successRate' | 'byStatus' | 'failureReasons' | 'byDownloader'>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
total: count(),
|
||||
completed: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'completed' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'failed' THEN 1 ELSE 0 END)`,
|
||||
running: sql<number>`SUM(CASE WHEN ${downloadTasks.status} IN (${RUNNING_DOWNLOAD_STATUSES[0]}, ${RUNNING_DOWNLOAD_STATUSES[1]}, ${RUNNING_DOWNLOAD_STATUSES[2]}, ${RUNNING_DOWNLOAD_STATUSES[3]}, ${RUNNING_DOWNLOAD_STATUSES[4]}) THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(downloadTasks)
|
||||
.where(gte(downloadTasks.createdAt, start))
|
||||
|
||||
return {
|
||||
total: toNumber(rows[0]?.total),
|
||||
completed: toNumber(rows[0]?.completed),
|
||||
failed: toNumber(rows[0]?.failed),
|
||||
running: toNumber(rows[0]?.running),
|
||||
}
|
||||
}
|
||||
|
||||
async function getDownloadStatus(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<AdminDetailedStatsBase['remoteDownloads']['byStatus']> {
|
||||
const rows = await db
|
||||
.select({ status: downloadTasks.status, count: count() })
|
||||
.from(downloadTasks)
|
||||
.where(gte(downloadTasks.createdAt, start))
|
||||
.groupBy(downloadTasks.status)
|
||||
.orderBy(desc(count()))
|
||||
return rows.map((row) => ({ status: row.status, count: toNumber(row.count) }))
|
||||
}
|
||||
|
||||
async function getDownloadFailureReasons(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<AdminDetailedStatsBase['remoteDownloads']['failureReasons']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
reason: sql<string>`COALESCE(${downloadTasks.errorCode}, ${downloadTasks.errorMessage}, 'unknown')`,
|
||||
count: count(),
|
||||
})
|
||||
.from(downloadTasks)
|
||||
.where(and(gte(downloadTasks.createdAt, start), eq(downloadTasks.status, 'failed')))
|
||||
.groupBy(sql`COALESCE(${downloadTasks.errorCode}, ${downloadTasks.errorMessage}, 'unknown')`)
|
||||
.orderBy(desc(count()))
|
||||
.limit(8)
|
||||
|
||||
return rows.map((row) => ({ reason: row.reason, count: toNumber(row.count) }))
|
||||
}
|
||||
|
||||
async function getDownloaderHealth(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<AdminDetailedStatsBase['remoteDownloads']['byDownloader']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
downloaderId: downloaders.id,
|
||||
name: downloaders.name,
|
||||
status: downloaders.status,
|
||||
lastHeartbeatAt: downloaders.lastHeartbeatAt,
|
||||
tasks: sql<number>`COUNT(${downloadTasks.id})`,
|
||||
failedTasks: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(downloaders)
|
||||
.leftJoin(
|
||||
downloadTasks,
|
||||
and(eq(downloadTasks.assignedDownloaderId, downloaders.id), gte(downloadTasks.createdAt, start)),
|
||||
)
|
||||
.groupBy(downloaders.id)
|
||||
.orderBy(desc(sql`COUNT(${downloadTasks.id})`))
|
||||
.limit(8)
|
||||
|
||||
return rows.map((row) => ({
|
||||
downloaderId: row.downloaderId,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
tasks: toNumber(row.tasks),
|
||||
failedTasks: toNumber(row.failedTasks),
|
||||
lastHeartbeatAt: row.lastHeartbeatAt?.toISOString() ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getBackgroundJobTotals(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<Pick<AdminDetailedStatsBase['backgroundJobs'], 'total' | 'failed'>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
total: count(),
|
||||
failed: sql<number>`SUM(CASE WHEN ${backgroundJobs.status} = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(backgroundJobs)
|
||||
.where(gte(backgroundJobs.createdAt, start))
|
||||
return { total: toNumber(rows[0]?.total), failed: toNumber(rows[0]?.failed) }
|
||||
}
|
||||
|
||||
async function getBackgroundJobStatus(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<AdminDetailedStatsBase['backgroundJobs']['byStatus']> {
|
||||
const rows = await db
|
||||
.select({ status: backgroundJobs.status, count: count() })
|
||||
.from(backgroundJobs)
|
||||
.where(gte(backgroundJobs.createdAt, start))
|
||||
.groupBy(backgroundJobs.status)
|
||||
.orderBy(desc(count()))
|
||||
return rows.map((row) => ({ status: row.status, count: toNumber(row.count) }))
|
||||
}
|
||||
|
||||
async function getBackgroundJobFailures(
|
||||
db: Database,
|
||||
start: Date,
|
||||
): Promise<AdminDetailedStatsBase['backgroundJobs']['failures']> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: backgroundJobs.id,
|
||||
type: backgroundJobs.type,
|
||||
errorMessage: backgroundJobs.errorMessage,
|
||||
createdAt: backgroundJobs.createdAt,
|
||||
})
|
||||
.from(backgroundJobs)
|
||||
.where(and(gte(backgroundJobs.createdAt, start), eq(backgroundJobs.status, 'failed')))
|
||||
.orderBy(desc(backgroundJobs.createdAt))
|
||||
.limit(6)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
errorMessage: row.errorMessage,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
async function getCloudReportReliability(db: Database): Promise<AdminDetailedStatsBase['cloudTrafficReports']> {
|
||||
const [trafficPending, trafficFailed, webhookPending, webhookFailed] = await Promise.all([
|
||||
countRowsWhere(db, cloudTrafficReports, eq(cloudTrafficReports.status, 'pending')),
|
||||
countRowsWhere(db, cloudTrafficReports, eq(cloudTrafficReports.status, 'failed')),
|
||||
countRowsWhere(db, webhookEvents, eq(webhookEvents.status, 'pending')),
|
||||
countRowsWhere(db, webhookEvents, eq(webhookEvents.status, 'failed')),
|
||||
])
|
||||
return {
|
||||
pending: trafficPending + webhookPending,
|
||||
failed: trafficFailed + webhookFailed,
|
||||
}
|
||||
}
|
||||
|
||||
function createTrendBuckets(start: Date, periodDays: number): Map<string, AdminStatsPoint> {
|
||||
const buckets = new Map<string, AdminStatsPoint>()
|
||||
for (let i = 0; i < periodDays; i += 1) {
|
||||
const date = new Date(start)
|
||||
date.setUTCDate(start.getUTCDate() + i)
|
||||
const key = dayKey(date)
|
||||
buckets.set(key, {
|
||||
date: key,
|
||||
signups: 0,
|
||||
activeUsers: 0,
|
||||
shareViews: 0,
|
||||
shareDownloads: 0,
|
||||
remoteTasks: 0,
|
||||
failedJobs: 0,
|
||||
})
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
function daysAgo(now: Date, days: number): Date {
|
||||
const date = new Date(now)
|
||||
date.setUTCDate(date.getUTCDate() - days)
|
||||
return date
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
}
|
||||
|
||||
function dayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function unixSeconds(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
function percent(part: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.round((part / total) * 1000) / 10
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const n = typeof value === 'number' ? value : Number(value ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Context } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { Auth } from './auth'
|
||||
import { createDeps } from './composition'
|
||||
import { adminStats } from './http/admin-stats'
|
||||
import { serveAvatarBlob } from './http/avatar-blobs'
|
||||
import backgroundJobs from './http/background-jobs'
|
||||
import downloadTasks from './http/downloads/download-tasks'
|
||||
@@ -222,6 +223,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
app.route('/api/site/licensing', licensingAdmin)
|
||||
app.route('/api/site/branding', brandingAdmin)
|
||||
app.route('/api/site/audit-events', adminAudit)
|
||||
app.route('/api/admin/stats', adminStats)
|
||||
app.route('/api/downloads/downloaders', downloaders)
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
@@ -332,3 +334,4 @@ export type LicensingAdminRoute = typeof licensingAdmin
|
||||
export type PublicBrandingRoute = typeof publicBranding
|
||||
export type BrandingAdminRoute = typeof brandingAdmin
|
||||
export type AdminAuditRoute = typeof adminAudit
|
||||
export type AdminStatsRoute = typeof adminStats
|
||||
|
||||
+16
-1
@@ -562,6 +562,7 @@ export async function createAuth(
|
||||
// is inserted but before the session row and cookie cache are
|
||||
// written, so activeOrganizationId is correct from the start.
|
||||
if (!orgId) {
|
||||
const revokedPersonalOrgId = await findPersonalOrgFromExistingSession(db, session.userId)
|
||||
// Legacy personal orgs used a deterministic slug. Preserve the
|
||||
// old no-duplicate behavior for rows that still exist without
|
||||
// membership (e.g. admin revoked access).
|
||||
@@ -572,7 +573,7 @@ export async function createAuth(
|
||||
.where(eq(authSchema.organization.slug, legacySlug))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
if (!revokedPersonalOrgId && !existing) {
|
||||
const [user] = await db
|
||||
.select({ id: authSchema.user.id, name: authSchema.user.name, username: authSchema.user.username })
|
||||
.from(authSchema.user)
|
||||
@@ -678,6 +679,20 @@ async function createPersonalOrg(
|
||||
return orgId
|
||||
}
|
||||
|
||||
async function findPersonalOrgFromExistingSession(db: Database, userId: string): Promise<string | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
orgId: authSchema.organization.id,
|
||||
slug: authSchema.organization.slug,
|
||||
metadata: authSchema.organization.metadata,
|
||||
})
|
||||
.from(authSchema.session)
|
||||
.innerJoin(authSchema.organization, eq(authSchema.organization.id, authSchema.session.activeOrganizationId))
|
||||
.where(eq(authSchema.session.userId, userId))
|
||||
|
||||
return rows.find(isPersonalOrgLike)?.orgId ?? null
|
||||
}
|
||||
|
||||
async function createOrgQuotaValues(_db: Database, orgId: string, now: Date): Promise<typeof orgQuotas.$inferInsert> {
|
||||
return {
|
||||
id: nanoid(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createZipGateway } from './adapters/gateways/zip'
|
||||
import { createCfClient } from './adapters/providers/cf-custom-hostnames'
|
||||
import { createChangelogProvider } from './adapters/providers/changelog'
|
||||
import { createActivityRepo } from './adapters/repos/activity'
|
||||
import { createAdminStatsRepo } from './adapters/repos/admin-stats'
|
||||
import { createAnnouncementRepo } from './adapters/repos/announcement'
|
||||
import { createApiKeyGateway } from './adapters/repos/api-keys'
|
||||
import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder'
|
||||
@@ -60,6 +61,7 @@ export function createDeps(platform: Platform): Deps {
|
||||
const licensingCloud = createLicensingCloudGateway()
|
||||
return {
|
||||
activity: createActivityRepo(db),
|
||||
adminStats: createAdminStatsRepo(db),
|
||||
announcements: createAnnouncementRepo(db),
|
||||
apiKeys: createApiKeyGateway(),
|
||||
archiveJobs: createArchiveJobsGateway(platform),
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('hasFeature', () => {
|
||||
expect(hasFeature('white_label', state)).toBe(true)
|
||||
expect(hasFeature('teams_unlimited', state)).toBe(true)
|
||||
expect(hasFeature('storages_unlimited', state)).toBe(true)
|
||||
expect(hasFeature('analytics', state)).toBe(true)
|
||||
expect(hasFeature('quota_store', state)).toBe(false)
|
||||
expect(hasFeature('site_announcements', state)).toBe(false)
|
||||
})
|
||||
@@ -31,5 +32,6 @@ describe('hasFeature', () => {
|
||||
expect(hasFeature('quota_store', state)).toBe(true)
|
||||
expect(hasFeature('site_announcements', state)).toBe(true)
|
||||
expect(hasFeature('white_label', state)).toBe(true)
|
||||
expect(hasFeature('analytics', state)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { currentTrafficPeriod } from '../domain/quota'
|
||||
import { adminHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
describe('admin stats routes', () => {
|
||||
it('returns core dashboard stats for admins without a Pro license', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const { orgId, userId } = await seedStatsFixture(db)
|
||||
|
||||
const res = await app.request('/api/admin/stats/core', { headers })
|
||||
const body = (await res.json()) as {
|
||||
users: { total: number; admins: number }
|
||||
storage: { usedBytes: number; backendCount: number }
|
||||
sharing: { views: number; downloads: number }
|
||||
operations: { pendingInvitations: number }
|
||||
}
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(orgId).toBeTruthy()
|
||||
expect(userId).toBeTruthy()
|
||||
expect(body.users.total).toBeGreaterThanOrEqual(1)
|
||||
expect(body.users.admins).toBe(1)
|
||||
expect(body.storage.usedBytes).toBe(512)
|
||||
expect(body.storage.backendCount).toBe(1)
|
||||
expect(body.sharing.views).toBe(12)
|
||||
expect(body.sharing.downloads).toBe(4)
|
||||
expect(body.operations.pendingInvitations).toBe(1)
|
||||
})
|
||||
|
||||
it('gates detailed dashboard stats behind the analytics feature', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await seedStatsFixture(db)
|
||||
|
||||
const res = await app.request('/api/admin/stats/details', { headers })
|
||||
const body = (await res.json()) as { error: { details: Array<{ metadata: Record<string, string> }> } }
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
expect(body.error.details[0].metadata.feature).toBe('analytics')
|
||||
})
|
||||
|
||||
it('returns detailed dashboard stats for Pro admins', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await seedProLicense(db)
|
||||
await seedStatsFixture(db)
|
||||
|
||||
const res = await app.request('/api/admin/stats/details?periodDays=7', { headers })
|
||||
const body = (await res.json()) as {
|
||||
periodDays: number
|
||||
trends: Array<{ remoteTasks: number; failedJobs: number }>
|
||||
topShares: Array<{ token: string; views: number }>
|
||||
remoteDownloads: { total: number; completed: number; failed: number; successRate: number }
|
||||
reliability: {
|
||||
backgroundJobs: { failed: number }
|
||||
license: { active: boolean; edition: string; lastRefreshAt: string }
|
||||
}
|
||||
}
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.periodDays).toBe(7)
|
||||
expect(body.topShares[0]).toMatchObject({ token: 'share-token-1', views: 12 })
|
||||
expect(body.remoteDownloads).toMatchObject({ total: 2, completed: 1, failed: 1, successRate: 50 })
|
||||
expect(body.reliability.backgroundJobs.failed).toBe(1)
|
||||
expect(body.reliability.license).toMatchObject({ active: true, edition: 'pro' })
|
||||
expect(body.reliability.license.lastRefreshAt).toMatch(/^20\d{2}-/)
|
||||
expect(body.trends.some((point) => point.remoteTasks > 0 || point.failedJobs > 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
async function seedStatsFixture(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
const nowSec = Math.floor(now / 1000)
|
||||
const future = now + 7 * 24 * 60 * 60 * 1000
|
||||
const futureSec = Math.floor(future / 1000)
|
||||
const period = currentTrafficPeriod(new Date(now))
|
||||
const [{ id: orgId }] = await db.all<{ id: string }>(
|
||||
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`,
|
||||
)
|
||||
const [{ id: userId }] = await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`)
|
||||
|
||||
await db.run(sql`
|
||||
UPDATE org_quotas
|
||||
SET used = 512, traffic_used = 256, traffic_period = ${period}
|
||||
WHERE org_id = ${orgId}
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('stats-storage', 'stats-bucket', 'https://s3.example', 'auto', 'AK', 'SK', '', '', 2048, 512, 'active', ${nowSec}, ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
||||
VALUES ('stats-file', ${orgId}, 'stats-file-alias', 'report.pdf', 'application/pdf', 512, 0, '', 'files/report.pdf', 'stats-storage', 'active', ${nowSec}, ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, expires_at, download_limit, views, downloads, status, created_at)
|
||||
VALUES ('share-1', 'share-token-1', 'landing', 'stats-file', ${orgId}, ${userId}, ${futureSec}, 10, 12, 4, 'active', ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO site_invitations (id, email, token, invited_by, accepted_by, accepted_at, revoked_by, revoked_at, expires_at, created_at, updated_at)
|
||||
VALUES ('invite-1', 'new@example.com', 'invite-token', ${userId}, NULL, NULL, NULL, NULL, ${futureSec}, ${nowSec}, ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO activity_events (id, org_id, user_id, action, target_type, target_id, target_name, metadata, created_at)
|
||||
VALUES ('activity-1', ${orgId}, ${userId}, 'upload', 'file', 'stats-file', 'report.pdf', NULL, ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO downloaders (id, name, token_hash, token_jti, status, enabled, version, hostname, platform, arch, engine, capabilities, max_concurrent_tasks, current_tasks, download_bps, upload_bps, free_disk_bytes, created_by, last_heartbeat_at, created_at, updated_at)
|
||||
VALUES ('downloader-1', 'Downloader One', 'hash', 'jti', 'online', 1, '1.0.0', 'host', 'linux', 'x64', 'http', '[]', 2, 0, 0, 0, 1000, ${userId}, ${now}, ${now}, ${now})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO download_tasks (id, org_id, created_by_user_id, source_type, source_uri, display_name, target_folder, category, tags, assigned_downloader_id, status, error_code, error_message, created_at, updated_at)
|
||||
VALUES
|
||||
('task-1', ${orgId}, ${userId}, 'http', 'https://example.com/ok.bin', 'ok.bin', '', 'direct', '[]', 'downloader-1', 'completed', NULL, NULL, ${now}, ${now}),
|
||||
('task-2', ${orgId}, ${userId}, 'http', 'https://example.com/bad.bin', 'bad.bin', '', 'direct', '[]', 'downloader-1', 'failed', 'network', 'Network error', ${now}, ${now})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO background_jobs (id, org_id, user_id, type, status, target_folder, target_path, metadata, input_bytes, output_bytes, processed_bytes, file_count, current_filename, error_message, result_metadata, retryable, cancelable, retried_from_job_id, created_at, updated_at, started_at, finished_at)
|
||||
VALUES ('job-1', ${orgId}, ${userId}, 'extract', 'failed', '', '', NULL, 0, 0, 0, 0, NULL, 'bad zip', NULL, 1, 0, NULL, ${now}, ${now}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
return { orgId, userId }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { requireFeature } from '../middleware/require-feature'
|
||||
import { getAdminCoreStats, getAdminDetailedStats } from '../usecases/admin-stats'
|
||||
import { errorResponse, jsonContent } from './openapi'
|
||||
|
||||
const coreStatsSchema = z
|
||||
.object({
|
||||
generatedAt: z.string(),
|
||||
users: z.object({
|
||||
total: z.number().int(),
|
||||
admins: z.number().int(),
|
||||
activeLast30Days: z.number().int(),
|
||||
newLast7Days: z.number().int(),
|
||||
}),
|
||||
spaces: z.object({
|
||||
total: z.number().int(),
|
||||
personal: z.number().int(),
|
||||
team: z.number().int(),
|
||||
newLast30Days: z.number().int(),
|
||||
}),
|
||||
storage: z.object({
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
quotaUtilization: z.number(),
|
||||
capacityBytes: z.number().int(),
|
||||
backendCount: z.number().int(),
|
||||
activeBackendCount: z.number().int(),
|
||||
}),
|
||||
traffic: z.object({
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
utilization: z.number(),
|
||||
period: z.string(),
|
||||
}),
|
||||
sharing: z.object({
|
||||
totalShares: z.number().int(),
|
||||
activeShares: z.number().int(),
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
}),
|
||||
operations: z.object({
|
||||
pendingInvitations: z.number().int(),
|
||||
failedBackgroundJobs: z.number().int(),
|
||||
offlineDownloaders: z.number().int(),
|
||||
runningDownloadTasks: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.openapi('AdminCoreStats')
|
||||
|
||||
const statusCountSchema = z.object({ status: z.string(), count: z.number().int() })
|
||||
|
||||
const detailedStatsSchema = z
|
||||
.object({
|
||||
generatedAt: z.string(),
|
||||
periodDays: z.number().int(),
|
||||
trends: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
signups: z.number().int(),
|
||||
activeUsers: z.number().int(),
|
||||
shareViews: z.number().int(),
|
||||
shareDownloads: z.number().int(),
|
||||
remoteTasks: z.number().int(),
|
||||
failedJobs: z.number().int(),
|
||||
}),
|
||||
),
|
||||
usageBySpace: z.array(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
orgName: z.string(),
|
||||
orgType: z.string(),
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
utilization: z.number(),
|
||||
}),
|
||||
),
|
||||
storageByType: z.array(z.object({ type: z.string(), bytes: z.number().int(), files: z.number().int() })),
|
||||
topShares: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
name: z.string(),
|
||||
creatorId: z.string(),
|
||||
creatorName: z.string(),
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
status: z.string(),
|
||||
}),
|
||||
),
|
||||
sharing: z.object({
|
||||
expiredShares: z.number().int(),
|
||||
revokedShares: z.number().int(),
|
||||
downloadLimitHitShares: z.number().int(),
|
||||
conversionRate: z.number(),
|
||||
}),
|
||||
remoteDownloads: z.object({
|
||||
total: z.number().int(),
|
||||
completed: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
running: z.number().int(),
|
||||
successRate: z.number(),
|
||||
byStatus: z.array(statusCountSchema),
|
||||
failureReasons: z.array(z.object({ reason: z.string(), count: z.number().int() })),
|
||||
byDownloader: z.array(
|
||||
z.object({
|
||||
downloaderId: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tasks: z.number().int(),
|
||||
failedTasks: z.number().int(),
|
||||
lastHeartbeatAt: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
reliability: z.object({
|
||||
backgroundJobs: z.object({
|
||||
total: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
failureRate: z.number(),
|
||||
byStatus: z.array(statusCountSchema),
|
||||
failures: z.array(
|
||||
z.object({ id: z.string(), type: z.string(), errorMessage: z.string().nullable(), createdAt: z.string() }),
|
||||
),
|
||||
}),
|
||||
cloudTrafficReports: z.object({ pending: z.number().int(), failed: z.number().int() }),
|
||||
license: z.object({
|
||||
active: z.boolean(),
|
||||
edition: z.string().nullable(),
|
||||
lastRefreshAt: z.string().nullable(),
|
||||
lastRefreshError: z.string().nullable(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.openapi('AdminDetailedStats')
|
||||
|
||||
const detailsQuerySchema = z.object({
|
||||
periodDays: z.coerce.number().int().min(7).max(90).default(30),
|
||||
})
|
||||
|
||||
const coreRoute = createRoute({
|
||||
operationId: 'getAdminCoreStats',
|
||||
summary: 'Get admin dashboard core stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/core',
|
||||
middleware: [requireAdmin] as const,
|
||||
responses: {
|
||||
200: jsonContent(coreStatsSchema, 'Admin core stats'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const detailsRoute = createRoute({
|
||||
operationId: 'getAdminDetailedStats',
|
||||
summary: 'Get admin dashboard detailed stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/details',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: detailsQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(detailedStatsSchema, 'Admin detailed stats'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
export const adminStats = new OpenAPIHono<Env>()
|
||||
.openapi(coreRoute, async (c) => c.json(await getAdminCoreStats(c.get('deps')), 200))
|
||||
.openapi(detailsRoute, async (c) => {
|
||||
const { periodDays } = c.req.valid('query')
|
||||
return c.json(await getAdminDetailedStats(c.get('deps'), { periodDays }), 200)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export function percent(part: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.round((part / total) * 1000) / 10
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AdminCoreStats, AdminDetailedStats, AdminUsageBySpace } from '@shared/types'
|
||||
import { currentTrafficPeriod } from '../domain/quota'
|
||||
import { percent } from './admin-stats-utils'
|
||||
import type { AdminStatsRepo, LicenseBindingRepo, QuotaRepo } from './ports'
|
||||
import { listQuotaOverview } from './quota'
|
||||
import { loadBindingState } from './site/licensing'
|
||||
|
||||
export type AdminStatsDeps = {
|
||||
adminStats: AdminStatsRepo
|
||||
quota: Pick<QuotaRepo, 'listOrgQuotaOverview' | 'getEffectiveQuotasByOrg'>
|
||||
licenseBinding: LicenseBindingRepo
|
||||
}
|
||||
|
||||
export async function getAdminCoreStats(deps: AdminStatsDeps, now = new Date()): Promise<AdminCoreStats> {
|
||||
const [base, quotas] = await Promise.all([deps.adminStats.getCoreStatsBase(now), listQuotaOverview(deps, now)])
|
||||
const quotaItems = quotas.items
|
||||
const usedBytes = quotaItems.reduce((sum, item) => sum + item.used, 0)
|
||||
const quotaBytes = quotaItems.reduce((sum, item) => sum + item.quota, 0)
|
||||
const trafficUsedBytes = quotaItems.reduce((sum, item) => sum + item.trafficUsed, 0)
|
||||
const trafficQuotaBytes = quotaItems.reduce((sum, item) => sum + item.trafficQuota, 0)
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
users: base.users,
|
||||
spaces: base.spaces,
|
||||
storage: {
|
||||
usedBytes,
|
||||
quotaBytes,
|
||||
quotaUtilization: percent(usedBytes, quotaBytes),
|
||||
capacityBytes: base.storageBackends.capacityBytes,
|
||||
backendCount: base.storageBackends.backendCount,
|
||||
activeBackendCount: base.storageBackends.activeBackendCount,
|
||||
},
|
||||
traffic: {
|
||||
usedBytes: trafficUsedBytes,
|
||||
quotaBytes: trafficQuotaBytes,
|
||||
utilization: percent(trafficUsedBytes, trafficQuotaBytes),
|
||||
period: quotaItems[0]?.trafficPeriod ?? currentTrafficPeriod(now),
|
||||
},
|
||||
sharing: base.sharing,
|
||||
operations: base.operations,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminDetailedStats(
|
||||
deps: AdminStatsDeps,
|
||||
params: { periodDays: number },
|
||||
now = new Date(),
|
||||
): Promise<AdminDetailedStats> {
|
||||
const periodDays = normalizePeriodDays(params.periodDays)
|
||||
const [base, quotas, license] = await Promise.all([
|
||||
deps.adminStats.getDetailedStatsBase(now, periodDays),
|
||||
listQuotaOverview(deps, now),
|
||||
loadBindingState(deps),
|
||||
])
|
||||
const usageBySpace = quotas.items
|
||||
.map<AdminUsageBySpace>((item) => ({
|
||||
orgId: item.orgId,
|
||||
orgName: item.orgName,
|
||||
orgType: item.orgType,
|
||||
usedBytes: item.used,
|
||||
quotaBytes: item.quota,
|
||||
utilization: percent(item.used, item.quota),
|
||||
}))
|
||||
.sort((a, b) => b.utilization - a.utilization || b.usedBytes - a.usedBytes)
|
||||
.slice(0, 8)
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
periodDays,
|
||||
trends: base.trends,
|
||||
usageBySpace,
|
||||
storageByType: base.storageByType,
|
||||
topShares: base.topShares,
|
||||
sharing: base.sharing,
|
||||
remoteDownloads: {
|
||||
...base.remoteDownloads,
|
||||
successRate: percent(base.remoteDownloads.completed, base.remoteDownloads.total),
|
||||
},
|
||||
reliability: {
|
||||
backgroundJobs: {
|
||||
...base.backgroundJobs,
|
||||
failureRate: percent(base.backgroundJobs.failed, base.backgroundJobs.total),
|
||||
},
|
||||
cloudTrafficReports: base.cloudTrafficReports,
|
||||
license: {
|
||||
active: Boolean(license.active),
|
||||
edition: license.edition ?? null,
|
||||
lastRefreshAt: license.last_refresh_at ? new Date(license.last_refresh_at * 1000).toISOString() : null,
|
||||
lastRefreshError: license.last_refresh_error ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePeriodDays(value: number): number {
|
||||
if (value <= 7) return 7
|
||||
if (value <= 30) return 30
|
||||
return 90
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type {
|
||||
ActivityRepo,
|
||||
AdminStatsRepo,
|
||||
AnnouncementRepo,
|
||||
ApiKeyGateway,
|
||||
ArchiveJobsGateway,
|
||||
@@ -50,6 +51,7 @@ import type {
|
||||
|
||||
export interface Deps {
|
||||
activity: ActivityRepo
|
||||
adminStats: AdminStatsRepo
|
||||
announcements: AnnouncementRepo
|
||||
apiKeys: ApiKeyGateway
|
||||
archiveJobs: ArchiveJobsGateway
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// resource owns its own file under ports/.
|
||||
|
||||
export * from './ports/activity'
|
||||
export * from './ports/admin-stats'
|
||||
export * from './ports/announcement'
|
||||
export * from './ports/api-keys'
|
||||
export * from './ports/app-error'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
AdminBackgroundJobFailure,
|
||||
AdminCoreStats,
|
||||
AdminCountByStatus,
|
||||
AdminDetailedStats,
|
||||
AdminDownloaderHealth,
|
||||
AdminDownloadFailureReason,
|
||||
AdminStatsPoint,
|
||||
AdminStorageByType,
|
||||
AdminTopShare,
|
||||
} from '@shared/types'
|
||||
|
||||
export interface AdminCoreStatsBase {
|
||||
users: AdminCoreStats['users']
|
||||
spaces: AdminCoreStats['spaces']
|
||||
storageBackends: Pick<AdminCoreStats['storage'], 'capacityBytes' | 'backendCount' | 'activeBackendCount'>
|
||||
sharing: AdminCoreStats['sharing']
|
||||
operations: AdminCoreStats['operations']
|
||||
}
|
||||
|
||||
export interface AdminDetailedStatsBase {
|
||||
trends: AdminStatsPoint[]
|
||||
storageByType: AdminStorageByType[]
|
||||
topShares: AdminTopShare[]
|
||||
sharing: AdminDetailedStats['sharing']
|
||||
remoteDownloads: Omit<AdminDetailedStats['remoteDownloads'], 'successRate'> & {
|
||||
failureReasons: AdminDownloadFailureReason[]
|
||||
byDownloader: AdminDownloaderHealth[]
|
||||
}
|
||||
backgroundJobs: {
|
||||
total: number
|
||||
failed: number
|
||||
byStatus: AdminCountByStatus[]
|
||||
failures: AdminBackgroundJobFailure[]
|
||||
}
|
||||
cloudTrafficReports: AdminDetailedStats['reliability']['cloudTrafficReports']
|
||||
}
|
||||
|
||||
export interface AdminStatsRepo {
|
||||
getCoreStatsBase(now: Date): Promise<AdminCoreStatsBase>
|
||||
getDetailedStatsBase(now: Date, periodDays: number): Promise<AdminDetailedStatsBase>
|
||||
}
|
||||
@@ -183,9 +183,9 @@ export const FEATURE_REGISTRY = [
|
||||
i18nKey: 'features.analytics',
|
||||
category: 'advanced',
|
||||
community: false,
|
||||
pro: false,
|
||||
pro: true,
|
||||
business: true,
|
||||
comingSoon: true,
|
||||
gateKey: 'analytics',
|
||||
},
|
||||
] as const satisfies readonly FeatureDefinition[]
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
export interface AdminCoreStats {
|
||||
generatedAt: string
|
||||
users: {
|
||||
total: number
|
||||
admins: number
|
||||
activeLast30Days: number
|
||||
newLast7Days: number
|
||||
}
|
||||
spaces: {
|
||||
total: number
|
||||
personal: number
|
||||
team: number
|
||||
newLast30Days: number
|
||||
}
|
||||
storage: {
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
quotaUtilization: number
|
||||
capacityBytes: number
|
||||
backendCount: number
|
||||
activeBackendCount: number
|
||||
}
|
||||
traffic: {
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
utilization: number
|
||||
period: string
|
||||
}
|
||||
sharing: {
|
||||
totalShares: number
|
||||
activeShares: number
|
||||
views: number
|
||||
downloads: number
|
||||
}
|
||||
operations: {
|
||||
pendingInvitations: number
|
||||
failedBackgroundJobs: number
|
||||
offlineDownloaders: number
|
||||
runningDownloadTasks: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminStatsPoint {
|
||||
date: string
|
||||
signups: number
|
||||
activeUsers: number
|
||||
shareViews: number
|
||||
shareDownloads: number
|
||||
remoteTasks: number
|
||||
failedJobs: number
|
||||
}
|
||||
|
||||
export interface AdminUsageBySpace {
|
||||
orgId: string
|
||||
orgName: string
|
||||
orgType: string
|
||||
usedBytes: number
|
||||
quotaBytes: number
|
||||
utilization: number
|
||||
}
|
||||
|
||||
export interface AdminStorageByType {
|
||||
type: string
|
||||
bytes: number
|
||||
files: number
|
||||
}
|
||||
|
||||
export interface AdminTopShare {
|
||||
id: string
|
||||
token: string
|
||||
name: string
|
||||
creatorId: string
|
||||
creatorName: string
|
||||
views: number
|
||||
downloads: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AdminCountByStatus {
|
||||
status: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminDownloadFailureReason {
|
||||
reason: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminDownloaderHealth {
|
||||
downloaderId: string
|
||||
name: string
|
||||
status: string
|
||||
tasks: number
|
||||
failedTasks: number
|
||||
lastHeartbeatAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminBackgroundJobFailure {
|
||||
id: string
|
||||
type: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminDetailedStats {
|
||||
generatedAt: string
|
||||
periodDays: number
|
||||
trends: AdminStatsPoint[]
|
||||
usageBySpace: AdminUsageBySpace[]
|
||||
storageByType: AdminStorageByType[]
|
||||
topShares: AdminTopShare[]
|
||||
sharing: {
|
||||
expiredShares: number
|
||||
revokedShares: number
|
||||
downloadLimitHitShares: number
|
||||
conversionRate: number
|
||||
}
|
||||
remoteDownloads: {
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
running: number
|
||||
successRate: number
|
||||
byStatus: AdminCountByStatus[]
|
||||
failureReasons: AdminDownloadFailureReason[]
|
||||
byDownloader: AdminDownloaderHealth[]
|
||||
}
|
||||
reliability: {
|
||||
backgroundJobs: {
|
||||
total: number
|
||||
failed: number
|
||||
failureRate: number
|
||||
byStatus: AdminCountByStatus[]
|
||||
failures: AdminBackgroundJobFailure[]
|
||||
}
|
||||
cloudTrafficReports: {
|
||||
pending: number
|
||||
failed: number
|
||||
}
|
||||
license: {
|
||||
active: boolean
|
||||
edition: string | null
|
||||
lastRefreshAt: string | null
|
||||
lastRefreshError: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,19 @@ export interface PaginatedResponse<T> {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type {
|
||||
AdminBackgroundJobFailure,
|
||||
AdminCoreStats,
|
||||
AdminCountByStatus,
|
||||
AdminDetailedStats,
|
||||
AdminDownloaderHealth,
|
||||
AdminDownloadFailureReason,
|
||||
AdminStatsPoint,
|
||||
AdminStorageByType,
|
||||
AdminTopShare,
|
||||
AdminUsageBySpace,
|
||||
} from './admin-stats'
|
||||
|
||||
export type DownloaderStatus = 'online' | 'offline' | 'disabled'
|
||||
export type DownloaderEngine = 'http' | 'aria2' | 'qbittorrent'
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const FEATURE_LABELS: Record<ProFeature, string> = {
|
||||
audit_log: 'audit logs',
|
||||
quota_store: 'storage quota store',
|
||||
site_announcements: 'site announcements',
|
||||
analytics: 'analytics',
|
||||
}
|
||||
|
||||
export interface UpgradeHintProps {
|
||||
|
||||
@@ -367,6 +367,81 @@
|
||||
"admin.overview.noStorages": "No storage backends configured yet.",
|
||||
"admin.overview.noStorageCapacity": "No capacity configured",
|
||||
"admin.overview.metrics.users": "Users",
|
||||
"admin.overview.errorTitle": "Dashboard unavailable",
|
||||
"admin.overview.errorDescription": "The dashboard metrics could not be loaded. Refresh the page or check the server logs.",
|
||||
"admin.overview.badge.pro": "Pro analytics",
|
||||
"admin.overview.badge.core": "Core metrics",
|
||||
"admin.overview.generatedAt": "Updated {{time}}",
|
||||
"admin.overview.metrics.usersDetail": "{{active}} active in 30d · {{newUsers}} new in 7d · {{admins}} admin(s)",
|
||||
"admin.overview.metrics.spaces": "Spaces",
|
||||
"admin.overview.metrics.spacesDetail": "{{team}} team · {{personal}} personal · {{newSpaces}} new in 30d",
|
||||
"admin.overview.metrics.traffic": "Traffic",
|
||||
"admin.overview.metrics.trafficDetail": "{{usage}} · {{period}}",
|
||||
"admin.overview.metrics.shares": "Shares",
|
||||
"admin.overview.metrics.sharesDetail": "{{total}} total · {{views}} views",
|
||||
"admin.overview.metrics.operations": "Operations",
|
||||
"admin.overview.metrics.operationsDetail": "{{running}} running downloads · {{invites}} pending invites",
|
||||
"admin.overview.coreCapacityTitle": "Core capacity",
|
||||
"admin.overview.coreCapacityDescription": "Storage, traffic, sharing, and backend health available to every admin.",
|
||||
"admin.overview.storageQuota": "Storage quota",
|
||||
"admin.overview.trafficQuota": "Traffic quota",
|
||||
"admin.overview.storageCapacity": "Backend capacity",
|
||||
"admin.overview.storageBackendCount": "{{active}} / {{total}} active backends",
|
||||
"admin.overview.shareActivity": "Share views",
|
||||
"admin.overview.shareActivityDetail": "{{downloads}} downloads",
|
||||
"admin.overview.analyticsTitle": "Detailed analytics",
|
||||
"admin.overview.analyticsBadge": "Pro+",
|
||||
"admin.overview.analyticsDescription": "Detailed trends, top spaces, file mix, remote downloads, and reliability data.",
|
||||
"admin.overview.period.7": "7 days",
|
||||
"admin.overview.period.30": "30 days",
|
||||
"admin.overview.period.90": "90 days",
|
||||
"admin.overview.analyticsLockedTitle": "Unlock detailed analytics",
|
||||
"admin.overview.analyticsLockedDescription": "Core dashboard metrics stay visible. Detailed operational statistics are available with ZPan Pro or Business.",
|
||||
"admin.overview.analyticsLockedAction": "Open licensing",
|
||||
"admin.overview.analyticsErrorTitle": "Detailed analytics unavailable",
|
||||
"admin.overview.analyticsErrorDescription": "The Pro analytics endpoint could not be loaded.",
|
||||
"admin.overview.charts.activityTitle": "Activity trends",
|
||||
"admin.overview.charts.activityDescription": "Daily active users, share views, remote tasks, and failed jobs.",
|
||||
"admin.overview.charts.activeUsers": "Active users",
|
||||
"admin.overview.charts.shareViews": "Share views",
|
||||
"admin.overview.charts.remoteTasks": "Remote tasks",
|
||||
"admin.overview.charts.failedJobs": "Failed jobs",
|
||||
"admin.overview.charts.storageByTypeTitle": "Storage by file type",
|
||||
"admin.overview.charts.storageByTypeDescription": "Largest active file categories by bytes.",
|
||||
"admin.overview.usageBySpaceTitle": "Top space utilization",
|
||||
"admin.overview.usageBySpaceDescription": "Spaces with the highest quota pressure.",
|
||||
"admin.overview.remoteDownloadsTitle": "Remote downloads",
|
||||
"admin.overview.remoteDownloadsDescription": "Task throughput, failure reasons, and downloader workload.",
|
||||
"admin.overview.remoteSuccess": "Success rate",
|
||||
"admin.overview.remoteCompleted": "{{completed}} / {{total}} completed",
|
||||
"admin.overview.remoteRunning": "Running",
|
||||
"admin.overview.remoteFailed": "{{failed}} failed",
|
||||
"admin.overview.statusBreakdown": "Status breakdown",
|
||||
"admin.overview.failureReasons": "Failure reasons",
|
||||
"admin.overview.noFailures": "No failures recorded.",
|
||||
"admin.overview.topSharesTitle": "Top shares",
|
||||
"admin.overview.topSharesDescription": "Most viewed and downloaded share links.",
|
||||
"admin.overview.table.share": "Share",
|
||||
"admin.overview.table.creator": "Creator",
|
||||
"admin.overview.table.views": "Views",
|
||||
"admin.overview.table.downloads": "Downloads",
|
||||
"admin.overview.reliabilityTitle": "Reliability",
|
||||
"admin.overview.reliabilityDescription": "Background jobs, cloud reporting, and license health.",
|
||||
"admin.overview.jobFailureRate": "Job failure rate",
|
||||
"admin.overview.jobFailures": "{{failed}} / {{total}} failed",
|
||||
"admin.overview.licenseStatus": "License",
|
||||
"admin.overview.licenseActive": "Active",
|
||||
"admin.overview.licenseInactive": "Inactive",
|
||||
"admin.overview.licenseNone": "No edition",
|
||||
"admin.overview.jobStatusBreakdown": "Job status",
|
||||
"admin.overview.cloudReportPending": "Report pending",
|
||||
"admin.overview.cloudReportPendingDetail": "Traffic reports and webhooks waiting to send",
|
||||
"admin.overview.cloudReportFailed": "Report failed",
|
||||
"admin.overview.cloudReportFailedDetail": "Traffic reports and webhooks that need attention",
|
||||
"admin.overview.recentJobFailures": "Recent job failures",
|
||||
"admin.overview.noErrorMessage": "No error message",
|
||||
"admin.overview.emptyChart": "No chart data for this period.",
|
||||
"admin.overview.emptyTable": "No data available.",
|
||||
"admin.downloaders.title": "Downloaders",
|
||||
"admin.downloaders.subtitle": "Monitor remote download workers.",
|
||||
"admin.downloaders.empty": "No downloaders registered",
|
||||
@@ -423,11 +498,18 @@
|
||||
"admin.overview.pending.storageDescription": "Uploads need at least one active storage backend.",
|
||||
"admin.overview.pending.quotaTitle": "Quota usage is high",
|
||||
"admin.overview.pending.quotaDescription": "Organization quota usage is at {{percent}}%.",
|
||||
"admin.overview.pending.trafficTitle": "Traffic usage is high",
|
||||
"admin.overview.pending.trafficDescription": "Traffic quota usage is at {{percent}}%.",
|
||||
"admin.overview.pending.invitesTitle": "{{count}} pending invitation(s)",
|
||||
"admin.overview.pending.invitesDescription": "Review pending site invitations and resend or revoke as needed.",
|
||||
"admin.overview.pending.downloadersTitle": "{{count}} offline downloader(s)",
|
||||
"admin.overview.pending.downloadersDescription": "Check downloader connectivity before assigning more remote download work.",
|
||||
"admin.overview.pending.jobsTitle": "{{count}} failed background job(s)",
|
||||
"admin.overview.pending.jobsDescription": "Review failed background jobs and retry or clean up affected work.",
|
||||
"admin.overview.actionsTitle": "Quick actions",
|
||||
"admin.overview.actions.manageUsers": "Manage users",
|
||||
"admin.overview.actions.configureStorage": "Configure storage",
|
||||
"admin.overview.actions.downloaders": "Downloaders",
|
||||
"admin.overview.actions.siteSettings": "Site settings",
|
||||
"admin.users.title": "Users",
|
||||
"admin.users.placeholder": "User management will be implemented here.",
|
||||
|
||||
@@ -367,6 +367,81 @@
|
||||
"admin.overview.noStorages": "还没有配置存储后端。",
|
||||
"admin.overview.noStorageCapacity": "未配置容量",
|
||||
"admin.overview.metrics.users": "用户",
|
||||
"admin.overview.errorTitle": "仪表盘不可用",
|
||||
"admin.overview.errorDescription": "无法加载仪表盘指标。请刷新页面或检查服务端日志。",
|
||||
"admin.overview.badge.pro": "Pro 统计",
|
||||
"admin.overview.badge.core": "核心指标",
|
||||
"admin.overview.generatedAt": "更新于 {{time}}",
|
||||
"admin.overview.metrics.usersDetail": "{{active}} 个 30 天活跃 · {{newUsers}} 个 7 天新增 · {{admins}} 个管理员",
|
||||
"admin.overview.metrics.spaces": "空间",
|
||||
"admin.overview.metrics.spacesDetail": "{{team}} 个团队 · {{personal}} 个个人 · {{newSpaces}} 个 30 天新增",
|
||||
"admin.overview.metrics.traffic": "流量",
|
||||
"admin.overview.metrics.trafficDetail": "{{usage}} · {{period}}",
|
||||
"admin.overview.metrics.shares": "分享",
|
||||
"admin.overview.metrics.sharesDetail": "{{total}} 个总分享 · {{views}} 次访问",
|
||||
"admin.overview.metrics.operations": "运维",
|
||||
"admin.overview.metrics.operationsDetail": "{{running}} 个下载运行中 · {{invites}} 个待处理邀请",
|
||||
"admin.overview.coreCapacityTitle": "核心容量",
|
||||
"admin.overview.coreCapacityDescription": "所有管理员都可查看的存储、流量、分享和后端健康状态。",
|
||||
"admin.overview.storageQuota": "存储配额",
|
||||
"admin.overview.trafficQuota": "流量配额",
|
||||
"admin.overview.storageCapacity": "后端容量",
|
||||
"admin.overview.storageBackendCount": "{{active}} / {{total}} 个后端可用",
|
||||
"admin.overview.shareActivity": "分享访问",
|
||||
"admin.overview.shareActivityDetail": "{{downloads}} 次下载",
|
||||
"admin.overview.analyticsTitle": "详细统计",
|
||||
"admin.overview.analyticsBadge": "Pro+",
|
||||
"admin.overview.analyticsDescription": "查看趋势、重点空间、文件类型分布、远程下载和可靠性数据。",
|
||||
"admin.overview.period.7": "7 天",
|
||||
"admin.overview.period.30": "30 天",
|
||||
"admin.overview.period.90": "90 天",
|
||||
"admin.overview.analyticsLockedTitle": "解锁详细统计",
|
||||
"admin.overview.analyticsLockedDescription": "核心仪表盘指标会继续展示。详细运营统计需要 ZPan Pro 或 Business。",
|
||||
"admin.overview.analyticsLockedAction": "打开授权",
|
||||
"admin.overview.analyticsErrorTitle": "详细统计不可用",
|
||||
"admin.overview.analyticsErrorDescription": "无法加载 Pro 统计接口。",
|
||||
"admin.overview.charts.activityTitle": "活动趋势",
|
||||
"admin.overview.charts.activityDescription": "按天查看活跃用户、分享访问、远程下载任务和失败任务。",
|
||||
"admin.overview.charts.activeUsers": "活跃用户",
|
||||
"admin.overview.charts.shareViews": "分享访问",
|
||||
"admin.overview.charts.remoteTasks": "远程任务",
|
||||
"admin.overview.charts.failedJobs": "失败任务",
|
||||
"admin.overview.charts.storageByTypeTitle": "按文件类型统计存储",
|
||||
"admin.overview.charts.storageByTypeDescription": "按占用容量排序的活跃文件类型。",
|
||||
"admin.overview.usageBySpaceTitle": "空间用量排行",
|
||||
"admin.overview.usageBySpaceDescription": "配额压力最高的空间。",
|
||||
"admin.overview.remoteDownloadsTitle": "远程下载",
|
||||
"admin.overview.remoteDownloadsDescription": "任务吞吐、失败原因和下载器负载。",
|
||||
"admin.overview.remoteSuccess": "成功率",
|
||||
"admin.overview.remoteCompleted": "{{completed}} / {{total}} 已完成",
|
||||
"admin.overview.remoteRunning": "运行中",
|
||||
"admin.overview.remoteFailed": "{{failed}} 个失败",
|
||||
"admin.overview.statusBreakdown": "状态分布",
|
||||
"admin.overview.failureReasons": "失败原因",
|
||||
"admin.overview.noFailures": "暂无失败记录。",
|
||||
"admin.overview.topSharesTitle": "热门分享",
|
||||
"admin.overview.topSharesDescription": "访问和下载最多的分享链接。",
|
||||
"admin.overview.table.share": "分享",
|
||||
"admin.overview.table.creator": "创建者",
|
||||
"admin.overview.table.views": "访问",
|
||||
"admin.overview.table.downloads": "下载",
|
||||
"admin.overview.reliabilityTitle": "可靠性",
|
||||
"admin.overview.reliabilityDescription": "后台任务、云端上报和授权健康状态。",
|
||||
"admin.overview.jobFailureRate": "任务失败率",
|
||||
"admin.overview.jobFailures": "{{failed}} / {{total}} 失败",
|
||||
"admin.overview.licenseStatus": "授权",
|
||||
"admin.overview.licenseActive": "已激活",
|
||||
"admin.overview.licenseInactive": "未激活",
|
||||
"admin.overview.licenseNone": "无版本",
|
||||
"admin.overview.jobStatusBreakdown": "任务状态",
|
||||
"admin.overview.cloudReportPending": "上报待处理",
|
||||
"admin.overview.cloudReportPendingDetail": "等待发送的流量上报和 Webhook",
|
||||
"admin.overview.cloudReportFailed": "上报失败",
|
||||
"admin.overview.cloudReportFailedDetail": "需要处理的流量上报和 Webhook",
|
||||
"admin.overview.recentJobFailures": "最近失败任务",
|
||||
"admin.overview.noErrorMessage": "无错误信息",
|
||||
"admin.overview.emptyChart": "当前周期暂无图表数据。",
|
||||
"admin.overview.emptyTable": "暂无数据。",
|
||||
"admin.downloaders.title": "下载器",
|
||||
"admin.downloaders.subtitle": "监控远程下载节点。",
|
||||
"admin.downloaders.empty": "暂无下载器",
|
||||
@@ -423,11 +498,18 @@
|
||||
"admin.overview.pending.storageDescription": "上传文件至少需要一个可用的存储后端。",
|
||||
"admin.overview.pending.quotaTitle": "配额用量偏高",
|
||||
"admin.overview.pending.quotaDescription": "组织配额用量已达到 {{percent}}%。",
|
||||
"admin.overview.pending.trafficTitle": "流量用量偏高",
|
||||
"admin.overview.pending.trafficDescription": "流量配额用量已达到 {{percent}}%。",
|
||||
"admin.overview.pending.invitesTitle": "{{count}} 个待处理邀请",
|
||||
"admin.overview.pending.invitesDescription": "检查待处理的站点邀请,并按需重发或撤销。",
|
||||
"admin.overview.pending.downloadersTitle": "{{count}} 个下载器离线",
|
||||
"admin.overview.pending.downloadersDescription": "继续分配远程下载任务前,请检查下载器连接状态。",
|
||||
"admin.overview.pending.jobsTitle": "{{count}} 个后台任务失败",
|
||||
"admin.overview.pending.jobsDescription": "检查失败的后台任务,并按需重试或清理受影响的工作。",
|
||||
"admin.overview.actionsTitle": "快捷操作",
|
||||
"admin.overview.actions.manageUsers": "管理用户",
|
||||
"admin.overview.actions.configureStorage": "配置存储",
|
||||
"admin.overview.actions.downloaders": "下载器",
|
||||
"admin.overview.actions.siteSettings": "站点设置",
|
||||
"admin.users.title": "用户",
|
||||
"admin.users.placeholder": "用户管理将在此实现。",
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
disconnectCloud,
|
||||
enableIhostFeature,
|
||||
generateInviteCodes,
|
||||
getAdminCoreStats,
|
||||
getAdminDetailedStats,
|
||||
getAnnouncement,
|
||||
getBackgroundJob,
|
||||
getBranding,
|
||||
@@ -3809,6 +3811,131 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin stats API', () => {
|
||||
const corePayload = {
|
||||
generatedAt: '2026-07-09T00:00:00.000Z',
|
||||
users: { total: 3, admins: 1, activeLast30Days: 2, newLast7Days: 1 },
|
||||
spaces: { total: 4, personal: 3, team: 1, newLast30Days: 1 },
|
||||
storage: {
|
||||
usedBytes: 100,
|
||||
quotaBytes: 1000,
|
||||
quotaUtilization: 10,
|
||||
capacityBytes: 2000,
|
||||
backendCount: 2,
|
||||
activeBackendCount: 1,
|
||||
},
|
||||
traffic: { usedBytes: 50, quotaBytes: 500, utilization: 10, period: '2026-07' },
|
||||
sharing: { totalShares: 4, activeShares: 3, views: 20, downloads: 5 },
|
||||
operations: { pendingInvitations: 1, failedBackgroundJobs: 2, offlineDownloaders: 1, runningDownloadTasks: 3 },
|
||||
}
|
||||
|
||||
const detailedPayload = {
|
||||
generatedAt: '2026-07-09T00:00:00.000Z',
|
||||
periodDays: 30,
|
||||
trends: [
|
||||
{
|
||||
date: '2026-07-09',
|
||||
signups: 1,
|
||||
activeUsers: 2,
|
||||
shareViews: 10,
|
||||
shareDownloads: 4,
|
||||
remoteTasks: 3,
|
||||
failedJobs: 1,
|
||||
},
|
||||
],
|
||||
usageBySpace: [
|
||||
{ orgId: 'org-1', orgName: 'Team', orgType: 'team', usedBytes: 100, quotaBytes: 200, utilization: 50 },
|
||||
],
|
||||
storageByType: [{ type: 'image/png', bytes: 100, files: 2 }],
|
||||
topShares: [
|
||||
{
|
||||
id: 'share-1',
|
||||
token: 'token-1',
|
||||
name: 'file.png',
|
||||
creatorId: 'user-1',
|
||||
creatorName: 'Alice',
|
||||
views: 10,
|
||||
downloads: 4,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
sharing: { expiredShares: 1, revokedShares: 1, downloadLimitHitShares: 1, conversionRate: 40 },
|
||||
remoteDownloads: {
|
||||
total: 5,
|
||||
completed: 3,
|
||||
failed: 1,
|
||||
running: 1,
|
||||
successRate: 60,
|
||||
byStatus: [{ status: 'completed', count: 3 }],
|
||||
failureReasons: [{ reason: 'network', count: 1 }],
|
||||
byDownloader: [
|
||||
{ downloaderId: 'dl-1', name: 'Node', status: 'online', tasks: 5, failedTasks: 1, lastHeartbeatAt: null },
|
||||
],
|
||||
},
|
||||
reliability: {
|
||||
backgroundJobs: {
|
||||
total: 4,
|
||||
failed: 1,
|
||||
failureRate: 25,
|
||||
byStatus: [{ status: 'failed', count: 1 }],
|
||||
failures: [{ id: 'job-1', type: 'extract', errorMessage: 'bad zip', createdAt: '2026-07-09T00:00:00.000Z' }],
|
||||
},
|
||||
cloudTrafficReports: { pending: 1, failed: 1 },
|
||||
license: { active: true, edition: 'pro', lastRefreshAt: null, lastRefreshError: null },
|
||||
},
|
||||
}
|
||||
|
||||
it('getAdminCoreStats fetches core dashboard stats', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(corePayload))
|
||||
|
||||
const result = await getAdminCoreStats()
|
||||
|
||||
expect(result).toEqual(corePayload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/stats/core')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('getAdminCoreStats throws ApiError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
|
||||
await expect(getAdminCoreStats()).rejects.toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('getAdminDetailedStats fetches detailed dashboard stats with periodDays', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(detailedPayload))
|
||||
|
||||
const result = await getAdminDetailedStats(90)
|
||||
|
||||
expect(result).toEqual(detailedPayload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/stats/details')
|
||||
expect(url).toContain('periodDays=90')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('getAdminDetailedStats defaults to 30 days', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(detailedPayload))
|
||||
|
||||
await getAdminDetailedStats()
|
||||
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('periodDays=30')
|
||||
})
|
||||
|
||||
it('getAdminDetailedStats throws ApiError on feature gate failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse(
|
||||
{ error: { code: 402, message: 'Feature not available', status: 'FAILED_PRECONDITION' } },
|
||||
false,
|
||||
402,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(getAdminDetailedStats()).rejects.toMatchObject({ status: 402 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAdminAuditLogs', () => {
|
||||
const auditEvent = {
|
||||
id: 'evt-1',
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
import type {
|
||||
ActivityEvent,
|
||||
AdminAuditEvent,
|
||||
AdminCoreStats,
|
||||
AdminDetailedStats,
|
||||
Announcement,
|
||||
AuthProvider,
|
||||
AuthProviderList,
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
adminDownloadersApi,
|
||||
adminQuotas,
|
||||
adminSiteInvitations,
|
||||
adminStatsApi,
|
||||
adminTeams,
|
||||
announcementsApi,
|
||||
authedSharesApi,
|
||||
@@ -425,6 +428,16 @@ export function retryBackgroundJob(id: string) {
|
||||
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].retries.$post({ param: { id } }))
|
||||
}
|
||||
|
||||
// Admin dashboard stats
|
||||
|
||||
export function getAdminCoreStats() {
|
||||
return unwrap<AdminCoreStats>(adminStatsApi.core.$get())
|
||||
}
|
||||
|
||||
export function getAdminDetailedStats(periodDays = 30) {
|
||||
return unwrap<AdminDetailedStats>(adminStatsApi.details.$get({ query: { periodDays: String(periodDays) } }))
|
||||
}
|
||||
|
||||
// Admin Storages API
|
||||
|
||||
export function listStorages() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AdminInviteCodesRoute,
|
||||
AdminQuotasRoute,
|
||||
AdminSiteInvitationsRoute,
|
||||
AdminStatsRoute,
|
||||
AdminTeamsRoute,
|
||||
AnnouncementsRoute,
|
||||
AuthedSharesRoute,
|
||||
@@ -78,4 +79,5 @@ export const licensingAdminApi = hc<LicensingAdminRoute>('/api/site/licensing',
|
||||
export const publicBrandingApi = hc<PublicBrandingRoute>('/api/site/branding', opts)
|
||||
export const brandingAdminApi = hc<BrandingAdminRoute>('/api/site/branding', opts)
|
||||
export const adminAuditApi = hc<AdminAuditRoute>('/api/site/audit-events', opts)
|
||||
export const adminStatsApi = hc<AdminStatsRoute>('/api/admin/stats', opts)
|
||||
export const publicSiteInvitations = hc<PublicSiteInvitationsRoute>('/api/site/invitations', opts)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AdminCoreStats } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEntitlement } from '@/hooks/useEntitlement'
|
||||
import { getAdminCoreStats, getAdminDetailedStats } from '@/lib/api'
|
||||
import { OverviewPage } from './index'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => {
|
||||
if (!values) return key
|
||||
return Object.entries(values).reduce(
|
||||
(message, [name, value]) => message.replace(`{{${name}}}`, String(value)),
|
||||
key,
|
||||
)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/UpgradeHint', () => ({
|
||||
UpgradeHint: ({ title }: { title: string }) => <div data-testid="upgrade-hint">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useEntitlement', () => ({
|
||||
useEntitlement: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getAdminCoreStats: vi.fn(),
|
||||
getAdminDetailedStats: vi.fn(),
|
||||
}))
|
||||
|
||||
const coreStats: AdminCoreStats = {
|
||||
generatedAt: '2026-07-09T00:00:00.000Z',
|
||||
users: { total: 42, admins: 2, activeLast30Days: 18, newLast7Days: 5 },
|
||||
spaces: { total: 10, personal: 8, team: 2, newLast30Days: 1 },
|
||||
storage: {
|
||||
usedBytes: 1024,
|
||||
quotaBytes: 4096,
|
||||
quotaUtilization: 25,
|
||||
capacityBytes: 8192,
|
||||
backendCount: 1,
|
||||
activeBackendCount: 1,
|
||||
},
|
||||
traffic: { usedBytes: 512, quotaBytes: 2048, utilization: 25, period: '2026-07' },
|
||||
sharing: { totalShares: 7, activeShares: 4, views: 120, downloads: 30 },
|
||||
operations: { pendingInvitations: 0, failedBackgroundJobs: 0, offlineDownloaders: 0, runningDownloadTasks: 1 },
|
||||
}
|
||||
|
||||
function renderOverviewPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OverviewPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Admin overview dashboard', () => {
|
||||
it('renders core stats without calling detailed stats when analytics is locked', async () => {
|
||||
vi.mocked(useEntitlement).mockReturnValue({
|
||||
bound: false,
|
||||
active: false,
|
||||
edition: null,
|
||||
licenseId: null,
|
||||
cloudDashboardUrl: null,
|
||||
hasFeature: () => false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
vi.mocked(getAdminCoreStats).mockResolvedValue(coreStats)
|
||||
|
||||
renderOverviewPage()
|
||||
|
||||
expect(await screen.findByText('42')).toBeTruthy()
|
||||
expect(screen.getByTestId('upgrade-hint').textContent).toBe('admin.overview.analyticsLockedTitle')
|
||||
expect(getAdminDetailedStats).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user