diff --git a/api/constants/constants.go b/api/constants/constants.go index 3af724d8d3f53..dcefd8e1dde62 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -579,3 +579,7 @@ const MaxPIVPINCacheTTL = time.Hour // routine running in every auth server. Any report older than this period should // be considered stale. const AutoUpdateAgentReportPeriod = time.Minute + +// AutoUpdateBotInstanceReportPeriod is the period of the autoupdate bot instance +// reporting routine. +const AutoUpdateBotInstanceReportPeriod = time.Minute diff --git a/gen/preset-roles.json b/gen/preset-roles.json index 0c3f98258ba5c..7542315fe6ff2 100755 --- a/gen/preset-roles.json +++ b/gen/preset-roles.json @@ -1051,6 +1051,15 @@ "read" ] }, + { + "resources": [ + "autoupdate_bot_instance_report" + ], + "verbs": [ + "list", + "read" + ] + }, { "resources": [ "git_server" diff --git a/lib/auth/auth.go b/lib/auth/auth.go index fab02cb6299c8..97e1e5c7c360a 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -1714,7 +1714,7 @@ func (a *Server) runPeriodicOperations() { }) ticker.Push(interval.SubInterval[periodicIntervalKey]{ Key: autoUpdateBotInstanceReportKey, - Duration: constants.AutoUpdateAgentReportPeriod, + Duration: constants.AutoUpdateBotInstanceReportPeriod, FirstDuration: retryutils.HalfJitter(10 * time.Second), Jitter: retryutils.SeventhJitter, }) diff --git a/lib/auth/autoupdate/autoupdatev1/service.go b/lib/auth/autoupdate/autoupdatev1/service.go index edbbf1724c645..f11eb88291c1a 100644 --- a/lib/auth/autoupdate/autoupdatev1/service.go +++ b/lib/auth/autoupdate/autoupdatev1/service.go @@ -22,6 +22,7 @@ import ( "context" "log/slog" "maps" + "slices" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -1055,8 +1056,15 @@ func (s *Service) GetAutoUpdateBotInstanceReport(ctx context.Context, _ *autoupd return nil, trace.Wrap(err) } - if err := authCtx.CheckAccessToKind(types.KindAutoUpdateBotInstanceReport, types.VerbRead); err != nil { - return nil, trace.Wrap(err) + // Because this report also powers the bot instance dashboard in the Web UI + // we allow users with `bot_instance:list` as well as `autoupdate_bot_instance_report:read` + // and return the first error if both checks fail. + authErrors := []error{ + authCtx.CheckAccessToKind(types.KindAutoUpdateBotInstanceReport, types.VerbRead), + authCtx.CheckAccessToKind(types.KindBotInstance, types.VerbList), + } + if !slices.Contains(authErrors, nil) { + return nil, trace.NewAggregate(authErrors...) } report, err := s.backend.GetAutoUpdateBotInstanceReport(ctx) diff --git a/lib/auth/autoupdate/autoupdatev1/service_test.go b/lib/auth/autoupdate/autoupdatev1/service_test.go index 435659e4262e8..4234e33e99f5e 100644 --- a/lib/auth/autoupdate/autoupdatev1/service_test.go +++ b/lib/auth/autoupdate/autoupdatev1/service_test.go @@ -362,6 +362,17 @@ func TestServiceAccess(t *testing.T) { kind: types.KindAutoUpdateBotInstanceReport, allowedVerbs: []string{types.VerbRead}, }, + { + name: "GetAutoUpdateBotInstanceReport", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthUnauthorized, + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + kind: types.KindBotInstance, + allowedVerbs: []string{types.VerbList}, + }, { name: "DeleteAutoUpdateBotInstanceReport", allowedStates: []authz.AdminActionAuthState{ diff --git a/lib/auth/machineid/machineidv1/expression/expression.go b/lib/auth/machineid/machineidv1/expression/expression.go index 43dd22d246747..1c6dae206950e 100644 --- a/lib/auth/machineid/machineidv1/expression/expression.go +++ b/lib/auth/machineid/machineidv1/expression/expression.go @@ -52,11 +52,8 @@ func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], erro "status.latest_heartbeat.one_shot": typical.DynamicVariable(func(env *Environment) (bool, error) { return env.GetLatestHeartbeat().GetOneShot(), nil }), - "status.latest_heartbeat.version": typical.DynamicVariable(func(env *Environment) (*semver.Version, error) { - if env.GetLatestHeartbeat().GetVersion() == "" { - return nil, nil - } - return semver.NewVersion(env.LatestHeartbeat.Version) + "status.latest_heartbeat.version": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetVersion(), nil }), "status.latest_authentication.join_method": typical.DynamicVariable(func(env *Environment) (string, error) { return env.GetLatestAuthentication().GetJoinMethod(), nil @@ -69,8 +66,6 @@ func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], erro spec.Functions["older_than"] = typical.BinaryFunction[*Environment](semverLt) // e.g. `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")` spec.Functions["between"] = typical.TernaryFunction[*Environment](semverBetween) - // e.g. `exact_version(status.latest_heartbeat.version, "19.1.0")` - spec.Functions["exact_version"] = typical.BinaryFunction[*Environment](semverEq) return typical.NewParser[*Environment, bool](spec) } diff --git a/lib/auth/machineid/machineidv1/expression/expression_test.go b/lib/auth/machineid/machineidv1/expression/expression_test.go index d5739bfbe61a0..44a64da40750e 100644 --- a/lib/auth/machineid/machineidv1/expression/expression_test.go +++ b/lib/auth/machineid/machineidv1/expression/expression_test.go @@ -108,8 +108,8 @@ func TestBotInstanceExpressionParser(t *testing.T) { }, { name: "exact version", - expTrue: `exact_version(status.latest_heartbeat.version, "19.0.1")`, - expFalse: `exact_version(status.latest_heartbeat.version, "19.0.2-rc.1+56001")`, + expTrue: `status.latest_heartbeat.version == "19.0.1"`, + expFalse: `status.latest_heartbeat.version == "19.0.2-rc.1+56001"`, }, { name: "between versions - lower", diff --git a/lib/services/presets.go b/lib/services/presets.go index c8f0e3144bf70..708b924b92618 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -212,6 +212,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindAutoUpdateVersion, RW()), types.NewRule(types.KindAutoUpdateConfig, RW()), types.NewRule(types.KindAutoUpdateAgentRollout, RO()), + types.NewRule(types.KindAutoUpdateBotInstanceReport, RO()), types.NewRule(types.KindGitServer, RW()), types.NewRule(types.KindWorkloadIdentityX509Revocation, RW()), types.NewRule(types.KindHealthCheckConfig, RW()), diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index b73ea54f055ad..2d0a893b11c19 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1175,6 +1175,8 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) // GET Machine ID bot instances (paged) h.GET("/v2/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstancesV2)) + // GET Machine ID bot instance metrics. + h.GET("/webapi/sites/:site/machine-id/bot-instance/metrics", h.WithClusterAuth(h.botInstanceMetrics)) // List workload identities h.GET("/webapi/sites/:site/workload-identity", h.WithClusterAuth(h.listWorkloadIdentities)) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index de0ab97342931..633e6ebfc5791 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -19,17 +19,20 @@ package web import ( "cmp" "context" + "fmt" "net/http" "strconv" "strings" "time" + "github.com/coreos/go-semver/semver" yaml "github.com/ghodss/yaml" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" + "github.com/gravitational/teleport/api/constants" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" @@ -539,3 +542,161 @@ type BotInstance struct { ActiveAtLatest string `json:"active_at_latest,omitempty"` OSLatest string `json:"os_latest,omitempty"` } + +func (h *Handler) botInstanceMetrics(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { + ctx := r.Context() + + clt, err := sctx.GetUserClient(ctx, cluster) + if err != nil { + return nil, trace.Wrap(err) + } + + rsp := BotInstanceMetricsResponse{ + RefreshAfterSeconds: int(constants.AutoUpdateBotInstanceReportPeriod.Seconds()), + } + + // If no report is available yet, `UpgradeStatuses` will be nil. + report, err := clt.GetAutoUpdateBotInstanceReport(ctx) + switch { + case trace.IsNotFound(err): + return rsp, nil + case err != nil: + return nil, trace.Wrap(err) + } + + // Our target version is the operator's selected auto-update tools version, + // or if there isn't one configured: the proxy version. + autoUpdateVersion, err := h.cfg.AccessPoint.GetAutoUpdateVersion(ctx) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + targetVersion, err := getToolsVersion(autoUpdateVersion) + if err != nil { + return nil, trace.Wrap(err) + } + + // Returns the earliest possible version in a major release. It's based on: + // lib/utils.VersionBeforeAlpha. + lowerBound := func(major int64) semver.Version { + return semver.Version{Major: major, PreRelease: "aa"} + } + + const versionField = "status.latest_heartbeat.version" + + rsp.UpgradeStatuses = &BotInstanceUpgradeStatuses{ + UpdatedAt: report.GetSpec().GetTimestamp().AsTime(), + UpToDate: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf("%[1]s == %[2]q", versionField, targetVersion), + }, + Unsupported: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "older_than(%[1]s, %[2]q) || %[1]s == %[3]q || newer_than(%[1]s, %[3]q)", + versionField, + lowerBound(targetVersion.Major-1), + lowerBound(targetVersion.Major+1), + ), + }, + PatchAvailable: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "between(%[1]s, %[2]q, %[3]q)", + versionField, + lowerBound(targetVersion.Major), + targetVersion, + ), + }, + RequiresUpgrade: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "between(%[1]s, %[2]q, %[3]q)", + versionField, + lowerBound(targetVersion.Major-1), + lowerBound(targetVersion.Major), + ), + }, + } + + for _, groupMetrics := range report.GetSpec().GetGroups() { + for versionString, versionMetrics := range groupMetrics.GetVersions() { + version, err := semver.NewVersion(versionString) + if err != nil { + h.logger.ErrorContext(ctx, + "Failed to parse bot instance version string", + "version_string", versionString, + "error", err, + ) + continue + } + + switch { + case targetVersion.Equal(*version): + // Bot is up to date. + rsp.UpgradeStatuses.UpToDate.Count += int(versionMetrics.Count) + + case targetVersion.LessThan(*version): + // Bot is running a newer version, we don't support this. + rsp.UpgradeStatuses.Unsupported.Count += int(versionMetrics.Count) + + case targetVersion.Major == version.Major: + // Bot is running the right major version, but there's a minor + // or patch update available + rsp.UpgradeStatuses.PatchAvailable.Count += int(versionMetrics.Count) + + case version.Major == targetVersion.Major-1: + // Bot is running the previous major version and should upgrade. + rsp.UpgradeStatuses.RequiresUpgrade.Count += int(versionMetrics.Count) + + case version.Major < targetVersion.Major-1: + // Bot is running a version that is too old. In this case, the + // connection would be terminated so we shouldn't really see it. + rsp.UpgradeStatuses.Unsupported.Count += int(versionMetrics.Count) + + default: + // The branches of this switch should be exhaustive, but just in case! + h.logger.DebugContext(ctx, + "Bot instance version comparison is missing a branch", + "bot_instance_version", version, + "target_version", targetVersion, + ) + } + } + } + return rsp, nil +} + +type BotInstanceMetricsResponse struct { + // RefreshAfterSeconds is the amount of time (in seconds) after receiving + // this response the client should poll for new metrics. + RefreshAfterSeconds int `json:"refresh_after_seconds"` + + // UpgradeStatuses contains instance counts by "upgrade status". + UpgradeStatuses *BotInstanceUpgradeStatuses `json:"upgrade_statuses"` +} + +type BotInstanceUpgradeStatuses struct { + // UpdatedAt is when these metrics were last updated. + UpdatedAt time.Time `json:"updated_at"` + + // UpToDate means the instance matches the desired version. + UpToDate BotInstanceUpgradeStatus `json:"up_to_date"` + + // Unsupported means the instance is running a release that is too old or + // too new for us to support. + Unsupported BotInstanceUpgradeStatus `json:"unsupported"` + + // RequiresUpgrade means the instance is running a release from the previous + // major series. We can support it for now, but the next major upgrade will + // break compatibility. + RequiresUpgrade BotInstanceUpgradeStatus `json:"requires_upgrade"` + + // PatchAvailable means the instance is running a release from the desired + // major series, but they're behind on a minor or patch release. + PatchAvailable BotInstanceUpgradeStatus `json:"patch_available"` +} + +type BotInstanceUpgradeStatus struct { + // Count is the number of bot instances. + Count int `json:"count"` + + // Filter is a predicate language filter that can be applied to the bot + // instance list to find matching instances. + Filter string `json:"filter"` +} diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 49aff0a007291..0644cd0449e82 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -35,8 +35,11 @@ import ( "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" + update "github.com/gravitational/teleport/api/types/autoupdate" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/web/ui" ) @@ -1258,3 +1261,110 @@ func TestGetBotInstance(t *testing.T) { }, protocmp.Transform(), protocmp.IgnoreFields(&machineidv1.BotInstance{}, "metadata"))) assert.YAMLEq(t, fmt.Sprintf("kind: bot_instance\nmetadata:\n name: %[1]s\n revision: %[2]s\nspec:\n bot_name: test-bot\n instance_id: %[1]s\nstatus:\n initial_heartbeat:\n recorded_at: \"1970-01-01T00:00:01Z\"\nversion: v1\n", instanceID, resp.BotInstance.Metadata.Revision), resp.YAML) } + +func TestBotInstanceMetrics_NotFound(t *testing.T) { + ctx := t.Context() + env := newWebPack(t, 1) + pack := env.proxies[0].authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + // No report yet should return an empty `UpgradeStatuses`. + endpoint := pack.clt.Endpoint( + "webapi", "sites", clusterName, "machine-id", "bot-instance", "metrics", + ) + rsp, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + + var body BotInstanceMetricsResponse + require.NoError(t, json.Unmarshal(rsp.Bytes(), &body)) + require.Nil(t, body.UpgradeStatuses) +} + +func TestBotInstanceMetrics_Success(t *testing.T) { + ctx := t.Context() + env := newWebPack(t, 1) + pack := env.proxies[0].authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + const targetVersion = "19.1.1" + + _, err := env.server.Auth(). + CreateAutoUpdateVersion(ctx, &autoupdate.AutoUpdateVersion{ + Kind: types.KindAutoUpdateVersion, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAutoUpdateVersion, + }, + Spec: &autoupdate.AutoUpdateVersionSpec{ + Tools: &autoupdate.AutoUpdateVersionSpecTools{ + TargetVersion: targetVersion, + }, + }, + }) + require.NoError(t, err) + + report, err := update.NewAutoUpdateBotInstanceReport(&autoupdate.AutoUpdateBotInstanceReportSpec{ + Timestamp: timestamppb.New(env.clock.Now()), + Groups: map[string]*autoupdate.AutoUpdateBotInstanceReportSpecGroup{ + "prod": { + Versions: map[string]*autoupdate.AutoUpdateBotInstanceReportSpecGroupVersion{ + "19.1.1": {Count: 1}, // Up to date + "19.2.0": {Count: 10}, // Unsupported (too new) + "19.1.0": {Count: 100}, // Patch available + "19.0.0-rc1": {Count: 1000}, // Patch available + "18.0.0": {Count: 10000}, // Requires upgrade + "17.0.0": {Count: 100000}, // Unsupported (too old) + }, + }, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().UpsertAutoUpdateBotInstanceReport(ctx, report) + require.NoError(t, err) + + endpoint := pack.clt.Endpoint( + "webapi", "sites", clusterName, "machine-id", "bot-instance", "metrics", + ) + rsp, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + + var body BotInstanceMetricsResponse + require.NoError(t, json.Unmarshal(rsp.Bytes(), &body)) + + // Up to date + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 1, + Filter: `status.latest_heartbeat.version == "19.1.1"`, + }, + body.UpgradeStatuses.UpToDate, + ) + + // Unsupported + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 100010, + Filter: `older_than(status.latest_heartbeat.version, "18.0.0-aa") || status.latest_heartbeat.version == "20.0.0-aa" || newer_than(status.latest_heartbeat.version, "20.0.0-aa")`, + }, + body.UpgradeStatuses.Unsupported, + ) + + // Patch available + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 1100, + Filter: `between(status.latest_heartbeat.version, "19.0.0-aa", "19.1.1")`, + }, + body.UpgradeStatuses.PatchAvailable, + ) + + // Requires upgrade + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 10000, + Filter: `between(status.latest_heartbeat.version, "18.0.0-aa", "19.0.0-aa")`, + }, + body.UpgradeStatuses.RequiresUpgrade, + ) +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx index 2d6fd654d3c6f..4dee985911b4c 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx @@ -30,6 +30,7 @@ import { TeleportProviderBasic } from 'teleport/mocks/providers'; import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { getBotInstanceError, + getBotInstanceMetricsSuccess, getBotInstanceSuccess, listBotInstancesError, listBotInstancesForever, @@ -50,7 +51,7 @@ type Story = StoryObj; export default meta; -const listBotInstancesSuccessHandler = listBotInstancesSuccess({ +const listBotInstances = { bot_instances: [ { bot_name: 'ansible-worker', @@ -94,13 +95,14 @@ const listBotInstancesSuccessHandler = listBotInstancesSuccess({ }, ], next_page_token: '', -}); +}; export const Happy: Story = { parameters: { msw: { handlers: [ - listBotInstancesSuccessHandler, + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), getBotInstanceSuccess({ bot_instance: { spec: { @@ -109,6 +111,7 @@ export const Happy: Story = { }, yaml: 'kind: bot_instance\nversion: v1\n', }), + getBotInstanceMetricsSuccess(), ], }, }, @@ -117,7 +120,10 @@ export const Happy: Story = { export const ErrorLoadingList: Story = { parameters: { msw: { - handlers: [listBotInstancesError(500, 'something went wrong')], + handlers: [ + listBotInstancesError(500, 'something went wrong'), + getBotInstanceMetricsSuccess(), + ], }, }, }; @@ -125,7 +131,7 @@ export const ErrorLoadingList: Story = { export const StillLoadingList: Story = { parameters: { msw: { - handlers: [listBotInstancesForever()], + handlers: [listBotInstancesForever(), getBotInstanceMetricsSuccess()], }, }, }; @@ -141,6 +147,7 @@ export const NoListPermission: Story = { 500, 'this call should never be made without permissions' ), + getBotInstanceMetricsSuccess(), ], }, }, @@ -153,11 +160,13 @@ export const NoReadPermission: Story = { parameters: { msw: { handlers: [ - listBotInstancesSuccessHandler, + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), getBotInstanceError( 500, 'this call should never be made without permissions' ), + getBotInstanceMetricsSuccess(), ], }, }, diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index fdc83a0bcbb7b..acdaf77bb9050 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -39,6 +39,7 @@ import { createTeleportContext } from 'teleport/mocks/contexts'; import { listBotInstances } from 'teleport/services/bot/bot'; import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { + getBotInstanceMetricsSuccess, getBotInstanceSuccess, listBotInstancesError, listBotInstancesSuccess, @@ -58,6 +59,9 @@ jest.mock('teleport/services/bot/bot', () => { getBotInstance: jest.fn((...all) => { return actual.getBotInstance(...all); }), + getBotInstanceMetrics: jest.fn((...all) => { + return actual.getBotInstanceMetrics(...all); + }), }; }); @@ -67,10 +71,6 @@ beforeAll(() => { server.listen(); }); -beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); -}); - afterEach(async () => { server.resetHandlers(); await testQueryClient.resetQueries(); @@ -84,11 +84,15 @@ afterAll(() => server.close()); describe('BotInstances', () => { it('Shows an empty state', async () => { server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -104,6 +108,7 @@ describe('BotInstances', () => { it('Shows an error state', async () => { server.use(listBotInstancesError(500, 'something went wrong')); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -116,6 +121,7 @@ describe('BotInstances', () => { const testErrorMessage = 'unsupported sort, only bot_name:asc is supported, but got "blah" (desc = true)'; server.use(listBotInstancesError(400, testErrorMessage)); + server.use(getBotInstanceMetricsSuccess()); const { user } = renderComponent(); @@ -124,14 +130,15 @@ describe('BotInstances', () => { expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally - const resetButton = screen.getByRole('button', { name: 'Reset sort' }); await user.click(resetButton); @@ -159,25 +166,31 @@ describe('BotInstances', () => { }); it('Shows a list', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); + server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'test-bot-1', - instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', - active_at_latest: '2025-05-19T07:32:00Z', - host_name_latest: 'test-hostname', - join_method_latest: 'github', - version_latest: '1.0.0-dev-a12b3c', - }, - { - bot_name: 'test-bot-2', - instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + active_at_latest: '2025-05-19T07:32:00Z', + host_name_latest: 'test-hostname', + join_method_latest: 'github', + version_latest: '1.0.0-dev-a12b3c', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -191,27 +204,29 @@ describe('BotInstances', () => { }); it('Selects an item', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally - server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'test-bot-1', - instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', - active_at_latest: '2025-05-19T07:32:00Z', - host_name_latest: 'test-hostname', - join_method_latest: 'github', - version_latest: '1.0.0-dev-a12b3c', - }, - { - bot_name: 'test-bot-2', - instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + active_at_latest: '2025-05-19T07:32:00Z', + host_name_latest: 'test-hostname', + join_method_latest: 'github', + version_latest: '1.0.0-dev-a12b3c', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); server.use( getBotInstanceSuccess({ @@ -251,7 +266,7 @@ describe('BotInstances', () => { }); it('Allows paging', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -327,7 +342,7 @@ describe('BotInstances', () => { }); it('Allows filtering (search)', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -407,7 +422,7 @@ describe('BotInstances', () => { }); it('Allows filtering (query)', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -491,8 +506,62 @@ describe('BotInstances', () => { ); }); + it('Allows a filter to be applied from the dashboard', async () => { + server.use(getBotInstanceMetricsSuccess()); + + jest.mocked(listBotInstances).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + bot_instances: [], + next_page_token: pageToken + '.next', + }); + }) + ); + + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + const item = screen.getByLabelText('Up to date'); + await user.click(item); + + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=up+to+date+filter+goes+here&is_advanced=1', + }); + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: undefined, + query: 'up to date filter goes here', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + }); + it('Allows sorting', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f412bbf91040e..fe1b5c1521641 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -24,8 +24,6 @@ import styled, { css } from 'styled-components'; import { Alert } from 'design/Alert/Alert'; import { CardTile } from 'design/CardTile/CardTile'; import Flex from 'design/Flex/Flex'; -import { Question } from 'design/Icon'; -import Text from 'design/Text'; import { SearchPanel } from 'shared/components/Search'; import { InfoGuideButton } from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; @@ -39,6 +37,7 @@ import { listBotInstances } from 'teleport/services/bot/bot'; import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; +import { BotInstancesDashboard } from './Dashboard/BotInstanceDashboard'; import { BotInstanceDetails } from './Details/BotInstanceDetails'; import { InfoGuide } from './InfoGuide'; import { @@ -163,6 +162,13 @@ export function BotInstances() { [data?.pages, isSuccess] ); + const handleFilterSelected = useCallback( + (filter: string) => { + handleQueryChange(filter, true); + }, + [handleQueryChange] + ); + if (!hasListPermission) { return ( @@ -207,6 +213,7 @@ export function BotInstances() { onLoadNextPage={fetchNextPage} selectedItem={selectedItemId} onItemSelected={handleItemSelected} + isFiltering={!!query} /> {selectedItemId ? ( {!selectedItemId ? ( - - - - Select an instance to see full details. - - + ) : undefined} @@ -259,20 +261,3 @@ const ListAndDetailsContainer = styled(CardTile)<{ $listOnlyMode: boolean }>` ` : ''} `; - -const DashboardContainer = styled(Flex)` - flex-direction: column; - overflow: auto; - flex-basis: 100%; - align-items: center; - justify-content: center; -`; - -const DashboardHelpText = styled(Text)` - color: ${props => props.theme.colors.text.muted}; - text-align: center; -`; - -const QuestionIcon = styled(Question)` - color: ${props => props.theme.colors.text.muted}; -`; diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx new file mode 100644 index 0000000000000..fa327d5028d7e --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx @@ -0,0 +1,118 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { + getBotInstanceMetricsError, + getBotInstanceMetricsForever, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const meta = { + title: 'Teleport/BotInstances/Dashboard', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsSuccess()], + }, + }, +}; + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + getBotInstanceMetricsSuccess({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }), + ], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsForever()], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsError(500, 'something went wrong')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper() { + const ctx = createTeleportContext(); + + return ( + + + {}} /> + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx new file mode 100644 index 0000000000000..12348d43f05b3 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx @@ -0,0 +1,264 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { QueryClientProvider } from '@tanstack/react-query'; +import { setupServer } from 'msw/node'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, + waitForElementToBeRemoved, + within, +} from 'design/utils/testing'; + +import { + getBotInstanceMetricsError, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const server = setupServer(); + +beforeAll(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('BotInstanceDashboard', () => { + it('renders', async () => { + withSuccessResponse(); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('Insights')).toBeInTheDocument(); + expect(screen.getByText('Version Compatibility')).toBeInTheDocument(); + + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + + const patch = screen.getByLabelText('Patch available'); + expect(within(patch).getByText('50 (29%)')).toBeInTheDocument(); + + const upgrade = screen.getByLabelText('Upgrade required'); + expect(within(upgrade).getByText('25 (14%)')).toBeInTheDocument(); + + const unsupported = screen.getByLabelText('Unsupported'); + expect(within(unsupported).getByText('0 (0%)')).toBeInTheDocument(); + + expect( + screen.getByText('Select a category above to filter bot instances.') + ).toBeInTheDocument(); + }); + + it('shows no data message', async () => { + withSuccessResponse({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('No data available')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('shows an error', async () => { + withErrorResponse(500, 'something went wrong'); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('items are selectable', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const item = screen.getByLabelText('Up to date'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(1); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock up-to-date filter' + ); + } + + { + const item = screen.getByLabelText('Patch available'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(2); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock patch filter'); + } + + { + const item = screen.getByLabelText('Upgrade required'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(3); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock upgrade filter'); + } + + { + const item = screen.getByLabelText('Unsupported'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(4); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock unsupported filter' + ); + } + }); + + it('refreshes', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + } + + withSuccessResponse({ + upgrade_statuses: { + up_to_date: { + count: 99, + }, + patch_available: { + count: 0, + }, + requires_upgrade: { + count: 0, + }, + unsupported: { + count: 0, + }, + updated_at: '1970-01-01T00:00:00Z', + }, + refresh_after_seconds: 60_000, + }); + + const refreshButton = screen.getByLabelText('refresh'); + await user.click(refreshButton); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('99 (100%)')).toBeInTheDocument(); + } + }); +}); + +function renderComponent(options?: { + props?: ComponentProps; +}) { + const { props } = options ?? {}; + const { onFilterSelected = jest.fn() } = props ?? {}; + + const user = userEvent.setup(); + + return { + ...render(, { + wrapper: makeWrapper(), + }), + user, + history, + }; +} + +function makeWrapper() { + return ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); + }; +} + +async function waitForLoading() { + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); +} + +function withSuccessResponse( + mock: Parameters[0] = { + upgrade_statuses: { + up_to_date: { + count: 100, + filter: 'mock up-to-date filter', + }, + patch_available: { + count: 50, + filter: 'mock patch filter', + }, + requires_upgrade: { + count: 25, + filter: 'mock upgrade filter', + }, + unsupported: { + count: 0, + filter: 'mock unsupported filter', + }, + updated_at: new Date().toISOString(), + }, + refresh_after_seconds: 60_000, + } +) { + server.use(getBotInstanceMetricsSuccess(mock)); +} + +function withErrorResponse( + ...params: Parameters +) { + server.use(getBotInstanceMetricsError(...params)); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx new file mode 100644 index 0000000000000..c6148dfd1bd11 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx @@ -0,0 +1,380 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useQuery } from '@tanstack/react-query'; +import { format, formatDistanceToNowStrict, parseISO } from 'date-fns'; +import { useEffect, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; +import { CardTile } from 'design/CardTile/CardTile'; +import Flex from 'design/Flex'; +import { Refresh } from 'design/Icon'; +import { Indicator } from 'design/Indicator/Indicator'; +import Text, { H2, H3 } from 'design/Text'; +import { IconTooltip } from 'design/Tooltip'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; + +import { getBotInstanceMetrics } from 'teleport/services/bot/bot'; +import { GetBotInstanceMetricsResponse } from 'teleport/services/bot/types'; + +export function BotInstancesDashboard(props: { + /** + * Callback used when a dashbaord item is selected (e.g. "unsupported" + * instance versions). The given filter is used as an advanced query (in the + * Teleport predicate language) to filter the items in the instances list. + * + * @param filter query (verbatum) used to filter the bot instance list. + */ + onFilterSelected: (filter: string) => void; +}) { + const { onFilterSelected } = props; + + const { data, error, isLoading, isPending, refetch } = useQuery({ + queryKey: ['bot_instance', 'metrics'], + queryFn: ({ signal }) => getBotInstanceMetrics(null, signal), + // The metrics endpoint (used by this query) returns a + // `refresh_after_seconds` value to indicate how frequently the client + // should poll for updated metrics, which may take jitter into account. This + // allows the polling rate to most closely match the backend data refresh, + // and allows the rate to be controlled server-side. + // + // The `refetchInterval` is set to this value from the lasty successful + // response, otherwise 1 min as a fallback. + refetchInterval: ({ state }) => + (state.data?.refresh_after_seconds ?? 60) * 1_000, + }); + + // Used to keep "Last updated x minutes ago" label current + useTick(30_000); + + return ( + + +

