diff --git a/components/api-server/openapi/openapi.users.yaml b/components/api-server/openapi/openapi.users.yaml index 5473a4b3b..072cb793a 100644 --- a/components/api-server/openapi/openapi.users.yaml +++ b/components/api-server/openapi/openapi.users.yaml @@ -23,6 +23,23 @@ paths: $ref: 'openapi.yaml#/components/responses/ForbiddenError' '500': $ref: 'openapi.yaml#/components/responses/InternalServerError' + /api/hypershell/v1/users/stats: + get: + summary: Get registered user activity statistics + operationId: getUserActivityStats + responses: + '200': + description: Registered and active user statistics + content: + application/json: + schema: + $ref: '#/components/schemas/UserActivityStats' + '401': + $ref: 'openapi.yaml#/components/responses/UnauthorizedError' + '403': + $ref: 'openapi.yaml#/components/responses/ForbiddenError' + '500': + $ref: 'openapi.yaml#/components/responses/InternalServerError' /api/hypershell/v1/users/{id}: get: summary: Get a registered user by ID @@ -75,6 +92,65 @@ components: items: $ref: '#/components/schemas/User' # NEW SCHEMA END + # NEW SCHEMA START + UserDailyCount: + type: object + required: + - date + - count + properties: + date: + type: string + format: date + description: UTC calendar day (YYYY-MM-DD) + count: + type: integer + format: int64 + minimum: 0 + # NEW SCHEMA END + # NEW SCHEMA START + UserActivityStats: + type: object + required: + - total_registered + - registered_last_7_days + - registered_last_30_days + - active_last_7_days + - active_last_30_days + - registration_daily + - active_daily + properties: + total_registered: + type: integer + format: int64 + minimum: 0 + registered_last_7_days: + type: integer + format: int64 + minimum: 0 + registered_last_30_days: + type: integer + format: int64 + minimum: 0 + active_last_7_days: + type: integer + format: int64 + minimum: 0 + active_last_30_days: + type: integer + format: int64 + minimum: 0 + registration_daily: + type: array + items: + $ref: '#/components/schemas/UserDailyCount' + description: New registrations per UTC day for the last 30 calendar days (inclusive) + active_daily: + type: array + items: + $ref: '#/components/schemas/UserDailyCount' + description: Distinct active users per UTC day for the last 30 calendar days (inclusive) + # NEW SCHEMA END parameters: id: name: id diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index a2feff370..eac59dfc3 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -62,6 +62,8 @@ paths: $ref: 'openapi.roleBindings.yaml#/paths/~1api~1hypershell~1v1~1role_bindings~1{id}' /api/hypershell/v1/users: $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users' + /api/hypershell/v1/users/stats: + $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users~1stats' /api/hypershell/v1/users/{id}: $ref: 'openapi.users.yaml#/paths/~1api~1hypershell~1v1~1users~1{id}' # AUTO-ADD NEW PATHS @@ -170,6 +172,10 @@ components: $ref: 'openapi.users.yaml#/components/schemas/User' UserList: $ref: 'openapi.users.yaml#/components/schemas/UserList' + UserActivityStats: + $ref: 'openapi.users.yaml#/components/schemas/UserActivityStats' + UserDailyCount: + $ref: 'openapi.users.yaml#/components/schemas/UserDailyCount' # AUTO-ADD NEW SCHEMAS parameters: id: diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index d6473e0ae..6d5ee871c 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -42,6 +42,8 @@ docs/RoleBinding.md docs/RoleBindingList.md docs/RoleList.md docs/User.md +docs/UserActivityStats.md +docs/UserDailyCount.md docs/UserList.md git_push.sh go.mod @@ -81,6 +83,8 @@ model_role_binding.go model_role_binding_list.go model_role_list.go model_user.go +model_user_activity_stats.go +model_user_daily_count.go model_user_list.go response.go test/api_default_test.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index 68aab1b13..532f0b3dd 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -102,6 +102,7 @@ Class | Method | HTTP request | Description *DefaultAPI* | [**GetRole**](docs/DefaultAPI.md#getrole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID *DefaultAPI* | [**GetRoleBinding**](docs/DefaultAPI.md#getrolebinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID *DefaultAPI* | [**GetUser**](docs/DefaultAPI.md#getuser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID +*DefaultAPI* | [**GetUserActivityStats**](docs/DefaultAPI.md#getuseractivitystats) | **Get** /api/hypershell/v1/users/stats | Get registered user activity statistics *DefaultAPI* | [**ListGatewayNetworks**](docs/DefaultAPI.md#listgatewaynetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks *DefaultAPI* | [**ListGatewayReleases**](docs/DefaultAPI.md#listgatewayreleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases *DefaultAPI* | [**ListGatewayServiceAccounts**](docs/DefaultAPI.md#listgatewayserviceaccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -156,6 +157,8 @@ Class | Method | HTTP request | Description - [RoleBindingList](docs/RoleBindingList.md) - [RoleList](docs/RoleList.md) - [User](docs/User.md) + - [UserActivityStats](docs/UserActivityStats.md) + - [UserDailyCount](docs/UserDailyCount.md) - [UserList](docs/UserList.md) diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 7f8abf98a..52680c188 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -2040,6 +2040,27 @@ paths: $ref: "#/components/schemas/Error" description: Unexpected error occurred summary: List registered users + /api/hypershell/v1/users/stats: + get: + operationId: getUserActivityStats + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserActivityStats" + description: Registered and active user statistics + "401": + description: Access token is missing or invalid + "403": + description: Access token does not have sufficient privileges + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + description: Unexpected error occurred + summary: Get registered user activity statistics /api/hypershell/v1/users/{id}: get: operationId: getUser @@ -3500,6 +3521,82 @@ components: href: href email: email username: username + UserActivityStats: + example: + registration_daily: + - date: 2000-01-23 + count: 0 + - date: 2000-01-23 + count: 0 + registered_last_30_days: 0 + active_daily: + - date: 2000-01-23 + count: 0 + - date: 2000-01-23 + count: 0 + total_registered: 0 + registered_last_7_days: 0 + active_last_30_days: 0 + active_last_7_days: 0 + properties: + total_registered: + format: int64 + minimum: 0 + type: integer + registered_last_7_days: + format: int64 + minimum: 0 + type: integer + registered_last_30_days: + format: int64 + minimum: 0 + type: integer + active_last_7_days: + format: int64 + minimum: 0 + type: integer + active_last_30_days: + format: int64 + minimum: 0 + type: integer + registration_daily: + description: New registrations per UTC day for the last 30 calendar days + (inclusive) + items: + $ref: "#/components/schemas/UserDailyCount" + type: array + active_daily: + description: Distinct active users per UTC day for the last 30 calendar + days (inclusive) + items: + $ref: "#/components/schemas/UserDailyCount" + type: array + required: + - active_daily + - active_last_30_days + - active_last_7_days + - registered_last_30_days + - registered_last_7_days + - registration_daily + - total_registered + type: object + UserDailyCount: + example: + date: 2000-01-23 + count: 0 + properties: + date: + description: UTC calendar day (YYYY-MM-DD) + format: date + type: string + count: + format: int64 + minimum: 0 + type: integer + required: + - count + - date + type: object GatewayCreateRequest: example: phase: phase diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 9bdc52156..e39142128 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -3384,6 +3384,114 @@ func (a *DefaultAPIService) GetUserExecute(r ApiGetUserRequest) (*User, *http.Re return localVarReturnValue, localVarHTTPResponse, nil } +type ApiGetUserActivityStatsRequest struct { + ctx context.Context + ApiService *DefaultAPIService +} + +func (r ApiGetUserActivityStatsRequest) Execute() (*UserActivityStats, *http.Response, error) { + return r.ApiService.GetUserActivityStatsExecute(r) +} + +/* +GetUserActivityStats Get registered user activity statistics + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiGetUserActivityStatsRequest +*/ +func (a *DefaultAPIService) GetUserActivityStats(ctx context.Context) ApiGetUserActivityStatsRequest { + return ApiGetUserActivityStatsRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return UserActivityStats +func (a *DefaultAPIService) GetUserActivityStatsExecute(r ApiGetUserActivityStatsRequest) (*UserActivityStats, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UserActivityStats + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GetUserActivityStats") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/hypershell/v1/users/stats" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiListGatewayNetworksRequest struct { ctx context.Context ApiService *DefaultAPIService diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index 31fac40bc..fe35fb9d6 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -28,6 +28,7 @@ Method | HTTP request | Description [**GetRole**](DefaultAPI.md#GetRole) | **Get** /api/hypershell/v1/roles/{id} | Get a role by ID [**GetRoleBinding**](DefaultAPI.md#GetRoleBinding) | **Get** /api/hypershell/v1/role_bindings/{id} | Get a role binding by ID [**GetUser**](DefaultAPI.md#GetUser) | **Get** /api/hypershell/v1/users/{id} | Get a registered user by ID +[**GetUserActivityStats**](DefaultAPI.md#GetUserActivityStats) | **Get** /api/hypershell/v1/users/stats | Get registered user activity statistics [**ListGatewayNetworks**](DefaultAPI.md#ListGatewayNetworks) | **Get** /api/hypershell/v1/gateway_networks | Returns a list of gatewayNetworks [**ListGatewayReleases**](DefaultAPI.md#ListGatewayReleases) | **Get** /api/hypershell/v1/gateway_releases | Returns a list of gatewayReleases [**ListGatewayServiceAccounts**](DefaultAPI.md#ListGatewayServiceAccounts) | **Get** /api/hypershell/v1/gateways/{gateway_id}/service_accounts | List OpenShell gateway service accounts @@ -1641,6 +1642,65 @@ Name | Type | Description | Notes [[Back to README]](../README.md) +## GetUserActivityStats + +> UserActivityStats GetUserActivityStats(ctx).Execute() + +Get registered user activity statistics + +### Example + +```go +package main + +import ( + "context" + "fmt" + "os" + openapiclient "github.com/GIT_USER_ID/GIT_REPO_ID" +) + +func main() { + + configuration := openapiclient.NewConfiguration() + apiClient := openapiclient.NewAPIClient(configuration) + resp, r, err := apiClient.DefaultAPI.GetUserActivityStats(context.Background()).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GetUserActivityStats``: %v\n", err) + fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + } + // response from `GetUserActivityStats`: UserActivityStats + fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GetUserActivityStats`: %v\n", resp) +} +``` + +### Path Parameters + +This endpoint does not need any parameter. + +### Other Parameters + +Other parameters are passed through a pointer to a apiGetUserActivityStatsRequest struct via the builder pattern + + +### Return type + +[**UserActivityStats**](UserActivityStats.md) + +### Authorization + +[Bearer](../README.md#Bearer) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## ListGatewayNetworks > GatewayNetworkList ListGatewayNetworks(ctx).Page(page).Size(size).Search(search).OrderBy(orderBy).Fields(fields).Execute() diff --git a/components/api-server/pkg/api/openapi/docs/UserActivityStats.md b/components/api-server/pkg/api/openapi/docs/UserActivityStats.md new file mode 100644 index 000000000..254e94000 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/UserActivityStats.md @@ -0,0 +1,177 @@ +# UserActivityStats + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**TotalRegistered** | **int64** | | +**RegisteredLast7Days** | **int64** | | +**RegisteredLast30Days** | **int64** | | +**ActiveLast7Days** | **int64** | | +**ActiveLast30Days** | **int64** | | +**RegistrationDaily** | [**[]UserDailyCount**](UserDailyCount.md) | New registrations per UTC day for the last 30 calendar days (inclusive) | +**ActiveDaily** | [**[]UserDailyCount**](UserDailyCount.md) | Distinct active users per UTC day for the last 30 calendar days (inclusive) | + +## Methods + +### NewUserActivityStats + +`func NewUserActivityStats(totalRegistered int64, registeredLast7Days int64, registeredLast30Days int64, activeLast7Days int64, activeLast30Days int64, registrationDaily []UserDailyCount, activeDaily []UserDailyCount, ) *UserActivityStats` + +NewUserActivityStats instantiates a new UserActivityStats object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewUserActivityStatsWithDefaults + +`func NewUserActivityStatsWithDefaults() *UserActivityStats` + +NewUserActivityStatsWithDefaults instantiates a new UserActivityStats object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetTotalRegistered + +`func (o *UserActivityStats) GetTotalRegistered() int64` + +GetTotalRegistered returns the TotalRegistered field if non-nil, zero value otherwise. + +### GetTotalRegisteredOk + +`func (o *UserActivityStats) GetTotalRegisteredOk() (*int64, bool)` + +GetTotalRegisteredOk returns a tuple with the TotalRegistered field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetTotalRegistered + +`func (o *UserActivityStats) SetTotalRegistered(v int64)` + +SetTotalRegistered sets TotalRegistered field to given value. + + +### GetRegisteredLast7Days + +`func (o *UserActivityStats) GetRegisteredLast7Days() int64` + +GetRegisteredLast7Days returns the RegisteredLast7Days field if non-nil, zero value otherwise. + +### GetRegisteredLast7DaysOk + +`func (o *UserActivityStats) GetRegisteredLast7DaysOk() (*int64, bool)` + +GetRegisteredLast7DaysOk returns a tuple with the RegisteredLast7Days field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRegisteredLast7Days + +`func (o *UserActivityStats) SetRegisteredLast7Days(v int64)` + +SetRegisteredLast7Days sets RegisteredLast7Days field to given value. + + +### GetRegisteredLast30Days + +`func (o *UserActivityStats) GetRegisteredLast30Days() int64` + +GetRegisteredLast30Days returns the RegisteredLast30Days field if non-nil, zero value otherwise. + +### GetRegisteredLast30DaysOk + +`func (o *UserActivityStats) GetRegisteredLast30DaysOk() (*int64, bool)` + +GetRegisteredLast30DaysOk returns a tuple with the RegisteredLast30Days field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRegisteredLast30Days + +`func (o *UserActivityStats) SetRegisteredLast30Days(v int64)` + +SetRegisteredLast30Days sets RegisteredLast30Days field to given value. + + +### GetActiveLast7Days + +`func (o *UserActivityStats) GetActiveLast7Days() int64` + +GetActiveLast7Days returns the ActiveLast7Days field if non-nil, zero value otherwise. + +### GetActiveLast7DaysOk + +`func (o *UserActivityStats) GetActiveLast7DaysOk() (*int64, bool)` + +GetActiveLast7DaysOk returns a tuple with the ActiveLast7Days field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetActiveLast7Days + +`func (o *UserActivityStats) SetActiveLast7Days(v int64)` + +SetActiveLast7Days sets ActiveLast7Days field to given value. + + +### GetActiveLast30Days + +`func (o *UserActivityStats) GetActiveLast30Days() int64` + +GetActiveLast30Days returns the ActiveLast30Days field if non-nil, zero value otherwise. + +### GetActiveLast30DaysOk + +`func (o *UserActivityStats) GetActiveLast30DaysOk() (*int64, bool)` + +GetActiveLast30DaysOk returns a tuple with the ActiveLast30Days field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetActiveLast30Days + +`func (o *UserActivityStats) SetActiveLast30Days(v int64)` + +SetActiveLast30Days sets ActiveLast30Days field to given value. + + +### GetRegistrationDaily + +`func (o *UserActivityStats) GetRegistrationDaily() []UserDailyCount` + +GetRegistrationDaily returns the RegistrationDaily field if non-nil, zero value otherwise. + +### GetRegistrationDailyOk + +`func (o *UserActivityStats) GetRegistrationDailyOk() (*[]UserDailyCount, bool)` + +GetRegistrationDailyOk returns a tuple with the RegistrationDaily field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetRegistrationDaily + +`func (o *UserActivityStats) SetRegistrationDaily(v []UserDailyCount)` + +SetRegistrationDaily sets RegistrationDaily field to given value. + + +### GetActiveDaily + +`func (o *UserActivityStats) GetActiveDaily() []UserDailyCount` + +GetActiveDaily returns the ActiveDaily field if non-nil, zero value otherwise. + +### GetActiveDailyOk + +`func (o *UserActivityStats) GetActiveDailyOk() (*[]UserDailyCount, bool)` + +GetActiveDailyOk returns a tuple with the ActiveDaily field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetActiveDaily + +`func (o *UserActivityStats) SetActiveDaily(v []UserDailyCount)` + +SetActiveDaily sets ActiveDaily field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/docs/UserDailyCount.md b/components/api-server/pkg/api/openapi/docs/UserDailyCount.md new file mode 100644 index 000000000..7aa240253 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/UserDailyCount.md @@ -0,0 +1,72 @@ +# UserDailyCount + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Date** | **string** | UTC calendar day (YYYY-MM-DD) | +**Count** | **int64** | | + +## Methods + +### NewUserDailyCount + +`func NewUserDailyCount(date string, count int64, ) *UserDailyCount` + +NewUserDailyCount instantiates a new UserDailyCount object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewUserDailyCountWithDefaults + +`func NewUserDailyCountWithDefaults() *UserDailyCount` + +NewUserDailyCountWithDefaults instantiates a new UserDailyCount object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetDate + +`func (o *UserDailyCount) GetDate() string` + +GetDate returns the Date field if non-nil, zero value otherwise. + +### GetDateOk + +`func (o *UserDailyCount) GetDateOk() (*string, bool)` + +GetDateOk returns a tuple with the Date field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetDate + +`func (o *UserDailyCount) SetDate(v string)` + +SetDate sets Date field to given value. + + +### GetCount + +`func (o *UserDailyCount) GetCount() int64` + +GetCount returns the Count field if non-nil, zero value otherwise. + +### GetCountOk + +`func (o *UserDailyCount) GetCountOk() (*int64, bool)` + +GetCountOk returns a tuple with the Count field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetCount + +`func (o *UserDailyCount) SetCount(v int64)` + +SetCount sets Count field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/model_user_activity_stats.go b/components/api-server/pkg/api/openapi/model_user_activity_stats.go new file mode 100644 index 000000000..549c1e9ac --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_user_activity_stats.go @@ -0,0 +1,326 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the UserActivityStats type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserActivityStats{} + +// UserActivityStats struct for UserActivityStats +type UserActivityStats struct { + TotalRegistered int64 `json:"total_registered"` + RegisteredLast7Days int64 `json:"registered_last_7_days"` + RegisteredLast30Days int64 `json:"registered_last_30_days"` + ActiveLast7Days int64 `json:"active_last_7_days"` + ActiveLast30Days int64 `json:"active_last_30_days"` + // New registrations per UTC day for the last 30 calendar days (inclusive) + RegistrationDaily []UserDailyCount `json:"registration_daily"` + // Distinct active users per UTC day for the last 30 calendar days (inclusive) + ActiveDaily []UserDailyCount `json:"active_daily"` +} + +type _UserActivityStats UserActivityStats + +// NewUserActivityStats instantiates a new UserActivityStats object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserActivityStats(totalRegistered int64, registeredLast7Days int64, registeredLast30Days int64, activeLast7Days int64, activeLast30Days int64, registrationDaily []UserDailyCount, activeDaily []UserDailyCount) *UserActivityStats { + this := UserActivityStats{} + this.TotalRegistered = totalRegistered + this.RegisteredLast7Days = registeredLast7Days + this.RegisteredLast30Days = registeredLast30Days + this.ActiveLast7Days = activeLast7Days + this.ActiveLast30Days = activeLast30Days + this.RegistrationDaily = registrationDaily + this.ActiveDaily = activeDaily + return &this +} + +// NewUserActivityStatsWithDefaults instantiates a new UserActivityStats object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserActivityStatsWithDefaults() *UserActivityStats { + this := UserActivityStats{} + return &this +} + +// GetTotalRegistered returns the TotalRegistered field value +func (o *UserActivityStats) GetTotalRegistered() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.TotalRegistered +} + +// GetTotalRegisteredOk returns a tuple with the TotalRegistered field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetTotalRegisteredOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.TotalRegistered, true +} + +// SetTotalRegistered sets field value +func (o *UserActivityStats) SetTotalRegistered(v int64) { + o.TotalRegistered = v +} + +// GetRegisteredLast7Days returns the RegisteredLast7Days field value +func (o *UserActivityStats) GetRegisteredLast7Days() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.RegisteredLast7Days +} + +// GetRegisteredLast7DaysOk returns a tuple with the RegisteredLast7Days field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetRegisteredLast7DaysOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.RegisteredLast7Days, true +} + +// SetRegisteredLast7Days sets field value +func (o *UserActivityStats) SetRegisteredLast7Days(v int64) { + o.RegisteredLast7Days = v +} + +// GetRegisteredLast30Days returns the RegisteredLast30Days field value +func (o *UserActivityStats) GetRegisteredLast30Days() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.RegisteredLast30Days +} + +// GetRegisteredLast30DaysOk returns a tuple with the RegisteredLast30Days field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetRegisteredLast30DaysOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.RegisteredLast30Days, true +} + +// SetRegisteredLast30Days sets field value +func (o *UserActivityStats) SetRegisteredLast30Days(v int64) { + o.RegisteredLast30Days = v +} + +// GetActiveLast7Days returns the ActiveLast7Days field value +func (o *UserActivityStats) GetActiveLast7Days() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.ActiveLast7Days +} + +// GetActiveLast7DaysOk returns a tuple with the ActiveLast7Days field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetActiveLast7DaysOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.ActiveLast7Days, true +} + +// SetActiveLast7Days sets field value +func (o *UserActivityStats) SetActiveLast7Days(v int64) { + o.ActiveLast7Days = v +} + +// GetActiveLast30Days returns the ActiveLast30Days field value +func (o *UserActivityStats) GetActiveLast30Days() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.ActiveLast30Days +} + +// GetActiveLast30DaysOk returns a tuple with the ActiveLast30Days field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetActiveLast30DaysOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.ActiveLast30Days, true +} + +// SetActiveLast30Days sets field value +func (o *UserActivityStats) SetActiveLast30Days(v int64) { + o.ActiveLast30Days = v +} + +// GetRegistrationDaily returns the RegistrationDaily field value +func (o *UserActivityStats) GetRegistrationDaily() []UserDailyCount { + if o == nil { + var ret []UserDailyCount + return ret + } + + return o.RegistrationDaily +} + +// GetRegistrationDailyOk returns a tuple with the RegistrationDaily field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetRegistrationDailyOk() ([]UserDailyCount, bool) { + if o == nil { + return nil, false + } + return o.RegistrationDaily, true +} + +// SetRegistrationDaily sets field value +func (o *UserActivityStats) SetRegistrationDaily(v []UserDailyCount) { + o.RegistrationDaily = v +} + +// GetActiveDaily returns the ActiveDaily field value +func (o *UserActivityStats) GetActiveDaily() []UserDailyCount { + if o == nil { + var ret []UserDailyCount + return ret + } + + return o.ActiveDaily +} + +// GetActiveDailyOk returns a tuple with the ActiveDaily field value +// and a boolean to check if the value has been set. +func (o *UserActivityStats) GetActiveDailyOk() ([]UserDailyCount, bool) { + if o == nil { + return nil, false + } + return o.ActiveDaily, true +} + +// SetActiveDaily sets field value +func (o *UserActivityStats) SetActiveDaily(v []UserDailyCount) { + o.ActiveDaily = v +} + +func (o UserActivityStats) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserActivityStats) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["total_registered"] = o.TotalRegistered + toSerialize["registered_last_7_days"] = o.RegisteredLast7Days + toSerialize["registered_last_30_days"] = o.RegisteredLast30Days + toSerialize["active_last_7_days"] = o.ActiveLast7Days + toSerialize["active_last_30_days"] = o.ActiveLast30Days + toSerialize["registration_daily"] = o.RegistrationDaily + toSerialize["active_daily"] = o.ActiveDaily + return toSerialize, nil +} + +func (o *UserActivityStats) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "total_registered", + "registered_last_7_days", + "registered_last_30_days", + "active_last_7_days", + "active_last_30_days", + "registration_daily", + "active_daily", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserActivityStats := _UserActivityStats{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varUserActivityStats) + + if err != nil { + return err + } + + *o = UserActivityStats(varUserActivityStats) + + return err +} + +type NullableUserActivityStats struct { + value *UserActivityStats + isSet bool +} + +func (v NullableUserActivityStats) Get() *UserActivityStats { + return v.value +} + +func (v *NullableUserActivityStats) Set(val *UserActivityStats) { + v.value = val + v.isSet = true +} + +func (v NullableUserActivityStats) IsSet() bool { + return v.isSet +} + +func (v *NullableUserActivityStats) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserActivityStats(val *UserActivityStats) *NullableUserActivityStats { + return &NullableUserActivityStats{value: val, isSet: true} +} + +func (v NullableUserActivityStats) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserActivityStats) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi/model_user_daily_count.go b/components/api-server/pkg/api/openapi/model_user_daily_count.go new file mode 100644 index 000000000..ec1638a54 --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_user_daily_count.go @@ -0,0 +1,185 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the UserDailyCount type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UserDailyCount{} + +// UserDailyCount struct for UserDailyCount +type UserDailyCount struct { + // UTC calendar day (YYYY-MM-DD) + Date string `json:"date"` + Count int64 `json:"count"` +} + +type _UserDailyCount UserDailyCount + +// NewUserDailyCount instantiates a new UserDailyCount object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUserDailyCount(date string, count int64) *UserDailyCount { + this := UserDailyCount{} + this.Date = date + this.Count = count + return &this +} + +// NewUserDailyCountWithDefaults instantiates a new UserDailyCount object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUserDailyCountWithDefaults() *UserDailyCount { + this := UserDailyCount{} + return &this +} + +// GetDate returns the Date field value +func (o *UserDailyCount) GetDate() string { + if o == nil { + var ret string + return ret + } + + return o.Date +} + +// GetDateOk returns a tuple with the Date field value +// and a boolean to check if the value has been set. +func (o *UserDailyCount) GetDateOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Date, true +} + +// SetDate sets field value +func (o *UserDailyCount) SetDate(v string) { + o.Date = v +} + +// GetCount returns the Count field value +func (o *UserDailyCount) GetCount() int64 { + if o == nil { + var ret int64 + return ret + } + + return o.Count +} + +// GetCountOk returns a tuple with the Count field value +// and a boolean to check if the value has been set. +func (o *UserDailyCount) GetCountOk() (*int64, bool) { + if o == nil { + return nil, false + } + return &o.Count, true +} + +// SetCount sets field value +func (o *UserDailyCount) SetCount(v int64) { + o.Count = v +} + +func (o UserDailyCount) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UserDailyCount) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["date"] = o.Date + toSerialize["count"] = o.Count + return toSerialize, nil +} + +func (o *UserDailyCount) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "date", + "count", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varUserDailyCount := _UserDailyCount{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varUserDailyCount) + + if err != nil { + return err + } + + *o = UserDailyCount(varUserDailyCount) + + return err +} + +type NullableUserDailyCount struct { + value *UserDailyCount + isSet bool +} + +func (v NullableUserDailyCount) Get() *UserDailyCount { + return v.value +} + +func (v *NullableUserDailyCount) Set(val *UserDailyCount) { + v.value = val + v.isSet = true +} + +func (v NullableUserDailyCount) IsSet() bool { + return v.isSet +} + +func (v *NullableUserDailyCount) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUserDailyCount(val *UserDailyCount) *NullableUserDailyCount { + return &NullableUserDailyCount{value: val, isSet: true} +} + +func (v NullableUserDailyCount) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUserDailyCount) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/rbac/authorization.go b/components/api-server/pkg/rbac/authorization.go index f291902a6..7e6121bed 100644 --- a/components/api-server/pkg/rbac/authorization.go +++ b/components/api-server/pkg/rbac/authorization.go @@ -248,6 +248,10 @@ func isAuthorized(method string, resource string, resourceID string, gatewayID s return hasUsersInventoryAccess(bindings, jwtRoles) } + if resource == "stats" && method == http.MethodGet { + return hasUsersInventoryAccess(bindings, jwtRoles) + } + if (resource == "managed_clusters" || resource == "managed_databases") && method == http.MethodGet && resourceID == "" { return hasDashboardInventoryAccess(bindings, jwtRoles) diff --git a/components/api-server/pkg/rbac/authorization_test.go b/components/api-server/pkg/rbac/authorization_test.go index 59037bbee..8928080e0 100644 --- a/components/api-server/pkg/rbac/authorization_test.go +++ b/components/api-server/pkg/rbac/authorization_test.go @@ -309,6 +309,22 @@ func TestIsAuthorized_UsersInventoryRequiresDashboardOperator(t *testing.T) { } } +func TestIsAuthorized_UserActivityStatsRequiresDashboardOperator(t *testing.T) { + creatorOnly := []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}} + if isAuthorized(http.MethodGet, "stats", "", "", creatorOnly, nil) { + t.Error("gateway:creator binding must not read user activity stats") + } + + platformAdmin := []BindingSummary{{RoleName: "platform:admin", Scope: "global"}} + if !isAuthorized(http.MethodGet, "stats", "", "", platformAdmin, nil) { + t.Error("platform:admin should read user activity stats") + } + + if !isAuthorized(http.MethodGet, "stats", "", "", nil, []string{HypershellAdminRole}) { + t.Error("hypershell-admins JWT role should read user activity stats") + } +} + func TestIsAuthorized_ManagedInventoryListRequiresDashboardOperatorOrCreator(t *testing.T) { ownerOnly := []BindingSummary{ {RoleName: "gateway:owner", Scope: "gateway", GatewayID: strPtr("gw-1")}, @@ -350,6 +366,14 @@ func TestExtractResourceInfoFromPath_Users(t *testing.T) { } } +func TestExtractResourceInfoFromPath_UserActivityStats(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/users/stats", nil) + resource, resourceID := extractResourceInfoFromPath(request.URL.Path) + if resource != "users" || resourceID != "stats" { + t.Fatalf("resource = %q, id = %q", resource, resourceID) + } +} + func TestExtractResourceInfoRecognizesNestedServiceAccountRoutes(t *testing.T) { router := mux.NewRouter() router.HandleFunc("/api/hypershell/v1/gateways/{gateway_id}/service_accounts/{service_account_id}/revoke", func(w http.ResponseWriter, r *http.Request) { @@ -455,6 +479,28 @@ func TestAuthorizeApiDeniesGatewayCreatorOnUsersList(t *testing.T) { } } +func TestAuthorizeApiDeniesGatewayCreatorBindingOnUsersStats(t *testing.T) { + lookup := authorizationLookup{bindings: []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}}} + middleware := NewRBACAuthzMiddleware(lookup, AuthzConfig{EnforceRBAC: true}) + + router := mux.NewRouter() + usersRouter := router.PathPrefix("/api/hypershell/v1/users").Subrouter() + usersRouter.Handle("/stats", middleware.AuthorizeApi(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("denied request reached the handler") + }))).Methods(http.MethodGet) + + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/users/stats", nil) + token := &jwt.Token{Claims: jwt.MapClaims{"preferred_username": "creator-user"}} + ctx := context.WithValue(request.Context(), auth.ContextAuthKey, token) + ctx = context.WithValue(ctx, ContextUserIDKey, "user-id") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request.WithContext(ctx)) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } +} + func TestAuthorizeApiConcealsDeniedUsersGet(t *testing.T) { lookup := authorizationLookup{bindings: []BindingSummary{{RoleName: "gateway:creator", Scope: "global"}}} middleware := NewRBACAuthzMiddleware(lookup, AuthzConfig{EnforceRBAC: true}) diff --git a/components/api-server/plugins/users/dao.go b/components/api-server/plugins/users/dao.go index 867257ff7..3cc37547a 100644 --- a/components/api-server/plugins/users/dao.go +++ b/components/api-server/plugins/users/dao.go @@ -2,6 +2,8 @@ package users import ( "context" + "fmt" + "time" "gorm.io/gorm/clause" @@ -18,6 +20,8 @@ type UserDao interface { Upsert(ctx context.Context, user *User) (*User, error) FindByIDs(ctx context.Context, ids []string) (UserList, error) All(ctx context.Context) (UserList, error) + RecordLogin(ctx context.Context, userID string, loginTime time.Time) error + GetActivityStats(ctx context.Context, evaluationTime time.Time) (*ActivityStats, error) } var _ UserDao = &sqlUserDao{} @@ -112,3 +116,119 @@ func (d *sqlUserDao) All(ctx context.Context) (UserList, error) { } return users, nil } + +func (d *sqlUserDao) RecordLogin(ctx context.Context, userID string, loginTime time.Time) error { + _ = ctx + // Best-effort telemetry on an independent session (see seedUserWithCreatedAt). + // Request-scoped transactions abort on any statement error in PostgreSQL, so + // tracking must not share the provisioning transaction even when the service + // logs and continues on failure. + g2 := (*d.sessionFactory).New(context.Background()) + loginAt := loginTime.UTC() + loginDay := utcDayStart(loginAt) + + loginRecord := UserLoginDay{ + UserID: userID, + LoginDate: loginDay, + } + result := g2.Clauses(clause.OnConflict{DoNothing: true}).Create(&loginRecord) + if result.Error != nil { + return fmt.Errorf("record login day: %w", result.Error) + } + + // Bump last_login_at only on the first authenticated request of each UTC day. + // Daily activity metrics come from user_login_days; skipping repeat updates + // within the same day avoids an unconditional write on every request. + if result.RowsAffected > 0 { + if err := g2.Model(&User{}).Where("id = ?", userID).Update("last_login_at", loginAt).Error; err != nil { + return fmt.Errorf("record login: %w", err) + } + } + + return nil +} + +type registrationDailyRow struct { + Date string + Count int64 +} + +type activeDailyRow struct { + Date string + Count int64 +} + +func (d *sqlUserDao) GetActivityStats(ctx context.Context, evaluationTime time.Time) (*ActivityStats, error) { + g2 := (*d.sessionFactory).New(ctx) + endDay := utcDayStart(evaluationTime) + startDay := dailySeriesStart(evaluationTime) + last7DayStart := registration7DayWindowStart(evaluationTime) + last30DayStart := registration30DayWindowStart(evaluationTime) + + stats := &ActivityStats{} + + if err := g2.Model(&User{}).Count(&stats.TotalRegistered).Error; err != nil { + return nil, fmt.Errorf("get activity stats: count total registered: %w", err) + } + + if err := g2.Model(&User{}). + Where("created_at >= ?", last7DayStart). + Count(&stats.RegisteredLast7Days).Error; err != nil { + return nil, fmt.Errorf("get activity stats: count registered last 7 days: %w", err) + } + + if err := g2.Model(&User{}). + Where("created_at >= ?", last30DayStart). + Count(&stats.RegisteredLast30Days).Error; err != nil { + return nil, fmt.Errorf("get activity stats: count registered last 30 days: %w", err) + } + + if err := g2.Raw( + "SELECT COUNT(DISTINCT user_id) FROM user_login_days WHERE login_date >= ?", + last7DayStart, + ).Scan(&stats.ActiveLast7Days).Error; err != nil { + return nil, fmt.Errorf("get activity stats: count active last 7 days: %w", err) + } + + if err := g2.Raw( + "SELECT COUNT(DISTINCT user_id) FROM user_login_days WHERE login_date >= ?", + last30DayStart, + ).Scan(&stats.ActiveLast30Days).Error; err != nil { + return nil, fmt.Errorf("get activity stats: count active last 30 days: %w", err) + } + + var registrationRows []registrationDailyRow + if err := g2.Model(&User{}). + Select("TO_CHAR(DATE(created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS date, COUNT(*) AS count"). + Where("created_at >= ?", last30DayStart). + Group("DATE(created_at AT TIME ZONE 'UTC')"). + Order("DATE(created_at AT TIME ZONE 'UTC') ASC"). + Scan(®istrationRows).Error; err != nil { + return nil, fmt.Errorf("get activity stats: registration daily series: %w", err) + } + + var activeRows []activeDailyRow + if err := g2.Model(&UserLoginDay{}). + Select("TO_CHAR(login_date, 'YYYY-MM-DD') AS date, COUNT(DISTINCT user_id) AS count"). + Where("login_date >= ?", last30DayStart). + Group("login_date"). + Order("login_date ASC"). + Scan(&activeRows).Error; err != nil { + return nil, fmt.Errorf("get activity stats: active daily series: %w", err) + } + + registrationCounts := make(map[string]int64, len(registrationRows)) + for _, row := range registrationRows { + registrationCounts[row.Date] = row.Count + } + + activeCounts := make(map[string]int64, len(activeRows)) + for _, row := range activeRows { + activeCounts[row.Date] = row.Count + } + + stats.RegistrationDaily = buildDailySeries(startDay, endDay, registrationCounts) + stats.ActiveDaily = buildDailySeries(startDay, endDay, activeCounts) + + return stats, nil +} diff --git a/components/api-server/plugins/users/handler.go b/components/api-server/plugins/users/handler.go index 8cae0c59f..fda33d626 100644 --- a/components/api-server/plugins/users/handler.go +++ b/components/api-server/plugins/users/handler.go @@ -85,3 +85,17 @@ func (h userHandler) Get(w http.ResponseWriter, r *http.Request) { handlers.HandleGet(w, r, cfg) } + +func (h userHandler) Stats(w http.ResponseWriter, r *http.Request) { + cfg := &handlers.HandlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + stats, err := h.user.GetActivityStats(r.Context()) + if err != nil { + return nil, err + } + return PresentActivityStats(stats), nil + }, + } + + handlers.HandleGet(w, r, cfg) +} diff --git a/components/api-server/plugins/users/integration_test.go b/components/api-server/plugins/users/integration_test.go index 669010de7..2a09a2a32 100644 --- a/components/api-server/plugins/users/integration_test.go +++ b/components/api-server/plugins/users/integration_test.go @@ -10,8 +10,10 @@ import ( "github.com/golang-jwt/jwt/v4" . "github.com/onsi/gomega" + "github.com/openshift-online/hypershell/components/api-server/pkg/api" "github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi" "github.com/openshift-online/hypershell/components/api-server/pkg/rbac" + "github.com/openshift-online/hypershell/components/api-server/plugins/roleBindings" "github.com/openshift-online/hypershell/components/api-server/plugins/roles" "github.com/openshift-online/hypershell/components/api-server/plugins/users" "github.com/openshift-online/hypershell/components/api-server/test" @@ -137,3 +139,108 @@ func TestUserGet_AllowedForAuthorizedCaller(t *testing.T) { Expect(user.Username).NotTo(BeEmpty()) Expect(user.CreatedAt).NotTo(BeNil()) } + +func TestUserActivityStats_AllowedForHypershellAdmin(t *testing.T) { + h, client := test.RegisterIntegration(t) + + seedUsers(3) + account := h.NewAccount("stats-admin", "Stats Admin", "stats@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{rbac.HypershellAdminRole}) + + stats, resp, err := client.DefaultAPI.GetUserActivityStats(ctx).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(stats.TotalRegistered).To(BeNumerically(">=", 3)) + Expect(len(stats.RegistrationDaily)).To(Equal(30)) + Expect(len(stats.ActiveDaily)).To(Equal(30)) +} + +func TestUserActivityStats_ForbiddenForGatewayCreator(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewAccount("stats-creator", "Stats Creator", "creator@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{roles.RoleGatewayCreator}) + + _, resp, err := client.DefaultAPI.GetUserActivityStats(ctx).Execute() + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) +} + +func TestUserActivityStats_ForbiddenForGatewayCreatorBinding(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewAccount("stats-bound-creator", "Bound Creator", "bound-creator@example.com") + ctx := h.NewAuthenticatedContext(account) + + userService := users.Service(&environments.Environment().Services) + userID, userErr := userService.UpsertByUsername(context.Background(), account.Username, nil, nil) + Expect(userErr).NotTo(HaveOccurred()) + + roleService := roles.Service(&environments.Environment().Services) + creatorRole, roleErr := roleService.GetByName(context.Background(), roles.RoleGatewayCreator) + Expect(roleErr).NotTo(HaveOccurred()) + + rbDao := roleBindings.NewRoleBindingDao(&environments.Environment().Database.SessionFactory) + _, bindErr := rbDao.Create(context.Background(), &roleBindings.RoleBinding{ + RoleID: creatorRole.ID, + Scope: roleBindings.ScopeGlobal, + UserID: &userID, + }) + Expect(bindErr).NotTo(HaveOccurred()) + + _, resp, err := client.DefaultAPI.GetUserActivityStats(ctx).Execute() + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) +} + +func seedUserWithCreatedAt(username string, createdAt time.Time) { + env := environments.Environment() + g2 := env.Database.SessionFactory.New(context.Background()) + now := time.Now().UTC() + Expect( + g2.Exec( + `INSERT INTO users (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)`, + api.NewID(), + username, + createdAt, + now, + ).Error, + ).NotTo(HaveOccurred()) +} + +func TestUserActivityStats_RegistrationWindowBoundaries(t *testing.T) { + _, _ = test.RegisterIntegration(t) + + evaluationTime := time.Date(2026, 9, 8, 15, 30, 0, 0, time.UTC) + endDay := time.Date(2026, 9, 8, 0, 0, 0, 0, time.UTC) + last7DayStart := endDay.AddDate(0, 0, -6) + last30DayStart := endDay.AddDate(0, 0, -29) + + seedUserWithCreatedAt("boundary-7-in", last7DayStart) + seedUserWithCreatedAt("boundary-7-out", last7DayStart.Add(-time.Nanosecond)) + seedUserWithCreatedAt("boundary-30-in", last30DayStart) + seedUserWithCreatedAt("boundary-30-out", last30DayStart.Add(-time.Nanosecond)) + + dao := users.NewUserDao(&environments.Environment().Database.SessionFactory) + stats, err := dao.GetActivityStats(context.Background(), evaluationTime) + Expect(err).NotTo(HaveOccurred()) + Expect(stats.TotalRegistered).To(Equal(int64(4))) + Expect(stats.RegisteredLast7Days).To(Equal(int64(1))) + Expect(stats.RegisteredLast30Days).To(Equal(int64(3))) +} + +func TestUserActivityStats_RecordsLoginOnProvisioning(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewAccount("login-tracker", "Login Tracker", "login@example.com") + ctx := jwtContextWithRealmRoles(h, account, []string{rbac.HypershellAdminRole}) + + _, resp, err := client.DefaultAPI.ListUsers(ctx).Page(1).Size(1).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + stats, statsResp, statsErr := client.DefaultAPI.GetUserActivityStats(ctx).Execute() + Expect(statsErr).NotTo(HaveOccurred()) + Expect(statsResp.StatusCode).To(Equal(http.StatusOK)) + Expect(stats.ActiveLast7Days).To(BeNumerically(">=", 1)) +} diff --git a/components/api-server/plugins/users/migration.go b/components/api-server/plugins/users/migration.go index cace69e12..fa7464e4b 100644 --- a/components/api-server/plugins/users/migration.go +++ b/components/api-server/plugins/users/migration.go @@ -1,6 +1,8 @@ package users import ( + "time" + "gorm.io/gorm" "github.com/go-gormigrate/gormigrate/v2" @@ -25,3 +27,24 @@ func migration() *gormigrate.Migration { }, } } + +func activityStatsMigration() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "2026090816000001", + Migrate: func(tx *gorm.DB) error { + type User struct { + LastLoginAt *time.Time + } + if err := tx.AutoMigrate(&User{}); err != nil { + return err + } + return tx.AutoMigrate(&UserLoginDay{}) + }, + Rollback: func(tx *gorm.DB) error { + if err := tx.Migrator().DropTable(&UserLoginDay{}); err != nil { + return err + } + return tx.Migrator().DropColumn(&User{}, "last_login_at") + }, + } +} diff --git a/components/api-server/plugins/users/model.go b/components/api-server/plugins/users/model.go index 8cbf29008..4ef5648a5 100644 --- a/components/api-server/plugins/users/model.go +++ b/components/api-server/plugins/users/model.go @@ -1,15 +1,23 @@ package users import ( + "time" + "github.com/openshift-online/rh-trex-ai/pkg/api" "gorm.io/gorm" ) type User struct { api.Meta - Username string `json:"username" gorm:"uniqueIndex"` - Email *string `json:"email"` - Name *string `json:"name"` + Username string `json:"username" gorm:"uniqueIndex"` + Email *string `json:"email"` + Name *string `json:"name"` + LastLoginAt *time.Time `json:"last_login_at"` +} + +type UserLoginDay struct { + UserID string `gorm:"primaryKey"` + LoginDate time.Time `gorm:"primaryKey;type:date"` } type UserList []*User diff --git a/components/api-server/plugins/users/plugin.go b/components/api-server/plugins/users/plugin.go index 3e4b00012..d91fb2a05 100644 --- a/components/api-server/plugins/users/plugin.go +++ b/components/api-server/plugins/users/plugin.go @@ -46,6 +46,7 @@ func init() { usersRouter := apiV1Router.PathPrefix("/users").Subrouter() usersRouter.HandleFunc("", userHandler.List).Methods(http.MethodGet) + usersRouter.HandleFunc("/stats", userHandler.Stats).Methods(http.MethodGet) usersRouter.HandleFunc("/{id}", userHandler.Get).Methods(http.MethodGet) usersRouter.Use(authMiddleware.AuthenticateAccountJWT) usersRouter.Use(authzMiddleware.AuthorizeApi) @@ -57,4 +58,5 @@ func init() { presenters.RegisterKind(&User{}, "User") db.RegisterMigration(migration()) + db.RegisterMigration(activityStatsMigration()) } diff --git a/components/api-server/plugins/users/presenter.go b/components/api-server/plugins/users/presenter.go index 1b3fe3583..bf9bc3bcf 100644 --- a/components/api-server/plugins/users/presenter.go +++ b/components/api-server/plugins/users/presenter.go @@ -18,3 +18,31 @@ func PresentUser(user *User) openapi.User { Name: user.Name, } } + +func PresentActivityStats(stats *ActivityStats) openapi.UserActivityStats { + registrationDaily := make([]openapi.UserDailyCount, 0, len(stats.RegistrationDaily)) + for _, point := range stats.RegistrationDaily { + registrationDaily = append(registrationDaily, openapi.UserDailyCount{ + Date: point.Date, + Count: point.Count, + }) + } + + activeDaily := make([]openapi.UserDailyCount, 0, len(stats.ActiveDaily)) + for _, point := range stats.ActiveDaily { + activeDaily = append(activeDaily, openapi.UserDailyCount{ + Date: point.Date, + Count: point.Count, + }) + } + + return openapi.UserActivityStats{ + TotalRegistered: stats.TotalRegistered, + RegisteredLast7Days: stats.RegisteredLast7Days, + RegisteredLast30Days: stats.RegisteredLast30Days, + ActiveLast7Days: stats.ActiveLast7Days, + ActiveLast30Days: stats.ActiveLast30Days, + RegistrationDaily: registrationDaily, + ActiveDaily: activeDaily, + } +} diff --git a/components/api-server/plugins/users/service.go b/components/api-server/plugins/users/service.go index 00c174491..0a51973b6 100644 --- a/components/api-server/plugins/users/service.go +++ b/components/api-server/plugins/users/service.go @@ -2,6 +2,9 @@ package users import ( "context" + "time" + + "github.com/golang/glog" "github.com/openshift-online/rh-trex-ai/pkg/errors" "github.com/openshift-online/rh-trex-ai/pkg/services" @@ -15,6 +18,7 @@ type UserService interface { UpsertByUsername(ctx context.Context, username string, email *string, name *string) (string, error) All(ctx context.Context) (UserList, *errors.ServiceError) FindByIDs(ctx context.Context, ids []string) (UserList, *errors.ServiceError) + GetActivityStats(ctx context.Context) (*ActivityStats, *errors.ServiceError) } func NewUserService(userDao UserDao) UserService { @@ -71,6 +75,9 @@ func (s *sqlUserService) UpsertByUsername(ctx context.Context, username string, if err != nil { return "", err } + if recordErr := s.userDao.RecordLogin(ctx, user.ID, time.Now().UTC()); recordErr != nil { + glog.Warningf("record login failed for user %q: %v", user.ID, recordErr) + } return user.ID, nil } @@ -89,3 +96,11 @@ func (s *sqlUserService) FindByIDs(ctx context.Context, ids []string) (UserList, } return users, nil } + +func (s *sqlUserService) GetActivityStats(ctx context.Context) (*ActivityStats, *errors.ServiceError) { + stats, err := s.userDao.GetActivityStats(ctx, time.Now().UTC()) + if err != nil { + return nil, errors.GeneralError("Unable to get user activity stats: %s", err) + } + return stats, nil +} diff --git a/components/api-server/plugins/users/stats.go b/components/api-server/plugins/users/stats.go new file mode 100644 index 000000000..ce11a09be --- /dev/null +++ b/components/api-server/plugins/users/stats.go @@ -0,0 +1,58 @@ +package users + +import "time" + +const activityLookbackDays = 30 + +type DailyCount struct { + Date string + Count int64 +} + +type ActivityStats struct { + TotalRegistered int64 + RegisteredLast7Days int64 + RegisteredLast30Days int64 + ActiveLast7Days int64 + ActiveLast30Days int64 + RegistrationDaily []DailyCount + ActiveDaily []DailyCount +} + +func utcDayStart(value time.Time) time.Time { + year, month, day := value.UTC().Date() + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) +} + +func formatUTCDate(value time.Time) string { + return utcDayStart(value).Format("2006-01-02") +} + +func dailySeriesStart(evaluationTime time.Time) time.Time { + endDay := utcDayStart(evaluationTime) + return endDay.AddDate(0, 0, -(activityLookbackDays - 1)) +} + +func registration7DayWindowStart(evaluationTime time.Time) time.Time { + return utcDayStart(evaluationTime).AddDate(0, 0, -6) +} + +func registration30DayWindowStart(evaluationTime time.Time) time.Time { + return dailySeriesStart(evaluationTime) +} + +func buildDailySeries( + startDay time.Time, + endDay time.Time, + counts map[string]int64, +) []DailyCount { + series := make([]DailyCount, 0, activityLookbackDays) + for day := startDay; !day.After(endDay); day = day.AddDate(0, 0, 1) { + date := formatUTCDate(day) + series = append(series, DailyCount{ + Date: date, + Count: counts[date], + }) + } + return series +} diff --git a/components/api-server/plugins/users/stats_test.go b/components/api-server/plugins/users/stats_test.go new file mode 100644 index 000000000..ee0253093 --- /dev/null +++ b/components/api-server/plugins/users/stats_test.go @@ -0,0 +1,87 @@ +package users + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" +) + +func TestBuildDailySeriesFillsMissingDaysWithZero(t *testing.T) { + RegisterTestingT(t) + + evaluationTime := time.Date(2026, 9, 8, 15, 30, 0, 0, time.UTC) + startDay := dailySeriesStart(evaluationTime) + endDay := utcDayStart(evaluationTime) + + series := buildDailySeries(startDay, endDay, map[string]int64{ + formatUTCDate(endDay): 3, + }) + + Expect(series).To(HaveLen(activityLookbackDays)) + Expect(series[0].Date).To(Equal(formatUTCDate(startDay))) + Expect(series[0].Count).To(Equal(int64(0))) + Expect(series[len(series)-1].Date).To(Equal(formatUTCDate(endDay))) + Expect(series[len(series)-1].Count).To(Equal(int64(3))) +} + +func TestFormatUTCDateUsesCalendarDay(t *testing.T) { + RegisterTestingT(t) + + value := time.Date(2026, 9, 8, 23, 59, 0, 0, time.UTC) + Expect(formatUTCDate(value)).To(Equal("2026-09-08")) +} + +func TestRegistrationWindowStartsUseInclusiveUTCCalendarDays(t *testing.T) { + RegisterTestingT(t) + + evaluationTime := time.Date(2026, 9, 8, 15, 30, 0, 0, time.UTC) + last7DayStart := registration7DayWindowStart(evaluationTime) + last30DayStart := registration30DayWindowStart(evaluationTime) + + Expect(last7DayStart).To(Equal(time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))) + Expect(last30DayStart).To(Equal(time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC))) + Expect(last7DayStart).To(Equal(last30DayStart.AddDate(0, 0, 23))) +} + +func registeredIn7DayWindow(createdAt time.Time, evaluationTime time.Time) bool { + return !createdAt.Before(registration7DayWindowStart(evaluationTime)) +} + +func registeredIn30DayWindow(createdAt time.Time, evaluationTime time.Time) bool { + return !createdAt.Before(registration30DayWindowStart(evaluationTime)) +} + +func TestRegistrationWindowBoundaryCounts(t *testing.T) { + RegisterTestingT(t) + + evaluationTime := time.Date(2026, 9, 8, 15, 30, 0, 0, time.UTC) + last7DayStart := registration7DayWindowStart(evaluationTime) + last30DayStart := registration30DayWindowStart(evaluationTime) + + createdAts := []time.Time{ + last7DayStart, + last7DayStart.Add(-time.Nanosecond), + last30DayStart, + last30DayStart.Add(-time.Nanosecond), + } + + registeredLast7Days := 0 + registeredLast30Days := 0 + for _, createdAt := range createdAts { + if registeredIn7DayWindow(createdAt, evaluationTime) { + registeredLast7Days++ + } + if registeredIn30DayWindow(createdAt, evaluationTime) { + registeredLast30Days++ + } + } + + Expect(registeredLast7Days).To(Equal(1)) + Expect(registeredLast30Days).To(Equal(3)) + Expect(registeredIn7DayWindow(last7DayStart, evaluationTime)).To(BeTrue()) + Expect(registeredIn7DayWindow(last7DayStart.Add(-time.Nanosecond), evaluationTime)).To(BeFalse()) + Expect(registeredIn30DayWindow(last30DayStart, evaluationTime)).To(BeTrue()) + Expect(registeredIn30DayWindow(last30DayStart.Add(-time.Nanosecond), evaluationTime)).To(BeFalse()) + Expect(registeredIn7DayWindow(last30DayStart, evaluationTime)).To(BeFalse()) +} diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index e2c2b95af..0fa6f332b 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 5917282d7..2a437b65b 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/gateway_network_api.go b/components/sdk-go/client/gateway_network_api.go index 7321cf074..b2617bb45 100644 --- a/components/sdk-go/client/gateway_network_api.go +++ b/components/sdk-go/client/gateway_network_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/gateway_release_api.go b/components/sdk-go/client/gateway_release_api.go index fad95baef..a5e498b78 100644 --- a/components/sdk-go/client/gateway_release_api.go +++ b/components/sdk-go/client/gateway_release_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index ef779f7c6..a1811f0d8 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index e79a943cc..0668ba592 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/managed_database_api.go b/components/sdk-go/client/managed_database_api.go index af0927f2c..0826bb1c1 100644 --- a/components/sdk-go/client/managed_database_api.go +++ b/components/sdk-go/client/managed_database_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index ab2a7f5d9..c4485614f 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 52212af8b..333e3d84e 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index d72f388f5..cc051fe83 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client diff --git a/components/sdk-go/client/user_api.go b/components/sdk-go/client/user_api.go index 52565b253..c1eb19abf 100644 --- a/components/sdk-go/client/user_api.go +++ b/components/sdk-go/client/user_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package client @@ -50,6 +50,14 @@ func (a *UserAPI) List(ctx context.Context, opts *types.ListOptions) (*types.Use return &result, nil } +func (a *UserAPI) ActivityStats(ctx context.Context) (*types.UserActivityStats, error) { + var result types.UserActivityStats + if err := a.client.do(ctx, http.MethodGet, "/users/stats", nil, http.StatusOK, &result); err != nil { + return nil, err + } + return &result, nil +} + func (a *UserAPI) ListAll(ctx context.Context, opts *types.ListOptions) *Iterator[types.User] { return NewIterator(func(page int) (*types.UserList, error) { o := *opts diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index e75dd988a..f73e9dd18 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 42834c50b..ba2c3652f 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/gateway_network.go b/components/sdk-go/types/gateway_network.go index 3d22b0379..dacddfb3e 100644 --- a/components/sdk-go/types/gateway_network.go +++ b/components/sdk-go/types/gateway_network.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/gateway_release.go b/components/sdk-go/types/gateway_release.go index 066a343cd..bbd5dfcb9 100644 --- a/components/sdk-go/types/gateway_release.go +++ b/components/sdk-go/types/gateway_release.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 1e90fc1b0..44258a51d 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index f0c9cee24..11db15aba 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/managed_database.go b/components/sdk-go/types/managed_database.go index 9499618a3..ffb96a048 100644 --- a/components/sdk-go/types/managed_database.go +++ b/components/sdk-go/types/managed_database.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 0465e74b4..10f134414 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 4a08adb46..3ed1dd0cd 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 08185720b..685d81b41 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types diff --git a/components/sdk-go/types/user.go b/components/sdk-go/types/user.go index ba2955fcb..fb1f07ce4 100644 --- a/components/sdk-go/types/user.go +++ b/components/sdk-go/types/user.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 package types @@ -72,3 +72,18 @@ func NewUserPatchBuilder() *UserPatchBuilder { func (b *UserPatchBuilder) Build() map[string]any { return b.patch } + +type UserActivityStats struct { + ActiveDaily []UserDailyCount `json:"active_daily"` + ActiveLast30Days int64 `json:"active_last_30_days"` + ActiveLast7Days int64 `json:"active_last_7_days"` + RegisteredLast30Days int64 `json:"registered_last_30_days"` + RegisteredLast7Days int64 `json:"registered_last_7_days"` + RegistrationDaily []UserDailyCount `json:"registration_daily"` + TotalRegistered int64 `json:"total_registered"` +} + +type UserDailyCount struct { + Count int64 `json:"count"` + Date string `json:"date"` +} diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 27733a4d8..31c3207dd 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 845582769..8830e816d 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig } from './base.js'; import { GatewayAPI } from './gateway_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 4b62b7d8b..3f2f9b4a7 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -67,6 +67,7 @@ export type GatewayPatchRequest = { tls_mode?: string; }; + export class GatewayBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 128dce349..0e02d25d4 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index 50e321183..9a0a3f77c 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -32,6 +32,7 @@ export type GatewayNetworkPatchRequest = { tunnel_mode?: string; }; + export class GatewayNetworkBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index 70374bcc9..c408298d6 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index 90b493487..f97db7218 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -35,6 +35,7 @@ export type GatewayReleasePatchRequest = { status?: string; }; + export class GatewayReleaseBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index f19f02c7d..ebedbd56d 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index 3e4e8d5c5..791d8411a 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; @@ -65,6 +65,6 @@ export { RoleBindingAPI } from './role_binding_api.js'; -export type { User, UserList, UserCreateRequest, UserPatchRequest } from './user.js'; +export type { User, UserList, UserCreateRequest, UserPatchRequest, UserActivityStats, UserDailyCount } from './user.js'; export { UserBuilder, UserPatchBuilder } from './user.js'; export { UserAPI } from './user_api.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index fa911208e..0dc122079 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -35,6 +35,7 @@ export type ManagedClusterPatchRequest = { status?: string; }; + export class ManagedClusterBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index 4d108614d..13d53c389 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database.ts b/components/sdk-typescript/src/managed_database.ts index a8fd9197f..f447b232c 100644 --- a/components/sdk-typescript/src/managed_database.ts +++ b/components/sdk-typescript/src/managed_database.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -42,6 +42,7 @@ export type ManagedDatabasePatchRequest = { status?: string; }; + export class ManagedDatabaseBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/managed_database_api.ts b/components/sdk-typescript/src/managed_database_api.ts index 782f2bb9c..01d6a4f61 100644 --- a/components/sdk-typescript/src/managed_database_api.ts +++ b/components/sdk-typescript/src/managed_database_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index d40784aab..caef840e8 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index a33a95e21..d2ba6d773 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index 9a568675e..6366d36f6 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -27,6 +27,7 @@ export type RoleCreateRequest = { export type RolePatchRequest = { }; + export class RoleBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 443366090..2f6b7d4f7 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index 6f8c5cd6d..2dd7b2d94 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -25,6 +25,7 @@ export type RoleBindingCreateRequest = { export type RoleBindingPatchRequest = { }; + export class RoleBindingBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 4d8b4a721..0a70a9fb6 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/user.ts b/components/sdk-typescript/src/user.ts index 57020c042..d0767eec6 100644 --- a/components/sdk-typescript/src/user.ts +++ b/components/sdk-typescript/src/user.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { ObjectReference, ListMeta } from './base.js'; @@ -23,6 +23,26 @@ export type UserCreateRequest = { export type UserPatchRequest = { }; + +export type UserActivityStats = { + active_daily: UserDailyCount[]; + active_last_30_days: number; + active_last_7_days: number; + registered_last_30_days: number; + registered_last_7_days: number; + registration_daily: UserDailyCount[]; + total_registered: number; +}; + + + +export type UserDailyCount = { + count: number; + date: string; +}; + + + export class UserBuilder { private data: Record = {}; diff --git a/components/sdk-typescript/src/user_api.ts b/components/sdk-typescript/src/user_api.ts index c8d18ed35..b71686e07 100644 --- a/components/sdk-typescript/src/user_api.ts +++ b/components/sdk-typescript/src/user_api.ts @@ -1,10 +1,10 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 8252880da1308768c2c719401eca070f54da2ca5ad61be4382f2fc3f12a6694d +// Spec SHA256: cd62a49545d70d45bec4d0b783bbb4b4a5312aa785c111f105b5cc39fadd8869 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; -import type { User, UserList, UserCreateRequest } from './user.js'; +import type { User, UserList, UserCreateRequest, UserActivityStats } from './user.js'; export class UserAPI { constructor(private readonly config: SDKClientConfig) {} @@ -22,6 +22,11 @@ export class UserAPI { return sdkFetch(this.config, 'GET', `/users${qs}`, undefined, opts); } + async activityStats(opts?: RequestOptions): Promise { + return sdkFetch(this.config, 'GET', '/users/stats', undefined, opts); + } + + async *listAll(size: number = 100, opts?: RequestOptions): AsyncGenerator { let page = 1; while (true) { diff --git a/components/web-console/app/adapters/api/dashboard-control-plane.test.ts b/components/web-console/app/adapters/api/dashboard-control-plane.test.ts index 50abbb8c4..86303fe51 100644 --- a/components/web-console/app/adapters/api/dashboard-control-plane.test.ts +++ b/components/web-console/app/adapters/api/dashboard-control-plane.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDashboardControlPlaneAdapter } from "./dashboard-control-plane"; const gatewayListApi = vi.fn(); -const usersListApi = vi.fn(); +const usersActivityStatsApi = vi.fn(); const managedClustersListApi = vi.fn(); const managedDatabasesListApi = vi.fn(); const fetchMock = vi.fn(); @@ -28,7 +28,7 @@ const apiFactory = vi.fn( list: managedDatabasesListApi, }, users: { - list: usersListApi, + activityStats: usersActivityStatsApi, }, }) as unknown as SDKClient, ); @@ -114,7 +114,7 @@ beforeEach(() => { vi.stubGlobal("fetch", fetchMock); fetchMock.mockReset(); gatewayListApi.mockReset(); - usersListApi.mockReset(); + usersActivityStatsApi.mockReset(); managedClustersListApi.mockReset(); managedDatabasesListApi.mockReset(); managedClustersListApi.mockResolvedValue({ @@ -248,6 +248,18 @@ function managedDatabase( }; } +function defaultUserActivityStats(total = 0) { + return { + active_daily: [], + active_last_7_days: 0, + active_last_30_days: 0, + registered_last_7_days: 0, + registered_last_30_days: 0, + registration_daily: [], + total_registered: total, + }; +} + function managedDatabaseList( items: ManagedDatabase[], total = items.length, @@ -266,12 +278,20 @@ describe("createDashboardControlPlaneAdapter", () => { it("aggregates paginated gateway lists into operational metrics", async () => { mockClusterMetricsResponses(254468212736, 236223201280); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 42, + usersActivityStatsApi.mockResolvedValueOnce({ + active_daily: [ + { count: 2, date: "2026-08-09" }, + { count: 4, date: "2026-08-10" }, + ], + active_last_7_days: 18, + active_last_30_days: 95, + registered_last_7_days: 5, + registered_last_30_days: 22, + registration_daily: [ + { count: 1, date: "2026-08-09" }, + { count: 3, date: "2026-08-10" }, + ], + total_registered: 42, }); const firstPage = Array.from({ length: 100 }, (_, index) => @@ -336,7 +356,26 @@ describe("createDashboardControlPlaneAdapter", () => { provisioning: 50, }); expect(sandboxesMetric?.value).toBe("200"); - expect(registeredUsersMetric?.value).toBe("42"); + expect(registeredUsersMetric).toEqual({ + activeLast7Days: "18", + activeLast30Days: "95", + activeTrend: { + points: [ + { label: "2026-08-09", value: 2 }, + { label: "2026-08-10", value: 4 }, + ], + }, + createdLast7Days: "5", + createdLast30Days: "22", + id: "registered-users", + trend: { + points: [ + { label: "2026-08-09", value: 1 }, + { label: "2026-08-10", value: 3 }, + ], + }, + value: "42", + }); expect(memoryMetric).toEqual({ id: "memory", total: "237", @@ -396,22 +435,15 @@ describe("createDashboardControlPlaneAdapter", () => { credentials: "same-origin", signal: undefined, }); - expect(usersListApi).toHaveBeenCalledWith( - { orderBy: "username asc", page: 1, size: 1 }, - { signal: undefined }, - ); + expect(usersActivityStatsApi).toHaveBeenCalledWith({ + signal: undefined, + }); }); it("maps gateway lifecycle fields into display-status buckets", async () => { mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce( gatewayList( @@ -441,13 +473,7 @@ describe("createDashboardControlPlaneAdapter", () => { it("treats omitted active_sandbox_count as zero when summing sandboxes", async () => { mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce( gatewayList( @@ -472,13 +498,7 @@ describe("createDashboardControlPlaneAdapter", () => { it("maps gateway provision duration histogram into average, P50, and P95 minutes", async () => { mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce( gatewayList( @@ -555,13 +575,7 @@ describe("createDashboardControlPlaneAdapter", () => { return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce( gatewayList( @@ -597,13 +611,7 @@ describe("createDashboardControlPlaneAdapter", () => { it("omits gateway-derived metrics for inconsistent pagination responses", async () => { mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 2)); const metrics = await adapter.getOperationalMetrics(context); @@ -621,13 +629,7 @@ describe("createDashboardControlPlaneAdapter", () => { const controller = new AbortController(); mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); await adapter.getOperationalMetrics({ @@ -639,10 +641,9 @@ describe("createDashboardControlPlaneAdapter", () => { { orderBy: "name asc", page: 1, size: 100 }, { signal: controller.signal }, ); - expect(usersListApi).toHaveBeenCalledWith( - { orderBy: "username asc", page: 1, size: 1 }, - { signal: controller.signal }, - ); + expect(usersActivityStatsApi).toHaveBeenCalledWith({ + signal: controller.signal, + }); expect(fetchMock).toHaveBeenCalledWith("/api/metrics/cluster-memory", { credentials: "same-origin", signal: controller.signal, @@ -708,13 +709,7 @@ describe("createDashboardControlPlaneAdapter", () => { } return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); const metrics = await adapter.getOperationalMetrics(context); @@ -775,13 +770,7 @@ describe("createDashboardControlPlaneAdapter", () => { } return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); const metrics = await adapter.getOperationalMetrics(context); @@ -844,13 +833,7 @@ describe("createDashboardControlPlaneAdapter", () => { } return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); const metrics = await adapter.getOperationalMetrics(context); @@ -905,13 +888,7 @@ describe("createDashboardControlPlaneAdapter", () => { } return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([gateway()], 1, 1)); const metrics = await adapter.getOperationalMetrics(context); @@ -927,7 +904,7 @@ describe("createDashboardControlPlaneAdapter", () => { it("fails when every metric source is unavailable", async () => { fetchMock.mockRejectedValue(new Error("network down")); - usersListApi.mockRejectedValueOnce(new Error("users unavailable")); + usersActivityStatsApi.mockRejectedValueOnce(new Error("users unavailable")); gatewayListApi.mockRejectedValueOnce(new Error("gateways unavailable")); managedClustersListApi.mockRejectedValueOnce( new Error("managed clusters unavailable"), @@ -946,13 +923,7 @@ describe("createDashboardControlPlaneAdapter", () => { vi.setSystemTime(new Date("2026-09-01T12:00:00.000Z")); mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([], 0, 1)); const firstClusterPage = Array.from({ length: 100 }, (_, index) => @@ -1044,13 +1015,7 @@ describe("createDashboardControlPlaneAdapter", () => { it("omits platform inventory metrics when managed database pagination is inconsistent", async () => { mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([], 0, 1)); managedClustersListApi.mockResolvedValueOnce(managedClusterList([], 0, 1)); managedDatabasesListApi.mockResolvedValueOnce( @@ -1075,13 +1040,7 @@ describe("createDashboardControlPlaneAdapter", () => { const controller = new AbortController(); mockClusterMetricsResponses(1024 ** 3, 512 * 1024 ** 2); - usersListApi.mockResolvedValueOnce({ - items: [], - kind: "UserList", - page: 1, - size: 1, - total: 0, - }); + usersActivityStatsApi.mockResolvedValueOnce(defaultUserActivityStats(0)); gatewayListApi.mockResolvedValueOnce(gatewayList([], 0, 1)); managedClustersListApi.mockResolvedValueOnce(managedClusterList([], 0, 1)); managedDatabasesListApi.mockResolvedValueOnce( diff --git a/components/web-console/app/adapters/api/dashboard-control-plane.ts b/components/web-console/app/adapters/api/dashboard-control-plane.ts index b129d3628..9e0945da9 100644 --- a/components/web-console/app/adapters/api/dashboard-control-plane.ts +++ b/components/web-console/app/adapters/api/dashboard-control-plane.ts @@ -17,6 +17,7 @@ import { buildManagedClustersMetric, buildManagedDatabasesMetric, } from "./platform-inventory-aggregation"; +import { userActivityStatsToMetric } from "./user-activity-stats"; type DashboardApiFactory = (correlationId: string) => SDKClient; @@ -322,17 +323,11 @@ async function fetchRegisteredUsersMetric( apiFactory: DashboardApiFactory, ): Promise { const client = apiFactory(context.correlationId); - const userList = await client.users.list( - { orderBy: "username asc", page: 1, size: 1 }, - { signal: context.signal }, - ); + const stats = await client.users.activityStats({ + signal: context.signal, + }); - return [ - { - id: "registered-users", - value: String(userList.total), - }, - ]; + return [userActivityStatsToMetric(stats)]; } async function fetchPlatformInventoryMetrics( diff --git a/components/web-console/app/adapters/api/user-activity-stats.test.ts b/components/web-console/app/adapters/api/user-activity-stats.test.ts new file mode 100644 index 000000000..23f8db3d4 --- /dev/null +++ b/components/web-console/app/adapters/api/user-activity-stats.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { userActivityStatsToMetric } from "./user-activity-stats"; + +describe("userActivityStatsToMetric", () => { + it("maps user activity stats into the registered-users operational metric", () => { + expect( + userActivityStatsToMetric({ + active_daily: [{ count: 2, date: "2026-09-01" }], + active_last_7_days: 4, + active_last_30_days: 10, + registered_last_7_days: 1, + registered_last_30_days: 3, + registration_daily: [{ count: 1, date: "2026-09-01" }], + total_registered: 42, + }), + ).toEqual({ + activeLast7Days: "4", + activeLast30Days: "10", + activeTrend: { + points: [{ label: "2026-09-01", value: 2 }], + }, + createdLast7Days: "1", + createdLast30Days: "3", + id: "registered-users", + trend: { + points: [{ label: "2026-09-01", value: 1 }], + }, + value: "42", + }); + }); +}); diff --git a/components/web-console/app/adapters/api/user-activity-stats.ts b/components/web-console/app/adapters/api/user-activity-stats.ts new file mode 100644 index 000000000..8f4cceefb --- /dev/null +++ b/components/web-console/app/adapters/api/user-activity-stats.ts @@ -0,0 +1,34 @@ +import type { + OperationalMetric, + OperationalMetricTrend, +} from "@openshift-online/hypershell-operational-dashboard-ui"; +import type { + UserActivityStats, + UserDailyCount, +} from "@openshift-online/hypershell-sdk"; + +function dailyCountsToTrend( + daily: readonly UserDailyCount[], +): OperationalMetricTrend { + return { + points: daily.map((point) => ({ + label: point.date, + value: point.count, + })), + }; +} + +export function userActivityStatsToMetric( + stats: UserActivityStats, +): OperationalMetric { + return { + activeLast7Days: String(stats.active_last_7_days), + activeLast30Days: String(stats.active_last_30_days), + activeTrend: dailyCountsToTrend(stats.active_daily), + createdLast7Days: String(stats.registered_last_7_days), + createdLast30Days: String(stats.registered_last_30_days), + id: "registered-users", + trend: dailyCountsToTrend(stats.registration_daily), + value: String(stats.total_registered), + }; +} diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 09f550f92..abcffe162 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -107,14 +107,6 @@ "defaultMessage": "Managed cluster regions chart", "description": "Accessible title for the managed cluster region donut chart." }, - "app.dashboard.managedClusterStatus.ariaDesc": { - "defaultMessage": "Managed cluster count by status", - "description": "Accessible description for the managed cluster status donut chart." - }, - "app.dashboard.managedClusterStatus.chartTitle": { - "defaultMessage": "Managed cluster status chart", - "description": "Accessible title for the managed cluster status donut chart." - }, "app.dashboard.managedDatabaseStatus.ariaDesc": { "defaultMessage": "Managed database count by status", "description": "Accessible description for the managed database status donut chart." @@ -300,7 +292,7 @@ "description": "Deprecated summary label retained for locale extraction." }, "app.dashboard.summary.registeredUsers": { - "defaultMessage": "Registered users", + "defaultMessage": "Users", "description": "Summary label for registered users." }, "app.dashboard.summary.sandboxes": { @@ -331,6 +323,14 @@ "defaultMessage": "Usage metrics", "description": "Accessible label for the usage metrics list in the summary widget." }, + "app.dashboard.summary.usersTrendDecrease": { + "defaultMessage": "{percent}% decrease in unique logins per day", + "description": "Tooltip for the Users row trend indicator when unique logins per day decreased." + }, + "app.dashboard.summary.usersTrendIncrease": { + "defaultMessage": "{percent}% increase in unique logins per day", + "description": "Tooltip for the Users row trend indicator when unique logins per day increased." + }, "app.dashboard.title": { "defaultMessage": "HyperShell operational dashboard", "description": "Main heading on the operational dashboard page." @@ -367,6 +367,22 @@ "defaultMessage": "{percent}% capacity{separator}{value} of {total} {unit}", "description": "Tooltip for utilization status in the system summary widget." }, + "app.dashboard.widget.activeUsersDaily": { + "defaultMessage": "Unique logins per day", + "description": "Title for the daily unique login sparkline in the users dashboard widget." + }, + "app.dashboard.widget.activeUsersDailyTooltip": { + "defaultMessage": "Unique logins", + "description": "Short metric label for unique login sparkline tooltips in the users widget." + }, + "app.dashboard.widget.activeUsersLast30Days": { + "defaultMessage": "Unique logins (30 days)", + "description": "Label for distinct users who logged in during the last thirty days." + }, + "app.dashboard.widget.activeUsersLast7Days": { + "defaultMessage": "Unique logins (7 days)", + "description": "Label for distinct users who logged in during the last seven days." + }, "app.dashboard.widget.cpu": { "defaultMessage": "CPU", "description": "Title for the CPU utilization dashboard widget." @@ -387,22 +403,10 @@ "defaultMessage": "Cluster regions", "description": "Title for the managed cluster region breakdown dashboard widget." }, - "app.dashboard.widget.managedClusterStatus": { - "defaultMessage": "Cluster status", - "description": "Title for the managed cluster status dashboard widget." - }, - "app.dashboard.widget.managedClusters": { - "defaultMessage": "Clusters", - "description": "Title for the managed clusters count dashboard widget." - }, "app.dashboard.widget.managedDatabaseStatus": { "defaultMessage": "Database status", "description": "Title for the managed database status dashboard widget." }, - "app.dashboard.widget.managedDatabases": { - "defaultMessage": "Databases", - "description": "Title for the managed databases count dashboard widget." - }, "app.dashboard.widget.memory": { "defaultMessage": "Memory", "description": "Title for the memory utilization dashboard widget." @@ -424,8 +428,24 @@ "description": "Title for the provisioned gateways dashboard widget." }, "app.dashboard.widget.registeredUsers": { + "defaultMessage": "Users", + "description": "Title for the users dashboard widget." + }, + "app.dashboard.widget.registeredUsersHeading": { "defaultMessage": "Registered users", - "description": "Title for the registered users dashboard widget." + "description": "Label for the total registered user count in the users widget heading." + }, + "app.dashboard.widget.registeredUsersLast30Days": { + "defaultMessage": "Added (30 days)", + "description": "Label for users added in the last thirty days." + }, + "app.dashboard.widget.registeredUsersLast7Days": { + "defaultMessage": "Added (7 days)", + "description": "Label for users added in the last seven days." + }, + "app.dashboard.widget.registeredUsersStatsAriaLabel": { + "defaultMessage": "User statistics", + "description": "Accessible label for user statistics in the users dashboard widget." }, "app.dashboard.widget.sectionTitle": { "defaultMessage": "Section title", diff --git a/packages/operational-dashboard-ui/DATA_SOURCES.md b/packages/operational-dashboard-ui/DATA_SOURCES.md index 31e99de63..6f234bd67 100644 --- a/packages/operational-dashboard-ui/DATA_SOURCES.md +++ b/packages/operational-dashboard-ui/DATA_SOURCES.md @@ -15,18 +15,18 @@ Data is loaded through `useGetMetricsData` → `dashboard.getOperationalMetrics` | `provisioned-gateways` | HyperShell API `GET /api/hypershell/v1/gateways` (paginated) | Display-status breakdown (`healthy`, `provisioning`, `degraded`, `failed`) using the same phase/status presentation rules as the gateway list. Total count. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `gateway-status` | Same as `provisioned-gateways` | Uses the `status` field on the provisioned-gateways metric. | | `provisioned-sandboxes` | HyperShell API `GET /api/hypershell/v1/gateways` (paginated) | Sum of `active_sandbox_count` across all gateways. Advisory control-plane field; omitted from the response when unset on a gateway. | -| `registered-users` | HyperShell API `GET /api/hypershell/v1/users` (`page=1`, `size=1`) | Total registered users from the List `total` field. Requires dashboard-operator authorization (`platform:admin` or `hypershell-admins`). Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | +| `registered-users` | HyperShell API `GET /api/hypershell/v1/users/stats` | Total registered users, 7/30-day addition and login counts, and a 30-day daily login histogram for the sparkline. Requires dashboard-operator authorization (`platform:admin` or `hypershell-admins`). Login counts are distinct users with authenticated API access per UTC day. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `memory` | BFF `GET /api/metrics/cluster-memory` (Prometheus) | Hub-cluster node memory from Prometheus node-exporter via `sum(node_memory_MemTotal_bytes)` (capacity) and `sum(node_memory_MemAvailable_bytes)` (available). Adapter maps used/capacity bytes to whole GiB for the utilization donut. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `cpu` | BFF `GET /api/metrics/cluster-cpu` (Prometheus) | Hub-cluster node CPU from the same node-exporter DaemonSet as memory. Capacity: `sum(count by (instance) (node_cpu_seconds_total{mode="idle"}))`. Used: `sum(rate(node_cpu_seconds_total{mode!="idle"}[5m]))`. Adapter maps fractional used/capacity cores to whole cores for the utilization donut. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `pods` | BFF `GET /api/metrics/cluster-pods` (Prometheus) | Hub-cluster pod capacity from kube-state-metrics. Capacity: `sum(kube_node_status_allocatable{resource="pods"})`. Used: `count(kube_pod_info)` (all phases while pod objects exist). Phase breakdown: `sum(kube_pod_status_phase{phase=""})`. Adapter maps phase fields to `podPhases`; `pods` widget uses `PodCapacityChart` with gray Unused segment (`total - value`). Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `nodes` | BFF `GET /api/metrics/cluster-nodes` (Prometheus) | Hub-cluster node inventory from kube-state-metrics. Total: `count(kube_node_info)`. Ready: `sum(kube_node_status_condition{condition="Ready",status="true"})`. Adapter maps `ready_nodes` → `status.healthy` and `not_ready_nodes` → `status.failed`. `nodes` widget uses `NodeStatusChart`. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `provision-time` | BFF `GET /api/metrics/gateway-provision-duration` (Prometheus) | Average in the system summary; average, P50, and P95 in the `provision-time` widget from `gateway_provision_duration_seconds` histogram per `platform/gateway-provision-time.spec.md`. Refreshes every 15 minutes (`operationalDashboardRefreshMilliseconds`). | | `managed-clusters` | HyperShell API `GET /api/hypershell/v1/managed_clusters` (paginated) | Total registered managed clusters with `inventoryStatus`, `createdLast30Days`, `inventoryProviders`, and `inventoryRegions`. Page size 100, ordered by `name asc`. Requires dashboard-operator or `gateway:creator` authorization. A failed list request omits the `platform-inventory` source (OP-DASH-19). Refreshes every 15 minutes. | -| `managed-cluster-providers` | Same as `managed-clusters` | Provider donut from `inventoryProviders`; legend ordered by descending count. On the default layout (OP-DASH-20). | -| `managed-cluster-regions` | Same as `managed-clusters` | Placement donut from `inventoryRegions` (`{region} ({provider})` keys); legend ordered by descending count. On the default layout (OP-DASH-20). | +| `managed-cluster-providers` | Same as `managed-clusters` | Provider donut from `inventoryProviders`; legend ordered by descending count. On the default layout (OP-DASH-22). | +| `managed-cluster-regions` | Same as `managed-clusters` | Placement donut from `inventoryRegions` (`{region} ({provider})` keys); legend ordered by descending count. On the default layout (OP-DASH-22). | | `managed-databases` | HyperShell API `GET /api/hypershell/v1/managed_databases` (paginated) | Total registered managed databases with `inventoryStatus` breakdown. Same pagination and authorization rules as `managed-clusters`. Loaded in the same `platform-inventory` metric source. | -| `managed-database-status` | Same as `managed-databases` | Status donut from `inventoryStatus`; on the default layout (OP-DASH-20). | -| `inventory-summary` | Same as `managed-clusters` and `managed-databases` | `InventorySummaryCard` DescriptionList on the default layout (OP-DASH-20). Shows totals, 30-day cluster creations, and failure/warning status icons when present. | +| `managed-database-status` | Same as `managed-databases` | Status donut from `inventoryStatus`; on the default layout (OP-DASH-22). | +| `inventory-summary` | Same as `managed-clusters` and `managed-databases` | `InventorySummaryCard` DescriptionList on the default layout (OP-DASH-22). Shows totals, 30-day cluster creations, and failure/warning status icons when present. | ## Not connected (widgets remain, data unavailable) diff --git a/packages/operational-dashboard-ui/src/application/dashboard-types.ts b/packages/operational-dashboard-ui/src/application/dashboard-types.ts index 7e9e9cc3a..dc177ea16 100644 --- a/packages/operational-dashboard-ui/src/application/dashboard-types.ts +++ b/packages/operational-dashboard-ui/src/application/dashboard-types.ts @@ -29,7 +29,11 @@ export interface OperationalMetricProvisionDuration { } export interface OperationalMetric { + activeLast7Days?: string; + activeLast30Days?: string; + activeTrend?: OperationalMetricTrend; createdLast30Days?: string; + createdLast7Days?: string; id: string; inventoryProviders?: Record; inventoryRegions?: Record; diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts index 2c262f1aa..a943f2f2c 100644 --- a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.test.ts @@ -6,6 +6,7 @@ import { getActiveWidgetTypes, isValidSavedTemplate, sanitizeDashboardTemplate, + stripRemovedWidgetTypes, } from "./dashboard-layout-persistence"; describe("dashboard layout persistence", () => { @@ -13,9 +14,9 @@ describe("dashboard layout persistence", () => { expect(getActiveWidgetTypes(defaultDashboardLayoutTemplate)).toEqual([ "section-title", "usage-summary", - "gateway-status", - "provisioned-sandboxes", "registered-users", + "provisioned-sandboxes", + "gateway-status", "system-summary", "memory", "provision-time", @@ -80,4 +81,27 @@ describe("dashboard layout persistence", () => { sanitized.xl.filter((item) => item.widgetType === "section-title"), ).toHaveLength(3); }); + + it("strips retired widget types from saved layouts", () => { + const retiredWidget = { + h: 3, + i: "managed-clusters#1", + title: "Clusters", + w: 1, + widgetType: "managed-clusters", + x: 0, + y: 99, + }; + const withRetired = { + ...defaultDashboardLayoutTemplate, + xl: [...defaultDashboardLayoutTemplate.xl, retiredWidget], + }; + + const stripped = stripRemovedWidgetTypes(withRetired); + + expect( + stripped.xl.filter((item) => item.widgetType === "managed-clusters"), + ).toHaveLength(0); + expect(stripped.xl).toHaveLength(defaultDashboardLayoutTemplate.xl.length); + }); }); diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts index 8a8b333ac..af41e83ba 100644 --- a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts @@ -3,6 +3,24 @@ import type { Variants, } from "@patternfly/widgetized-dashboard"; +/** Widget types removed from the catalog; stripped from saved layouts on load. */ +const REMOVED_WIDGET_TYPES = new Set([ + "managed-clusters", + "managed-cluster-status", + "managed-databases", +]); + +export function stripRemovedWidgetTypes( + template: ExtendedTemplateConfig, +): ExtendedTemplateConfig { + return (Object.keys(template) as Variants[]).reduce((acc, variant) => { + acc[variant] = template[variant].filter( + (item) => !REMOVED_WIDGET_TYPES.has(item.widgetType), + ); + return acc; + }, {} as ExtendedTemplateConfig); +} + export function getActiveWidgetTypes( template: ExtendedTemplateConfig, ): string[] { diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts index 4f1c7ae92..df4cb9719 100644 --- a/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-layout-template.ts @@ -18,16 +18,25 @@ const SUMMARY_COLUMN_HEIGHT = METRIC_WIDGET_HEIGHT + 2 * METRIC_ROW_STEP; const BASE_SUMMARY_WIDGET_HEIGHT = (SUMMARY_COLUMN_HEIGHT - METRIC_ROW_GAP) / 2; /** Equal height for usage and system summary widgets in the left column. */ export const USAGE_SUMMARY_WIDGET_HEIGHT = BASE_SUMMARY_WIDGET_HEIGHT + 1; -/** Gateway status matches usage summary height in the platform adoption section. */ -export const GATEWAY_STATUS_WIDGET_HEIGHT = USAGE_SUMMARY_WIDGET_HEIGHT; +/** Compact donut height; matches pods and nodes status widgets. */ +export const GATEWAY_STATUS_WIDGET_HEIGHT = POD_CAPACITY_WIDGET_HEIGHT; /** One row taller than usage summary; fits exception status rows on pods and nodes. */ export const SYSTEM_SUMMARY_WIDGET_HEIGHT = USAGE_SUMMARY_WIDGET_HEIGHT + 1; /** Stats list and P95 note. */ export const PROVISION_TIME_WIDGET_HEIGHT = METRIC_WIDGET_HEIGHT + 1; + +export const REGISTERED_USERS_WIDGET_HEIGHT = USAGE_SUMMARY_WIDGET_HEIGHT + 1; const ADOPTION_SECTION_START_Y = TITLE_ROW_OFFSET; -/** Grid row for the hub cluster section title. */ +/** Grid row for the hub cluster section title (below platform adoption widgets). */ const HUB_CLUSTER_TITLE_Y = - ADOPTION_SECTION_START_Y + GATEWAY_STATUS_WIDGET_HEIGHT + METRIC_ROW_GAP; + ADOPTION_SECTION_START_Y + + Math.max( + USAGE_SUMMARY_WIDGET_HEIGHT, + REGISTERED_USERS_WIDGET_HEIGHT, + METRIC_WIDGET_HEIGHT, + METRIC_ROW_STEP + GATEWAY_STATUS_WIDGET_HEIGHT, + ) + + METRIC_ROW_GAP; /** Grid row where hub-cluster capacity widgets begin (below hub cluster title). */ export const HUB_CLUSTER_START_Y = HUB_CLUSTER_TITLE_Y + TITLE_ROW_OFFSET; /** Grid row for the platform inventory section title. */ @@ -57,9 +66,6 @@ const WIDGET_TITLE_MESSAGES = { "registered-users": messages.registeredUsers, "managed-cluster-providers": messages.widgetManagedClusterProviders, "managed-cluster-regions": messages.widgetManagedClusterRegions, - "managed-clusters": messages.widgetManagedClusters, - "managed-cluster-status": messages.widgetManagedClusterStatus, - "managed-databases": messages.widgetManagedDatabases, "managed-database-status": messages.widgetManagedDatabaseStatus, "gateway-status": messages.gatewayStatusWidget, memory: messages.widgetMemory, @@ -92,11 +98,11 @@ const fourColumnLayout = [ y: ADOPTION_SECTION_START_Y, }, { - h: GATEWAY_STATUS_WIDGET_HEIGHT, - i: "gateway-status#1", - title: "Gateway status", + h: REGISTERED_USERS_WIDGET_HEIGHT, + i: "registered-users#1", + title: "Users", w: 2, - widgetType: "gateway-status", + widgetType: "registered-users", x: 1, y: ADOPTION_SECTION_START_Y, }, @@ -110,11 +116,11 @@ const fourColumnLayout = [ y: ADOPTION_SECTION_START_Y, }, { - h: METRIC_WIDGET_HEIGHT, - i: "registered-users#1", - title: "Registered users", + h: GATEWAY_STATUS_WIDGET_HEIGHT, + i: "gateway-status#1", + title: "Gateway status", w: 1, - widgetType: "registered-users", + widgetType: "gateway-status", x: 3, y: ADOPTION_SECTION_START_Y + METRIC_ROW_STEP, }, diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.css b/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.css new file mode 100644 index 000000000..8023fb35b --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.css @@ -0,0 +1,30 @@ +.hypershell-dashboard-stat-panel { + --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); +} + +/* + * Single-column horizontal lists only need block display on the list root so + * groups stack vertically inside widget tiles. Multi-column lists must keep + * PatternFly's grid placement for columnModifier + isFillColumns. + */ +.hypershell-dashboard-stat-panel__list--single.pf-v6-c-description-list { + display: block; +} + +.hypershell-dashboard-stat-panel__sparkline { + margin-block-start: var(--pf-t--global--spacer--xl); + padding-top: var(--pf-t--global--spacer--xl); + border-top: 1px solid var(--pf-t--global--border--color--200); + width: 100%; +} + +.hypershell-dashboard-stat-panel__sparkline-title { + margin-block-end: var(--pf-t--global--spacer--xs); + text-align: start; +} + +.hypershell-dashboard-stat-panel__footer { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--sm); + margin-block-end: 0; +} diff --git a/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.tsx b/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.tsx new file mode 100644 index 000000000..7cbdaa440 --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/dashboard-stat-panel.tsx @@ -0,0 +1,107 @@ +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Stack, + StackItem, + Title, +} from "@patternfly/react-core"; +import type { ReactNode } from "react"; + +import type { OperationalMetricTrend } from "../application/dashboard-types"; +import { TrendSparklineChart } from "./trend-sparkline-chart"; +import "./dashboard-stat-panel.css"; + +export interface DashboardStatPanelRow { + id: string; + label: ReactNode; + value: ReactNode; +} + +export interface DashboardStatPanelSparkline { + plotHeight?: number; + title: string; + tooltipLabel?: string; + trend: OperationalMetricTrend; +} + +const HORIZONTAL_TERM_WIDTH = { + default: "22ch", +} as const; + +export function DashboardStatPanel({ + ariaLabel, + columns = "single", + footer, + heading, + rows, + sparkline, +}: Readonly<{ + ariaLabel: string; + columns?: "single" | "two"; + footer?: ReactNode; + heading?: ReactNode; + rows: readonly DashboardStatPanelRow[]; + sparkline?: DashboardStatPanelSparkline; +}>) { + const listClassName = + columns === "two" + ? "hypershell-dashboard-stat-panel__list hypershell-dashboard-stat-panel__list--two" + : "hypershell-dashboard-stat-panel__list hypershell-dashboard-stat-panel__list--single"; + + return ( + + {heading ? ( + + + {heading} + + + ) : null} + + + {rows.map((row) => ( + + {row.label} + + {row.value} + + + ))} + + + {sparkline ? ( + +
+ + {sparkline.title} + + +
+
+ ) : null} + {footer ? {footer} : null} +
+ ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx index 40dfe0ecd..39b507136 100644 --- a/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx +++ b/packages/operational-dashboard-ui/src/dashboard/gateway-status-chart.tsx @@ -43,6 +43,7 @@ export function GatewayStatusChart({ : null } legendData={legendData} + size="compact" subTitle={intl.formatMessage(messages.gateways)} title={formatOperationalMetricDisplayValue(metric.value, intl)} /> diff --git a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts index 7beda676d..a388264cf 100644 --- a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts +++ b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OperationalMetric } from "../application/dashboard-types"; -import { getMetricTrendChange } from "./metric-trend-change"; +import { getMetricTrendChange, getTrendChange } from "./metric-trend-change"; function metricWithTrend(values: number[]): OperationalMetric { return { @@ -39,3 +39,19 @@ describe("getMetricTrendChange", () => { expect(getMetricTrendChange(metricWithTrend([0, 50]))).toBeUndefined(); }); }); + +describe("getTrendChange", () => { + it("detects an increase from a trend series", () => { + expect( + getTrendChange({ + points: [ + { label: "2026-08-10", value: 98 }, + { label: "2026-09-08", value: 190 }, + ], + }), + ).toEqual({ + direction: "increase", + percent: 94, + }); + }); +}); diff --git a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts index 0c71e724e..f4beeac82 100644 --- a/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts +++ b/packages/operational-dashboard-ui/src/dashboard/metric-trend-change.ts @@ -1,4 +1,7 @@ -import type { OperationalMetric } from "../application/dashboard-types"; +import type { + OperationalMetric, + OperationalMetricTrend, +} from "../application/dashboard-types"; export const TREND_CHANGE_THRESHOLD_PERCENT = 5; @@ -9,13 +12,12 @@ export interface MetricTrendChange { percent: number; } -export function getMetricTrendChange( - metric: OperationalMetric, +export function getTrendChange( + trend?: OperationalMetricTrend, thresholdPercent = TREND_CHANGE_THRESHOLD_PERCENT, ): MetricTrendChange | undefined { - const trendPoints = metric.trend?.points; - const firstPoint = trendPoints?.[0]; - const lastPoint = trendPoints?.at(-1); + const firstPoint = trend?.points[0]; + const lastPoint = trend?.points.at(-1); if (!firstPoint || !lastPoint) { return undefined; } @@ -44,3 +46,10 @@ export function getMetricTrendChange( return undefined; } + +export function getMetricTrendChange( + metric: OperationalMetric, + thresholdPercent = TREND_CHANGE_THRESHOLD_PERCENT, +): MetricTrendChange | undefined { + return getTrendChange(metric.trend, thresholdPercent); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/metric-trend-indicator.tsx b/packages/operational-dashboard-ui/src/dashboard/metric-trend-indicator.tsx new file mode 100644 index 000000000..f6528a5ae --- /dev/null +++ b/packages/operational-dashboard-ui/src/dashboard/metric-trend-indicator.tsx @@ -0,0 +1,47 @@ +import { Button, Tooltip } from "@patternfly/react-core"; +import { TrendDownIcon, TrendUpIcon } from "@patternfly/react-icons"; +import type { MessageDescriptor } from "react-intl"; +import { useIntl } from "react-intl"; + +import { messages } from "../messages"; +import type { MetricTrendChange } from "./metric-trend-change"; +import "../pages/dashboard-widget.css"; + +export interface MetricTrendTooltipMessages { + decrease: MessageDescriptor; + increase: MessageDescriptor; +} + +export function MetricTrendIndicator({ + trendChange, + tooltipMessages, +}: Readonly<{ + trendChange: MetricTrendChange; + tooltipMessages?: MetricTrendTooltipMessages; +}>) { + const intl = useIntl(); + const isIncrease = trendChange.direction === "increase"; + const tooltipMessage = isIncrease + ? (tooltipMessages?.increase ?? messages.summaryTrendIncrease) + : (tooltipMessages?.decrease ?? messages.summaryTrendDecrease); + const tooltipContent = intl.formatMessage(tooltipMessage, { + percent: trendChange.percent, + }); + + return ( + + + + ); +} diff --git a/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx index 57ae2a85c..23a0f238b 100644 --- a/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx +++ b/packages/operational-dashboard-ui/src/dashboard/provision-time-chart.tsx @@ -1,16 +1,9 @@ -import { - Content, - DescriptionList, - DescriptionListDescription, - DescriptionListGroup, - DescriptionListTerm, - Stack, - StackItem, -} from "@patternfly/react-core"; +import { Content } from "@patternfly/react-core"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import type { OperationalMetric } from "../application/dashboard-types"; +import { DashboardStatPanel } from "./dashboard-stat-panel"; import { messages } from "../messages"; import { formatProvisionDurationValue, @@ -40,32 +33,12 @@ export function ProvisionTimeChart({ } as const; return ( - - - - {STAT_ROWS.map((row) => ( - - - - - - {formatProvisionDurationValue( - intl, - statValues[row.key], - metric.unit, - )} - - - ))} - - - + - - + } + rows={STAT_ROWS.map((row) => ({ + id: row.key, + label: , + value: formatProvisionDurationValue( + intl, + statValues[row.key], + metric.unit, + ), + }))} + /> ); } diff --git a/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx index 09395fde1..953cf80fc 100644 --- a/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx +++ b/packages/operational-dashboard-ui/src/dashboard/trend-sparkline-chart.tsx @@ -17,14 +17,19 @@ interface SparklineDatum { y: number; } -const SPARKLINE_PLOT_HEIGHT = 36; +export const DEFAULT_SPARKLINE_PLOT_HEIGHT = 36; +export const USERS_SPARKLINE_PLOT_HEIGHT = 72; export function TrendSparklineChart({ + plotHeight = DEFAULT_SPARKLINE_PLOT_HEIGHT, trend, title, + tooltipLabel, }: Readonly<{ + plotHeight?: number; trend: OperationalMetricTrend; title: string; + tooltipLabel?: string; }>) { const intl = useIntl(); const containerRef = useRef(null); @@ -62,7 +67,7 @@ export function TrendSparklineChart({ const formatTooltip = (datum: SparklineDatum) => intl.formatMessage(messages.trendTooltip, { date: datum.x, - metric: title, + metric: tooltipLabel ?? title, value: datum.y, }); @@ -70,7 +75,10 @@ export function TrendSparklineChart({ return (
-
+
formatTooltip(datum as SparklineDatum)} /> } - height={SPARKLINE_PLOT_HEIGHT} + height={plotHeight} padding={{ bottom: 1, left: 2, right: 2, top: 1 }} themeColor={ChartThemeColor.blue} width={width} diff --git a/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts index a64a443a7..1863c4abb 100644 --- a/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts +++ b/packages/operational-dashboard-ui/src/fixtures/mock-operational-dashboard-metrics.ts @@ -1,8 +1,41 @@ -import type { OperationalDashboardMetrics } from "../application/dashboard-types"; +import type { + OperationalDashboardMetrics, + OperationalMetricTrend, +} from "../application/dashboard-types"; + +function createDailyTrend( + startDate: string, + values: readonly number[], +): OperationalMetricTrend { + const start = new Date(`${startDate}T00:00:00.000Z`); + + return Object.freeze({ + points: Object.freeze( + values.map((value, index) => { + const date = new Date(start); + date.setUTCDate(date.getUTCDate() + index); + + return Object.freeze({ + label: date.toISOString().slice(0, 10), + value, + }); + }), + ), + }); +} + +const mockRegisteredUsersActiveDaily = createDailyTrend( + "2026-08-10", + [ + 98, 101, 103, 105, 108, 110, 112, 115, 117, 119, 122, 124, 127, 129, 132, + 134, 137, 139, 142, 145, 148, 151, 154, 158, 162, 166, 171, 176, 182, 190, + ], +); /** * Storybook and local-dev fixture shaped like `createDashboardControlPlaneAdapter` - * output: instantaneous values only (no trend series in production v1). + * output. Most metrics are instantaneous values only; registered-users includes a + * 30-day unique-login histogram for the sparkline. */ export const mockOperationalDashboardMetrics: OperationalDashboardMetrics = Object.freeze({ @@ -22,6 +55,11 @@ export const mockOperationalDashboardMetrics: OperationalDashboardMetrics = value: "214", }), Object.freeze({ + activeLast7Days: "186", + activeLast30Days: "312", + activeTrend: mockRegisteredUsersActiveDaily, + createdLast7Days: "12", + createdLast30Days: "48", id: "registered-users", value: "450", }), diff --git a/packages/operational-dashboard-ui/src/messages.ts b/packages/operational-dashboard-ui/src/messages.ts index 6d361f10d..01faf369d 100644 --- a/packages/operational-dashboard-ui/src/messages.ts +++ b/packages/operational-dashboard-ui/src/messages.ts @@ -1,6 +1,30 @@ import { defineMessages } from "react-intl"; export const messages = defineMessages({ + activeUsersDaily: { + id: "app.dashboard.widget.activeUsersDaily", + defaultMessage: "Unique logins per day", + description: + "Title for the daily unique login sparkline in the users dashboard widget.", + }, + activeUsersDailyTooltip: { + id: "app.dashboard.widget.activeUsersDailyTooltip", + defaultMessage: "Unique logins", + description: + "Short metric label for unique login sparkline tooltips in the users widget.", + }, + activeUsersLast7Days: { + id: "app.dashboard.widget.activeUsersLast7Days", + defaultMessage: "Unique logins (7 days)", + description: + "Label for distinct users who logged in during the last seven days.", + }, + activeUsersLast30Days: { + id: "app.dashboard.widget.activeUsersLast30Days", + defaultMessage: "Unique logins (30 days)", + description: + "Label for distinct users who logged in during the last thirty days.", + }, addWidgets: { id: "app.dashboard.addWidgets", defaultMessage: "Add widgets", @@ -148,17 +172,6 @@ export const messages = defineMessages({ defaultMessage: "Clusters", description: "Summary label for total managed clusters.", }, - managedClusterStatusAriaDesc: { - id: "app.dashboard.managedClusterStatus.ariaDesc", - defaultMessage: "Managed cluster count by status", - description: - "Accessible description for the managed cluster status donut chart.", - }, - managedClusterStatusChartTitle: { - id: "app.dashboard.managedClusterStatus.chartTitle", - defaultMessage: "Managed cluster status chart", - description: "Accessible title for the managed cluster status donut chart.", - }, managedDatabasesSummary: { id: "app.dashboard.summary.managedDatabases", defaultMessage: "Databases", @@ -367,12 +380,34 @@ export const messages = defineMessages({ }, registeredUsers: { id: "app.dashboard.widget.registeredUsers", + defaultMessage: "Users", + description: "Title for the users dashboard widget.", + }, + registeredUsersHeading: { + id: "app.dashboard.widget.registeredUsersHeading", defaultMessage: "Registered users", - description: "Title for the registered users dashboard widget.", + description: + "Label for the total registered user count in the users widget heading.", + }, + registeredUsersLast7Days: { + id: "app.dashboard.widget.registeredUsersLast7Days", + defaultMessage: "Added (7 days)", + description: "Label for users added in the last seven days.", + }, + registeredUsersLast30Days: { + id: "app.dashboard.widget.registeredUsersLast30Days", + defaultMessage: "Added (30 days)", + description: "Label for users added in the last thirty days.", + }, + registeredUsersStatsAriaLabel: { + id: "app.dashboard.widget.registeredUsersStatsAriaLabel", + defaultMessage: "User statistics", + description: + "Accessible label for user statistics in the users dashboard widget.", }, registeredUsersSummary: { id: "app.dashboard.summary.registeredUsers", - defaultMessage: "Registered users", + defaultMessage: "Users", description: "Summary label for registered users.", }, resetToDefault: { @@ -452,6 +487,18 @@ export const messages = defineMessages({ description: "Accessible label for the usage metrics list in the summary widget.", }, + summaryUsersTrendDecrease: { + id: "app.dashboard.summary.usersTrendDecrease", + defaultMessage: "{percent}% decrease in unique logins per day", + description: + "Tooltip for the Users row trend indicator when unique logins per day decreased.", + }, + summaryUsersTrendIncrease: { + id: "app.dashboard.summary.usersTrendIncrease", + defaultMessage: "{percent}% increase in unique logins per day", + description: + "Tooltip for the Users row trend indicator when unique logins per day increased.", + }, systemSummaryWidget: { id: "app.dashboard.widget.systemSummary", defaultMessage: "System summary", @@ -524,21 +571,6 @@ export const messages = defineMessages({ description: "Title for the managed cluster region breakdown dashboard widget.", }, - widgetManagedClusters: { - id: "app.dashboard.widget.managedClusters", - defaultMessage: "Clusters", - description: "Title for the managed clusters count dashboard widget.", - }, - widgetManagedClusterStatus: { - id: "app.dashboard.widget.managedClusterStatus", - defaultMessage: "Cluster status", - description: "Title for the managed cluster status dashboard widget.", - }, - widgetManagedDatabases: { - id: "app.dashboard.widget.managedDatabases", - defaultMessage: "Databases", - description: "Title for the managed databases count dashboard widget.", - }, widgetManagedDatabaseStatus: { id: "app.dashboard.widget.managedDatabaseStatus", defaultMessage: "Database status", diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.css b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css index c8f18b8a1..8be452dd2 100644 --- a/packages/operational-dashboard-ui/src/pages/dashboard-widget.css +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.css @@ -57,7 +57,7 @@ } .hypershell-dashboard-sparkline-chart__plot { - height: 36px; + min-height: 36px; } .hypershell-dashboard-sparkline-chart__caption { @@ -101,6 +101,11 @@ margin-block-start: calc(-1 * var(--pf-t--global--spacer--xs)); } +.hypershell-dashboard-gateway-status-card + .hypershell-dashboard-sparkline-chart { + margin-block-start: var(--pf-t--global--spacer--2xs); +} + .hypershell-dashboard-status-donut-chart { min-width: 0; --hypershell-gateway-status-degraded-color: var( @@ -165,22 +170,3 @@ .hypershell-dashboard-provision-time-card { --pf-v6-c-content--MarginBottom: 0; } - -.hypershell-dashboard-provision-time { - --pf-v6-c-stack--m-gutter--Gap: var(--pf-t--global--spacer--sm); -} - -/* - * Widgetized dashboard summary cards apply display:grid to description lists. - * Restore the default horizontal description-list layout here. - */ -.hypershell-dashboard-provision-time .pf-v6-c-description-list, -.hypershell-dashboard-provision-time .pf-v6-c-description-list > .pf-v6-c-card { - display: block; -} - -.hypershell-dashboard-provision-time__note { - color: var(--pf-t--global--text--color--subtle); - font-size: var(--pf-t--global--font--size--sm); - margin-block-end: 0; -} diff --git a/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx index 371abf90d..39b4b5520 100644 --- a/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx +++ b/packages/operational-dashboard-ui/src/pages/dashboard-widget.tsx @@ -20,8 +20,6 @@ import { CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon, - TrendDownIcon, - TrendUpIcon, } from "@patternfly/react-icons"; import type { PropsWithChildren } from "react"; import { FormattedMessage, useIntl } from "react-intl"; @@ -29,13 +27,20 @@ import { FormattedMessage, useIntl } from "react-intl"; import type { OperationalMetric } from "../application/dashboard-types"; import { getMetricTrendChange, - type MetricTrendChange, + getTrendChange, } from "../dashboard/metric-trend-change"; +import { + MetricTrendIndicator, + type MetricTrendTooltipMessages, +} from "../dashboard/metric-trend-indicator"; import { formatOperationalMetricDisplayValue, isDisplayableOperationalMetricValue, } from "../dashboard/operational-metric-display"; -import { TrendSparklineChart } from "../dashboard/trend-sparkline-chart"; +import { + TrendSparklineChart, + USERS_SPARKLINE_PLOT_HEIGHT, +} from "../dashboard/trend-sparkline-chart"; import { getGatewayExceptionStatusCounts } from "../dashboard/gateway-exception-status-counts"; import { GatewayStatusChart } from "../dashboard/gateway-status-chart"; import { InventoryStatusChart } from "../dashboard/inventory-status-chart"; @@ -43,6 +48,7 @@ import { ManagedClusterProvidersChart } from "../dashboard/managed-cluster-provi import { ManagedClusterRegionsChart } from "../dashboard/managed-cluster-regions-chart"; import { NodeStatusChart } from "../dashboard/node-status-chart"; import { PodCapacityChart } from "../dashboard/pod-capacity-chart"; +import { DashboardStatPanel } from "../dashboard/dashboard-stat-panel"; import { ProvisionTimeChart } from "../dashboard/provision-time-chart"; import { isPodCapacityMetric } from "../dashboard/pod-capacity-metric"; import { @@ -109,77 +115,165 @@ export function MetricCard({ ); } -export function GatewayStatusCard({ +function RegisteredUsersStatValue({ + metric, + value, +}: Readonly<{ + metric?: OperationalMetric; + value?: string; +}>) { + if (value === undefined) { + return ; + } + + return ( + + ); +} + +export function RegisteredUsersCard({ metric, }: Readonly<{ metric: OperationalMetric }>) { const intl = useIntl(); - const trendTitle = intl.formatMessage(messages.provisionedGateways); + const displayValue = formatOperationalMetricDisplayValue(metric.value, intl); + const metricHeading = isDisplayableOperationalMetricValue(metric.value) + ? intl.formatMessage(messages.metricValue, { + label: intl.formatMessage(messages.registeredUsersHeading), + value: displayValue, + }) + : displayValue; + const activeTrendTitle = intl.formatMessage(messages.activeUsersDaily); + const activeTrendTooltipLabel = intl.formatMessage( + messages.activeUsersDailyTooltip, + ); return ( - - - - - - - {metric.trend ? ( - - - - ) : null} - + + + + {metricHeading} + + } + rows={[ + { + id: "added-7-days", + label: ( + + ), + value: ( + + ), + }, + { + id: "logins-7-days", + label: , + value: ( + + ), + }, + { + id: "added-30-days", + label: ( + + ), + value: ( + + ), + }, + { + id: "logins-30-days", + label: , + value: ( + + ), + }, + ]} + sparkline={ + metric.activeTrend + ? { + plotHeight: USERS_SPARKLINE_PLOT_HEIGHT, + title: activeTrendTitle, + tooltipLabel: activeTrendTooltipLabel, + trend: metric.activeTrend, + } + : undefined + } + /> ); } -export function NodeStatusCard({ +export function GatewayStatusCard({ metric, }: Readonly<{ metric: OperationalMetric }>) { + const intl = useIntl(); + const trendTitle = intl.formatMessage(messages.provisionedGateways); + return ( - - + + + {metric.trend ? ( + + ) : null} ); } -export function ManagedClusterProvidersCard({ +export function NodeStatusCard({ metric, }: Readonly<{ metric: OperationalMetric }>) { return ( - + ); } -export function ManagedClusterRegionsCard({ +export function ManagedClusterProvidersCard({ metric, }: Readonly<{ metric: OperationalMetric }>) { return ( - + ); } -export function ManagedClusterStatusCard({ +export function ManagedClusterRegionsCard({ metric, }: Readonly<{ metric: OperationalMetric }>) { return ( - + ); @@ -243,34 +337,6 @@ export function UtilizationCard({ ); } -function SummaryTrendIndicator({ - trendChange, -}: Readonly<{ trendChange: MetricTrendChange }>) { - const intl = useIntl(); - const isIncrease = trendChange.direction === "increase"; - const tooltipContent = intl.formatMessage( - isIncrease ? messages.summaryTrendIncrease : messages.summaryTrendDecrease, - { percent: trendChange.percent }, - ); - - return ( - - - - ); -} - function UtilizationStatusIcon({ percentage, total, @@ -432,16 +498,27 @@ function SummaryUnavailableValue() { ); } +const USERS_SUMMARY_TREND_TOOLTIP_MESSAGES: MetricTrendTooltipMessages = { + decrease: messages.summaryUsersTrendDecrease, + increase: messages.summaryUsersTrendIncrease, +}; + function SummaryMetricValue({ metric, -}: Readonly<{ metric: OperationalMetric | undefined }>) { + trendChange, + trendTooltipMessages, +}: Readonly<{ + metric: OperationalMetric | undefined; + trendChange?: ReturnType; + trendTooltipMessages?: MetricTrendTooltipMessages; +}>) { const intl = useIntl(); if (!metric) { return ; } - const trendChange = getMetricTrendChange(metric); + const resolvedTrendChange = trendChange ?? getMetricTrendChange(metric); const displayValue = formatOperationalMetricDisplayValue(metric.value, intl); return ( @@ -450,9 +527,12 @@ function SummaryMetricValue({ spaceItems={{ default: "spaceItemsSm" }} > {displayValue} - {trendChange ? ( + {resolvedTrendChange ? ( - + ) : null} @@ -760,24 +840,36 @@ export function UsageSummaryCard({ isHorizontal aria-label={intl.formatMessage(messages.summaryUsageAriaLabel)} > - {USAGE_SUMMARY_METRIC_IDS.map((metricId) => ( - - - - - - {metricId === "provisioned-gateways" ? ( - metric.id === metricId)} - /> - ) : ( - metric.id === metricId)} - /> - )} - - - ))} + {USAGE_SUMMARY_METRIC_IDS.map((metricId) => { + const metric = metrics.find((entry) => entry.id === metricId); + + return ( + + + + + + {metricId === "provisioned-gateways" ? ( + + ) : ( + + )} + + + ); + })} ); diff --git a/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx index 568831599..e98c9acfd 100644 --- a/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx +++ b/packages/operational-dashboard-ui/src/pages/operational-dashboard-page.tsx @@ -48,6 +48,7 @@ import { INVENTORY_SUMMARY_WIDGET_HEIGHT, localizeDashboardLayoutTemplate, PROVISION_TIME_WIDGET_HEIGHT, + REGISTERED_USERS_WIDGET_HEIGHT, SECTION_TITLE_WIDGET_TYPE, SYSTEM_SUMMARY_WIDGET_HEIGHT, TITLE_WIDGET_HEIGHT, @@ -57,6 +58,7 @@ import { getActiveWidgetTypes, isValidSavedTemplate, sanitizeDashboardTemplate, + stripRemovedWidgetTypes, } from "../dashboard/dashboard-layout-persistence"; import { UtilizationChart } from "../dashboard/utilization-chart"; import { useDashboardUi } from "../dashboard-ui-provider"; @@ -68,9 +70,9 @@ import { InventorySummaryCard, ManagedClusterProvidersCard, ManagedClusterRegionsCard, - ManagedClusterStatusCard, ManagedDatabaseStatusCard, MetricCard, + RegisteredUsersCard, NodeStatusCard, PodCapacityCard, ProvisionTimeCard, @@ -82,7 +84,7 @@ import { useGetMetricsData } from "./get-metrics-data"; const baseTemplate = defaultDashboardLayoutTemplate; -const LAYOUT_STORAGE_KEY = "hypershell.operational-dashboard.layout.v28"; +const LAYOUT_STORAGE_KEY = "hypershell.operational-dashboard.layout.v32"; const CUSTOM_COLUMNS: Record = { xl: 4, lg: 4, @@ -115,7 +117,9 @@ function readSavedTemplate( return { invalid: false, template: localizedBaseTemplate }; } - const parsed = JSON.parse(rawTemplate) as ExtendedTemplateConfig; + const parsed = stripRemovedWidgetTypes( + JSON.parse(rawTemplate) as ExtendedTemplateConfig, + ); if (!isValidSavedTemplate(parsed, localizedBaseTemplate)) { return { invalid: true, template: localizedBaseTemplate }; } @@ -177,6 +181,7 @@ function createWidgetMapping( titleMessage: (typeof messages)[keyof typeof messages], metricType: | "metric" + | "registered-users" | "gateway-status" | "node-status" | "pod-capacity" @@ -208,6 +213,10 @@ function createWidgetMapping( return ; } + if (metricType === "registered-users") { + return ; + } + if (metricType === "gateway-status") { return ; } @@ -225,10 +234,6 @@ function createWidgetMapping( } if (metricType === "inventory-status") { - if (metricId === "managed-clusters") { - return ; - } - return ; } @@ -299,7 +304,12 @@ function createWidgetMapping( renderWidget: () => , }, "registered-users": { - defaults: METRIC_WIDGET_DEFAULTS, + defaults: { + h: REGISTERED_USERS_WIDGET_HEIGHT, + maxH: REGISTERED_USERS_WIDGET_HEIGHT + 2, + minH: METRIC_WIDGET_DEFAULTS.minH, + w: 2, + }, config: { icon: , title: intl.formatMessage(messages.registeredUsers), @@ -309,7 +319,7 @@ function createWidgetMapping( "registered-users", "", messages.registeredUsers, - "metric", + "registered-users", ), }, "gateway-status": { @@ -317,7 +327,7 @@ function createWidgetMapping( h: GATEWAY_STATUS_WIDGET_HEIGHT, maxH: GATEWAY_STATUS_WIDGET_HEIGHT + 2, minH: METRIC_WIDGET_DEFAULTS.minH, - w: 2, + w: 1, }, config: { icon: , @@ -461,53 +471,6 @@ function createWidgetMapping( "inventory-regions", ), }, - "managed-clusters": { - defaults: METRIC_WIDGET_DEFAULTS, - config: { - icon: , - title: intl.formatMessage(messages.widgetManagedClusters), - }, - renderWidget: () => - renderMetric( - "managed-clusters", - "", - messages.widgetManagedClusters, - "metric", - ), - }, - "managed-cluster-status": { - defaults: { - h: NODE_STATUS_WIDGET_HEIGHT, - maxH: NODE_STATUS_WIDGET_HEIGHT + 2, - minH: METRIC_WIDGET_DEFAULTS.minH, - w: 1, - }, - config: { - icon: , - title: intl.formatMessage(messages.widgetManagedClusterStatus), - }, - renderWidget: () => - renderMetric( - "managed-clusters", - "", - messages.widgetManagedClusterStatus, - "inventory-status", - ), - }, - "managed-databases": { - defaults: METRIC_WIDGET_DEFAULTS, - config: { - icon: , - title: intl.formatMessage(messages.widgetManagedDatabases), - }, - renderWidget: () => - renderMetric( - "managed-databases", - "", - messages.widgetManagedDatabases, - "metric", - ), - }, "managed-database-status": { defaults: { h: NODE_STATUS_WIDGET_HEIGHT, diff --git a/scripts/sdk-generator/main.go b/scripts/sdk-generator/main.go index 2bd79cc7b..f4f3cde04 100644 --- a/scripts/sdk-generator/main.go +++ b/scripts/sdk-generator/main.go @@ -70,8 +70,8 @@ func main() { fmt.Printf("Parsed %d resources from %s\n", len(spec.Resources), *specPath) for _, r := range spec.Resources { - fmt.Printf(" %s (%s): %d fields, delete=%v, patch=%v, actions=%v\n", - r.Name, r.PathSegment, len(r.Fields), r.HasDelete, r.HasPatch, r.Actions) + fmt.Printf(" %s (%s): %d fields, delete=%v, patch=%v, actions=%v, collection_ops=%v\n", + r.Name, r.PathSegment, len(r.Fields), r.HasDelete, r.HasPatch, r.Actions, collectionOperationNames(r.CollectionOperations)) } if *goOut != "" { @@ -405,6 +405,14 @@ func executeTemplate(tmpl *template.Template, outPath string, data interface{}) return tmpl.Execute(f, data) } +func collectionOperationNames(operations []CollectionOperation) []string { + names := make([]string, 0, len(operations)) + for _, operation := range operations { + names = append(names, operation.Name) + } + return names +} + func computeSpecHash(specPath string) (string, error) { specDir := filepath.Dir(specPath) h := sha256.New() diff --git a/scripts/sdk-generator/model.go b/scripts/sdk-generator/model.go index 853f29c8f..209fcd20c 100644 --- a/scripts/sdk-generator/model.go +++ b/scripts/sdk-generator/model.go @@ -32,6 +32,18 @@ type Resource struct { HasPatch bool HasStatusPatch bool Actions []string + CollectionOperations []CollectionOperation + ExtraModels []Model +} + +// CollectionOperation is a non-CRUD operation on a resource collection path, +// such as GET /users/stats. +type CollectionOperation struct { + Name string + HTTPMethod string + GoHTTPMethod string + Path string + ResponseType string } type PathParameter struct { diff --git a/scripts/sdk-generator/parser.go b/scripts/sdk-generator/parser.go index b253889a8..78d45a4e0 100644 --- a/scripts/sdk-generator/parser.go +++ b/scripts/sdk-generator/parser.go @@ -5,6 +5,7 @@ import ( "sort" "strconv" "strings" + "unicode" ir "github.com/openshift-online/hypershell/scripts/openapi-ir" ) @@ -28,7 +29,7 @@ func parseSpec(specPath, apiPrefix string) (*Spec, error) { if schema.Name == "ObjectReference" { continue } - resource, err := projectResource(document, schema, view) + resource, err := projectResource(document, schema, view, apiPrefix) if err != nil { return nil, fmt.Errorf("project resource %s: %w", schema.Name, err) } @@ -424,7 +425,7 @@ func tsPathExpression(path string, scope []PathParameter, item *PathParameter) s return "`" + result + "`" } -func projectResource(document *ir.Document, schema *ir.Schema, collection *ir.ResourceView) (Resource, error) { +func projectResource(document *ir.Document, schema *ir.Schema, collection *ir.ResourceView, apiPrefix string) (Resource, error) { fields, required := projectFields(document, schema.Ref, true) patchFields, _ := projectFieldsByName(document, schema.Name+"PatchRequest", false) statusPatchFields, _ := projectFieldsByName(document, schema.Name+"StatusPatchRequest", false) @@ -454,9 +455,85 @@ func projectResource(document *ir.Document, schema *ir.Schema, collection *ir.Re } } sort.Strings(resource.Actions) + resource.CollectionOperations = projectCollectionOperations(document, collection, schema.Name, apiPrefix) + if len(resource.CollectionOperations) > 0 { + roots := make([]string, 0, len(resource.CollectionOperations)) + for _, operation := range resource.CollectionOperations { + roots = append(roots, operation.ResponseType) + } + resource.ExtraModels = projectModels(document, roots) + } return resource, nil } +func projectCollectionOperations(document *ir.Document, collection *ir.ResourceView, resourceName, apiPrefix string) []CollectionOperation { + prefix := strings.TrimSuffix(collection.Path, "/") + "/" + operations := make([]CollectionOperation, 0) + seen := make(map[string]bool) + + for _, operation := range document.Operations { + if !strings.HasPrefix(operation.Path, prefix) { + continue + } + remainder := strings.TrimPrefix(operation.Path, prefix) + if remainder == "" || strings.Contains(remainder, "/") || strings.Contains(remainder, "{") { + continue + } + responseType := successSchemaName(document, operation, "200") + if responseType == "" || seen[operation.ID] { + continue + } + seen[operation.ID] = true + operations = append(operations, CollectionOperation{ + Name: collectionOperationMethodName(operation.ID, resourceName), + HTTPMethod: operation.Method, + GoHTTPMethod: goHTTPMethodConstant(operation.Method), + Path: relativeAPIPath(operation.Path, apiPrefix), + ResponseType: responseType, + }) + } + sort.Slice(operations, func(i, j int) bool { return operations[i].Name < operations[j].Name }) + return operations +} + +func goHTTPMethodConstant(method string) string { + switch strings.ToUpper(method) { + case "GET": + return "Get" + case "POST": + return "Post" + case "PUT": + return "Put" + case "PATCH": + return "Patch" + case "DELETE": + return "Delete" + default: + return "Get" + } +} + +func collectionOperationMethodName(operationID, resourceName string) string { + name := operationID + lower := strings.ToLower(operationID) + for _, prefix := range []string{"get", "list", "create", "delete", "update", "patch"} { + if strings.HasPrefix(lower, prefix) && len(operationID) > len(prefix) { + next := operationID[len(prefix):] + if next != "" && unicode.IsUpper(rune(next[0])) { + name = next + break + } + } + } + if strings.HasPrefix(name, resourceName) { + name = strings.TrimPrefix(name, resourceName) + } + if name == "" { + return lowerFirst(operationID) + } + return lowerFirst(name) +} + func projectFieldsByName(document *ir.Document, name string, includeReadOnly bool) ([]Field, []string) { for _, schema := range document.Schemas { if schema.Name == name { diff --git a/scripts/sdk-generator/parser_test.go b/scripts/sdk-generator/parser_test.go index 80ac38ecc..4c343cfae 100644 --- a/scripts/sdk-generator/parser_test.go +++ b/scripts/sdk-generator/parser_test.go @@ -69,3 +69,47 @@ func TestParseSpecProjectsScopedServiceAccountResource(t *testing.T) { } } } + +func TestParseSpecProjectsUserActivityStatsCollectionOperation(t *testing.T) { + specPath := filepath.Join("..", "..", "components", "api-server", "openapi", "openapi.yaml") + spec, err := parseSpec(specPath, "/api/hypershell/v1") + if err != nil { + t.Fatalf("parse spec: %v", err) + } + + var resource *Resource + for index := range spec.Resources { + if spec.Resources[index].Name == "User" { + resource = &spec.Resources[index] + break + } + } + if resource == nil { + t.Fatal("user resource was not projected") + } + if len(resource.CollectionOperations) != 1 { + t.Fatalf("collection operations = %#v, want one activity stats operation", resource.CollectionOperations) + } + + operation := resource.CollectionOperations[0] + if operation.Name != "activityStats" { + t.Fatalf("collection operation name = %q, want activityStats", operation.Name) + } + if operation.HTTPMethod != "GET" || operation.Path != "/users/stats" { + t.Fatalf("collection operation = %#v", operation) + } + if operation.ResponseType != "UserActivityStats" { + t.Fatalf("response type = %q, want UserActivityStats", operation.ResponseType) + } + + extraModels := make(map[string]Model, len(resource.ExtraModels)) + for _, model := range resource.ExtraModels { + extraModels[model.Name] = model + } + if _, ok := extraModels["UserActivityStats"]; !ok { + t.Fatal("UserActivityStats model was not projected") + } + if _, ok := extraModels["UserDailyCount"]; !ok { + t.Fatal("UserDailyCount model was not projected from nested stats schema") + } +} diff --git a/scripts/sdk-generator/templates/go/client.go.tmpl b/scripts/sdk-generator/templates/go/client.go.tmpl index 6064832c2..7d4dde33b 100644 --- a/scripts/sdk-generator/templates/go/client.go.tmpl +++ b/scripts/sdk-generator/templates/go/client.go.tmpl @@ -95,6 +95,16 @@ func (a *{{$.Resource.Name}}API) {{. | title}}(ctx context.Context, id string) ( return &result, nil } {{end}} +{{- range .Resource.CollectionOperations}} + +func (a *{{$.Resource.Name}}API) {{.Name | goName}}(ctx context.Context) (*types.{{.ResponseType}}, error) { + var result types.{{.ResponseType}} + if err := a.client.do(ctx, http.Method{{.GoHTTPMethod}}, "{{.Path}}", nil, http.StatusOK, &result); err != nil { + return nil, err + } + return &result, nil +} +{{end}} func (a *{{.Resource.Name}}API) ListAll(ctx context.Context, opts *types.ListOptions) *Iterator[types.{{.Resource.Name}}] { return NewIterator(func(page int) (*types.{{.Resource.Name}}List, error) { diff --git a/scripts/sdk-generator/templates/go/types.go.tmpl b/scripts/sdk-generator/templates/go/types.go.tmpl index 06f96f605..1bd8aa8b0 100644 --- a/scripts/sdk-generator/templates/go/types.go.tmpl +++ b/scripts/sdk-generator/templates/go/types.go.tmpl @@ -100,3 +100,20 @@ func (b *{{.Resource.Name}}StatusPatchBuilder) Build() map[string]any { return b.patch } {{end}} +{{range .Resource.ExtraModels}}{{$model := .}} +{{if .IsEnum}} +type {{.Name}} string + +const ( +{{- range .EnumValues}} + {{.Name}} {{$model.Name}} = "{{.Value}}" +{{- end}} +) +{{else}} +type {{.Name}} struct { +{{- range .Fields}} + {{.GoName}} {{.GoType}} {{.JSONTag}} +{{- end}} +} +{{end}} +{{end}} diff --git a/scripts/sdk-generator/templates/ts/client.ts.tmpl b/scripts/sdk-generator/templates/ts/client.ts.tmpl index d07b28399..52bfcc7ad 100644 --- a/scripts/sdk-generator/templates/ts/client.ts.tmpl +++ b/scripts/sdk-generator/templates/ts/client.ts.tmpl @@ -5,7 +5,7 @@ import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; -import type { {{.Resource.Name}}, {{.Resource.Name}}List, {{.Resource.Name}}CreateRequest{{if .Resource.HasPatch}}, {{.Resource.Name}}PatchRequest{{end}}{{if .Resource.HasStatusPatch}}, {{.Resource.Name}}StatusPatchRequest{{end}} } from './{{.Resource.Name | snakeCase}}.js'; +import type { {{.Resource.Name}}, {{.Resource.Name}}List, {{.Resource.Name}}CreateRequest{{if .Resource.HasPatch}}, {{.Resource.Name}}PatchRequest{{end}}{{if .Resource.HasStatusPatch}}, {{.Resource.Name}}StatusPatchRequest{{end}}{{range .Resource.CollectionOperations}}, {{.ResponseType}}{{end}} } from './{{.Resource.Name | snakeCase}}.js'; export class {{.Resource.Name}}API { constructor(private readonly config: SDKClientConfig) {} @@ -47,6 +47,12 @@ export class {{.Resource.Name}}API { return sdkFetch<{{$.Resource.Name}}>(this.config, 'POST', `/{{$.Resource.PathSegment}}/${id}/{{.}}`, undefined, opts); } {{end}} +{{- range .Resource.CollectionOperations}} + + async {{.Name}}(opts?: RequestOptions): Promise<{{.ResponseType}}> { + return sdkFetch<{{.ResponseType}}>(this.config, '{{.HTTPMethod}}', '{{.Path}}', undefined, opts); + } +{{end}} async *listAll(size: number = 100, opts?: RequestOptions): AsyncGenerator<{{.Resource.Name}}> { let page = 1; diff --git a/scripts/sdk-generator/templates/ts/index.ts.tmpl b/scripts/sdk-generator/templates/ts/index.ts.tmpl index fcda9fef2..382f822e9 100644 --- a/scripts/sdk-generator/templates/ts/index.ts.tmpl +++ b/scripts/sdk-generator/templates/ts/index.ts.tmpl @@ -12,7 +12,7 @@ export { SDKAPIError, buildQueryString } from './base.js'; {{end}}export type { {{.Name}}ListOptions } from './{{.Name | snakeCase}}.js'; export { {{.Name}}API } from './{{.Name | snakeCase}}_api.js'; {{else}} -export type { {{.Name}}, {{.Name}}List, {{.Name}}CreateRequest, {{.Name}}PatchRequest{{if .HasStatusPatch}}, {{.Name}}StatusPatchRequest{{end}} } from './{{.Name | snakeCase}}.js'; +export type { {{.Name}}, {{.Name}}List, {{.Name}}CreateRequest, {{.Name}}PatchRequest{{if .HasStatusPatch}}, {{.Name}}StatusPatchRequest{{end}}{{range .ExtraModels}}, {{.Name}}{{end}} } from './{{.Name | snakeCase}}.js'; export { {{.Name}}Builder, {{.Name}}PatchBuilder{{if .HasStatusPatch}}, {{.Name}}StatusPatchBuilder{{end}} } from './{{.Name | snakeCase}}.js'; export { {{.Name}}API } from './{{.Name | snakeCase}}_api.js'; {{end}} diff --git a/scripts/sdk-generator/templates/ts/types.ts.tmpl b/scripts/sdk-generator/templates/ts/types.ts.tmpl index da06564df..8238bb435 100644 --- a/scripts/sdk-generator/templates/ts/types.ts.tmpl +++ b/scripts/sdk-generator/templates/ts/types.ts.tmpl @@ -26,6 +26,17 @@ export type {{.Resource.Name}}PatchRequest = { {{.Name}}?: {{.TSType}}; {{- end}} }; +{{range .Resource.ExtraModels}} +{{if .IsEnum}} +export type {{.Name}} = {{range $index, $value := .EnumValues}}{{if $index}} | {{end}}'{{$value.Value}}'{{end}}; +{{else}} +export type {{.Name}} = { +{{- range .Fields}} + {{.Name}}{{if not .Required}}?{{end}}: {{.TSType}}; +{{- end}} +}; +{{end}} +{{end}} {{if .Resource.HasStatusPatch}} export type {{.Resource.Name}}StatusPatchRequest = { {{- range .Resource.StatusPatchFields}} diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index c350c9b20..efdf6afea 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-09-04 (scoped reanalysis of the CP-OBS-07 reconcile-queue metric changes after review; operational-dashboard through OP-DASH-20; OP-DASH-18 NaN fallback; OP-DASH-19 independent metric sources + partial failure; OP-DASH-20 section titles + header refresh consolidation; cluster memory/cpu/pods/nodes metrics; gateway-provision-time GPT-W1; registered-users complete; the last full-corpus analysis remains 2026-08-31) -**Spec corpus**: 48 spec files; the coverage table tracks 39 analyzed feature/spec groups after adding OpenShell Gateway Console, OpenShift Development, Operational Dashboard, Registered Users, Cluster Memory, Cluster CPU, Cluster Pods, Cluster Nodes, and Gateway Provision Time -**Codebase commit**: `c9d68e0` (rebased HYPERSHELL-276 initial dashboard data branch; section titles + last-refreshed header; partial metric-source failure; NaN/Infinity display fallback; layout persistence v23) +**Last analyzed**: 2026-09-09 (scoped reanalysis of HYPERSHELL-278 platform inventory + HYPERSHELL-279 user activity stats and dashboard UI; PI-01..PI-09; RU-09/RU-10; OP-DASH-08/19/20/21; layout persistence v32; the last full-corpus analysis remains 2026-08-31) +**Spec corpus**: 49 spec files; the coverage table tracks 40 analyzed feature/spec groups after adding Platform Inventory (HYPERSHELL-278) +**Codebase commit**: `c8ffd54` (HYPERSHELL-279 registered-users stats card + platform inventory widgets; layout v32) ### Coverage Summary @@ -74,17 +74,18 @@ skills/ | Platform - OpenShift Development | 1 | 13 | 7 | 2 | 4 | 0 | 54% | | Platform - OIDC Integration | 1 | 7 | 6 | 1 | 0 | 0 | 93% | | Platform - Gateway Metrics Dashboard | 1 | 8 | 8 | 0 | 0 | 0 | 100% | -| Platform - Registered Users | 1 | 8 | 8 | 0 | 0 | 0 | 100% | +| Platform - Registered Users | 1 | 10 | 10 | 0 | 0 | 0 | 100% | +| Platform - Platform Inventory | 1 | 9 | 9 | 0 | 0 | 0 | 100% | | Platform - Cluster Memory | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - Cluster CPU | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - Cluster Pods | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - Cluster Nodes | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - Gateway Provision Time | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Web Console - Architecture | 1 | 28 | 21 | 5 | 2 | 0 | 86% | -| Web Console - Operational Dashboard | 1 | 20 | 20 | 0 | 0 | 0 | 100% | +| Web Console - Operational Dashboard | 1 | 22 | 22 | 0 | 0 | 0 | 100% | | Security - RBAC Enforcement | 1 | 13 | 11 | 0 | 0 | 2 | 85% | | Standards | 13 | 0 | 0 | 0 | 0 | 0 | N/A | -| **TOTAL** | **39** | **291** | **248** | **17** | **13** | **5** | **85%** | +| **TOTAL** | **40** | **304** | **261** | **17** | **13** | **5** | **86%** | ### Spec Dependency Order @@ -421,19 +422,21 @@ Local-dev lifecycle (`make openshift-up` / `down` / component swaps) is implemen | OP-DASH-05 | SPA and BFF route surfaces | Present | - | `routes/dashboard.tsx`, `routes/home.tsx`, `route-contract.json` | - | | OP-DASH-06 | Gateway list metrics adapter (paginated REST) | Present | - | `app/adapters/api/dashboard-control-plane.ts`, `dashboard-control-plane.test.ts` | OP-W1 ✅ | | OP-DASH-07 | Gateway display status aggregation | Present | - | `dashboard-control-plane.ts`, `gateway-management-ui/gateway-data.ts:aggregateGatewayDisplayStatusCounts` | - | -| OP-DASH-08 | Connected vs placeholder metrics | Present | - | `DATA_SOURCES.md`, `dashboard/dashboard-data.ts` | OP-W1 ✅ | +| OP-DASH-08 | Connected vs placeholder metrics | Present | - | `DATA_SOURCES.md`, `dashboard-control-plane.ts`, `platform-inventory-aggregation.ts` | PI-W1 ✅ | | OP-DASH-09 | Metrics refresh policy (15 min + manual refresh, partial failure) | Present | - | `get-metrics-data.ts`, `operational-dashboard-page.tsx` | OP-W2 ✅ | -| OP-DASH-10 | Widgetized grid layout | Present | - | `operational-dashboard-page.tsx`, `dashboard-layout-template.ts` | - | -| OP-DASH-11 | Layout persistence (`localStorage` v23) | Present | - | `operational-dashboard-page.tsx`, `dashboard-layout-persistence.ts` | - | +| OP-DASH-10 | Widgetized grid layout | Present | - | `operational-dashboard-page.tsx`, `dashboard-layout-template.ts` | PI-W1 ✅ | +| OP-DASH-11 | Layout persistence (`localStorage` v32) | Present | - | `operational-dashboard-page.tsx`, `dashboard-layout-persistence.ts` | PI-W1 ✅, RU-W3 ✅ | | OP-DASH-12 | Gateway status donut widget | Present | - | `dashboard/gateway-status-chart.tsx`, `dashboard/status-donut-chart.tsx`, `dashboard-widget.tsx` | - | -| OP-DASH-13 | Metric, utilization, and summary widgets | Present | - | `dashboard-widget.tsx`, `dashboard/utilization-chart.tsx` | - | -| OP-DASH-14 | Localization and accessibility | Present | - | `messages.ts`, `web-console/locales/en.json` | - | -| OP-DASH-15 | Verification fixtures and Storybook | Present | - | `fixtures/`, `operational-dashboard.stories.tsx`, `src/dashboard/*.test.ts`, `dashboard-control-plane.test.ts` | OP-W1 ✅ | -| OP-DASH-16 | Shared status donut + nodes widget | Present | - | `dashboard/status-donut-*.ts(x)`, `dashboard/node-status-*.ts(x)`, `dashboard-layout-template.ts` | - | +| OP-DASH-13 | Metric, utilization, and summary widgets | Present | - | `dashboard-widget.tsx`, `dashboard/utilization-chart.tsx`, `dashboard-stat-panel.tsx` | RU-W3 ✅ | +| OP-DASH-14 | Localization and accessibility | Present | - | `messages.ts`, `web-console/locales/en.json` | PI-W1 ✅, RU-W3 ✅ | +| OP-DASH-15 | Verification fixtures and Storybook | Present | - | `fixtures/`, `operational-dashboard.stories.tsx`, `src/dashboard/*.test.ts`, `dashboard-control-plane.test.ts` | OP-W1 ✅, PI-W1 ✅ | +| OP-DASH-16 | Shared status donut + nodes widget | Present | - | `dashboard/status-donut-*.ts(x)`, `dashboard/node-status-*.ts(x)`, `inventory-status-*.ts(x)`, `dashboard-layout-template.ts` | PI-W1 ✅ | | OP-DASH-17 | Pod capacity widget (phase + Unused segments) | Present | - | `dashboard/pod-capacity-*.ts(x)`, `bff/src/metrics-cluster-pods.ts`, `dashboard-control-plane.ts` | - | | OP-DASH-18 | Non-displayable metric values (NaN/Infinity fallback) | Present | - | `dashboard-widget.tsx`, `messages.ts` | - | -| OP-DASH-19 | Independent metric sources and partial failure | Present | - | `dashboard-control-plane.ts`, `dashboard-metric-sources.ts`, `get-metrics-data.ts`, `operational-dashboard-page.tsx` | OP-W2 ✅ | +| OP-DASH-19 | Independent metric sources and partial failure | Present | - | `dashboard-control-plane.ts`, `dashboard-metric-sources.ts`, `platform-inventory-aggregation.ts`, `user-activity-stats.ts`, `get-metrics-data.ts` | OP-W2 ✅, PI-W1 ✅, RU-W3 ✅ | | OP-DASH-20 | Section title widgets (platform adoption / hub cluster) | Present | - | `dashboard-layout-template.ts`, `dashboard-widget.tsx`, `dashboard-widget.css` | - | +| OP-DASH-22 | Platform inventory summary section | Present | - | `dashboard-layout-template.ts`, `dashboard-widget.tsx` (`InventorySummaryCard`) | PI-W1 ✅ | +| OP-DASH-21 | Optional platform inventory widgets | Present | - | `operational-dashboard-page.tsx`, `managed-cluster-*-chart.tsx`, `inventory-status-chart.tsx` | PI-W1 ✅ | **Scoped analysis notes:** @@ -446,6 +449,8 @@ Local-dev lifecycle (`make openshift-up` / `down` / component swaps) is implemen - OP-DASH-20 (2026-09-02): `section-title` widget for platform adoption and hub cluster headers; full-width title rows; headerless/borderless presentation; layout persistence key `hypershell.operational-dashboard.layout.v23`; `sanitizeDashboardTemplate` preserves multiple `section-title` instances; page header consolidates refresh, reset, and add-widgets controls (no separate toolbar). - Post-connect polish (2026-09-02, `06d6c56`): removed interim `usesSampleData` info banner and i18n keys; all OP-DASH-08 metrics are connected so the banner is no longer required. - OP-W2 (2026-09-03): partial metric-source failure handling - adapter fetches sources independently, page shows warning + metric-unavailable per widget/summary row, refresh merges stale data for failed sources (`dashboard-metric-sources.ts`, `get-metrics-data.ts` `keepPreviousData`). +- PI-W1 (2026-09-09, `d54c341`/`c8ffd54`): platform inventory section with `inventory-summary`, provider/region/database-status donuts; paginated managed cluster/database aggregation (`platform-inventory-aggregation.ts`); RBAC dashboard-operator List access; layout key v26-v28 then v32 after HYPERSHELL-279 layout polish. +- RU-W3 (2026-09-09, `ee18d8a`/`c8ffd54`): `GET /users/stats` API + login-day recording; `RegisteredUsersCard` with 7/30-day additions and logins + active sparkline; adapter maps full `UserActivityStats`; layout key v31-v32 for taller two-column registered-users widget. ### registered-users.spec.md @@ -455,15 +460,45 @@ Local-dev lifecycle (`make openshift-up` / `down` / component swaps) is implemen | RU-02 | User resource schema (OpenAPI) | Present | - | `openapi.users.yaml`, `plugins/users/presenter.go`, `plugins/users/model.go` | RU-W1 ✅ | | RU-03 | User inventory authorization | Present | - | `pkg/rbac/authorization.go`, `pkg/rbac/user_provisioning.go` | RU-W1 ✅ | | RU-04 | Paginated List contract | Present | - | `plugins/users/handler.go`, generic list wiring | RU-W1 ✅ | -| RU-05 | Operational dashboard `registered-users` metric | Present | - | `dashboard-control-plane.ts`, `sdk-typescript` users client | RU-W2 ✅ | -| RU-06 | UI presentation (Registered users) | Present | - | `operational-dashboard-page.tsx`, `messages.ts`, layout key v14 | RU-W2 ✅ | -| RU-07 | Refresh and error semantics | Present | - | `get-metrics-data.ts`, `operational-dashboard-page.tsx` | - | -| RU-08 | Verification (API + adapter tests) | Present | - | `plugins/users/integration_test.go`, `dashboard-control-plane.test.ts` | RU-W1 ✅, RU-W2 ✅ | +| RU-05 | Operational dashboard `registered-users` metric | Present | - | `user-activity-stats.ts`, `dashboard-control-plane.ts`, `sdk-typescript` users client | RU-W3 ✅ | +| RU-06 | UI presentation (Registered users) | Present | - | `dashboard-widget.tsx` (`RegisteredUsersCard`), `dashboard-stat-panel.tsx`, `messages.ts` | RU-W3 ✅ | +| RU-07 | Refresh and error semantics | Present | - | `get-metrics-data.ts`, `dashboard-control-plane.ts` | - | +| RU-08 | Verification (API + adapter tests) | Present | - | `plugins/users/integration_test.go`, `stats_test.go`, `dashboard-control-plane.test.ts`, `user-activity-stats.test.ts` | RU-W1 ✅, RU-W3 ✅ | +| RU-09 | User activity statistics API | Present | - | `plugins/users/stats.go`, `dao.go`, `handler.go`, `openapi.users.yaml` | RU-W3 ✅ | +| RU-10 | Users widget presentation | Present | - | `dashboard-widget.tsx` (`RegisteredUsersCard`), `dashboard-stat-panel.tsx` | RU-W3 ✅ | **Scoped analysis notes:** -- Delivered in `eb99f6b`: OpenAPI + List/Get handlers, `platform:admin` binding or `hypershell-admins` JWT authorization, integration tests, and dashboard adapter emitting `registered-users` from `users.list({ page: 1, size: 1 }).total`. -- `DATA_SOURCES.md` and OP-DASH-08 `registered-users` row updated to connected. +- RU-W1 (`eb99f6b`): OpenAPI + List/Get handlers, `platform:admin` binding or `hypershell-admins` JWT authorization, integration tests, initial dashboard adapter via `users.list({ page: 1, size: 1 }).total`. +- RU-W3 (`ee18d8a`/`c8ffd54`): `GET /users/stats` with registration/active histograms; `user_login_days` table + `RecordLogin` on authenticated requests; adapter maps full `UserActivityStats`; `RegisteredUsersCard` shows total, 7/30-day additions, 7/30-day logins, and active sparkline. +- `DATA_SOURCES.md` and OP-DASH-08 `registered-users` row updated to connected via stats API. + +### platform-inventory.spec.md + +| # | Requirement | Status | Gap | Code Location | Wave | +|---|-------------|--------|-----|---------------|------| +| PI-01 | Inventory resource scope | Present | - | `platform-inventory-aggregation.ts` | PI-W1 ✅ | +| PI-02 | Paginated list aggregation | Present | - | `platform-inventory-aggregation.ts`, `platform-inventory-aggregation.test.ts` | PI-W1 ✅ | +| PI-03 | Dashboard-operator authorization | Present | - | `pkg/rbac/authorization.go`, `authorization_test.go` | PI-W1 ✅ | +| PI-04 | Status and dimension mapping | Present | - | `platform-inventory-aggregation.ts`, `inventory-status-data.ts`, `inventory-dimension-donut-data.ts` | PI-W1 ✅ | +| PI-05 | Operational dashboard metrics | Present | - | `dashboard-control-plane.ts`, `dashboard-types.ts` | PI-W1 ✅ | +| PI-06 | Inventory summary card | Present | - | `dashboard-widget.tsx` (`InventorySummaryCard`) | PI-W1 ✅ | +| PI-07 | Optional inventory detail widgets | Present | - | `operational-dashboard-page.tsx`, `managed-cluster-*-chart.tsx`, `inventory-status-chart.tsx` | PI-W1 ✅ | +| PI-08 | Refresh and error semantics | Present | - | `dashboard-control-plane.ts`, `dashboard-metric-sources.ts` | PI-W1 ✅ | +| PI-09 | Documentation and verification | Present | - | `DATA_SOURCES.md`, `dashboard-control-plane.test.ts`, `mock-operational-dashboard-metrics.ts`, `operational-dashboard.stories.tsx` | PI-W1 ✅ | + +**Scoped analysis notes:** + +- Delivered in `d54c341`/`c8ffd54`: paginated managed cluster/database List aggregation with status/provider/region buckets and 30-day creation window; `platform-inventory` metric source with partial-failure semantics; inventory summary + provider/region/database-status donuts on default layout. +- RBAC: `hasDashboardInventoryAccess` grants List to dashboard operators and existing `gateway:creator` callers. +- Status donuts suppress segments when more than five non-zero status buckets (PI-04). +- No `/dashboard/inventory` route in v1 (PI-07). + +**Direction checks:** + +- Spec to code: All nine PI requirements are present on branch `HYPERSHELL-279-registrations`. +- Code to spec: Layout persistence key v32 and bump history documented in OP-DASH-11. +- OpenAPI to spec: No new public fields beyond existing ManagedCluster/ManagedDatabase List schemas; aggregation is adapter-side. ### cluster-memory.spec.md @@ -1035,6 +1070,34 @@ label-selected pod informer. 4. Add adapter unit tests for registered-users mapping; update Storybook fixtures 5. Verify: `pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check`, web-console `check` +### Wave PI-W1: Platform Inventory Dashboard Integration ✅ + +**Scope:** PI-01 through PI-09, OP-DASH-08/19/22/21 inventory rows +**Dependency:** `platform-inventory.spec.md` authored (`a8ce5a5`); managed cluster/database List APIs +**Status:** Complete (`d54c341`, `c8ffd54`) + +1. Add `platform-inventory-aggregation.ts` with paginated List aggregation, bucket rules, and 30-day lookback +2. Extend RBAC for dashboard-operator List access to `managed_clusters` and `managed_databases` +3. Wire `platform-inventory` metric source in `dashboard-control-plane.ts` (`managed-clusters`, `managed-databases`) +4. Add inventory widgets: summary card, provider/region/database-status donuts; register optional widget types +5. Extend default layout with Platform inventory section; bump layout key through v28 then v32 (registered-users layout polish) +6. Update `DATA_SOURCES.md`, fixtures, Storybook, adapter unit tests +7. Verify: `pnpm --filter @openshift-online/hypershell-operational-dashboard-ui check`, web-console adapter tests (19 pass) + +### Wave RU-W3: User Activity Stats and Registered Users Card ✅ + +**Scope:** RU-05 through RU-10 (updated), OP-DASH-08/19 `registered-users` source +**Dependency:** `registered-users.spec.md` v2 (`a8ce5a5`); RU-W1 List API +**Status:** Complete (`ee18d8a`, `c8ffd54`) + +1. Add `GET /users/stats` OpenAPI + handler + `user_login_days` migration; record login on authenticated requests +2. Add `user-activity-stats.ts` adapter helper; switch dashboard adapter from List total to full stats mapping +3. Add `RegisteredUsersCard` + `DashboardStatPanel` with 7/30-day additions, logins, and active sparkline +4. Extend `OperationalMetric` with `createdLast7Days`, `activeLast7Days`, `activeTrend`, etc. +5. Bump layout key for taller two-column registered-users widget (v31-v32) +6. Add unit tests: `stats_test.go`, `user-activity-stats.test.ts`, extended `dashboard-control-plane.test.ts` +7. Verify: operational-dashboard-ui check (34 tests pass), web-console adapter tests + ### Wave CM-W1: Prometheus Node Memory Scrape ✅ **Scope:** CM-01 (query target), CM-03, CM-06 @@ -1233,6 +1296,8 @@ label-selected pod informer. | Date | Commit | Action | Coverage | Notes | |------|--------|--------|----------|-------| +| 2026-09-09 | working tree | Spec drift fix: operational-dashboard OP-DASH-19/11/20/22 | 86% (unchanged) | Aligned spec with code: OP-DASH-19 `users/stats` source; OP-DASH-11 layout key v32 with v23-v32 bump table; renamed duplicate OP-DASH-20 platform inventory requirement to OP-DASH-22; updated DATA_SOURCES.md cross-refs. | +| 2026-09-09 | `c8ffd54` | Scoped reanalysis: HYPERSHELL-278/279 dashboard UI | 86% | Verified PI-01..PI-09 (platform inventory), RU-09/RU-10 (user activity stats), OP-DASH-21/22 (inventory widgets). All 13 new scoped requirements present. Layout persistence v32. operational-dashboard-ui check (34 tests) and web-console adapter tests (19 tests) pass. | | 2026-09-07 | working tree | Reconciled gateway-reconcile-concurrency.spec.md (CP-CONC-01..03) | 3/3 scoped requirements present | Made the gateway reconcile worker-pool size deployment configuration via `GATEWAY_RECONCILE_WORKERS` (new `getEnvInt` helper + `Config.GatewayReconcileWorkers`, default 4 = prior hardcoded pool), plumbed config -> `WatchGateways` -> `withWorkers`, clamped non-positive to the default at the watcher boundary (preserving the test-only 0-worker queue pattern), and added config/getEnvInt tests. Per-gateway serialization and bounded throttle already held (existing queue tests). The full-corpus percentage is unchanged. | | 2026-09-04 | `bd02232` | Reanalyzed CP-OBS-RQ-W1 after review fixes | 5/5 scoped fields present | Defined one locked worker-claim boundary for depth and wait, kept dirty adds in backoff out of ready depth, and made the design rationale apply to each shared reconcile queue. The full-corpus percentage is unchanged. | | 2026-09-04 | `9c01984` | Completed CP-OBS-RQ-W1 reconcile-queue metrics | 5/5 scoped fields present | Added ready queue depth and ready-to-worker wait metrics with one bounded resource-kind attribute. Coalesced work produces one wait observation, and scheduled retry backoff is excluded. | diff --git a/specs/platform/registered-users.spec.md b/specs/platform/registered-users.spec.md index d8ccae27a..07d86376d 100644 --- a/specs/platform/registered-users.spec.md +++ b/specs/platform/registered-users.spec.md @@ -123,15 +123,76 @@ The List response SHALL include accurate `page`, `size`, `total`, and `items` fi --- -### Requirement: RU-05 -- Operational Dashboard Metric +### Requirement: RU-09 -- User Activity Statistics API + +The API server SHALL expose `GET /api/hypershell/v1/users/stats` returning a `UserActivityStats` document with: + +| Field | Meaning | +| --- | --- | +| `total_registered` | Count of all registered users | +| `registered_last_7_days` | Users whose `created_at` is on or after the UTC midnight at the start of the 7th calendar day before the evaluation day (`created_at >= evaluation_day - 6 days`) | +| `registered_last_30_days` | Users whose `created_at` is on or after the UTC midnight at the start of the 30th calendar day before the evaluation day (`created_at >= evaluation_day - 29 days`) | +| `active_last_7_days` | Distinct users with at least one authenticated API access on any of the last 7 UTC calendar days | +| `active_last_30_days` | Distinct users with at least one authenticated API access on any of the last 30 UTC calendar days | +| `registration_daily` | 30-element histogram of new registrations per UTC day (zeros for days with no signups) | +| `active_daily` | 30-element histogram of distinct active users per UTC day (zeros for days with no logins) | + +The endpoint SHALL use the same dashboard-operator authorization as user inventory (RU-03). + +Authenticated API access SHALL record the caller's user ID in a `user_login_days` table (one row per user per UTC day) and update `last_login_at` on the `User` record. Recording SHALL occur during JWT auto-provisioning on each authenticated request. + +#### Scenario: Dashboard operator loads user activity stats -The operational dashboard host adapter SHALL populate an `OperationalMetric` with `id: "registered-users"` and `value` set to the decimal string of the user List `total`. +- GIVEN 42 registered users exist and 18 logged in during the last 7 UTC days +- AND an authorized dashboard operator opens `/dashboard` +- WHEN the adapter calls `GET /api/hypershell/v1/users/stats` +- THEN the response SHALL include `total_registered: 42` and `active_last_7_days: 18` +- AND `registration_daily` and `active_daily` SHALL each contain 30 dated buckets + +#### Scenario: Registration windows use inclusive UTC midnight boundaries + +- GIVEN the evaluation time is `2026-09-08T15:30:00Z` +- AND four users exist with `created_at` at `2026-09-02T00:00:00Z`, `2026-09-01T23:59:59.999999999Z`, `2026-08-10T00:00:00Z`, and `2026-08-09T23:59:59.999999999Z` respectively +- WHEN `GetActivityStats` runs for that evaluation time +- THEN `registered_last_7_days` SHALL be `1` (only the user created at the 7-day window start) +- AND `registered_last_30_days` SHALL be `3` (the 7-day inclusive user, the user one nanosecond before that window, and the 30-day inclusive user) + +--- + +### Requirement: RU-10 -- Users Widget Presentation + +The `registered-users` widget SHALL render through `RegisteredUsersCard` with the user-facing title **Users**, showing: + +- Total registered users as the primary heading (`value`), labeled **Registered users** (for example, `42 Registered users`) +- Rows for users added during the last 7 and 30 UTC days (`createdLast7Days`, `createdLast30Days`) +- Rows for users who logged in during the last 7 and 30 UTC days (`activeLast7Days`, `activeLast30Days`) +- A sparkline of `active_daily` (`activeTrend`) for the last 30 UTC days (30 data points) + +The usage summary card SHALL show only the total registered user count under **Users**. It MAY also show a trend direction indicator derived from `activeTrend` when daily unique logins changed by at least 5% between the first and last points in the 30-day window. + +#### Scenario: Widget shows total, additions, and login activity + +- GIVEN user activity stats loaded successfully +- WHEN the registered-users widget renders +- THEN it SHALL display the total, 7/30-day addition counts, 7/30-day login counts, and the daily-login sparkline + +--- + +### Requirement: RU-05 -- Operational Dashboard Metric -The adapter SHALL obtain `total` from the HyperShell REST API through the browser TypeScript SDK. It SHOULD use a single List request with `page=1` and `size=1` to minimize payload size; it SHALL NOT paginate through all user records when only the count is required. +The operational dashboard host adapter SHALL populate an `OperationalMetric` with `id: "registered-users"` and fields mapped from `UserActivityStats`: -The metric SHALL NOT include `trend`, `status`, `unit`, or `total` fields in version 1. +| Metric field | API field | +| --- | --- | +| `value` | `total_registered` | +| `createdLast7Days` | `registered_last_7_days` | +| `createdLast30Days` | `registered_last_30_days` | +| `activeLast7Days` | `active_last_7_days` | +| `activeLast30Days` | `active_last_30_days` | +| `trend` | `registration_daily` | +| `activeTrend` | `active_daily` | -The operational dashboard package SHALL rename the widget and summary labels from **Active users** to **Registered users**. The widget type key SHALL change from `active-users` to `registered-users` in the layout template, widget mapping, usage summary, fixtures, and `DATA_SOURCES.md`. +The adapter SHALL obtain stats from `GET /api/hypershell/v1/users/stats` through the browser TypeScript SDK. #### Scenario: Dashboard shows registered user total @@ -139,13 +200,13 @@ The operational dashboard package SHALL rename the widget and summary labels fro - AND an authorized dashboard operator opens `/dashboard` - WHEN operational metrics load successfully - THEN the `registered-users` metric SHALL have `value: "42"` -- AND the usage summary row SHALL display `42` under **Registered users** +- AND the usage summary row SHALL display `42` under **Users** #### Scenario: Unauthorized adapter call omits registered-users metric - GIVEN the signed-in user lacks dashboard-operator API authorization - AND at least one other metric source succeeds -- WHEN the host adapter calls `GET /api/hypershell/v1/users` +- WHEN the host adapter calls `GET /api/hypershell/v1/users/stats` - THEN the `registered-users` metric SHALL be omitted from the adapter response - AND the dashboard SHALL show its localized partial-load warning (OP-DASH-09) - AND the registered-users widget and usage-summary row SHALL render the localized metric-unavailable state (not a silent zero count) @@ -154,26 +215,27 @@ The operational dashboard package SHALL rename the widget and summary labels fro ### Requirement: RU-06 -- UI Presentation -The `registered-users` widget SHALL render through the existing `MetricCard` presentation (large numeric heading, localized title). +The `registered-users` widget SHALL render through `RegisteredUsersCard` (see RU-10). -The usage summary card SHALL include a **Registered users** row sourced from the same metric. +The usage summary card SHALL include a **Users** row sourced from the metric `value` (total only). All user-visible strings SHALL use `defineMessages` in `operational-dashboard-ui` and SHALL be extracted into the web-console `locales/en.json` catalog. -#### Scenario: Metric card shows the count +#### Scenario: Widget shows the total and activity breakdown - GIVEN `registered-users` is on the dashboard layout and metrics loaded successfully - WHEN the widget renders - THEN it SHALL display the metric `value` as the card heading -- AND the title SHALL read **Registered users** +- AND the title SHALL read **Users** +- AND the 7/30-day addition and login rows and daily-login sparkline SHALL be visible --- ### Requirement: RU-07 -- Refresh and Error Semantics -Registered user counts SHALL load through the existing operational dashboard metrics query (`useGetMetricsData`) and SHALL inherit its refresh policy (`operationalDashboardRefreshMilliseconds`, currently 15 minutes) and manual refresh behavior defined in `web-console/operational-dashboard.spec.md` OP-DASH-09. +Registered user stats SHALL load through the existing operational dashboard metrics query (`useGetMetricsData`) and SHALL inherit its refresh policy (`operationalDashboardRefreshMilliseconds`, currently 15 minutes) and manual refresh behavior defined in `web-console/operational-dashboard.spec.md` OP-DASH-09. -A failed users List request SHALL fail only the registered-users metric source (OP-DASH-19); the dashboard SHALL NOT display `0` as a fallback count. +A failed `GET /api/hypershell/v1/users/stats` request SHALL fail only the registered-users metric source (OP-DASH-19); the dashboard SHALL NOT display `0` as a fallback count. #### Scenario: Refresh updates the displayed total @@ -192,14 +254,17 @@ The API server SHALL include integration tests for: - Forbidden List for non-admin callers - Opaque 404 on unauthorized singleton Get - Accurate `total` with `size=1` +- Authorized and forbidden `GET /users/stats` +- Login recording on authenticated API access +- Registration window boundary counting (`registered_last_7_days` and `registered_last_30_days` at inclusive UTC midnight edges) -The web console SHALL include unit tests for the dashboard adapter mapping `UserList.total` into `registered-users`. +The web console SHALL include unit tests for the dashboard adapter mapping `UserActivityStats` into `registered-users`. -The operational dashboard package SHALL update Storybook fixtures and `mockOperationalDashboardMetrics` to use `registered-users` instead of `active-users`. +The operational dashboard package SHALL update Storybook fixtures and `mockOperationalDashboardMetrics` with extended registered-user fields. #### Scenario: CI exercises authorization and mapping - GIVEN the integration test suite runs with RBAC enforcement enabled - WHEN user inventory tests execute - THEN they SHALL cover both allow and deny paths -- AND the dashboard adapter unit tests SHALL assert the metric ID and stringified total +- AND the dashboard adapter unit tests SHALL assert histogram and summary field mapping diff --git a/specs/web-console/operational-dashboard.spec.md b/specs/web-console/operational-dashboard.spec.md index db6269b17..ecc617f96 100644 --- a/specs/web-console/operational-dashboard.spec.md +++ b/specs/web-console/operational-dashboard.spec.md @@ -287,7 +287,7 @@ The host `DashboardControlPlane` adapter SHALL load operational metrics from ind | Source | Metric IDs affected | | --- | --- | | Paginated gateway list (`GET /api/hypershell/v1/gateways`) | `provisioned-gateways`, `provisioned-sandboxes`, `provision-time` | -| Users list (`GET /api/hypershell/v1/users`, `page=1`, `size=1`) | `registered-users` | +| User activity stats (`GET /api/hypershell/v1/users/stats`) | `registered-users` | | BFF `GET /api/metrics/cluster-memory` | `memory` | | BFF `GET /api/metrics/cluster-cpu` | `cpu` | | BFF `GET /api/metrics/cluster-pods` | `pods` | @@ -313,7 +313,7 @@ The dashboard page SHALL derive partial-failure warnings from the adapter result #### Scenario: Prometheus down does not hide gateway metrics -- GIVEN the gateway list and users list requests succeed +- GIVEN the gateway list and user activity stats requests succeed - AND every BFF cluster-metrics request fails - WHEN the operator opens `/dashboard` - THEN gateway, sandbox, and registered-user widgets SHALL display loaded values @@ -359,9 +359,9 @@ The default layout template (`defaultDashboardLayoutTemplate`) SHALL place these | --- | --- | --- | | `section-title` | `section-title#platform-adoption` | Full width, row 0 (platform adoption header) | | `usage-summary` | `usage-summary#1` | Column 0, platform adoption | -| `gateway-status` | `gateway-status#1` | Columns 1–2, platform adoption, spans two columns | +| `registered-users` | `registered-users#1` | Columns 1–2, platform adoption, spans two columns | | `provisioned-sandboxes` | `provisioned-sandboxes#1` | Column 3, platform adoption | -| `registered-users` | `registered-users#1` | Column 3, platform adoption second row | +| `gateway-status` | `gateway-status#1` | Column 3, platform adoption second row | | `section-title` | `section-title#hub-cluster` | Full width, hub cluster header row | | `system-summary` | `system-summary#1` | Column 0, hub cluster | | `memory` | `memory#1` | Column 1, hub cluster | @@ -389,7 +389,16 @@ Users SHALL be able to add widgets from the drawer, drag to rearrange, and remov ### Requirement: OP-DASH-11 -- Layout Persistence -The dashboard SHALL persist the sanitized layout template to `localStorage` under the key `hypershell.operational-dashboard.layout.v23`. +The dashboard SHALL persist the sanitized layout template to `localStorage` under the key `hypershell.operational-dashboard.layout.v32`. + +Each default-layout change that would leave saved positions incompatible with the new template SHALL bump the layout persistence key so browsers load the updated default instead of a stale saved grid. Documented bumps: + +| Key | Trigger | +| --- | --- | +| `v23` | Section title widgets (OP-DASH-20) | +| `v30` | Platform inventory section and default inventory widgets (OP-DASH-22, OP-DASH-21) | +| `v31` | Taller `registered-users` widget for activity stats rows (RU-10) | +| `v32` | `registered-users` spans two columns; `gateway-status` compact single column | On mount, a saved template SHALL be loaded when it parses as valid JSON and contains an array entry for every responsive variant (`xl`, `lg`, `md`, `sm`). Invalid or corrupt saved state SHALL fall back to the default template without surfacing an error to the user. @@ -409,7 +418,7 @@ When persistence fails (for example, storage quota exceeded), the dashboard SHAL The `gateway-status` widget SHALL render a `GatewayStatusChart` donut using the shared `StatusDonutChart` primitive (`@patternfly/react-charts/victory` `ChartDonut`), driven by the `provisioned-gateways` metric's `status` and `value` fields. -The default layout template SHALL place `gateway-status` at `GATEWAY_STATUS_WIDGET_HEIGHT`, equal to `USAGE_SUMMARY_WIDGET_HEIGHT` in the platform adoption section. +The default layout template SHALL place `gateway-status` at `GATEWAY_STATUS_WIDGET_HEIGHT`, equal to `POD_CAPACITY_WIDGET_HEIGHT` (compact status donut) in the platform adoption section. The chart SHALL: @@ -494,7 +503,7 @@ The `pods` widget SHALL use `PodCapacityChart` (OP-DASH-17), not `UtilizationCha **Summary widgets:** -- `usage-summary` - horizontal `DescriptionList` for active users, gateways (with exception status counts), and sandboxes +- `usage-summary` - horizontal `DescriptionList` for registered users (total only, with optional login-activity trend indicator from `activeTrend`), gateways (with exception status counts), and sandboxes - `system-summary` - horizontal `DescriptionList` for memory, CPU, pods (with failed pod count when `podPhases.failed` is non-zero), nodes (with exception status counts when `status.failed` is non-zero), and provision duration (average, P50, and P95 rows when `provisionDuration` is present on the `provision-time` metric; see `platform/gateway-provision-time.spec.md` GPT-05) Trend direction indicators in summary rows SHALL appear only when `getMetricTrendChange` detects at least a 5% change between the first and last trend point. @@ -615,7 +624,7 @@ The host mock adapter (`createMockDashboardControlPlane`) MAY introduce an artif --- -### Requirement: OP-DASH-20 -- Platform Inventory Summary +### Requirement: OP-DASH-22 -- Platform Inventory Summary The operational dashboard SHALL include an `inventory-summary` widget that renders platform inventory totals and top dimensions from the `managed-clusters` and `managed-databases` metrics defined in `platform/platform-inventory.spec.md` (PI-05, PI-06). @@ -634,7 +643,7 @@ The inventory summary widget SHALL use the same `DescriptionList` summary presen The `managed-cluster-providers`, `managed-cluster-regions`, and `managed-database-status` widgets SHALL use the shared `StatusDonutChart` stack (OP-DASH-16) with labels from `inventoryProviders`, `inventoryRegions`, and `inventoryStatus` keys respectively. -Adding the two-column region donut to the default layout SHALL bump the layout persistence key to `hypershell.operational-dashboard.layout.v26` (OP-DASH-11). Aligning `gateway-status` height with `usage-summary` SHALL bump the layout persistence key to `hypershell.operational-dashboard.layout.v27`. Adding `managed-database-status` to the default layout SHALL bump the layout persistence key to `hypershell.operational-dashboard.layout.v28`. +The default layout changes in this requirement are covered by the layout persistence key bumps documented in OP-DASH-11 (`v30` through `v32`). #### Scenario: Default layout includes inventory summary @@ -649,14 +658,14 @@ Adding the two-column region donut to the default layout SHALL bump the layout p The widget catalog SHALL register optional inventory detail widgets defined in `platform/platform-inventory.spec.md` (PI-07): -- `managed-cluster-providers` - provider donut driven by `managed-clusters.inventoryProviders` (on default layout; OP-DASH-20) -- `managed-cluster-regions` - placement donut driven by `managed-clusters.inventoryRegions` (`{region} ({provider})` keys; on default layout; OP-DASH-20) +- `managed-cluster-providers` - provider donut driven by `managed-clusters.inventoryProviders` (on default layout; OP-DASH-22) +- `managed-cluster-regions` - placement donut driven by `managed-clusters.inventoryRegions` (`{region} ({provider})` keys; on default layout; OP-DASH-22) - `managed-clusters` - large number tile for total managed clusters - `managed-cluster-status` - status donut driven by `managed-clusters.inventoryStatus` - `managed-databases` - large number tile for total managed databases -- `managed-database-status` - status donut driven by `managed-databases.inventoryStatus` (on default layout; OP-DASH-20) +- `managed-database-status` - status donut driven by `managed-databases.inventoryStatus` (on default layout; OP-DASH-22) -These optional widget types SHALL be available in the add-widgets drawer. `managed-cluster-providers`, `managed-cluster-regions`, and `managed-database-status` SHALL also appear in `defaultDashboardLayoutTemplate` (OP-DASH-20). +These optional widget types SHALL be available in the add-widgets drawer. `managed-cluster-providers`, `managed-cluster-regions`, and `managed-database-status` SHALL also appear in `defaultDashboardLayoutTemplate` (OP-DASH-22). Status donut widgets SHALL reuse the shared `StatusDonutChart` stack (OP-DASH-16) with inventory-specific bucket labels from `inventoryStatus` keys. They SHALL NOT reuse gateway display-status colors or vocabulary.