feat(admin): redesign dashboard with pro analytics

This commit is contained in:
saltbo
2026-07-09 02:06:21 -04:00
committed by Jasper Van
parent a5cc581586
commit 554c231536
26 changed files with 3019 additions and 160 deletions
+405
View File
@@ -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)
+1
View File
@@ -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",
+278
View File
@@ -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
+530
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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(),
+2
View File
@@ -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),
+2
View File
@@ -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)
})
})
+125
View File
@@ -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 }
}
+174
View File
@@ -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)
})
+4
View File
@@ -0,0 +1,4 @@
export function percent(part: number, total: number): number {
if (total <= 0) return 0
return Math.round((part / total) * 1000) / 10
}
+100
View File
@@ -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
}
+2
View File
@@ -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
+1
View File
@@ -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'
+42
View File
@@ -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>
}
+2 -2
View File
@@ -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[]
+147
View File
@@ -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
}
}
}
+13
View File
@@ -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'
+1
View File
@@ -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 {
+82
View File
@@ -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.",
+82
View File
@@ -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": "用户管理将在此实现。",
+127
View File
@@ -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',
+13
View File
@@ -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() {
+2
View File
@@ -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