Insights

+ + refetch()} + aria-label="refresh" + disabled={isLoading} + > + + + +
+ + + {error ? ( + + {error.message} + + ) : undefined} + + {isLoading ? ( + + + + ) : undefined} + + {isPending ? undefined : ( + <> + + + + + {data?.upgrade_statuses ? ( + + Select a category above to filter bot instances. + + ) : undefined} + + )} +
+ ); +} + +const Container = styled(CardTile)` + flex-direction: column; + flex-basis: 100%; + margin: ${props => props.theme.space[1]}px; + padding: 0; + gap: 0; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + min-height: ${p => p.theme.space[8]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + gap: ${p => p.theme.space[2]}px; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const InnerContainer = styled(Flex)` + overflow: auto; + flex-direction: column; + padding: ${p => p.theme.space[3]}px; +`; + +function UpgradeStatusChart(props: { + data: GetBotInstanceMetricsResponse['upgrade_statuses']; + onFilterSelected: (status: string) => void; +}) { + const { data, onFilterSelected } = props; + + const theme = useTheme(); + + const max = Math.max( + 1, // Never zero + data?.up_to_date?.count ?? 0, + data?.patch_available?.count ?? 0, + data?.requires_upgrade?.count ?? 0, + data?.unsupported?.count ?? 0 + ); + + const total = Math.max( + 1, // Never zero + (data?.up_to_date?.count ?? 0) + + (data?.patch_available?.count ?? 0) + + (data?.requires_upgrade?.count ?? 0) + + (data?.unsupported?.count ?? 0) + ); + + const series = data + ? [ + { + name: 'Up to date', + percent: (data.up_to_date?.count ?? 0) / max, + count: data.up_to_date?.count ?? 0, + label: `${data.up_to_date?.count ?? 0}\xa0(${formatPercent((data.up_to_date?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.success.default, + onClick: () => + data.up_to_date?.filter + ? onFilterSelected(data.up_to_date?.filter) + : undefined, + tooltip: + 'Up-to-date instances are running the same version as the Teleport cluster.', + }, + { + name: 'Patch available', + percent: (data.patch_available?.count ?? 0) / max, + count: data.patch_available?.count ?? 0, + label: `${data.patch_available?.count ?? 0}\xa0(${formatPercent((data.patch_available?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.accent.default, + onClick: () => + data.patch_available?.filter + ? onFilterSelected(data.patch_available?.filter) + : undefined, + tooltip: + 'Instances with a patch available are running the same major version as the Teleport cluster.', + }, + { + name: 'Upgrade required', + percent: (data.requires_upgrade?.count ?? 0) / max, + count: data.requires_upgrade?.count ?? 0, + label: `${data.requires_upgrade?.count ?? 0}\xa0(${formatPercent((data.requires_upgrade?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.alert.default, + onClick: () => + data.requires_upgrade?.filter + ? onFilterSelected(data.requires_upgrade?.filter) + : undefined, + tooltip: + 'Instances requiring an upgrade are running the one major version behind the Teleport cluster.', + }, + { + name: 'Unsupported', + percent: (data.unsupported?.count ?? 0) / max, + count: data.unsupported?.count ?? 0, + label: `${data.unsupported?.count ?? 0}\xa0(${formatPercent((data.unsupported?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.danger.default, + onClick: () => + data.unsupported?.filter + ? onFilterSelected(data.unsupported?.filter) + : undefined, + tooltip: + 'Unsupported instances are running two or more major versions behind the Teleport cluster, or are running a newer version.', + }, + ] + : null; + + return ( + + +

Version Compatibility

+ {data?.updated_at ? ( + + + Last updated{' '} + {formatDistanceToNowStrict(parseISO(data.updated_at))} ago + + + ) : undefined} +
+ + {series ? ( + series.map(s => ( + { + if (event.key === 'Enter') { + s.onClick(); + } + }} + role="button" + tabIndex={0} + aria-label={`${s.name}`} + > + + {s.name} + + {s.tooltip} + + + + + )) + ) : ( + No data available + )} + +
+ ); +} + +const UpgradeStatusContainer = styled(Flex)` + flex-direction: column; + padding: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + gap: ${({ theme }) => theme.space[3]}px; + border: 1px solid ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const BarsContainer = styled(Flex)` + flex-direction: column; +`; + +const SeriesContainer = styled.div` + padding: ${({ theme }) => theme.space[2]}px ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.levels.sunken}; + } + &:focus, + &:active { + outline: none; + + background-color: ${({ theme }) => theme.colors.levels.deep}; + } + + transition: background-color 200ms linear; +`; + +const ChartLabelContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const ChartLabelText = styled(Text)` + white-space: nowrap; + font-size: ${({ theme }) => theme.fontSizes[1]}px; +`; + +const ChartNoDataContainer = styled(Flex)` + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.space[4]}px; + color: ${({ theme }) => theme.colors.text.muted}; +`; + +const ChartUpdatedAtText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[1]}px; + font-weight: ${({ theme }) => theme.fontWeights.medium}; + text-align: right; +`; + +function Bar(props: { percent: number; label: string; color: string }) { + const { percent, label, color } = props; + + return ( + + + {label} + + ); +} + +const BarContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const BarAmount = styled.div<{ $percent: number; $color: string }>` + flex-grow: ${({ $percent }) => $percent}; + background-color: ${({ $color }) => $color}; + height: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[1]}px; + min-width: ${({ theme }) => theme.space[1]}px; + + transition: flex-grow 1000ms ease-in-out; +`; + +const BarLabel = styled.div<{ $percent: number }>` + flex-grow: ${({ $percent }) => 1 - $percent}; + + transition: flex-grow 1000ms ease-in-out; +`; + +function formatPercent(percent: number) { + return `${(percent * 100).toFixed(0)}%`; +} + +/** + * A hook which ticks at the given interval and will cause a re-render of + * components which use it. Useful for updating messaging such as "updated 10 + * seconds ago". + * @param interval how often to tick (in milliseconds) + * @returns A date instance representing the last tick + */ +function useTick(interval: number) { + const [tick, setTick] = useState(new Date()); + + useEffect(() => { + const id = setInterval(() => setTick(new Date()), interval); + return () => clearInterval(id); + }); + + return tick; +} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx index 5ff2668b219b5..f6606d633f8bc 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx @@ -48,6 +48,13 @@ export const Empty: Story = { }, }; +export const EmptyWithFilter: Story = { + args: { + data: [], + isFiltering: true, + }, +}; + export const ErrorLoadingList: Story = { args: { error: new Error('something went wrong'), @@ -91,7 +98,12 @@ function Wrapper( props?: Partial< Pick< ComponentProps, - 'error' | 'isLoading' | 'hasNextPage' | 'isFetchingNextPage' | 'data' + | 'error' + | 'isLoading' + | 'hasNextPage' + | 'isFetchingNextPage' + | 'data' + | 'isFiltering' > > ) { @@ -129,6 +141,7 @@ function Wrapper( hasNextPage = true, isFetchingNextPage = false, isLoading = false, + isFiltering = false, } = props ?? {}; const [allData, setAllData] = useState(data); @@ -178,6 +191,7 @@ function Wrapper( onItemSelected={function (item: BotInstanceSummary | null): void { setSelected(item ? `${item.bot_name}/${item.instance_id}` : null); }} + isFiltering={isFiltering} /> diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx index a3e22c58dcfd2..4f2004d21aac2 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx @@ -220,6 +220,38 @@ describe('BotIntancesList', () => { expect(onSortChanged).toHaveBeenLastCalledWith('bot_name', 'ASC'); }); + + describe('When filtering', () => { + it('Shows an alterate title', async () => { + renderComponent({ + props: { + isFiltering: true, + }, + }); + + expect( + screen.getByRole('heading', { name: 'Filtered Instances' }) + ).toBeInTheDocument(); + }); + + it('Shows an empty state', async () => { + renderComponent({ + props: { + data: [], + isFiltering: true, + }, + }); + + expect( + screen.getByText('No instances matching filter') + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).not.toBeInTheDocument(); + }); + }); }); const renderComponent = (options?: { @@ -238,6 +270,7 @@ const renderComponent = (options?: { onSortChanged = jest.fn(), onLoadNextPage = jest.fn(), onItemSelected = jest.fn(), + isFiltering = false, } = props ?? {}; const user = userEvent.setup(); @@ -255,6 +288,7 @@ const renderComponent = (options?: { onSortChanged={onSortChanged} onLoadNextPage={onLoadNextPage} onItemSelected={onItemSelected} + isFiltering={isFiltering} />, { wrapper: makeWrapper(), diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 6ce8874992a81..86e83710342fa 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -49,6 +49,7 @@ function InternalBotInstancesList( onSortChanged: (sortField: string, sortDir: 'ASC' | 'DESC') => void; onLoadNextPage: () => void; onItemSelected: (item: BotInstanceSummary) => void; + isFiltering: boolean; }, ref: React.RefObject ) { @@ -64,6 +65,7 @@ function InternalBotInstancesList( onSortChanged, onLoadNextPage, onItemSelected, + isFiltering, } = props; const contentRef = React.useRef(null); @@ -86,7 +88,9 @@ function InternalBotInstancesList( return ( - Active Instances + + {isFiltering ? 'Filtered Instances' : 'Active Instances'} + ) : ( - No active instances - - Bot instances are ephemeral, and disappear once all issued - credentials have expired. - + + {isFiltering + ? 'No instances matching filter' + : 'No active instances'} + + {!isFiltering ? ( + + Bot instances are ephemeral, and disappear once all issued + credentials have expired. + + ) : undefined} )} diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx index abf2867a2f278..813dd00f15aa2 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx @@ -752,20 +752,23 @@ const withFetchJoinTokensOutdatedProxy = () => { function withFetchInstancesSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } diff --git a/web/packages/teleport/src/Bots/Details/Instance.tsx b/web/packages/teleport/src/Bots/Details/Instance.tsx index ab99e4d20e130..a37128c7e6172 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.tsx @@ -177,6 +177,8 @@ const Container = styled(Flex)<{ } ` : ''} + + transition: background-color 200ms linear; `; const TopRow = styled(Flex)` @@ -254,8 +256,7 @@ function Version(props: { version: string | undefined }) { break; case 'too-new': Wrapper = DangerOutlined; - tooltip = - 'Version is one or more major versions ahead, and is not compatible.'; + tooltip = 'Version is ahead, and is not compatible.'; break; } } diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx index c231a6813e263..8fc30ffdfc49f 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx @@ -107,20 +107,23 @@ const waitForLoading = async () => { function withFetchSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index eb0ad030ca2c6..d88a6b9f7115e 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -502,6 +502,7 @@ const cfg = { read: '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', listV2: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', + metrics: '/v1/webapi/sites/:clusterId/machine-id/bot-instance/metrics', }, workloadIdentity: { @@ -1706,6 +1707,9 @@ const cfg = { botName: string; instanceId: string; } + | { + action: 'metrics'; + } ) & { clusterId?: string } ) { const { clusterId = cfg.proxyCluster } = req; @@ -1724,6 +1728,10 @@ const cfg = { botName: req.botName, instanceId: req.instanceId, }); + case 'metrics': + return generatePath(cfg.api.botInstance.metrics, { + clusterId, + }); default: req satisfies never; return ''; diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index 018636a554b02..61b545c58caee 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -25,6 +25,7 @@ import { canUseV2Edit, makeBot, toApiGitHubTokenSpec, + validateGetBotInstanceMetricsResponse, validateGetBotInstanceResponse, validateListBotInstancesResponse, } from 'teleport/services/bot/consts'; @@ -264,3 +265,22 @@ export async function getBotInstance( return data; } + +export async function getBotInstanceMetrics( + variables: null, + signal?: AbortSignal +) { + const path = cfg.getBotInstanceUrl({ action: 'metrics' }); + + try { + const data = await api.get(path, signal); + + if (!validateGetBotInstanceMetricsResponse(data)) { + throw new Error('failed to validate get bot instance metrics response'); + } + + return data; + } catch (err: unknown) { + withGenericUnsupportedError(err, '18.4.0'); + } +} diff --git a/web/packages/teleport/src/services/bot/consts.ts b/web/packages/teleport/src/services/bot/consts.ts index 4bb7b0a99f321..f6f3dcb7cbbe5 100644 --- a/web/packages/teleport/src/services/bot/consts.ts +++ b/web/packages/teleport/src/services/bot/consts.ts @@ -22,6 +22,7 @@ import { BotUiFlow, EditBotRequest, FlatBot, + GetBotInstanceMetricsResponse, GetBotInstanceResponse, GitHubRepoRule, ListBotInstancesResponse, @@ -137,6 +138,24 @@ export function validateGetBotInstanceResponse( return true; } +export function validateGetBotInstanceMetricsResponse( + data: unknown +): data is GetBotInstanceMetricsResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('upgrade_statuses' in data)) { + return false; + } + + if (typeof data.upgrade_statuses !== 'object') { + return false; + } + + return true; +} + export function getBotType(labels: Map): BotType { if (!labels) { return null; diff --git a/web/packages/teleport/src/services/bot/types.ts b/web/packages/teleport/src/services/bot/types.ts index f42d7612b281c..06e4f29efe87c 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -82,6 +82,22 @@ export type GetBotInstanceResponse = { yaml?: string; }; +export type GetBotInstanceMetricsResponse = { + upgrade_statuses?: { + unsupported?: BotInstanceMetric | null; + patch_available?: BotInstanceMetric | null; + requires_upgrade?: BotInstanceMetric | null; + up_to_date?: BotInstanceMetric | null; + updated_at?: string; + } | null; + refresh_after_seconds: number; +}; + +type BotInstanceMetric = { + count?: number; + filter?: string; +}; + export type BotList = { bots: FlatBot[]; }; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index f2a285b30e3bd..bb42fbae872c3 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -20,18 +20,27 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { + GetBotInstanceMetricsResponse, GetBotInstanceResponse, ListBotInstancesResponse, } from 'teleport/services/bot/types'; -export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => - http.get(cfg.api.botInstance.list, () => { - return HttpResponse.json(mock); - }); +export const listBotInstancesSuccess = ( + mock: ListBotInstancesResponse, + version: ListBotInstancesApiVersion = 'v2' +) => + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json(mock); + } + ); -export const listBotInstancesForever = () => +export const listBotInstancesForever = ( + version: ListBotInstancesApiVersion = 'v1' +) => http.get( - cfg.api.botInstance.list, + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, () => new Promise(() => { /* never resolved */ @@ -40,11 +49,15 @@ export const listBotInstancesForever = () => export const listBotInstancesError = ( status: number, - error: string | null = null + error: string | null = null, + version: ListBotInstancesApiVersion = 'v1' ) => - http.get(cfg.api.botInstance.list, () => { - return HttpResponse.json({ error: { message: error } }, { status }); - }); + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json({ error: { message: error } }, { status }); + } + ); export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => http.get(cfg.api.botInstance.read, () => { @@ -67,3 +80,56 @@ export const getBotInstanceForever = () => /* never resolved */ }) ); + +export const getBotInstanceMetricsSuccess = ( + mock?: GetBotInstanceMetricsResponse +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json( + mock ?? { + upgrade_statuses: { + updated_at: new Date().toISOString(), + up_to_date: { + count: randBetween(0, 2000), + filter: 'up to date filter goes here', + }, + patch_available: { + count: randBetween(0, 2000), + filter: 'patch filter goes here', + }, + requires_upgrade: { + count: randBetween(0, 2000), + filter: 'upgrade filter goes here', + }, + unsupported: { + count: randBetween(0, 2000), + filter: 'unsupported filter goes here', + }, + }, + } + ); + }); + +export const getBotInstanceMetricsForever = () => + http.get( + cfg.api.botInstance.metrics, + () => + new Promise(() => { + /* never resolved */ + }) + ); + +export const getBotInstanceMetricsError = ( + status: number, + error: string | null = null +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json({ error: { message: error } }, { status }); + }); + +function randBetween(low: number, high: number) { + if (low > high) [low, high] = [high, low]; + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +export type ListBotInstancesApiVersion = 'v1' | 'v2'; diff --git a/web/packages/teleport/src/useClusterVersion.test.tsx b/web/packages/teleport/src/useClusterVersion.test.tsx index 98b43c01c7cb6..b26a0740d902b 100644 --- a/web/packages/teleport/src/useClusterVersion.test.tsx +++ b/web/packages/teleport/src/useClusterVersion.test.tsx @@ -31,15 +31,15 @@ describe('useClusterVersion', () => { }); it.each` - clientVersion | compatibility - ${'4.4.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.4.1'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'5.0.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} - ${'6.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} - ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} + clientVersion | compatibility + ${'4.4.0-dev'} | ${{ isCompatible: true, reason: 'match' }} + ${'4.4.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.4.1'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'5.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} + ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} `( 'diff("$clientVersion") should be "$compatibility"', ({ clientVersion, compatibility }) => { diff --git a/web/packages/teleport/src/useClusterVersion.ts b/web/packages/teleport/src/useClusterVersion.ts index 5434982f57b88..e05f6acd6e1d7 100644 --- a/web/packages/teleport/src/useClusterVersion.ts +++ b/web/packages/teleport/src/useClusterVersion.ts @@ -73,20 +73,32 @@ export function checkClientCompatibility( const client = parse(clientVersion); const cluster = parse(clusterVersion); if (!client || !cluster) return null; + if (client.compare(cluster) === 0) { + return { + isCompatible: true, + reason: 'match', + }; + } + if (client.compare(cluster) === 1) { + return { + isCompatible: false, + reason: 'too-new', + }; + } if (client.major === cluster.major) { return { isCompatible: true, - reason: client.compare(cluster) === -1 ? 'upgrade-minor' : 'match', + reason: 'upgrade-minor', }; } - if (Math.abs(client.major - cluster.major) == 1) { + if (client.major === cluster.major - 1) { return { isCompatible: true, - reason: client.major > cluster.major ? 'match' : 'upgrade-major', + reason: 'upgrade-major', }; } return { isCompatible: false, - reason: client.major > cluster.major ? 'too-new' : 'too-old', + reason: 'too-old', }; }