From 4d4247e62b4624db049f9f646bb395000c094b33 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Sat, 25 Apr 2026 00:45:19 -0400 Subject: [PATCH] feat: add shard liveness API and UI indicator Surface per-shard liveness in the API and UI, derived from heartbeat ConfigMaps in the Kargo namespace. A shard is considered alive when its heartbeat ConfigMap carries an observedAt timestamp within AGENT_STATUS_DEADLINE (default 10m), and dead otherwise. - Add REST endpoint GET /v1beta1/system/shards returning each known shard's alive/dead status and last-heartbeat timestamp. - Add api.defaultShardName chart value plus DEFAULT_SHARD_NAME on the API server, used to resolve Stages with no explicit spec.shard. - Render a small shard-status indicator (broadcast tower icon, green for alive, red for dead, hidden when no shard data is available) on each Stage card in the project DAG view. - Default the Tilt dev environment to a single shard named 'tilt-shard' so the indicator has something to render. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Kent Rancourt --- charts/kargo/templates/api/configmap.yaml | 3 + hack/tilt/values.dev.yaml | 3 + .../generated/models/list_shards_response.go | 130 ++++++++ pkg/client/generated/models/shard_info.go | 116 +++++++ pkg/client/generated/models/shard_status.go | 75 +++++ .../system/list_shards_parameters.go | 125 +++++++ .../generated/system/list_shards_responses.go | 104 ++++++ pkg/client/generated/system/system_client.go | 56 ++++ pkg/server/config/config.go | 15 +- pkg/server/list_shards_v1alpha1.go | 120 +++++++ pkg/server/list_shards_v1alpha1_test.go | 307 ++++++++++++++++++ pkg/server/rest_router.go | 3 + swagger.json | 65 ++++ .../pipelines/context/dictionary-context.ts | 3 + .../pipelines/nodes/shard-status-icon.tsx | 59 ++++ .../project/pipelines/nodes/stage-node.tsx | 13 +- .../features/project/pipelines/pipelines.tsx | 18 + ui/src/gen/api/v2/models/index.ts | 3 + .../gen/api/v2/models/listShardsResponse.ts | 13 + ui/src/gen/api/v2/models/shardInfo.ts | 14 + ui/src/gen/api/v2/models/shardStatus.ts | 15 + ui/src/gen/api/v2/system/system.ts | 120 +++++++ 22 files changed, 1375 insertions(+), 5 deletions(-) create mode 100644 pkg/client/generated/models/list_shards_response.go create mode 100644 pkg/client/generated/models/shard_info.go create mode 100644 pkg/client/generated/models/shard_status.go create mode 100644 pkg/client/generated/system/list_shards_parameters.go create mode 100644 pkg/client/generated/system/list_shards_responses.go create mode 100644 pkg/server/list_shards_v1alpha1.go create mode 100644 pkg/server/list_shards_v1alpha1_test.go create mode 100644 ui/src/features/project/pipelines/nodes/shard-status-icon.tsx create mode 100644 ui/src/gen/api/v2/models/listShardsResponse.ts create mode 100644 ui/src/gen/api/v2/models/shardInfo.ts create mode 100644 ui/src/gen/api/v2/models/shardStatus.ts diff --git a/charts/kargo/templates/api/configmap.yaml b/charts/kargo/templates/api/configmap.yaml index 82d4ed3d5f..a356f55c71 100644 --- a/charts/kargo/templates/api/configmap.yaml +++ b/charts/kargo/templates/api/configmap.yaml @@ -25,6 +25,9 @@ data: SYSTEM_RESOURCES_NAMESPACE: {{ quote .Values.global.systemResources.namespace }} SHARED_RESOURCES_NAMESPACE: {{ quote .Values.global.sharedResources.namespace }} PERMISSIVE_CORS_POLICY_ENABLED: {{ quote .Values.api.permissiveCORSPolicyEnabled }} + {{- if .Values.api.defaultShardName }} + DEFAULT_SHARD_NAME: {{ .Values.api.defaultShardName | quote }} + {{- end }} {{- if .Values.api.adminAccount.enabled }} ADMIN_ACCOUNT_ENABLED: "true" ADMIN_ACCOUNT_TOKEN_ISSUER: {{ include "kargo.api.baseURL" . }} diff --git a/hack/tilt/values.dev.yaml b/hack/tilt/values.dev.yaml index 15b0ede064..f5fb571b55 100644 --- a/hack/tilt/values.dev.yaml +++ b/hack/tilt/values.dev.yaml @@ -6,6 +6,7 @@ api: tls: enabled: false permissiveCORSPolicyEnabled: true + defaultShardName: tilt-shard probes: enabled: false adminAccount: @@ -35,6 +36,8 @@ api: - kilgore@kilgore.trout controller: logLevel: DEBUG + shardName: tilt-shard + isDefault: true gitClient: name: "Kargo Test Key" email: "no-reply@kargo.io" diff --git a/pkg/client/generated/models/list_shards_response.go b/pkg/client/generated/models/list_shards_response.go new file mode 100644 index 0000000000..dbc8d110c0 --- /dev/null +++ b/pkg/client/generated/models/list_shards_response.go @@ -0,0 +1,130 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +import ( + "context" + stderrors "errors" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// ListShardsResponse list shards response +// +// swagger:model ListShardsResponse +type ListShardsResponse struct { + + // default shard name + DefaultShardName string `json:"defaultShardName,omitempty"` + + // shards + Shards []*ShardInfo `json:"shards"` +} + +// Validate validates this list shards response +func (m *ListShardsResponse) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateShards(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *ListShardsResponse) validateShards(formats strfmt.Registry) error { + if swag.IsZero(m.Shards) { // not required + return nil + } + + for i := 0; i < len(m.Shards); i++ { + if swag.IsZero(m.Shards[i]) { // not required + continue + } + + if m.Shards[i] != nil { + if err := m.Shards[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("shards" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("shards" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this list shards response based on the context it is used +func (m *ListShardsResponse) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidateShards(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *ListShardsResponse) contextValidateShards(ctx context.Context, formats strfmt.Registry) error { + + for i := 0; i < len(m.Shards); i++ { + + if m.Shards[i] != nil { + + if swag.IsZero(m.Shards[i]) { // not required + return nil + } + + if err := m.Shards[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("shards" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("shards" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// MarshalBinary interface implementation +func (m *ListShardsResponse) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *ListShardsResponse) UnmarshalBinary(b []byte) error { + var res ListShardsResponse + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/pkg/client/generated/models/shard_info.go b/pkg/client/generated/models/shard_info.go new file mode 100644 index 0000000000..0309c11a02 --- /dev/null +++ b/pkg/client/generated/models/shard_info.go @@ -0,0 +1,116 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +import ( + "context" + stderrors "errors" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// ShardInfo shard info +// +// swagger:model ShardInfo +type ShardInfo struct { + + // last seen + LastSeen string `json:"lastSeen,omitempty"` + + // name + Name string `json:"name,omitempty"` + + // status + Status ShardStatus `json:"status,omitempty"` +} + +// Validate validates this shard info +func (m *ShardInfo) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateStatus(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *ShardInfo) validateStatus(formats strfmt.Registry) error { + if swag.IsZero(m.Status) { // not required + return nil + } + + if err := m.Status.Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("status") + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("status") + } + + return err + } + + return nil +} + +// ContextValidate validate this shard info based on the context it is used +func (m *ShardInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidateStatus(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *ShardInfo) contextValidateStatus(ctx context.Context, formats strfmt.Registry) error { + + if swag.IsZero(m.Status) { // not required + return nil + } + + if err := m.Status.ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("status") + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("status") + } + + return err + } + + return nil +} + +// MarshalBinary interface implementation +func (m *ShardInfo) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *ShardInfo) UnmarshalBinary(b []byte) error { + var res ShardInfo + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/pkg/client/generated/models/shard_status.go b/pkg/client/generated/models/shard_status.go new file mode 100644 index 0000000000..90c73822a3 --- /dev/null +++ b/pkg/client/generated/models/shard_status.go @@ -0,0 +1,75 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +import ( + "context" + "encoding/json" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" +) + +// ShardStatus shard status +// +// swagger:model ShardStatus +type ShardStatus string + +func NewShardStatus(value ShardStatus) *ShardStatus { + return &value +} + +// Pointer returns a pointer to a freshly-allocated ShardStatus. +func (m ShardStatus) Pointer() *ShardStatus { + return &m +} + +const ( + + // ShardStatusAlive captures enum value "alive" + ShardStatusAlive ShardStatus = "alive" + + // ShardStatusDead captures enum value "dead" + ShardStatusDead ShardStatus = "dead" +) + +// for schema +var shardStatusEnum []any + +func init() { + var res []ShardStatus + if err := json.Unmarshal([]byte(`["alive","dead"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + shardStatusEnum = append(shardStatusEnum, v) + } +} + +func (m ShardStatus) validateShardStatusEnum(path, location string, value ShardStatus) error { + if err := validate.EnumCase(path, location, value, shardStatusEnum, true); err != nil { + return err + } + return nil +} + +// Validate validates this shard status +func (m ShardStatus) Validate(formats strfmt.Registry) error { + var res []error + + // value enum + if err := m.validateShardStatusEnum("", "body", m); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// ContextValidate validates this shard status based on context it is used +func (m ShardStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} diff --git a/pkg/client/generated/system/list_shards_parameters.go b/pkg/client/generated/system/list_shards_parameters.go new file mode 100644 index 0000000000..2f5b9dfc3d --- /dev/null +++ b/pkg/client/generated/system/list_shards_parameters.go @@ -0,0 +1,125 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package system + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewListShardsParams creates a new ListShardsParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewListShardsParams() *ListShardsParams { + return &ListShardsParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewListShardsParamsWithTimeout creates a new ListShardsParams object +// with the ability to set a timeout on a request. +func NewListShardsParamsWithTimeout(timeout time.Duration) *ListShardsParams { + return &ListShardsParams{ + timeout: timeout, + } +} + +// NewListShardsParamsWithContext creates a new ListShardsParams object +// with the ability to set a context for a request. +func NewListShardsParamsWithContext(ctx context.Context) *ListShardsParams { + return &ListShardsParams{ + Context: ctx, + } +} + +// NewListShardsParamsWithHTTPClient creates a new ListShardsParams object +// with the ability to set a custom HTTPClient for a request. +func NewListShardsParamsWithHTTPClient(client *http.Client) *ListShardsParams { + return &ListShardsParams{ + HTTPClient: client, + } +} + +/* +ListShardsParams contains all the parameters to send to the API endpoint + + for the list shards operation. + + Typically these are written to a http.Request. +*/ +type ListShardsParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the list shards params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListShardsParams) WithDefaults() *ListShardsParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the list shards params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListShardsParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the list shards params +func (o *ListShardsParams) WithTimeout(timeout time.Duration) *ListShardsParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the list shards params +func (o *ListShardsParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the list shards params +func (o *ListShardsParams) WithContext(ctx context.Context) *ListShardsParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the list shards params +func (o *ListShardsParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the list shards params +func (o *ListShardsParams) WithHTTPClient(client *http.Client) *ListShardsParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the list shards params +func (o *ListShardsParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *ListShardsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/pkg/client/generated/system/list_shards_responses.go b/pkg/client/generated/system/list_shards_responses.go new file mode 100644 index 0000000000..ee796ecb47 --- /dev/null +++ b/pkg/client/generated/system/list_shards_responses.go @@ -0,0 +1,104 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package system + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/akuity/kargo/pkg/client/generated/models" +) + +// ListShardsReader is a Reader for the ListShards structure. +type ListShardsReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *ListShardsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewListShardsOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + return nil, runtime.NewAPIError("[GET /v1beta1/system/shards] ListShards", response, response.Code()) + } +} + +// NewListShardsOK creates a ListShardsOK with default headers values +func NewListShardsOK() *ListShardsOK { + return &ListShardsOK{} +} + +/* +ListShardsOK describes a response with status code 200, with default header values. + +OK +*/ +type ListShardsOK struct { + Payload *models.ListShardsResponse +} + +// IsSuccess returns true when this list shards o k response has a 2xx status code +func (o *ListShardsOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this list shards o k response has a 3xx status code +func (o *ListShardsOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this list shards o k response has a 4xx status code +func (o *ListShardsOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this list shards o k response has a 5xx status code +func (o *ListShardsOK) IsServerError() bool { + return false +} + +// IsCode returns true when this list shards o k response a status code equal to that given +func (o *ListShardsOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the list shards o k response +func (o *ListShardsOK) Code() int { + return 200 +} + +func (o *ListShardsOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1beta1/system/shards][%d] listShardsOK %s", 200, payload) +} + +func (o *ListShardsOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1beta1/system/shards][%d] listShardsOK %s", 200, payload) +} + +func (o *ListShardsOK) GetPayload() *models.ListShardsResponse { + return o.Payload +} + +func (o *ListShardsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.ListShardsResponse) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} diff --git a/pkg/client/generated/system/system_client.go b/pkg/client/generated/system/system_client.go index 625cbd25f5..fb4a8ee51b 100644 --- a/pkg/client/generated/system/system_client.go +++ b/pkg/client/generated/system/system_client.go @@ -65,6 +65,8 @@ type ClientService interface { GetVersionInfo(params *GetVersionInfoParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*GetVersionInfoOK, error) + ListShards(params *ListShardsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ListShardsOK, error) + RefreshClusterConfig(params *RefreshClusterConfigParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*RefreshClusterConfigOK, error) SetTransport(transport runtime.ClientTransport) @@ -350,6 +352,60 @@ func (a *Client) GetVersionInfo(params *GetVersionInfoParams, authInfo runtime.C panic(msg) } +/* + ListShards lists shard liveness + + List shards known to the system along with a liveness status + +derived from each shard's heartbeat ConfigMap. A shard is +`alive` when its heartbeat is fresh, and `dead` when the +heartbeat is stale or unparseable. Shards with no heartbeat +ConfigMap at all are not represented in the response. The +`defaultShardName` field, when set, indicates which shard +Stages with no explicit `spec.shard` should be associated with +for liveness purposes. +*/ +func (a *Client) ListShards(params *ListShardsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*ListShardsOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewListShardsParams() + } + op := &runtime.ClientOperation{ + ID: "ListShards", + Method: "GET", + PathPattern: "/v1beta1/system/shards", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &ListShardsReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*ListShardsOK) + if ok { + return success, nil + } + + // unexpected success response. + + // no default response is defined. + // + // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for ListShards: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + /* RefreshClusterConfig refreshes the cluster config diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go index af36abf6e3..3fa0372196 100644 --- a/pkg/server/config/config.go +++ b/pkg/server/config/config.go @@ -37,7 +37,14 @@ type ServerConfig struct { SharedResourcesNamespace string SystemResourcesNamespace string KargoNamespace string - RestConfig *rest.Config + // DefaultShardName is the name of the shard that Stages with no explicit + // spec.shard should be associated with for shard-liveness purposes. When + // empty, such Stages are not associated with any shard. + DefaultShardName string + // AgentStatusDeadline is the maximum age of a shard heartbeat before the + // shard is considered dead. + AgentStatusDeadline time.Duration + RestConfig *rest.Config // AdditionalHandlers is a map of path patterns to HTTP handlers that will // be registered on the server's HTTP mux alongside its own handlers. This @@ -100,6 +107,12 @@ func ServerConfigFromEnv() ServerConfig { "kargo-shared-resources", ) cfg.KargoNamespace = os.GetEnv("KARGO_NAMESPACE", "kargo") + cfg.DefaultShardName = os.GetEnv("DEFAULT_SHARD_NAME", "") + deadline, err := time.ParseDuration(os.GetEnv("AGENT_STATUS_DEADLINE", "10m")) + if err != nil { + panic(fmt.Sprintf("invalid AGENT_STATUS_DEADLINE: %s", err)) + } + cfg.AgentStatusDeadline = deadline return cfg } diff --git a/pkg/server/list_shards_v1alpha1.go b/pkg/server/list_shards_v1alpha1.go new file mode 100644 index 0000000000..df2244096d --- /dev/null +++ b/pkg/server/list_shards_v1alpha1.go @@ -0,0 +1,120 @@ +package server + +import ( + "net/http" + "slices" + "strings" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// kargoAgentLabelKey is the label key used by the Akuity Platform agent on +// its per-shard heartbeat ConfigMap. The label value is the shard name. +const kargoAgentLabelKey = "akuity.io/kargo-agent-name" + +// agentObservedAtKey is the key in the heartbeat ConfigMap's data field that +// holds the RFC3339 timestamp of the agent's most recent heartbeat. +const agentObservedAtKey = "observedAt" + +// shardStatus is the liveness state derived from a heartbeat ConfigMap. +type shardStatus string // @name ShardStatus + +const ( + shardStatusAlive shardStatus = "alive" + shardStatusDead shardStatus = "dead" +) + +// shardInfo describes the liveness of a single shard. +type shardInfo struct { + Name string `json:"name"` + Status shardStatus `json:"status"` + LastSeen *time.Time `json:"lastSeen,omitempty"` +} // @name ShardInfo + +// listShardsResponse is the response body of GET /v1beta1/system/shards. +type listShardsResponse struct { + Shards []shardInfo `json:"shards"` + DefaultShardName string `json:"defaultShardName,omitempty"` +} // @name ListShardsResponse + +// @id ListShards +// @Summary List shard liveness +// @Description List shards known to the system along with a liveness status +// @Description derived from each shard's heartbeat ConfigMap. A shard is +// @Description `alive` when its heartbeat is fresh, and `dead` when the +// @Description heartbeat is stale or unparseable. Shards with no heartbeat +// @Description ConfigMap at all are not represented in the response. The +// @Description `defaultShardName` field, when set, indicates which shard +// @Description Stages with no explicit `spec.shard` should be associated with +// @Description for liveness purposes. +// @Tags System +// @Security BearerAuth +// @Produce json +// @Success 200 {object} listShardsResponse +// @Router /v1beta1/system/shards [get] +func (s *server) listShards(c *gin.Context) { + ctx := c.Request.Context() + + // The heartbeat ConfigMaps live in the Kargo namespace and may not be + // readable by typical authenticated users. Use the internal (non- + // authorizing) client: shard liveness is operational information that + // should be visible to any authenticated user, and the data exposed + // here (shard name, alive/dead, last-seen timestamp) is non-sensitive. + list := &corev1.ConfigMapList{} + if err := s.client.InternalClient().List( + ctx, + list, + client.InNamespace(s.cfg.KargoNamespace), + client.HasLabels{kargoAgentLabelKey}, + ); err != nil { + _ = c.Error(err) + return + } + + now := time.Now() + shards := make([]shardInfo, 0, len(list.Items)) + for _, cm := range list.Items { + name := cm.Labels[kargoAgentLabelKey] + if name == "" { + continue + } + shards = append(shards, deriveShardInfo(name, cm.Data, now, s.cfg.AgentStatusDeadline)) + } + + slices.SortFunc(shards, func(lhs, rhs shardInfo) int { + return strings.Compare(lhs.Name, rhs.Name) + }) + + c.JSON(http.StatusOK, listShardsResponse{ + Shards: shards, + DefaultShardName: s.cfg.DefaultShardName, + }) +} + +// deriveShardInfo computes the liveness of a shard from the data field of its +// heartbeat ConfigMap. A shard is alive when observedAt is present, parseable, +// and within `deadline` of `now`. Otherwise it is dead. +func deriveShardInfo( + name string, + data map[string]string, + now time.Time, + deadline time.Duration, +) shardInfo { + info := shardInfo{Name: name, Status: shardStatusDead} + raw, ok := data[agentObservedAtKey] + if !ok || raw == "" { + return info + } + observedAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + return info + } + info.LastSeen = &observedAt + if now.Sub(observedAt) < deadline { + info.Status = shardStatusAlive + } + return info +} diff --git a/pkg/server/list_shards_v1alpha1_test.go b/pkg/server/list_shards_v1alpha1_test.go new file mode 100644 index 0000000000..b963c3d6e9 --- /dev/null +++ b/pkg/server/list_shards_v1alpha1_test.go @@ -0,0 +1,307 @@ +package server + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/akuity/kargo/pkg/server/config" +) + +func Test_server_listShards(t *testing.T) { + const deadline = 10 * time.Minute + now := time.Now() + freshTime := now.Add(-1 * time.Minute) + staleTime := now.Add(-1 * time.Hour) + + heartbeatCM := func(shard string, observedAt *time.Time, extraData map[string]string) *corev1.ConfigMap { + data := map[string]string{} + if observedAt != nil { + data[agentObservedAtKey] = observedAt.Format(time.RFC3339) + } + maps.Copy(data, extraData) + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testKargoNamespace, + Name: "agent-" + shard + ".status", + Labels: map[string]string{ + kargoAgentLabelKey: shard, + }, + }, + Data: data, + } + } + + testRESTEndpoint( + t, + &config.ServerConfig{ + KargoNamespace: testKargoNamespace, + AgentStatusDeadline: deadline, + }, + http.MethodGet, "/v1beta1/system/shards", + []restTestCase{ + { + name: "no heartbeat ConfigMaps exist", + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Empty(t, resp.Shards) + require.Empty(t, resp.DefaultShardName) + }, + }, + { + name: "default shard name is reported", + serverConfig: &config.ServerConfig{ + KargoNamespace: testKargoNamespace, + AgentStatusDeadline: deadline, + DefaultShardName: "primary", + }, + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Empty(t, resp.Shards) + require.Equal(t, "primary", resp.DefaultShardName) + }, + }, + { + name: "alive shard with fresh heartbeat", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("alpha", &freshTime, nil), + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "alpha", resp.Shards[0].Name) + require.Equal(t, shardStatusAlive, resp.Shards[0].Status) + require.NotNil(t, resp.Shards[0].LastSeen) + }, + }, + { + name: "dead shard with stale heartbeat", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("beta", &staleTime, nil), + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "beta", resp.Shards[0].Name) + require.Equal(t, shardStatusDead, resp.Shards[0].Status) + require.NotNil(t, resp.Shards[0].LastSeen) + }, + }, + { + name: "heartbeat with missing observedAt is dead", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("gamma", nil, map[string]string{"agentVersion": "v1"}), + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "gamma", resp.Shards[0].Name) + require.Equal(t, shardStatusDead, resp.Shards[0].Status) + require.Nil(t, resp.Shards[0].LastSeen) + }, + }, + { + name: "heartbeat with unparseable observedAt is dead", + clientBuilder: fake.NewClientBuilder().WithObjects( + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testKargoNamespace, + Name: "agent-delta.status", + Labels: map[string]string{ + kargoAgentLabelKey: "delta", + }, + }, + Data: map[string]string{ + agentObservedAtKey: "not-a-timestamp", + }, + }, + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "delta", resp.Shards[0].Name) + require.Equal(t, shardStatusDead, resp.Shards[0].Status) + require.Nil(t, resp.Shards[0].LastSeen) + }, + }, + { + name: "ConfigMaps without the agent label are excluded", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("alpha", &freshTime, nil), + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testKargoNamespace, + Name: "unrelated-cm", + }, + Data: map[string]string{ + agentObservedAtKey: freshTime.Format(time.RFC3339), + }, + }, + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "alpha", resp.Shards[0].Name) + }, + }, + { + name: "ConfigMaps in other namespaces are excluded", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("alpha", &freshTime, nil), + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "other-namespace", + Name: "agent-stranger.status", + Labels: map[string]string{ + kargoAgentLabelKey: "stranger", + }, + }, + Data: map[string]string{ + agentObservedAtKey: freshTime.Format(time.RFC3339), + }, + }, + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 1) + require.Equal(t, "alpha", resp.Shards[0].Name) + }, + }, + { + name: "shards are sorted by name", + clientBuilder: fake.NewClientBuilder().WithObjects( + heartbeatCM("zeta", &freshTime, nil), + heartbeatCM("alpha", &staleTime, nil), + heartbeatCM("mu", &freshTime, nil), + ), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) { + require.Equal(t, http.StatusOK, w.Code) + var resp listShardsResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Shards, 3) + require.Equal(t, "alpha", resp.Shards[0].Name) + require.Equal(t, shardStatusDead, resp.Shards[0].Status) + require.Equal(t, "mu", resp.Shards[1].Name) + require.Equal(t, shardStatusAlive, resp.Shards[1].Status) + require.Equal(t, "zeta", resp.Shards[2].Name) + require.Equal(t, shardStatusAlive, resp.Shards[2].Status) + }, + }, + }, + ) +} + +func Test_deriveShardInfo(t *testing.T) { + const deadline = 10 * time.Minute + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + testCases := []struct { + name string + data map[string]string + assert func(*testing.T, shardInfo) + }{ + { + name: "empty data is dead", + data: map[string]string{}, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.Nil(t, info.LastSeen) + }, + }, + { + name: "missing observedAt is dead", + data: map[string]string{"agentVersion": "v1"}, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.Nil(t, info.LastSeen) + }, + }, + { + name: "blank observedAt is dead", + data: map[string]string{agentObservedAtKey: ""}, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.Nil(t, info.LastSeen) + }, + }, + { + name: "unparseable observedAt is dead", + data: map[string]string{agentObservedAtKey: "garbage"}, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.Nil(t, info.LastSeen) + }, + }, + { + name: "fresh observedAt is alive", + data: map[string]string{ + agentObservedAtKey: now.Add(-1 * time.Minute).Format(time.RFC3339), + }, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusAlive, info.Status) + require.NotNil(t, info.LastSeen) + }, + }, + { + name: "observedAt exactly at deadline is dead", + data: map[string]string{ + agentObservedAtKey: now.Add(-deadline).Format(time.RFC3339), + }, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.NotNil(t, info.LastSeen) + }, + }, + { + name: "stale observedAt is dead", + data: map[string]string{ + agentObservedAtKey: now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + assert: func(t *testing.T, info shardInfo) { + require.Equal(t, shardStatusDead, info.Status) + require.NotNil(t, info.LastSeen) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + info := deriveShardInfo("test-shard", tc.data, now, deadline) + require.Equal(t, "test-shard", info.Name) + tc.assert(t, info) + }) + } +} diff --git a/pkg/server/rest_router.go b/pkg/server/rest_router.go index fca39b259a..4fbd6245e5 100644 --- a/pkg/server/rest_router.go +++ b/pkg/server/rest_router.go @@ -97,6 +97,9 @@ func (s *server) setupRESTRouter(ctx context.Context) *gin.Engine { system.POST("/cluster-config/refresh", s.refreshClusterConfig) system.DELETE("/cluster-config", s.deleteClusterConfig) + // Shard liveness + system.GET("/shards", s.listShards) + // Roles system.GET("/roles", s.listSystemRoles) system.GET("/roles/:role", s.getSystemRole) diff --git a/swagger.json b/swagger.json index dd5246df41..65627bf37e 100644 --- a/swagger.json +++ b/swagger.json @@ -4607,6 +4607,32 @@ } } } + }, + "/v1beta1/system/shards": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List shards known to the system along with a liveness status\nderived from each shard's heartbeat ConfigMap. A shard is\n`alive` when its heartbeat is fresh, and `dead` when the\nheartbeat is stale or unparseable. Shards with no heartbeat\nConfigMap at all are not represented in the response. The\n`defaultShardName` field, when set, indicates which shard\nStages with no explicit `spec.shard` should be associated with\nfor liveness purposes.", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "List shard liveness", + "operationId": "ListShards", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ListShardsResponse" + } + } + } + } } }, "definitions": { @@ -4843,6 +4869,20 @@ } } }, + "ListShardsResponse": { + "type": "object", + "properties": { + "defaultShardName": { + "type": "string" + }, + "shards": { + "type": "array", + "items": { + "$ref": "#/definitions/ShardInfo" + } + } + } + }, "OIDCConfig": { "type": "object", "properties": { @@ -4994,6 +5034,31 @@ } } }, + "ShardInfo": { + "type": "object", + "properties": { + "lastSeen": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ShardStatus" + } + } + }, + "ShardStatus": { + "type": "string", + "enum": [ + "alive", + "dead" + ], + "x-enum-varnames": [ + "shardStatusAlive", + "shardStatusDead" + ] + }, "TagMap": { "type": "object", "properties": { diff --git a/ui/src/features/project/pipelines/context/dictionary-context.ts b/ui/src/features/project/pipelines/context/dictionary-context.ts index 799f083149..95b0e059a6 100644 --- a/ui/src/features/project/pipelines/context/dictionary-context.ts +++ b/ui/src/features/project/pipelines/context/dictionary-context.ts @@ -2,6 +2,7 @@ import { createContext, useContext } from 'react'; import { ArgoCDShard } from '@ui/gen/api/service/v1alpha1/service_pb'; import { Freight, Stage } from '@ui/gen/api/v1alpha1/generated_pb'; +import { ShardInfo } from '@ui/gen/api/v2/models'; export type DictionaryContextType = { freightInStages: Record; @@ -10,6 +11,8 @@ export type DictionaryContextType = { subscribersByStage: Record>; stageByName: Record; argocdShards?: Record; + shardsByName?: Record; + defaultShardName?: string; hasAnalysisRunLogsUrlTemplate?: boolean; }; diff --git a/ui/src/features/project/pipelines/nodes/shard-status-icon.tsx b/ui/src/features/project/pipelines/nodes/shard-status-icon.tsx new file mode 100644 index 0000000000..be19032fe4 --- /dev/null +++ b/ui/src/features/project/pipelines/nodes/shard-status-icon.tsx @@ -0,0 +1,59 @@ +import { faTowerBroadcast } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tooltip } from 'antd'; +import { formatDistance } from 'date-fns'; + +import { ShardInfo, ShardStatus } from '@ui/gen/api/v2/models'; + +const ALIVE_COLOR = '#52c41a'; +const DEAD_COLOR = '#f5222d'; + +export const ShardStatusIcon = (props: { shard?: ShardInfo; shardName?: string }) => { + const { shard, shardName } = props; + + if (!shard) { + return null; + } + + const isAlive = shard.status === ShardStatus.shardStatusAlive; + const color = isAlive ? ALIVE_COLOR : DEAD_COLOR; + const name = shardName || shard.name || ''; + + const lastSeen = shard.lastSeen + ? formatDistance(new Date(shard.lastSeen), new Date(), { addSuffix: true }) + : 'never'; + + return ( + +
+ Shard: {name} +
+
+ Agent: {isAlive ? 'alive' : 'dead'} +
+
+ Last heartbeat: {lastSeen} +
+ + } + > + + + +
+ ); +}; diff --git a/ui/src/features/project/pipelines/nodes/stage-node.tsx b/ui/src/features/project/pipelines/nodes/stage-node.tsx index 23a33029e9..9370e52426 100644 --- a/ui/src/features/project/pipelines/nodes/stage-node.tsx +++ b/ui/src/features/project/pipelines/nodes/stage-node.tsx @@ -34,6 +34,7 @@ import { AnalysisRunLogsLink } from './analysis-run-logs-link'; import style from './node-size-source-of-truth.module.less'; import { useGetPromotionDropdownItems } from './promotion/use-get-promotion-dropdown-items'; import { PullRequestLink } from './pull-request-link'; +import { ShardStatusIcon } from './shard-status-icon'; import { StageFreight } from './stage-freight'; import { getLastPromotionDate, @@ -82,6 +83,9 @@ export const StageNode = (props: { stage: Stage }) => { const dropdownItems = useGetPromotionDropdownItems(props.stage); + const shardName = props.stage.spec?.shard || dictionaryContext?.defaultShardName || ''; + const shardInfo = shardName ? dictionaryContext?.shardsByName?.[shardName] : undefined; + let descriptionItems: ReactNode; const lastPromotion = getLastPromotionDate(props.stage); @@ -139,12 +143,13 @@ export const StageNode = (props: { stage: Stage }) => { } }} title={ - <> + + {autoPromotionMode && ( - + )} - {props.stage.metadata?.name} - + {props.stage.metadata?.name} + } extra={ diff --git a/ui/src/features/project/pipelines/pipelines.tsx b/ui/src/features/project/pipelines/pipelines.tsx index 4f78b07738..b3e6fbab38 100644 --- a/ui/src/features/project/pipelines/pipelines.tsx +++ b/ui/src/features/project/pipelines/pipelines.tsx @@ -28,6 +28,8 @@ import { } from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery'; import { FreightList } from '@ui/gen/api/service/v1alpha1/service_pb'; import { Freight, Project, Stage } from '@ui/gen/api/v1alpha1/generated_pb'; +import { ShardInfo } from '@ui/gen/api/v2/models'; +import { useListShards } from '@ui/gen/api/v2/system/system'; import { ActionContext } from './context/action-context'; import { DictionaryContext } from './context/dictionary-context'; @@ -62,6 +64,20 @@ export const Pipelines = (props: { creatingStage?: boolean; creatingWarehouse?: const argocdShards = getConfigQuery?.data?.argocdShards; + const listShardsQuery = useListShards(); + + const shardsByName = useMemo(() => { + const map: Record = {}; + for (const shard of listShardsQuery.data?.data?.shards || []) { + if (shard.name) { + map[shard.name] = shard; + } + } + return map; + }, [listShardsQuery.data]); + + const defaultShardName = listShardsQuery.data?.data?.defaultShardName; + const projectQuery = useQuery(getProject, { name }); const project = projectQuery.data?.result?.value as Project; @@ -204,6 +220,8 @@ export const Pipelines = (props: { creatingStage?: boolean; creatingWarehouse?: subscribersByStage, stageByName, argocdShards, + shardsByName, + defaultShardName, hasAnalysisRunLogsUrlTemplate: getConfigQuery?.data?.hasAnalysisRunLogsUrlTemplate }} > diff --git a/ui/src/gen/api/v2/models/index.ts b/ui/src/gen/api/v2/models/index.ts index 4cda2465eb..5bfa16d8ab 100644 --- a/ui/src/gen/api/v2/models/index.ts +++ b/ui/src/gen/api/v2/models/index.ts @@ -107,6 +107,7 @@ export * from './listImages200'; export * from './listProjectAPITokensParams'; export * from './listProjectRoles200'; export * from './listPromotionsParams'; +export * from './listShardsResponse'; export * from './listStagesParams'; export * from './listSystemAPITokensParams'; export * from './listSystemRoles200'; @@ -202,6 +203,8 @@ export * from './rolloutsValueFrom'; export * from './rolloutsWavefrontMetric'; export * from './rolloutsWebMetric'; export * from './rolloutsWebMetricHeader'; +export * from './shardInfo'; +export * from './shardStatus'; export * from './stage'; export * from './stageList'; export * from './stageSpec'; diff --git a/ui/src/gen/api/v2/models/listShardsResponse.ts b/ui/src/gen/api/v2/models/listShardsResponse.ts new file mode 100644 index 0000000000..c39a194d23 --- /dev/null +++ b/ui/src/gen/api/v2/models/listShardsResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.19.0 🍺 + * Do not edit manually. + * Kargo API + * REST API for Kargo + * OpenAPI spec version: v1alpha1 + */ +import type { ShardInfo } from './shardInfo'; + +export interface ListShardsResponse { + defaultShardName?: string; + shards?: ShardInfo[]; +} diff --git a/ui/src/gen/api/v2/models/shardInfo.ts b/ui/src/gen/api/v2/models/shardInfo.ts new file mode 100644 index 0000000000..9f32eb87c2 --- /dev/null +++ b/ui/src/gen/api/v2/models/shardInfo.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.19.0 🍺 + * Do not edit manually. + * Kargo API + * REST API for Kargo + * OpenAPI spec version: v1alpha1 + */ +import type { ShardStatus } from './shardStatus'; + +export interface ShardInfo { + lastSeen?: string; + name?: string; + status?: ShardStatus; +} diff --git a/ui/src/gen/api/v2/models/shardStatus.ts b/ui/src/gen/api/v2/models/shardStatus.ts new file mode 100644 index 0000000000..6da3a0d05d --- /dev/null +++ b/ui/src/gen/api/v2/models/shardStatus.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.19.0 🍺 + * Do not edit manually. + * Kargo API + * REST API for Kargo + * OpenAPI spec version: v1alpha1 + */ + +export type ShardStatus = (typeof ShardStatus)[keyof typeof ShardStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ShardStatus = { + shardStatusAlive: 'alive', + shardStatusDead: 'dead' +} as const; diff --git a/ui/src/gen/api/v2/system/system.ts b/ui/src/gen/api/v2/system/system.ts index e6acaf15a6..34b85b6394 100644 --- a/ui/src/gen/api/v2/system/system.ts +++ b/ui/src/gen/api/v2/system/system.ts @@ -25,6 +25,7 @@ import type { AdminLoginResponse, ClusterConfig, GetConfigResponse, + ListShardsResponse, PublicConfig, VersionInfo } from '.././models'; @@ -750,3 +751,122 @@ export function useGetVersionInfo< return query; } + +/** + * List shards known to the system along with a liveness status +derived from each shard's heartbeat ConfigMap. A shard is +`alive` when its heartbeat is fresh, and `dead` when the +heartbeat is stale or unparseable. Shards with no heartbeat +ConfigMap at all are not represented in the response. The +`defaultShardName` field, when set, indicates which shard +Stages with no explicit `spec.shard` should be associated with +for liveness purposes. + * @summary List shard liveness + */ +export type listShardsResponse200 = { + data: ListShardsResponse; + status: 200; +}; + +export type listShardsResponseSuccess = listShardsResponse200 & { + headers: Headers; +}; +export type listShardsResponse = listShardsResponseSuccess; + +export const getListShardsUrl = () => { + return `/v1beta1/system/shards`; +}; + +export const listShards = async (options?: RequestInit): Promise => { + return customFetch(getListShardsUrl(), { + ...options, + method: 'GET' + }); +}; + +export const getListShardsQueryKey = () => { + return [`/v1beta1/system/shards`] as const; +}; + +export const getListShardsQueryOptions = < + TData = Awaited>, + TError = unknown +>(options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListShardsQueryKey(); + + const queryFn: QueryFunction>> = () => + listShards(requestOptions); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListShardsQueryResult = NonNullable>>; +export type ListShardsQueryError = unknown; + +export function useListShards>, TError = unknown>( + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListShards>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + 'initialData' + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListShards>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List shard liveness + */ + +export function useListShards>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListShardsQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + query.queryKey = queryOptions.queryKey; + + return query; +}