From 69e4a09b70f654f737761992b2d414b59161876f Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Sat, 14 Dec 2024 09:46:16 +0000 Subject: [PATCH 01/22] add user_status_changes table --- coderd/database/dump.sql | 38 ++++++++++++++++ coderd/database/foreign_key_constraint.go | 1 + .../000279_user_status_changes.down.sql | 12 ++++++ .../000279_user_status_changes.up.sql | 43 +++++++++++++++++++ coderd/database/models.go | 8 ++++ coderd/database/unique_constraint.go | 1 + 6 files changed, 103 insertions(+) create mode 100644 coderd/database/migrations/000279_user_status_changes.down.sql create mode 100644 coderd/database/migrations/000279_user_status_changes.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 782bc4969d799..d7a1bf1196533 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -414,6 +414,23 @@ $$; COMMENT ON FUNCTION provisioner_tagset_contains(provisioner_tags tagset, job_tags tagset) IS 'Returns true if the provisioner_tags contains the job_tags, or if the job_tags represents an untagged provisioner and the superset is exactly equal to the subset.'; +CREATE FUNCTION record_user_status_change() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO user_status_changes ( + user_id, + new_status + ) VALUES ( + NEW.id, + NEW.status + ); + END IF; + RETURN NEW; +END; +$$; + CREATE FUNCTION remove_organization_member_role() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1386,6 +1403,15 @@ COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key us COMMENT ON COLUMN user_links.claims IS 'Claims from the IDP for the linked user. Includes both id_token and userinfo claims. '; +CREATE TABLE user_status_changes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + new_status user_status NOT NULL, + changed_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +COMMENT ON TABLE user_status_changes IS 'Tracks the history of user status changes'; + CREATE TABLE workspace_agent_log_sources ( workspace_agent_id uuid NOT NULL, id uuid NOT NULL, @@ -1973,6 +1999,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); +ALTER TABLE ONLY user_status_changes + ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); + ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); @@ -2083,6 +2112,10 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id); CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); +CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); + +CREATE INDEX idx_user_status_changes_user_id ON user_status_changes USING btree (user_id); + CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); @@ -2225,6 +2258,8 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash(); +CREATE TRIGGER user_status_change_trigger BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change(); + ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; @@ -2357,6 +2392,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_status_changes + ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 669ab85f945bd..18c82b83750fa 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -50,6 +50,7 @@ const ( ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentMetadataWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_metadata_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentPortShareWorkspaceID ForeignKeyConstraint = "workspace_agent_port_share_workspace_id_fkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000279_user_status_changes.down.sql b/coderd/database/migrations/000279_user_status_changes.down.sql new file mode 100644 index 0000000000000..b75acbcafca46 --- /dev/null +++ b/coderd/database/migrations/000279_user_status_changes.down.sql @@ -0,0 +1,12 @@ +-- Drop the trigger first +DROP TRIGGER IF EXISTS user_status_change_trigger ON users; + +-- Drop the trigger function +DROP FUNCTION IF EXISTS record_user_status_change(); + +-- Drop the indexes +DROP INDEX IF EXISTS idx_user_status_changes_changed_at; +DROP INDEX IF EXISTS idx_user_status_changes_user_id; + +-- Drop the table +DROP TABLE IF EXISTS user_status_changes; diff --git a/coderd/database/migrations/000279_user_status_changes.up.sql b/coderd/database/migrations/000279_user_status_changes.up.sql new file mode 100644 index 0000000000000..545705aba50eb --- /dev/null +++ b/coderd/database/migrations/000279_user_status_changes.up.sql @@ -0,0 +1,43 @@ +CREATE TABLE user_status_changes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + new_status user_status NOT NULL, + changed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE user_status_changes IS 'Tracks the history of user status changes'; + +CREATE INDEX idx_user_status_changes_user_id ON user_status_changes(user_id); +CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes(changed_at); + +INSERT INTO user_status_changes ( + user_id, + new_status, + changed_at +) +SELECT + id, + status, + created_at +FROM users +WHERE NOT deleted; + +CREATE FUNCTION record_user_status_change() RETURNS trigger AS $$ +BEGIN + IF OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO user_status_changes ( + user_id, + new_status + ) VALUES ( + NEW.id, + NEW.status + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER user_status_change_trigger + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION record_user_status_change(); diff --git a/coderd/database/models.go b/coderd/database/models.go index e5ddebcbc8b9a..c38722ae1c208 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2898,6 +2898,14 @@ type UserLink struct { Claims UserLinkClaims `db:"claims" json:"claims"` } +// Tracks the history of user status changes +type UserStatusChange struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + NewStatus UserStatus `db:"new_status" json:"new_status"` + ChangedAt time.Time `db:"changed_at" json:"changed_at"` +} + // Visible fields of users are allowed to be joined with other tables for including context of other resources. type VisibleUser struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index f4470c6546698..b59cb0cbc8091 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -63,6 +63,7 @@ const ( UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); + UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); From 0913355b8b9c8ee3d9eb525dc926683946b00d06 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Sat, 14 Dec 2024 10:37:43 +0000 Subject: [PATCH 02/22] add GetUserStatusCountsByDay --- coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbmem/dbmem.go | 9 ++ coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 103 ++++++++++++++++++++++ coderd/database/queries/insights.sql | 68 ++++++++++++++ 7 files changed, 210 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f64dbcc166591..ffc1eb63d8dd0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2413,6 +2413,13 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } +func (q *querier) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetUserStatusCountsByDay(ctx, arg) +} + func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { u, err := q.db.GetUserByID(ctx, params.OwnerID) if err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 7f974aa1a76a2..a6857c9701b9d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5664,6 +5664,15 @@ func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID u return out, nil } +func (q *FakeQuerier) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 645357d6f095e..9e15081fa9c36 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1337,6 +1337,13 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } +func (m queryMetricsStore) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { + start := time.Now() + r0, r1 := m.s.GetUserStatusCountsByDay(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserStatusCountsByDay").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, ownerID database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { start := time.Now() r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, ownerID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 73a0e6d60af55..ca59e5c50b0bd 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2811,6 +2811,21 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), arg0, arg1) } +// GetUserStatusCountsByDay mocks base method. +func (m *MockStore) GetUserStatusCountsByDay(arg0 context.Context, arg1 database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserStatusCountsByDay", arg0, arg1) + ret0, _ := ret[0].([]database.GetUserStatusCountsByDayRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserStatusCountsByDay indicates an expected call of GetUserStatusCountsByDay. +func (mr *MockStoreMockRecorder) GetUserStatusCountsByDay(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusCountsByDay", reflect.TypeOf((*MockStore)(nil).GetUserStatusCountsByDay), arg0, arg1) +} + // GetUserWorkspaceBuildParameters mocks base method. func (m *MockStore) GetUserWorkspaceBuildParameters(arg0 context.Context, arg1 database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2128315ce6dad..cf98d56ea5cfb 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -285,6 +285,7 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) + GetUserStatusCountsByDay(ctx context.Context, arg GetUserStatusCountsByDayParams) ([]GetUserStatusCountsByDayRow, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fa448d35f0b8e..0cb5da583c4ca 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3094,6 +3094,109 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate return items, nil } +const getUserStatusCountsByDay = `-- name: GetUserStatusCountsByDay :many +WITH dates AS ( + -- Generate a series of dates between start and end + SELECT + day::date + FROM + generate_series( + date_trunc('day', $1::timestamptz), + date_trunc('day', $2::timestamptz), + '1 day'::interval + ) AS day +), +initial_statuses AS ( + -- Get the status of each user right before the start date + SELECT DISTINCT ON (user_id) + user_id, + new_status as status + FROM + user_status_changes + WHERE + changed_at < $1::timestamptz + ORDER BY + user_id, + changed_at DESC +), +relevant_changes AS ( + -- Get only the status changes within our date range + SELECT + date_trunc('day', changed_at)::date AS day, + user_id, + new_status as status + FROM + user_status_changes + WHERE + changed_at >= $1::timestamptz + AND changed_at <= $2::timestamptz +), +daily_status AS ( + -- Combine initial statuses with changes + SELECT + d.day, + COALESCE(rc.status, i.status) as status, + COALESCE(rc.user_id, i.user_id) as user_id + FROM + dates d + CROSS JOIN + initial_statuses i + LEFT JOIN + relevant_changes rc + ON + rc.day = d.day + AND rc.user_id = i.user_id +) +SELECT + day, + status, + COUNT(*) AS count +FROM + daily_status +WHERE + status IS NOT NULL +GROUP BY + day, + status +ORDER BY + day ASC, + status ASC +` + +type GetUserStatusCountsByDayParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +type GetUserStatusCountsByDayRow struct { + Day time.Time `db:"day" json:"day"` + Status UserStatus `db:"status" json:"status"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetUserStatusCountsByDay(ctx context.Context, arg GetUserStatusCountsByDayParams) ([]GetUserStatusCountsByDayRow, error) { + rows, err := q.db.QueryContext(ctx, getUserStatusCountsByDay, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserStatusCountsByDayRow + for rows.Next() { + var i GetUserStatusCountsByDayRow + if err := rows.Scan(&i.Day, &i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertTemplateUsageStats = `-- name: UpsertTemplateUsageStats :exec WITH latest_start AS ( diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index de107bc0e80c7..248edd89fac23 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -771,3 +771,71 @@ SELECT FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; + +-- name: GetUserStatusCountsByDay :many +WITH dates AS ( + -- Generate a series of dates between start and end + SELECT + day::date + FROM + generate_series( + date_trunc('day', @start_time::timestamptz), + date_trunc('day', @end_time::timestamptz), + '1 day'::interval + ) AS day +), +initial_statuses AS ( + -- Get the status of each user right before the start date + SELECT DISTINCT ON (user_id) + user_id, + new_status as status + FROM + user_status_changes + WHERE + changed_at < @start_time::timestamptz + ORDER BY + user_id, + changed_at DESC +), +relevant_changes AS ( + -- Get only the status changes within our date range + SELECT + date_trunc('day', changed_at)::date AS day, + user_id, + new_status as status + FROM + user_status_changes + WHERE + changed_at >= @start_time::timestamptz + AND changed_at <= @end_time::timestamptz +), +daily_status AS ( + -- Combine initial statuses with changes + SELECT + d.day, + COALESCE(rc.status, i.status) as status, + COALESCE(rc.user_id, i.user_id) as user_id + FROM + dates d + CROSS JOIN + initial_statuses i + LEFT JOIN + relevant_changes rc + ON + rc.day = d.day + AND rc.user_id = i.user_id +) +SELECT + day, + status, + COUNT(*) AS count +FROM + daily_status +WHERE + status IS NOT NULL +GROUP BY + day, + status +ORDER BY + day ASC, + status ASC; From d952af0011a006f6d9d68c665e72fde987f6547a Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Sat, 14 Dec 2024 10:39:31 +0000 Subject: [PATCH 03/22] rename unused variable --- coderd/database/dbmem/dbmem.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index a6857c9701b9d..de7b418cb602d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5664,7 +5664,10 @@ func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID u return out, nil } -func (q *FakeQuerier) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { +func (q *FakeQuerier) GetUserStatusCountsByDay(_ context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + err := validateDatabaseType(arg) if err != nil { return nil, err From 34ac6346515b5c42b2ceb727a9839196a5fb6aba Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 17 Dec 2024 08:09:28 +0000 Subject: [PATCH 04/22] Test GetUserStatusCountsByDay --- coderd/database/dump.sql | 10 +- .../000279_user_status_changes.up.sql | 13 +- coderd/database/querier_test.go | 446 ++++++++++++++++++ coderd/database/queries.sql.go | 59 ++- coderd/database/queries/insights.sql | 53 +-- 5 files changed, 510 insertions(+), 71 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d7a1bf1196533..0cbf2301245d1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -418,13 +418,15 @@ CREATE FUNCTION record_user_status_change() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - IF OLD.status IS DISTINCT FROM NEW.status THEN + IF TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status THEN INSERT INTO user_status_changes ( user_id, - new_status + new_status, + changed_at ) VALUES ( NEW.id, - NEW.status + NEW.status, + NEW.updated_at ); END IF; RETURN NEW; @@ -2258,7 +2260,7 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash(); -CREATE TRIGGER user_status_change_trigger BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change(); +CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change(); ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000279_user_status_changes.up.sql b/coderd/database/migrations/000279_user_status_changes.up.sql index 545705aba50eb..f16164862b3e5 100644 --- a/coderd/database/migrations/000279_user_status_changes.up.sql +++ b/coderd/database/migrations/000279_user_status_changes.up.sql @@ -7,7 +7,6 @@ CREATE TABLE user_status_changes ( COMMENT ON TABLE user_status_changes IS 'Tracks the history of user status changes'; -CREATE INDEX idx_user_status_changes_user_id ON user_status_changes(user_id); CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes(changed_at); INSERT INTO user_status_changes ( @@ -22,15 +21,17 @@ SELECT FROM users WHERE NOT deleted; -CREATE FUNCTION record_user_status_change() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION record_user_status_change() RETURNS trigger AS $$ BEGIN - IF OLD.status IS DISTINCT FROM NEW.status THEN + IF TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status THEN INSERT INTO user_status_changes ( user_id, - new_status + new_status, + changed_at ) VALUES ( NEW.id, - NEW.status + NEW.status, + NEW.updated_at ); END IF; RETURN NEW; @@ -38,6 +39,6 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER user_status_change_trigger - BEFORE UPDATE ON users + AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION record_user_status_change(); diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 28d7108ae31ad..3da6499f3ec82 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2255,6 +2255,452 @@ func TestGroupRemovalTrigger(t *testing.T) { }, db2sdk.List(extraUserGroups, onlyGroupIDs)) } +func TestGetUserStatusCountsByDay(t *testing.T) { + t.Parallel() + + t.Run("No Users", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + end := dbtime.Now() + start := end.Add(-30 * 24 * time.Hour) + + counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + StartTime: start, + EndTime: end, + }) + require.NoError(t, err) + require.Empty(t, counts, "should return no results when there are no users") + }) + + t.Run("Single User/Single State", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + status database.UserStatus + }{ + { + name: "Active Only", + status: database.UserStatusActive, + }, + { + name: "Dormant Only", + status: database.UserStatusDormant, + }, + { + name: "Suspended Only", + status: database.UserStatusSuspended, + }, + // { + // name: "Deleted Only", + // status: database.UserStatusDeleted, + // }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a user that's been in the specified status for the past 30 days + now := dbtime.Now() + createdAt := now.Add(-29 * 24 * time.Hour) + dbgen.User(t, db, database.User{ + Status: tc.status, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // Query for the last 30 days + counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + StartTime: createdAt, + EndTime: now, + }) + require.NoError(t, err) + require.NotEmpty(t, counts, "should return results") + + // We should have an entry for each day, all with 1 user in the specified status + require.Len(t, counts, 30, "should have 30 days of data") + for _, count := range counts { + if count.Status.Valid && count.Status.UserStatus == tc.status { + require.Equal(t, int64(1), count.Count, "should have 1 %s user", tc.status) + } else { + require.Equal(t, int64(0), count.Count, "should have 0 users for other statuses") + } + } + }) + } + }) + + t.Run("Single Transition", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + initialStatus database.UserStatus + targetStatus database.UserStatus + }{ + { + name: "Active to Dormant", + initialStatus: database.UserStatusActive, + targetStatus: database.UserStatusDormant, + }, + { + name: "Active to Suspended", + initialStatus: database.UserStatusActive, + targetStatus: database.UserStatusSuspended, + }, + // { + // name: "Active to Deleted", + // initialStatus: database.UserStatusActive, + // targetStatus: database.UserStatusDeleted, + // }, + { + name: "Dormant to Active", + initialStatus: database.UserStatusDormant, + targetStatus: database.UserStatusActive, + }, + { + name: "Dormant to Suspended", + initialStatus: database.UserStatusDormant, + targetStatus: database.UserStatusSuspended, + }, + // { + // name: "Dormant to Deleted", + // initialStatus: database.UserStatusDormant, + // targetStatus: database.UserStatusDeleted, + // }, + { + name: "Suspended to Active", + initialStatus: database.UserStatusSuspended, + targetStatus: database.UserStatusActive, + }, + { + name: "Suspended to Dormant", + initialStatus: database.UserStatusSuspended, + targetStatus: database.UserStatusDormant, + }, + // { + // name: "Suspended to Deleted", + // initialStatus: database.UserStatusSuspended, + // targetStatus: database.UserStatusDeleted, + // }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a user that starts with initial status + now := dbtime.Now() + createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago + user := dbgen.User(t, db, database.User{ + Status: tc.initialStatus, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // After 2 days, change status to target status + statusChangeTime := createdAt.Add(2 * 24 * time.Hour) + user, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user.ID, + Status: tc.targetStatus, + UpdatedAt: statusChangeTime, + }) + require.NoError(t, err) + + // Query for the last 5 days + counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + StartTime: createdAt, + EndTime: now, + }) + require.NoError(t, err) + require.NotEmpty(t, counts, "should return results") + + // We should have entries for each day + require.Len(t, counts, 6, "should have 6 days of data") + + // Helper to get count for a specific day and status + getCount := func(day time.Time, status database.UserStatus) int64 { + day = day.Truncate(24 * time.Hour) + for _, c := range counts { + if c.Day.Truncate(24*time.Hour).Equal(day) && c.Status.Valid && c.Status.UserStatus == status { + return c.Count + } + } + return 0 + } + + // First 2 days should show initial status + require.Equal(t, int64(1), getCount(createdAt, tc.initialStatus), "day 1 should be %s", tc.initialStatus) + require.Equal(t, int64(1), getCount(createdAt.Add(24*time.Hour), tc.initialStatus), "day 2 should be %s", tc.initialStatus) + + // Remaining days should show target status + for i := 2; i < 6; i++ { + dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) + require.Equal(t, int64(1), getCount(dayTime, tc.targetStatus), + "day %d should be %s", i+1, tc.targetStatus) + require.Equal(t, int64(0), getCount(dayTime, tc.initialStatus), + "day %d should have no %s users", i+1, tc.initialStatus) + } + }) + } + }) + + t.Run("Two Users Transitioning", func(t *testing.T) { + t.Parallel() + + type transition struct { + from database.UserStatus + to database.UserStatus + } + + type testCase struct { + name string + user1Transition transition + user2Transition transition + expectedCounts map[string]map[database.UserStatus]int64 + } + + testCases := []testCase{ + { + name: "Active->Dormant and Dormant->Suspended", + user1Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusDormant, + }, + user2Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusSuspended, + }, + expectedCounts: map[string]map[database.UserStatus]int64{ + "initial": { + database.UserStatusActive: 1, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + "between": { + database.UserStatusActive: 0, + database.UserStatusDormant: 2, + database.UserStatusSuspended: 0, + }, + "final": { + database.UserStatusActive: 0, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 1, + }, + }, + }, + { + name: "Suspended->Active and Active->Dormant", + user1Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusActive, + }, + user2Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusDormant, + }, + expectedCounts: map[string]map[database.UserStatus]int64{ + "initial": { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 1, + }, + "between": { + database.UserStatusActive: 2, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 0, + }, + "final": { + database.UserStatusActive: 1, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + }, + }, + { + name: "Dormant->Active and Suspended->Dormant", + user1Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusActive, + }, + user2Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusDormant, + }, + expectedCounts: map[string]map[database.UserStatus]int64{ + "initial": { + database.UserStatusActive: 0, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 1, + }, + "between": { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 1, + }, + "final": { + database.UserStatusActive: 1, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + }, + }, + { + name: "Active->Suspended and Suspended->Active", + user1Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusSuspended, + }, + user2Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusActive, + }, + expectedCounts: map[string]map[database.UserStatus]int64{ + "initial": { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 1, + }, + "between": { + database.UserStatusActive: 0, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 2, + }, + "final": { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 1, + }, + }, + }, + { + name: "Dormant->Suspended and Dormant->Active", + user1Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusSuspended, + }, + user2Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusActive, + }, + expectedCounts: map[string]map[database.UserStatus]int64{ + "initial": { + database.UserStatusActive: 0, + database.UserStatusDormant: 2, + database.UserStatusSuspended: 0, + }, + "between": { + database.UserStatusActive: 0, + database.UserStatusDormant: 1, + database.UserStatusSuspended: 1, + }, + "final": { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + database.UserStatusSuspended: 1, + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + now := dbtime.Now() + createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago + + user1 := dbgen.User(t, db, database.User{ + Status: tc.user1Transition.from, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + user2 := dbgen.User(t, db, database.User{ + Status: tc.user2Transition.from, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // First transition at 2 days + user1TransitionTime := createdAt.Add(2 * 24 * time.Hour) + user1, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user1.ID, + Status: tc.user1Transition.to, + UpdatedAt: user1TransitionTime, + }) + require.NoError(t, err) + + // Second transition at 4 days + user2TransitionTime := createdAt.Add(4 * 24 * time.Hour) + user2, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user2.ID, + Status: tc.user2Transition.to, + UpdatedAt: user2TransitionTime, + }) + require.NoError(t, err) + + // Query for the last 5 days + counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + StartTime: createdAt, + EndTime: now, + }) + require.NoError(t, err) + require.NotEmpty(t, counts) + + // Helper to get count for a specific day and status + getCount := func(day time.Time, status database.UserStatus) int64 { + day = day.Truncate(24 * time.Hour) + for _, c := range counts { + if c.Day.Truncate(24*time.Hour).Equal(day) && c.Status.Valid && c.Status.UserStatus == status { + return c.Count + } + } + return 0 + } + + // Check initial period (days 0-1) + for i := 0; i < 2; i++ { + dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) + for status, expectedCount := range tc.expectedCounts["initial"] { + require.Equal(t, expectedCount, getCount(dayTime, status), + "day %d should have %d %s users", i+1, expectedCount, status) + } + } + + // Check between transitions (days 2-3) + for i := 2; i < 4; i++ { + dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) + for status, expectedCount := range tc.expectedCounts["between"] { + require.Equal(t, expectedCount, getCount(dayTime, status), + "day %d should have %d %s users", i+1, expectedCount, status) + } + } + + // Check final period (days 4-5) + for i := 4; i < 6; i++ { + dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) + for status, expectedCount := range tc.expectedCounts["final"] { + require.Equal(t, expectedCount, getCount(dayTime, status), + "day %d should have %d %s users", i+1, expectedCount, status) + } + } + }) + } + }) +} + func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { t.Helper() require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0cb5da583c4ca..59accada4e3d2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3106,46 +3106,41 @@ WITH dates AS ( '1 day'::interval ) AS day ), -initial_statuses AS ( - -- Get the status of each user right before the start date - SELECT DISTINCT ON (user_id) - user_id, - new_status as status - FROM - user_status_changes - WHERE - changed_at < $1::timestamptz - ORDER BY - user_id, - changed_at DESC +all_users AS ( + -- Get all users who have had any status changes in or before the range + SELECT DISTINCT user_id + FROM user_status_changes + WHERE changed_at <= $2::timestamptz ), -relevant_changes AS ( - -- Get only the status changes within our date range - SELECT - date_trunc('day', changed_at)::date AS day, +initial_statuses AS ( + -- Get the status of each user right before each day + SELECT DISTINCT ON (user_id, day) user_id, + day, new_status as status FROM - user_status_changes - WHERE - changed_at >= $1::timestamptz - AND changed_at <= $2::timestamptz + all_users + CROSS JOIN + dates + LEFT JOIN LATERAL ( + SELECT new_status, changed_at + FROM user_status_changes + WHERE user_status_changes.user_id = all_users.user_id + AND changed_at < day + interval '1 day' + ORDER BY changed_at DESC + LIMIT 1 + ) changes ON true + WHERE changes.new_status IS NOT NULL ), daily_status AS ( - -- Combine initial statuses with changes SELECT d.day, - COALESCE(rc.status, i.status) as status, - COALESCE(rc.user_id, i.user_id) as user_id + i.status, + i.user_id FROM dates d - CROSS JOIN - initial_statuses i LEFT JOIN - relevant_changes rc - ON - rc.day = d.day - AND rc.user_id = i.user_id + initial_statuses i ON i.day = d.day ) SELECT day, @@ -3169,9 +3164,9 @@ type GetUserStatusCountsByDayParams struct { } type GetUserStatusCountsByDayRow struct { - Day time.Time `db:"day" json:"day"` - Status UserStatus `db:"status" json:"status"` - Count int64 `db:"count" json:"count"` + Day time.Time `db:"day" json:"day"` + Status NullUserStatus `db:"status" json:"status"` + Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetUserStatusCountsByDay(ctx context.Context, arg GetUserStatusCountsByDayParams) ([]GetUserStatusCountsByDayRow, error) { diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 248edd89fac23..1edb8666e8f1e 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -784,46 +784,41 @@ WITH dates AS ( '1 day'::interval ) AS day ), -initial_statuses AS ( - -- Get the status of each user right before the start date - SELECT DISTINCT ON (user_id) - user_id, - new_status as status - FROM - user_status_changes - WHERE - changed_at < @start_time::timestamptz - ORDER BY - user_id, - changed_at DESC +all_users AS ( + -- Get all users who have had any status changes in or before the range + SELECT DISTINCT user_id + FROM user_status_changes + WHERE changed_at <= @end_time::timestamptz ), -relevant_changes AS ( - -- Get only the status changes within our date range - SELECT - date_trunc('day', changed_at)::date AS day, +initial_statuses AS ( + -- Get the status of each user right before each day + SELECT DISTINCT ON (user_id, day) user_id, + day, new_status as status FROM - user_status_changes - WHERE - changed_at >= @start_time::timestamptz - AND changed_at <= @end_time::timestamptz + all_users + CROSS JOIN + dates + LEFT JOIN LATERAL ( + SELECT new_status, changed_at + FROM user_status_changes + WHERE user_status_changes.user_id = all_users.user_id + AND changed_at < day + interval '1 day' + ORDER BY changed_at DESC + LIMIT 1 + ) changes ON true + WHERE changes.new_status IS NOT NULL ), daily_status AS ( - -- Combine initial statuses with changes SELECT d.day, - COALESCE(rc.status, i.status) as status, - COALESCE(rc.user_id, i.user_id) as user_id + i.status, + i.user_id FROM dates d - CROSS JOIN - initial_statuses i LEFT JOIN - relevant_changes rc - ON - rc.day = d.day - AND rc.user_id = i.user_id + initial_statuses i ON i.day = d.day ) SELECT day, From d5d3021459fa571f8ce0818547fb211881af3b0d Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 17 Dec 2024 08:14:35 +0000 Subject: [PATCH 05/22] make gen --- coderd/database/dump.sql | 2 -- coderd/database/migrations/000279_user_status_changes.down.sql | 1 - 2 files changed, 3 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0cbf2301245d1..d3835897f84d3 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2116,8 +2116,6 @@ CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); -CREATE INDEX idx_user_status_changes_user_id ON user_status_changes USING btree (user_id); - CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); diff --git a/coderd/database/migrations/000279_user_status_changes.down.sql b/coderd/database/migrations/000279_user_status_changes.down.sql index b75acbcafca46..30514ef467ebe 100644 --- a/coderd/database/migrations/000279_user_status_changes.down.sql +++ b/coderd/database/migrations/000279_user_status_changes.down.sql @@ -6,7 +6,6 @@ DROP FUNCTION IF EXISTS record_user_status_change(); -- Drop the indexes DROP INDEX IF EXISTS idx_user_status_changes_changed_at; -DROP INDEX IF EXISTS idx_user_status_changes_user_id; -- Drop the table DROP TABLE IF EXISTS user_status_changes; From c6b50af3456098e6eea91de19faef2f17ba29e76 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 17 Dec 2024 11:37:35 +0000 Subject: [PATCH 06/22] fix dbauthz tests --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/dbauthz/dbauthz_test.go | 6 +++++ coderd/database/dbmem/dbmem.go | 36 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ffc1eb63d8dd0..c6853a7af969a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2414,7 +2414,7 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui } func (q *querier) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err } return q.db.GetUserStatusCountsByDay(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 961f5d535b280..7db81a6b02ede 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1490,6 +1490,12 @@ func (s *MethodTestSuite) TestUser() { rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, ) })) + s.Run("GetUserStatusCountsByDay", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.GetUserStatusCountsByDayParams{ + StartTime: time.Now().Add(-time.Hour * 24 * 30), + EndTime: time.Now(), + }).Asserts(rbac.ResourceUser, policy.ActionRead) + })) } func (s *MethodTestSuite) TestWorkspace() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index de7b418cb602d..627ca816ad4a5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -88,6 +88,7 @@ func New() database.Store { customRoles: make([]database.CustomRole, 0), locks: map[int64]struct{}{}, runtimeConfig: map[string]string{}, + userStatusChanges: make([]database.UserStatusChange, 0), }, } // Always start with a default org. Matching migration 198. @@ -256,6 +257,7 @@ type data struct { lastLicenseID int32 defaultProxyDisplayName string defaultProxyIconURL string + userStatusChanges []database.UserStatusChange } func tryPercentile(fs []float64, p float64) float64 { @@ -5673,7 +5675,21 @@ func (q *FakeQuerier) GetUserStatusCountsByDay(_ context.Context, arg database.G return nil, err } - panic("not implemented") + result := make([]database.GetUserStatusCountsByDayRow, 0) + for _, change := range q.userStatusChanges { + if change.ChangedAt.Before(arg.StartTime) || change.ChangedAt.After(arg.EndTime) { + continue + } + result = append(result, database.GetUserStatusCountsByDayRow{ + Status: database.NullUserStatus{ + UserStatus: change.NewStatus, + Valid: true, + }, + Count: 1, + }) + } + + return result, nil } func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { @@ -8008,6 +8024,12 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam sort.Slice(q.users, func(i, j int) bool { return q.users[i].CreatedAt.Before(q.users[j].CreatedAt) }) + + q.userStatusChanges = append(q.userStatusChanges, database.UserStatusChange{ + UserID: user.ID, + NewStatus: user.Status, + ChangedAt: user.UpdatedAt, + }) return user, nil } @@ -9044,12 +9066,18 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat Username: user.Username, LastSeenAt: user.LastSeenAt, }) + q.userStatusChanges = append(q.userStatusChanges, database.UserStatusChange{ + UserID: user.ID, + NewStatus: database.UserStatusDormant, + ChangedAt: params.UpdatedAt, + }) } } if len(updated) == 0 { return nil, sql.ErrNoRows } + return updated, nil } @@ -9850,6 +9878,12 @@ func (q *FakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUse user.Status = arg.Status user.UpdatedAt = arg.UpdatedAt q.users[index] = user + + q.userStatusChanges = append(q.userStatusChanges, database.UserStatusChange{ + UserID: user.ID, + NewStatus: user.Status, + ChangedAt: user.UpdatedAt, + }) return user, nil } return database.User{}, sql.ErrNoRows From b2fb3464b37df36075e23302837e7a477147e3de Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 23 Dec 2024 08:57:32 +0000 Subject: [PATCH 07/22] do the plumbing to get sql, api and frontend talking to one another --- coderd/apidoc/docs.go | 61 ++++++ coderd/apidoc/swagger.json | 57 ++++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmem/dbmem.go | 12 +- coderd/database/dbmetrics/querymetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 14 +- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 109 +++-------- coderd/database/queries.sql.go | 104 +++-------- coderd/database/queries/insights.sql | 67 +------ coderd/insights.go | 67 +++++++ codersdk/insights.go | 31 ++++ docs/reference/api/insights.md | 50 +++++ docs/reference/api/schemas.md | 44 +++++ site/src/api/api.ts | 13 ++ site/src/api/queries/insights.ts | 7 + site/src/api/queries/users.ts | 1 - site/src/api/typesGenerated.ts | 16 ++ .../ActiveUserChart.stories.tsx | 69 ++++++- .../ActiveUserChart/ActiveUserChart.tsx | 121 +++++++++--- .../GeneralSettingsPage.tsx | 7 +- .../GeneralSettingsPageView.stories.tsx | 174 +++++++++++------- .../GeneralSettingsPageView.tsx | 82 ++++----- .../TemplateInsightsPage.tsx | 12 +- 26 files changed, 740 insertions(+), 395 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 791b3c6f145e8..4ca5d46a83914 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1398,6 +1398,40 @@ const docTemplate = `{ } } }, + "/insights/user-status-counts-over-time": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Insights" + ], + "summary": "Get insights about user status counts over time", + "operationId": "get-insights-about-user-status-counts-over-time", + "parameters": [ + { + "type": "integer", + "description": "Time-zone offset (e.g. -2)", + "name": "tz_offset", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GetUserStatusChangesResponse" + } + } + } + } + }, "/integrations/jfrog/xray-scan": { "get": { "security": [ @@ -11115,6 +11149,20 @@ const docTemplate = `{ } } }, + "codersdk.GetUserStatusChangesResponse": { + "type": "object", + "properties": { + "status_counts": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserStatusChangeCount" + } + } + } + } + }, "codersdk.GetUsersResponse": { "type": "object", "properties": { @@ -14468,6 +14516,19 @@ const docTemplate = `{ "UserStatusSuspended" ] }, + "codersdk.UserStatusChangeCount": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 10 + }, + "date": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.ValidateUserPasswordRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index abd329103579e..cebe74446f9a5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1219,6 +1219,36 @@ } } }, + "/insights/user-status-counts-over-time": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Insights"], + "summary": "Get insights about user status counts over time", + "operationId": "get-insights-about-user-status-counts-over-time", + "parameters": [ + { + "type": "integer", + "description": "Time-zone offset (e.g. -2)", + "name": "tz_offset", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GetUserStatusChangesResponse" + } + } + } + } + }, "/integrations/jfrog/xray-scan": { "get": { "security": [ @@ -9970,6 +10000,20 @@ } } }, + "codersdk.GetUserStatusChangesResponse": { + "type": "object", + "properties": { + "status_counts": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserStatusChangeCount" + } + } + } + } + }, "codersdk.GetUsersResponse": { "type": "object", "properties": { @@ -13150,6 +13194,19 @@ "UserStatusSuspended" ] }, + "codersdk.UserStatusChangeCount": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 10 + }, + "date": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.ValidateUserPasswordRequest": { "type": "object", "required": ["password"], diff --git a/coderd/coderd.go b/coderd/coderd.go index fd8a10a44f140..c197c08fd5cd9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1281,6 +1281,7 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Get("/daus", api.deploymentDAUs) r.Get("/user-activity", api.insightsUserActivity) + r.Get("/user-status-counts-over-time", api.insightsUserStatusCountsOverTime) r.Get("/user-latency", api.insightsUserLatency) r.Get("/templates", api.insightsTemplates) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c6853a7af969a..cbae48e83c8ba 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2413,11 +2413,11 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } -func (q *querier) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { +func (q *querier) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err } - return q.db.GetUserStatusCountsByDay(ctx, arg) + return q.db.GetUserStatusChanges(ctx, arg) } func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 7db81a6b02ede..e1258a8eed087 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1490,8 +1490,8 @@ func (s *MethodTestSuite) TestUser() { rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, ) })) - s.Run("GetUserStatusCountsByDay", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetUserStatusCountsByDayParams{ + s.Run("GetUserStatusChanges", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.GetUserStatusChangesParams{ StartTime: time.Now().Add(-time.Hour * 24 * 30), EndTime: time.Now(), }).Asserts(rbac.ResourceUser, policy.ActionRead) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 627ca816ad4a5..798d16045161a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5666,7 +5666,7 @@ func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID u return out, nil } -func (q *FakeQuerier) GetUserStatusCountsByDay(_ context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { +func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5675,18 +5675,12 @@ func (q *FakeQuerier) GetUserStatusCountsByDay(_ context.Context, arg database.G return nil, err } - result := make([]database.GetUserStatusCountsByDayRow, 0) + result := make([]database.UserStatusChange, 0) for _, change := range q.userStatusChanges { if change.ChangedAt.Before(arg.StartTime) || change.ChangedAt.After(arg.EndTime) { continue } - result = append(result, database.GetUserStatusCountsByDayRow{ - Status: database.NullUserStatus{ - UserStatus: change.NewStatus, - Valid: true, - }, - Count: 1, - }) + result = append(result, change) } return result, nil diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9e15081fa9c36..989a18a8856f4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1337,10 +1337,10 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } -func (m queryMetricsStore) GetUserStatusCountsByDay(ctx context.Context, arg database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { +func (m queryMetricsStore) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { start := time.Now() - r0, r1 := m.s.GetUserStatusCountsByDay(ctx, arg) - m.queryLatencies.WithLabelValues("GetUserStatusCountsByDay").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.GetUserStatusChanges(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserStatusChanges").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ca59e5c50b0bd..a8cb8effc3214 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2811,19 +2811,19 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), arg0, arg1) } -// GetUserStatusCountsByDay mocks base method. -func (m *MockStore) GetUserStatusCountsByDay(arg0 context.Context, arg1 database.GetUserStatusCountsByDayParams) ([]database.GetUserStatusCountsByDayRow, error) { +// GetUserStatusChanges mocks base method. +func (m *MockStore) GetUserStatusChanges(arg0 context.Context, arg1 database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserStatusCountsByDay", arg0, arg1) - ret0, _ := ret[0].([]database.GetUserStatusCountsByDayRow) + ret := m.ctrl.Call(m, "GetUserStatusChanges", arg0, arg1) + ret0, _ := ret[0].([]database.UserStatusChange) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetUserStatusCountsByDay indicates an expected call of GetUserStatusCountsByDay. -func (mr *MockStoreMockRecorder) GetUserStatusCountsByDay(arg0, arg1 any) *gomock.Call { +// GetUserStatusChanges indicates an expected call of GetUserStatusChanges. +func (mr *MockStoreMockRecorder) GetUserStatusChanges(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusCountsByDay", reflect.TypeOf((*MockStore)(nil).GetUserStatusCountsByDay), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusChanges", reflect.TypeOf((*MockStore)(nil).GetUserStatusChanges), arg0, arg1) } // GetUserWorkspaceBuildParameters mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cf98d56ea5cfb..248fd7bec39a7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -285,7 +285,7 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) - GetUserStatusCountsByDay(ctx context.Context, arg GetUserStatusCountsByDayParams) ([]GetUserStatusCountsByDayRow, error) + GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]UserStatusChange, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 3da6499f3ec82..c724789df58ac 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2255,7 +2255,7 @@ func TestGroupRemovalTrigger(t *testing.T) { }, db2sdk.List(extraUserGroups, onlyGroupIDs)) } -func TestGetUserStatusCountsByDay(t *testing.T) { +func TestGetUserStatusChanges(t *testing.T) { t.Parallel() t.Run("No Users", func(t *testing.T) { @@ -2266,7 +2266,7 @@ func TestGetUserStatusCountsByDay(t *testing.T) { end := dbtime.Now() start := end.Add(-30 * 24 * time.Hour) - counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + counts, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ StartTime: start, EndTime: end, }) @@ -2316,22 +2316,16 @@ func TestGetUserStatusCountsByDay(t *testing.T) { }) // Query for the last 30 days - counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ StartTime: createdAt, EndTime: now, }) require.NoError(t, err) - require.NotEmpty(t, counts, "should return results") - - // We should have an entry for each day, all with 1 user in the specified status - require.Len(t, counts, 30, "should have 30 days of data") - for _, count := range counts { - if count.Status.Valid && count.Status.UserStatus == tc.status { - require.Equal(t, int64(1), count.Count, "should have 1 %s user", tc.status) - } else { - require.Equal(t, int64(0), count.Count, "should have 0 users for other statuses") - } - } + require.NotEmpty(t, userStatusChanges, "should return results") + + // We should have an entry for each status change + require.Len(t, userStatusChanges, 1, "should have 1 status change") + require.Equal(t, userStatusChanges[0].NewStatus, tc.status, "should have the correct status") }) } }) @@ -2417,39 +2411,17 @@ func TestGetUserStatusCountsByDay(t *testing.T) { require.NoError(t, err) // Query for the last 5 days - counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ StartTime: createdAt, EndTime: now, }) require.NoError(t, err) - require.NotEmpty(t, counts, "should return results") - - // We should have entries for each day - require.Len(t, counts, 6, "should have 6 days of data") - - // Helper to get count for a specific day and status - getCount := func(day time.Time, status database.UserStatus) int64 { - day = day.Truncate(24 * time.Hour) - for _, c := range counts { - if c.Day.Truncate(24*time.Hour).Equal(day) && c.Status.Valid && c.Status.UserStatus == status { - return c.Count - } - } - return 0 - } + require.NotEmpty(t, userStatusChanges, "should return results") - // First 2 days should show initial status - require.Equal(t, int64(1), getCount(createdAt, tc.initialStatus), "day 1 should be %s", tc.initialStatus) - require.Equal(t, int64(1), getCount(createdAt.Add(24*time.Hour), tc.initialStatus), "day 2 should be %s", tc.initialStatus) - - // Remaining days should show target status - for i := 2; i < 6; i++ { - dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) - require.Equal(t, int64(1), getCount(dayTime, tc.targetStatus), - "day %d should be %s", i+1, tc.targetStatus) - require.Equal(t, int64(0), getCount(dayTime, tc.initialStatus), - "day %d should have no %s users", i+1, tc.initialStatus) - } + // We should have an entry for each status change, including the initial status + require.Len(t, userStatusChanges, 2, "should have 2 status changes") + require.Equal(t, userStatusChanges[0].NewStatus, tc.initialStatus, "should have the initial status") + require.Equal(t, userStatusChanges[1].NewStatus, tc.targetStatus, "should have the target status") }) } }) @@ -2652,50 +2624,23 @@ func TestGetUserStatusCountsByDay(t *testing.T) { require.NoError(t, err) // Query for the last 5 days - counts, err := db.GetUserStatusCountsByDay(ctx, database.GetUserStatusCountsByDayParams{ + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ StartTime: createdAt, EndTime: now, }) require.NoError(t, err) - require.NotEmpty(t, counts) - - // Helper to get count for a specific day and status - getCount := func(day time.Time, status database.UserStatus) int64 { - day = day.Truncate(24 * time.Hour) - for _, c := range counts { - if c.Day.Truncate(24*time.Hour).Equal(day) && c.Status.Valid && c.Status.UserStatus == status { - return c.Count - } - } - return 0 - } - - // Check initial period (days 0-1) - for i := 0; i < 2; i++ { - dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) - for status, expectedCount := range tc.expectedCounts["initial"] { - require.Equal(t, expectedCount, getCount(dayTime, status), - "day %d should have %d %s users", i+1, expectedCount, status) - } - } - - // Check between transitions (days 2-3) - for i := 2; i < 4; i++ { - dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) - for status, expectedCount := range tc.expectedCounts["between"] { - require.Equal(t, expectedCount, getCount(dayTime, status), - "day %d should have %d %s users", i+1, expectedCount, status) - } - } - - // Check final period (days 4-5) - for i := 4; i < 6; i++ { - dayTime := createdAt.Add(time.Duration(i) * 24 * time.Hour) - for status, expectedCount := range tc.expectedCounts["final"] { - require.Equal(t, expectedCount, getCount(dayTime, status), - "day %d should have %d %s users", i+1, expectedCount, status) - } - } + require.NotEmpty(t, userStatusChanges) + + // We should have an entry with the correct status changes for each user, including the initial status + require.Len(t, userStatusChanges, 4, "should have 4 status changes") + require.Equal(t, userStatusChanges[0].UserID, user1.ID, "should have the first user") + require.Equal(t, userStatusChanges[0].NewStatus, tc.user1Transition.from, "should have the first user's initial status") + require.Equal(t, userStatusChanges[1].UserID, user1.ID, "should have the first user") + require.Equal(t, userStatusChanges[1].NewStatus, tc.user1Transition.to, "should have the first user's target status") + require.Equal(t, userStatusChanges[2].UserID, user2.ID, "should have the second user") + require.Equal(t, userStatusChanges[2].NewStatus, tc.user2Transition.from, "should have the second user's initial status") + require.Equal(t, userStatusChanges[3].UserID, user2.ID, "should have the second user") + require.Equal(t, userStatusChanges[3].NewStatus, tc.user2Transition.to, "should have the second user's target status") }) } }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 59accada4e3d2..65f0ad5499361 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3094,91 +3094,35 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate return items, nil } -const getUserStatusCountsByDay = `-- name: GetUserStatusCountsByDay :many -WITH dates AS ( - -- Generate a series of dates between start and end - SELECT - day::date - FROM - generate_series( - date_trunc('day', $1::timestamptz), - date_trunc('day', $2::timestamptz), - '1 day'::interval - ) AS day -), -all_users AS ( - -- Get all users who have had any status changes in or before the range - SELECT DISTINCT user_id - FROM user_status_changes - WHERE changed_at <= $2::timestamptz -), -initial_statuses AS ( - -- Get the status of each user right before each day - SELECT DISTINCT ON (user_id, day) - user_id, - day, - new_status as status - FROM - all_users - CROSS JOIN - dates - LEFT JOIN LATERAL ( - SELECT new_status, changed_at - FROM user_status_changes - WHERE user_status_changes.user_id = all_users.user_id - AND changed_at < day + interval '1 day' - ORDER BY changed_at DESC - LIMIT 1 - ) changes ON true - WHERE changes.new_status IS NOT NULL -), -daily_status AS ( - SELECT - d.day, - i.status, - i.user_id - FROM - dates d - LEFT JOIN - initial_statuses i ON i.day = d.day -) +const GetUserStatusChanges = `-- name: GetUserStatusChanges :many SELECT - day, - status, - COUNT(*) AS count -FROM - daily_status -WHERE - status IS NOT NULL -GROUP BY - day, - status -ORDER BY - day ASC, - status ASC + id, user_id, new_status, changed_at +FROM user_status_changes +WHERE changed_at >= $1::timestamptz + AND changed_at < $2::timestamptz +ORDER BY changed_at ` -type GetUserStatusCountsByDayParams struct { +type GetUserStatusChangesParams struct { StartTime time.Time `db:"start_time" json:"start_time"` EndTime time.Time `db:"end_time" json:"end_time"` } -type GetUserStatusCountsByDayRow struct { - Day time.Time `db:"day" json:"day"` - Status NullUserStatus `db:"status" json:"status"` - Count int64 `db:"count" json:"count"` -} - -func (q *sqlQuerier) GetUserStatusCountsByDay(ctx context.Context, arg GetUserStatusCountsByDayParams) ([]GetUserStatusCountsByDayRow, error) { - rows, err := q.db.QueryContext(ctx, getUserStatusCountsByDay, arg.StartTime, arg.EndTime) +func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]UserStatusChange, error) { + rows, err := q.db.QueryContext(ctx, GetUserStatusChanges, arg.StartTime, arg.EndTime) if err != nil { return nil, err } defer rows.Close() - var items []GetUserStatusCountsByDayRow + var items []UserStatusChange for rows.Next() { - var i GetUserStatusCountsByDayRow - if err := rows.Scan(&i.Day, &i.Status, &i.Count); err != nil { + var i UserStatusChange + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.NewStatus, + &i.ChangedAt, + ); err != nil { return nil, err } items = append(items, i) @@ -3488,7 +3432,7 @@ func (q *sqlQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, } const upsertJFrogXrayScanByWorkspaceAndAgentID = `-- name: UpsertJFrogXrayScanByWorkspaceAndAgentID :exec -INSERT INTO +INSERT INTO jfrog_xray_scans ( agent_id, workspace_id, @@ -3497,7 +3441,7 @@ INSERT INTO medium, results_url ) -VALUES +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, workspace_id) DO UPDATE SET critical = $3, high = $4, medium = $5, results_url = $6 @@ -6372,7 +6316,7 @@ FROM provisioner_keys WHERE organization_id = $1 -AND +AND lower(name) = lower($2) ` @@ -6488,10 +6432,10 @@ WHERE AND -- exclude reserved built-in key id != '00000000-0000-0000-0000-000000000001'::uuid -AND +AND -- exclude reserved user-auth key id != '00000000-0000-0000-0000-000000000002'::uuid -AND +AND -- exclude reserved psk key id != '00000000-0000-0000-0000-000000000003'::uuid ` @@ -8177,7 +8121,7 @@ func (q *sqlQuerier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUI } const updateTailnetPeerStatusByCoordinator = `-- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -12748,7 +12692,7 @@ WITH agent_stats AS ( coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 FROM workspace_agent_stats -- The greater than 0 is to support legacy agents that don't report connection_median_latency_ms. - WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 + WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 GROUP BY user_id, agent_id, workspace_id, template_id ), latest_agent_stats AS ( SELECT diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 1edb8666e8f1e..dfa236dfbd6d4 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -772,65 +772,10 @@ FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; --- name: GetUserStatusCountsByDay :many -WITH dates AS ( - -- Generate a series of dates between start and end - SELECT - day::date - FROM - generate_series( - date_trunc('day', @start_time::timestamptz), - date_trunc('day', @end_time::timestamptz), - '1 day'::interval - ) AS day -), -all_users AS ( - -- Get all users who have had any status changes in or before the range - SELECT DISTINCT user_id - FROM user_status_changes - WHERE changed_at <= @end_time::timestamptz -), -initial_statuses AS ( - -- Get the status of each user right before each day - SELECT DISTINCT ON (user_id, day) - user_id, - day, - new_status as status - FROM - all_users - CROSS JOIN - dates - LEFT JOIN LATERAL ( - SELECT new_status, changed_at - FROM user_status_changes - WHERE user_status_changes.user_id = all_users.user_id - AND changed_at < day + interval '1 day' - ORDER BY changed_at DESC - LIMIT 1 - ) changes ON true - WHERE changes.new_status IS NOT NULL -), -daily_status AS ( - SELECT - d.day, - i.status, - i.user_id - FROM - dates d - LEFT JOIN - initial_statuses i ON i.day = d.day -) +-- name: GetUserStatusChanges :many SELECT - day, - status, - COUNT(*) AS count -FROM - daily_status -WHERE - status IS NOT NULL -GROUP BY - day, - status -ORDER BY - day ASC, - status ASC; + * +FROM user_status_changes +WHERE changed_at >= @start_time::timestamptz + AND changed_at < @end_time::timestamptz +ORDER BY changed_at; diff --git a/coderd/insights.go b/coderd/insights.go index 7234a88d44fe9..2f925429708ef 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -292,6 +292,73 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +// @Summary Get insights about user status counts over time +// @ID get-insights-about-user-status-counts-over-time +// @Security CoderSessionToken +// @Produce json +// @Tags Insights +// @Param tz_offset query int true "Time-zone offset (e.g. -2)" +// @Success 200 {object} codersdk.GetUserStatusChangesResponse +// @Router /insights/user-status-counts-over-time [get] +func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() + tzOffset := p.Int(vals, 0, "tz_offset") + p.ErrorExcessParams(vals) + + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + loc := time.FixedZone("", tzOffset*3600) + // If the time is 14:01 or 14:31, we still want to include all the + // data between 14:00 and 15:00. Our rollups buckets are 30 minutes + // so this works nicely. It works just as well for 23:59 as well. + nextHourInLoc := time.Now().In(loc).Truncate(time.Hour).Add(time.Hour) + // Always return 60 days of data (2 months). + sixtyDaysAgo := nextHourInLoc.In(loc).Truncate(24*time.Hour).AddDate(0, 0, -60) + + rows, err := api.Database.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: sixtyDaysAgo, + EndTime: nextHourInLoc, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user status counts over time.", + Detail: err.Error(), + }) + return + } + + resp := codersdk.GetUserStatusChangesResponse{ + StatusCounts: make(map[codersdk.UserStatus][]codersdk.UserStatusChangeCount), + } + + slices.SortFunc(rows, func(a, b database.UserStatusChange) int { + return a.ChangedAt.Compare(b.ChangedAt) + }) + + for _, row := range rows { + date := row.ChangedAt.Truncate(24 * time.Hour) + status := codersdk.UserStatus(row.NewStatus) + if _, ok := resp.StatusCounts[status]; !ok { + resp.StatusCounts[status] = make([]codersdk.UserStatusChangeCount, 0) + } + resp.StatusCounts[status] = append(resp.StatusCounts[status], codersdk.UserStatusChangeCount{ + Date: date, + Count: 1, + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + // @Summary Get insights about templates // @ID get-insights-about-templates // @Security CoderSessionToken diff --git a/codersdk/insights.go b/codersdk/insights.go index c9e708de8f34a..b3a3cc728378a 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -282,3 +282,34 @@ func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsReque var result TemplateInsightsResponse return result, json.NewDecoder(resp.Body).Decode(&result) } + +type GetUserStatusChangesResponse struct { + StatusCounts map[UserStatus][]UserStatusChangeCount `json:"status_counts"` +} + +type UserStatusChangeCount struct { + Date time.Time `json:"date" format:"date-time"` + Count int64 `json:"count" example:"10"` +} + +type GetUserStatusChangesRequest struct { + Offset time.Time `json:"offset" format:"date-time"` +} + +func (c *Client) GetUserStatusChanges(ctx context.Context, req GetUserStatusChangesRequest) (GetUserStatusChangesResponse, error) { + qp := url.Values{} + qp.Add("offset", req.Offset.Format(insightsTimeLayout)) + + reqURL := fmt.Sprintf("/api/v2/insights/user-status-counts-over-time?%s", qp.Encode()) + resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return GetUserStatusChangesResponse{}, xerrors.Errorf("make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return GetUserStatusChangesResponse{}, ReadBodyAsError(resp) + } + var result GetUserStatusChangesResponse + return result, json.NewDecoder(resp.Body).Decode(&result) +} diff --git a/docs/reference/api/insights.md b/docs/reference/api/insights.md index d9bb2327a9517..8d6f5640f9e60 100644 --- a/docs/reference/api/insights.md +++ b/docs/reference/api/insights.md @@ -244,3 +244,53 @@ curl -X GET http://coder-server:8080/api/v2/insights/user-latency?start_time=201 | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserLatencyInsightsResponse](schemas.md#codersdkuserlatencyinsightsresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get insights about user status counts over time + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/insights/user-status-counts-over-time?tz_offset=0 \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /insights/user-status-counts-over-time` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ----- | ------- | -------- | -------------------------- | +| `tz_offset` | query | integer | true | Time-zone offset (e.g. -2) | + +### Example responses + +> 200 Response + +```json +{ + "status_counts": { + "property1": [ + { + "count": 10, + "date": "2019-08-24T14:15:22Z" + } + ], + "property2": [ + { + "count": 10, + "date": "2019-08-24T14:15:22Z" + } + ] + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GetUserStatusChangesResponse](schemas.md#codersdkgetuserstatuschangesresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b124e7be93b26..8fa040f99fe75 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2882,6 +2882,34 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | ----- | ------ | -------- | ------------ | ----------- | | `key` | string | false | | | +## codersdk.GetUserStatusChangesResponse + +```json +{ + "status_counts": { + "property1": [ + { + "count": 10, + "date": "2019-08-24T14:15:22Z" + } + ], + "property2": [ + { + "count": 10, + "date": "2019-08-24T14:15:22Z" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `status_counts` | object | false | | | +| » `[any property]` | array of [codersdk.UserStatusChangeCount](#codersdkuserstatuschangecount) | false | | | + ## codersdk.GetUsersResponse ```json @@ -6482,6 +6510,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `dormant` | | `suspended` | +## codersdk.UserStatusChangeCount + +```json +{ + "count": 10, + "date": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | ------- | -------- | ------------ | ----------- | +| `count` | integer | false | | | +| `date` | string | false | | | + ## codersdk.ValidateUserPasswordRequest ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6b0e685b177eb..16c0506774295 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2097,6 +2097,19 @@ class ApiMethods { return response.data; }; + getInsightsUserStatusCountsOverTime = async ( + offset = Math.trunc(new Date().getTimezoneOffset() / 60), + ): Promise => { + const searchParams = new URLSearchParams({ + offset: offset.toString(), + }); + const response = await this.axios.get( + `/api/v2/insights/user-status-counts-over-time?${searchParams}`, + ); + + return response.data; + }; + getHealth = async (force = false) => { const params = new URLSearchParams({ force: force.toString() }); const response = await this.axios.get( diff --git a/site/src/api/queries/insights.ts b/site/src/api/queries/insights.ts index a7044a2f2469f..8f56b5982cd84 100644 --- a/site/src/api/queries/insights.ts +++ b/site/src/api/queries/insights.ts @@ -20,3 +20,10 @@ export const insightsUserActivity = (params: InsightsParams) => { queryFn: () => API.getInsightsUserActivity(params), }; }; + +export const userStatusCountsOverTime = () => { + return { + queryKey: ["userStatusCountsOverTime"], + queryFn: () => API.getInsightsUserStatusCountsOverTime(), + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 77d879abe3258..833d88e6baeef 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -9,7 +9,6 @@ import type { UpdateUserProfileRequest, User, UsersRequest, - ValidateUserPasswordRequest, } from "api/typesGenerated"; import { type MetadataState, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 35bcdeb052c9d..ad6e3c034ecee 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -890,6 +890,16 @@ export interface GenerateAPIKeyResponse { readonly key: string; } +// From codersdk/insights.go +export interface GetUserStatusChangesRequest { + readonly offset: string; +} + +// From codersdk/insights.go +export interface GetUserStatusChangesResponse { + readonly status_counts: Record; +} + // From codersdk/users.go export interface GetUsersResponse { readonly users: readonly User[]; @@ -2638,6 +2648,12 @@ export interface UserRoles { // From codersdk/users.go export type UserStatus = "active" | "dormant" | "suspended"; +// From codersdk/insights.go +export interface UserStatusChangeCount { + readonly date: string; + readonly count: number; +} + export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; // From codersdk/users.go diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx index 4f28d7243a0bf..b77886b63fd2a 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx @@ -5,14 +5,19 @@ const meta: Meta = { title: "components/ActiveUserChart", component: ActiveUserChart, args: { - data: [ - { date: "1/1/2024", amount: 5 }, - { date: "1/2/2024", amount: 6 }, - { date: "1/3/2024", amount: 7 }, - { date: "1/4/2024", amount: 8 }, - { date: "1/5/2024", amount: 9 }, - { date: "1/6/2024", amount: 10 }, - { date: "1/7/2024", amount: 11 }, + series: [ + { + label: "Daily", + data: [ + { date: "1/1/2024", amount: 5 }, + { date: "1/2/2024", amount: 6 }, + { date: "1/3/2024", amount: 7 }, + { date: "1/4/2024", amount: 8 }, + { date: "1/5/2024", amount: 9 }, + { date: "1/6/2024", amount: 10 }, + { date: "1/7/2024", amount: 11 }, + ], + }, ], interval: "day", }, @@ -22,3 +27,51 @@ export default meta; type Story = StoryObj; export const Example: Story = {}; + +export const MultipleSeries: Story = { + args: { + series: [ + { + label: "Active", + data: [ + { date: "1/1/2024", amount: 150 }, + { date: "1/2/2024", amount: 165 }, + { date: "1/3/2024", amount: 180 }, + { date: "1/4/2024", amount: 155 }, + { date: "1/5/2024", amount: 190 }, + { date: "1/6/2024", amount: 200 }, + { date: "1/7/2024", amount: 210 }, + ], + color: "green", + }, + { + label: "Dormant", + data: [ + { date: "1/1/2024", amount: 80 }, + { date: "1/2/2024", amount: 82 }, + { date: "1/3/2024", amount: 85 }, + { date: "1/4/2024", amount: 88 }, + { date: "1/5/2024", amount: 90 }, + { date: "1/6/2024", amount: 92 }, + { date: "1/7/2024", amount: 95 }, + ], + color: "grey", + }, + { + label: "Suspended", + data: [ + { date: "1/1/2024", amount: 20 }, + { date: "1/2/2024", amount: 22 }, + { date: "1/3/2024", amount: 25 }, + { date: "1/4/2024", amount: 23 }, + { date: "1/5/2024", amount: 28 }, + { date: "1/6/2024", amount: 30 }, + { date: "1/7/2024", amount: 32 }, + ], + color: "red", + }, + ], + interval: "day", + userLimit: 100, + }, +}; diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx index 41345ea8f03f8..10acb6ec9fc90 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -1,5 +1,7 @@ import "chartjs-adapter-date-fns"; import { useTheme } from "@emotion/react"; +import LaunchOutlined from "@mui/icons-material/LaunchOutlined"; +import Button from "@mui/material/Button"; import { CategoryScale, Chart as ChartJS, @@ -14,6 +16,7 @@ import { Tooltip, defaults, } from "chart.js"; +import annotationPlugin from "chartjs-plugin-annotation"; import { HelpTooltip, HelpTooltipContent, @@ -35,32 +38,51 @@ ChartJS.register( Title, Tooltip, Legend, + annotationPlugin, ); -export interface ActiveUserChartProps { +export interface DataSeries { + label?: string; data: readonly { date: string; amount: number }[]; + color?: string; // Optional custom color +} + +export interface ActiveUserChartProps { + series: DataSeries[]; + userLimit?: number; interval: "day" | "week"; } export const ActiveUserChart: FC = ({ - data, + series, + userLimit, interval, }) => { const theme = useTheme(); - const labels = data.map((val) => dayjs(val.date).format("YYYY-MM-DD")); - const chartData = data.map((val) => val.amount); - defaults.font.family = theme.typography.fontFamily as string; defaults.color = theme.palette.text.secondary; const options: ChartOptions<"line"> = { responsive: true, animation: false, + interaction: { + mode: "index", + }, plugins: { - legend: { - display: false, - }, + legend: + series.length > 1 + ? { + display: false, + position: "top" as const, + labels: { + usePointStyle: true, + pointStyle: "line", + }, + } + : { + display: false, + }, tooltip: { displayColors: false, callbacks: { @@ -70,6 +92,24 @@ export const ActiveUserChart: FC = ({ }, }, }, + annotation: { + annotations: [ + { + type: "line", + scaleID: "y", + value: userLimit, + borderColor: "white", + borderWidth: 2, + label: { + content: "Active User limit", + color: theme.palette.primary.contrastText, + display: true, + textStrokeWidth: 2, + textStrokeColor: theme.palette.background.paper, + }, + }, + ], + }, }, scales: { y: { @@ -78,11 +118,12 @@ export const ActiveUserChart: FC = ({ ticks: { precision: 0, }, + stacked: true, }, x: { grid: { color: theme.palette.divider }, ticks: { - stepSize: data.length > 10 ? 2 : undefined, + stepSize: series[0].data.length > 10 ? 2 : undefined, }, type: "time", time: { @@ -97,16 +138,16 @@ export const ActiveUserChart: FC = ({ + dayjs(val.date).format("YYYY-MM-DD"), + ), + datasets: series.map((s) => ({ + label: s.label, + data: s.data.map((val) => val.amount), + pointBackgroundColor: s.color || theme.roles.active.outline, + pointBorderColor: s.color || theme.roles.active.outline, + borderColor: s.color || theme.roles.active.outline, + })), }} options={options} /> @@ -120,11 +161,13 @@ type ActiveUsersTitleProps = { export const ActiveUsersTitle: FC = ({ interval }) => { return (
- {interval === "day" ? "Daily" : "Weekly"} Active Users + {interval === "day" ? "Daily" : "Weekly"} User Activity - How do we calculate active users? + + How do we calculate user activity? + When a connection is initiated to a user's workspace they are considered an active user. e.g. apps, web terminal, SSH. This is for @@ -136,3 +179,39 @@ export const ActiveUsersTitle: FC = ({ interval }) => {
); }; + +export type UserStatusTitleProps = { + interval: "day" | "week"; +}; + +export const UserStatusTitle: FC = ({ interval }) => { + return ( +
+ {interval === "day" ? "Daily" : "Weekly"} User Status + + + + What are user statuses? + + + Active users count towards your license consumption. Dormant or + suspended users do not. Any user who has logged into the coder + platform within the last 90 days is considered active. + + + + + +
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index 2b094cbf89b26..3de614a42ac39 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -1,6 +1,6 @@ import { deploymentDAUs } from "api/queries/deployment"; -import { entitlements } from "api/queries/entitlements"; import { availableExperiments, experiments } from "api/queries/experiments"; +import { userStatusCountsOverTime } from "api/queries/insights"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider"; import type { FC } from "react"; @@ -15,9 +15,8 @@ const GeneralSettingsPage: FC = () => { const safeExperimentsQuery = useQuery(availableExperiments()); const { metadata } = useEmbeddedMetadata(); - const entitlementsQuery = useQuery(entitlements(metadata.entitlements)); const enabledExperimentsQuery = useQuery(experiments(metadata.experiments)); - + const userStatusCountsOverTimeQuery = useQuery(userStatusCountsOverTime()); const safeExperiments = safeExperimentsQuery.data?.safe ?? []; const invalidExperiments = enabledExperimentsQuery.data?.filter((exp) => { @@ -33,9 +32,9 @@ const GeneralSettingsPage: FC = () => { deploymentOptions={deploymentConfig.options} deploymentDAUs={deploymentDAUsQuery.data} deploymentDAUsError={deploymentDAUsQuery.error} - entitlements={entitlementsQuery.data} invalidExperiments={invalidExperiments} safeExperiments={safeExperiments} + userStatusCountsOverTime={userStatusCountsOverTimeQuery.data} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 05ed426d5dcc9..78291ee03b4d8 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -1,9 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { - MockDeploymentDAUResponse, - MockEntitlementsWithUserLimit, - mockApiError, -} from "testHelpers/entities"; +import { MockDeploymentDAUResponse, mockApiError } from "testHelpers/entities"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; const meta: Meta = { @@ -42,7 +38,100 @@ const meta: Meta = { deploymentDAUs: MockDeploymentDAUResponse, invalidExperiments: [], safeExperiments: [], - entitlements: undefined, + userStatusCountsOverTime: { + status_counts: { + active: [ + { + date: "1/1/2024", + count: 1, + }, + { + date: "1/2/2024", + count: 8, + }, + { + date: "1/3/2024", + count: 8, + }, + { + date: "1/4/2024", + count: 6, + }, + { + date: "1/5/2024", + count: 6, + }, + { + date: "1/6/2024", + count: 6, + }, + { + date: "1/7/2024", + count: 6, + }, + ], + dormant: [ + { + date: "1/1/2024", + count: 0, + }, + { + date: "1/2/2024", + count: 3, + }, + { + date: "1/3/2024", + count: 3, + }, + { + date: "1/4/2024", + count: 3, + }, + { + date: "1/5/2024", + count: 3, + }, + { + date: "1/6/2024", + count: 3, + }, + { + date: "1/7/2024", + count: 3, + }, + ], + suspended: [ + { + date: "1/1/2024", + count: 0, + }, + { + date: "1/2/2024", + count: 0, + }, + { + date: "1/3/2024", + count: 0, + }, + { + date: "1/4/2024", + count: 2, + }, + { + date: "1/5/2024", + count: 2, + }, + { + date: "1/6/2024", + count: 2, + }, + { + date: "1/7/2024", + count: 2, + }, + ], + }, + }, }, }; @@ -138,73 +227,26 @@ export const invalidExperimentsEnabled: Story = { }, }; -export const WithLicenseUtilization: Story = { - args: { - entitlements: { - ...MockEntitlementsWithUserLimit, - features: { - ...MockEntitlementsWithUserLimit.features, - user_limit: { - ...MockEntitlementsWithUserLimit.features.user_limit, - enabled: true, - actual: 75, - limit: 100, - entitlement: "entitled", - }, - }, - }, - }, +export const UnlicensedInstallation: Story = { + args: {}, }; -export const HighLicenseUtilization: Story = { - args: { - entitlements: { - ...MockEntitlementsWithUserLimit, - features: { - ...MockEntitlementsWithUserLimit.features, - user_limit: { - ...MockEntitlementsWithUserLimit.features.user_limit, - enabled: true, - actual: 95, - limit: 100, - entitlement: "entitled", - }, - }, - }, - }, +export const LicensedWithNoUserLimit: Story = { + args: {}, }; -export const ExceedsLicenseUtilization: Story = { +export const LicensedWithPlentyOfSpareLicenses: Story = { args: { - entitlements: { - ...MockEntitlementsWithUserLimit, - features: { - ...MockEntitlementsWithUserLimit.features, - user_limit: { - ...MockEntitlementsWithUserLimit.features.user_limit, - enabled: true, - actual: 100, - limit: 95, - entitlement: "entitled", - }, - }, - }, + activeUserLimit: 100, }, }; -export const NoLicenseLimit: Story = { + +export const TotalUsersExceedsLicenseButNotActiveUsers: Story = { args: { - entitlements: { - ...MockEntitlementsWithUserLimit, - features: { - ...MockEntitlementsWithUserLimit.features, - user_limit: { - ...MockEntitlementsWithUserLimit.features.user_limit, - enabled: false, - actual: 0, - limit: 0, - entitlement: "entitled", - }, - }, - }, + activeUserLimit: 8, }, }; + +export const ManyUsers: Story = { + args: {}, +}; diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index df5550d70e965..bf663fecaa945 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,14 +1,15 @@ import AlertTitle from "@mui/material/AlertTitle"; -import LinearProgress from "@mui/material/LinearProgress"; import type { DAUsResponse, - Entitlements, Experiments, + GetUserStatusChangesResponse, SerpentOption, } from "api/typesGenerated"; import { ActiveUserChart, ActiveUsersTitle, + type DataSeries, + UserStatusTitle, } from "components/ActiveUserChart/ActiveUserChart"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; @@ -24,7 +25,8 @@ export type GeneralSettingsPageViewProps = { deploymentOptions: SerpentOption[]; deploymentDAUs?: DAUsResponse; deploymentDAUsError: unknown; - entitlements: Entitlements | undefined; + userStatusCountsOverTime?: GetUserStatusChangesResponse; + activeUserLimit?: number; readonly invalidExperiments: Experiments | string[]; readonly safeExperiments: Experiments | string[]; }; @@ -33,16 +35,29 @@ export const GeneralSettingsPageView: FC = ({ deploymentOptions, deploymentDAUs, deploymentDAUsError, - entitlements, + userStatusCountsOverTime, + activeUserLimit, safeExperiments, invalidExperiments, }) => { - const licenseUtilizationPercentage = - entitlements?.features?.user_limit?.actual && - entitlements?.features?.user_limit?.limit - ? entitlements.features.user_limit.actual / - entitlements.features.user_limit.limit - : undefined; + const colors: Record = { + active: "green", + dormant: "grey", + deleted: "red", + }; + let series: DataSeries[] = []; + if (userStatusCountsOverTime?.status_counts) { + series = Object.entries(userStatusCountsOverTime.status_counts).map( + ([status, counts]) => ({ + label: status, + data: counts.map((count) => ({ + date: count.date.toString(), + amount: count.count, + })), + color: colors[status], + }), + ); + } return ( <> = ({ {Boolean(deploymentDAUsError) && ( )} - {deploymentDAUs && ( -
- }> - - -
+ {series.length && ( + }> + + )} - {licenseUtilizationPercentage && ( - - }> + - - {Math.round(licenseUtilizationPercentage * 100)}% used ( - {entitlements!.features.user_limit.actual}/ - {entitlements!.features.user_limit.limit} users) - )} {invalidExperiments.length > 0 && ( diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index f205194a1aded..4a135d29f11c6 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -258,10 +258,14 @@ const ActiveUsersPanel: FC = ({ {data && data.length > 0 && ( ({ - amount: d.active_users, - date: d.start_time, - }))} + series={[ + { + data: data.map((d) => ({ + amount: d.active_users, + date: d.start_time, + })), + }, + ]} /> )} From ad42c163a433987c60f8bc5c5e9d252c194d2365 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 23 Dec 2024 09:57:56 +0000 Subject: [PATCH 08/22] rename migration --- ...tatus_changes.down.sql => 000280_user_status_changes.down.sql} | 0 ...er_status_changes.up.sql => 000280_user_status_changes.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000279_user_status_changes.down.sql => 000280_user_status_changes.down.sql} (100%) rename coderd/database/migrations/{000279_user_status_changes.up.sql => 000280_user_status_changes.up.sql} (100%) diff --git a/coderd/database/migrations/000279_user_status_changes.down.sql b/coderd/database/migrations/000280_user_status_changes.down.sql similarity index 100% rename from coderd/database/migrations/000279_user_status_changes.down.sql rename to coderd/database/migrations/000280_user_status_changes.down.sql diff --git a/coderd/database/migrations/000279_user_status_changes.up.sql b/coderd/database/migrations/000280_user_status_changes.up.sql similarity index 100% rename from coderd/database/migrations/000279_user_status_changes.up.sql rename to coderd/database/migrations/000280_user_status_changes.up.sql From ed8682054242ceca224fac5445936228cfbe2e8b Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 08:49:05 +0000 Subject: [PATCH 09/22] move aggregation logic for GetUserStatusChanges into the SQL --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/dbmem/dbmem.go | 21 ++++++- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 4 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 74 ++++++++++++++++------- coderd/database/queries/insights.sql | 37 ++++++++++-- coderd/insights.go | 9 +-- 8 files changed, 108 insertions(+), 43 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index cbae48e83c8ba..78d620bd82020 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2413,7 +2413,7 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } -func (q *querier) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { +func (q *querier) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 798d16045161a..3bff59e12d11e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5666,7 +5666,7 @@ func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID u return out, nil } -func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { +func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5675,12 +5675,27 @@ func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUs return nil, err } - result := make([]database.UserStatusChange, 0) + result := make([]database.GetUserStatusChangesRow, 0) for _, change := range q.userStatusChanges { if change.ChangedAt.Before(arg.StartTime) || change.ChangedAt.After(arg.EndTime) { continue } - result = append(result, change) + if !slices.ContainsFunc(result, func(r database.GetUserStatusChangesRow) bool { + return r.ChangedAt.Equal(change.ChangedAt) && r.NewStatus == change.NewStatus + }) { + result = append(result, database.GetUserStatusChangesRow{ + NewStatus: change.NewStatus, + ChangedAt: change.ChangedAt, + Count: 1, + }) + } else { + for i, r := range result { + if r.ChangedAt.Equal(change.ChangedAt) && r.NewStatus == change.NewStatus { + result[i].Count++ + break + } + } + } } return result, nil diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 989a18a8856f4..c1662346adf63 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1337,7 +1337,7 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } -func (m queryMetricsStore) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { +func (m queryMetricsStore) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { start := time.Now() r0, r1 := m.s.GetUserStatusChanges(ctx, arg) m.queryLatencies.WithLabelValues("GetUserStatusChanges").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index a8cb8effc3214..eedac4ced468f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2812,10 +2812,10 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) } // GetUserStatusChanges mocks base method. -func (m *MockStore) GetUserStatusChanges(arg0 context.Context, arg1 database.GetUserStatusChangesParams) ([]database.UserStatusChange, error) { +func (m *MockStore) GetUserStatusChanges(arg0 context.Context, arg1 database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUserStatusChanges", arg0, arg1) - ret0, _ := ret[0].([]database.UserStatusChange) + ret0, _ := ret[0].([]database.GetUserStatusChangesRow) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 248fd7bec39a7..373a5f28b66df 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -285,7 +285,7 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) - GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]UserStatusChange, error) + GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]GetUserStatusChangesRow, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 65f0ad5499361..bb494b8b5e462 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3094,13 +3094,40 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate return items, nil } -const GetUserStatusChanges = `-- name: GetUserStatusChanges :many +const getUserStatusChanges = `-- name: GetUserStatusChanges :many +WITH last_status_per_day AS ( + -- First get the last status change for each user for each day + SELECT DISTINCT ON (date_trunc('day', changed_at), user_id) + date_trunc('day', changed_at)::timestamptz AS date, + new_status, + user_id + FROM user_status_changes + WHERE changed_at >= $1::timestamptz + AND changed_at < $2::timestamptz + ORDER BY + date_trunc('day', changed_at), + user_id, + changed_at DESC -- This ensures we get the last status for each day +), +daily_counts AS ( + -- Then count unique users per status per day + SELECT + date, + new_status, + COUNT(*) AS count + FROM last_status_per_day + GROUP BY + date, + new_status +) SELECT - id, user_id, new_status, changed_at -FROM user_status_changes -WHERE changed_at >= $1::timestamptz - AND changed_at < $2::timestamptz -ORDER BY changed_at + date::timestamptz AS changed_at, + new_status, + count::bigint +FROM daily_counts +ORDER BY + new_status ASC, + date ASC ` type GetUserStatusChangesParams struct { @@ -3108,21 +3135,22 @@ type GetUserStatusChangesParams struct { EndTime time.Time `db:"end_time" json:"end_time"` } -func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]UserStatusChange, error) { - rows, err := q.db.QueryContext(ctx, GetUserStatusChanges, arg.StartTime, arg.EndTime) +type GetUserStatusChangesRow struct { + ChangedAt time.Time `db:"changed_at" json:"changed_at"` + NewStatus UserStatus `db:"new_status" json:"new_status"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]GetUserStatusChangesRow, error) { + rows, err := q.db.QueryContext(ctx, getUserStatusChanges, arg.StartTime, arg.EndTime) if err != nil { return nil, err } defer rows.Close() - var items []UserStatusChange + var items []GetUserStatusChangesRow for rows.Next() { - var i UserStatusChange - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.NewStatus, - &i.ChangedAt, - ); err != nil { + var i GetUserStatusChangesRow + if err := rows.Scan(&i.ChangedAt, &i.NewStatus, &i.Count); err != nil { return nil, err } items = append(items, i) @@ -3432,7 +3460,7 @@ func (q *sqlQuerier) GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, } const upsertJFrogXrayScanByWorkspaceAndAgentID = `-- name: UpsertJFrogXrayScanByWorkspaceAndAgentID :exec -INSERT INTO +INSERT INTO jfrog_xray_scans ( agent_id, workspace_id, @@ -3441,7 +3469,7 @@ INSERT INTO medium, results_url ) -VALUES +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, workspace_id) DO UPDATE SET critical = $3, high = $4, medium = $5, results_url = $6 @@ -6316,7 +6344,7 @@ FROM provisioner_keys WHERE organization_id = $1 -AND +AND lower(name) = lower($2) ` @@ -6432,10 +6460,10 @@ WHERE AND -- exclude reserved built-in key id != '00000000-0000-0000-0000-000000000001'::uuid -AND +AND -- exclude reserved user-auth key id != '00000000-0000-0000-0000-000000000002'::uuid -AND +AND -- exclude reserved psk key id != '00000000-0000-0000-0000-000000000003'::uuid ` @@ -8121,7 +8149,7 @@ func (q *sqlQuerier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUI } const updateTailnetPeerStatusByCoordinator = `-- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -12692,7 +12720,7 @@ WITH agent_stats AS ( coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 FROM workspace_agent_stats -- The greater than 0 is to support legacy agents that don't report connection_median_latency_ms. - WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 + WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 GROUP BY user_id, agent_id, workspace_id, template_id ), latest_agent_stats AS ( SELECT diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index dfa236dfbd6d4..513ff7eeec1ec 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -773,9 +773,36 @@ JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.wor GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; -- name: GetUserStatusChanges :many +WITH last_status_per_day AS ( + -- First get the last status change for each user for each day + SELECT DISTINCT ON (date_trunc('day', changed_at), user_id) + date_trunc('day', changed_at)::timestamptz AS date, + new_status, + user_id + FROM user_status_changes + WHERE changed_at >= @start_time::timestamptz + AND changed_at < @end_time::timestamptz + ORDER BY + date_trunc('day', changed_at), + user_id, + changed_at DESC -- This ensures we get the last status for each day +), +daily_counts AS ( + -- Then count unique users per status per day + SELECT + date, + new_status, + COUNT(*) AS count + FROM last_status_per_day + GROUP BY + date, + new_status +) SELECT - * -FROM user_status_changes -WHERE changed_at >= @start_time::timestamptz - AND changed_at < @end_time::timestamptz -ORDER BY changed_at; + date::timestamptz AS changed_at, + new_status, + count::bigint +FROM daily_counts +ORDER BY + new_status ASC, + date ASC; diff --git a/coderd/insights.go b/coderd/insights.go index 2f925429708ef..38e4ba9d44af9 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -340,19 +340,14 @@ func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http StatusCounts: make(map[codersdk.UserStatus][]codersdk.UserStatusChangeCount), } - slices.SortFunc(rows, func(a, b database.UserStatusChange) int { - return a.ChangedAt.Compare(b.ChangedAt) - }) - for _, row := range rows { - date := row.ChangedAt.Truncate(24 * time.Hour) status := codersdk.UserStatus(row.NewStatus) if _, ok := resp.StatusCounts[status]; !ok { resp.StatusCounts[status] = make([]codersdk.UserStatusChangeCount, 0) } resp.StatusCounts[status] = append(resp.StatusCounts[status], codersdk.UserStatusChangeCount{ - Date: date, - Count: 1, + Date: row.ChangedAt, + Count: row.Count, }) } From 0f17038b67da177e5d933a4222e02aa3f6fa390b Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 10:20:09 +0000 Subject: [PATCH 10/22] use window functions for efficiency --- coderd/database/querier_test.go | 57 ++++++++++++----------- coderd/database/queries.sql.go | 68 ++++++++++++++++++++-------- coderd/database/queries/insights.sql | 68 ++++++++++++++++++++-------- 3 files changed, 127 insertions(+), 66 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c724789df58ac..1c5d4778f6dcb 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2258,6 +2258,10 @@ func TestGroupRemovalTrigger(t *testing.T) { func TestGetUserStatusChanges(t *testing.T) { t.Parallel() + now := dbtime.Now() + createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago + firstTransitionTime := createdAt.Add(2 * 24 * time.Hour) // 3 days ago + secondTransitionTime := firstTransitionTime.Add(2 * 24 * time.Hour) // 1 days ago t.Run("No Users", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -2307,8 +2311,6 @@ func TestGetUserStatusChanges(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) // Create a user that's been in the specified status for the past 30 days - now := dbtime.Now() - createdAt := now.Add(-29 * 24 * time.Hour) dbgen.User(t, db, database.User{ Status: tc.status, CreatedAt: createdAt, @@ -2324,8 +2326,10 @@ func TestGetUserStatusChanges(t *testing.T) { require.NotEmpty(t, userStatusChanges, "should return results") // We should have an entry for each status change - require.Len(t, userStatusChanges, 1, "should have 1 status change") - require.Equal(t, userStatusChanges[0].NewStatus, tc.status, "should have the correct status") + require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") + for _, row := range userStatusChanges { + require.Equal(t, row.NewStatus, tc.status, "should have the correct status") + } }) } }) @@ -2393,8 +2397,6 @@ func TestGetUserStatusChanges(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) // Create a user that starts with initial status - now := dbtime.Now() - createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago user := dbgen.User(t, db, database.User{ Status: tc.initialStatus, CreatedAt: createdAt, @@ -2402,11 +2404,10 @@ func TestGetUserStatusChanges(t *testing.T) { }) // After 2 days, change status to target status - statusChangeTime := createdAt.Add(2 * 24 * time.Hour) user, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ ID: user.ID, Status: tc.targetStatus, - UpdatedAt: statusChangeTime, + UpdatedAt: firstTransitionTime, }) require.NoError(t, err) @@ -2418,10 +2419,15 @@ func TestGetUserStatusChanges(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, userStatusChanges, "should return results") - // We should have an entry for each status change, including the initial status - require.Len(t, userStatusChanges, 2, "should have 2 status changes") - require.Equal(t, userStatusChanges[0].NewStatus, tc.initialStatus, "should have the initial status") - require.Equal(t, userStatusChanges[1].NewStatus, tc.targetStatus, "should have the target status") + // We should have an entry for each status (active, dormant, suspended) for each day + require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") + for _, row := range userStatusChanges { + if row.ChangedAt.Before(firstTransitionTime) { + require.Equal(t, row.NewStatus, tc.initialStatus, "should have the initial status") + } else { + require.Equal(t, row.NewStatus, tc.targetStatus, "should have the target status") + } + } }) } }) @@ -2606,20 +2612,18 @@ func TestGetUserStatusChanges(t *testing.T) { }) // First transition at 2 days - user1TransitionTime := createdAt.Add(2 * 24 * time.Hour) user1, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ ID: user1.ID, Status: tc.user1Transition.to, - UpdatedAt: user1TransitionTime, + UpdatedAt: firstTransitionTime, }) require.NoError(t, err) // Second transition at 4 days - user2TransitionTime := createdAt.Add(4 * 24 * time.Hour) user2, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ ID: user2.ID, Status: tc.user2Transition.to, - UpdatedAt: user2TransitionTime, + UpdatedAt: secondTransitionTime, }) require.NoError(t, err) @@ -2631,16 +2635,17 @@ func TestGetUserStatusChanges(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, userStatusChanges) - // We should have an entry with the correct status changes for each user, including the initial status - require.Len(t, userStatusChanges, 4, "should have 4 status changes") - require.Equal(t, userStatusChanges[0].UserID, user1.ID, "should have the first user") - require.Equal(t, userStatusChanges[0].NewStatus, tc.user1Transition.from, "should have the first user's initial status") - require.Equal(t, userStatusChanges[1].UserID, user1.ID, "should have the first user") - require.Equal(t, userStatusChanges[1].NewStatus, tc.user1Transition.to, "should have the first user's target status") - require.Equal(t, userStatusChanges[2].UserID, user2.ID, "should have the second user") - require.Equal(t, userStatusChanges[2].NewStatus, tc.user2Transition.from, "should have the second user's initial status") - require.Equal(t, userStatusChanges[3].UserID, user2.ID, "should have the second user") - require.Equal(t, userStatusChanges[3].NewStatus, tc.user2Transition.to, "should have the second user's target status") + // Expected counts before, between and after the transitions should match: + for _, row := range userStatusChanges { + switch { + case row.ChangedAt.Before(firstTransitionTime): + require.Equal(t, row.Count, tc.expectedCounts["initial"][row.NewStatus], "should have the correct count before the first transition") + case row.ChangedAt.Before(secondTransitionTime): + require.Equal(t, row.Count, tc.expectedCounts["between"][row.NewStatus], "should have the correct count between the transitions") + case row.ChangedAt.Before(now): + require.Equal(t, row.Count, tc.expectedCounts["final"][row.NewStatus], "should have the correct count after the second transition") + } + } }) } }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index bb494b8b5e462..40c0d8d941803 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3095,36 +3095,64 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate } const getUserStatusChanges = `-- name: GetUserStatusChanges :many -WITH last_status_per_day AS ( - -- First get the last status change for each user for each day - SELECT DISTINCT ON (date_trunc('day', changed_at), user_id) - date_trunc('day', changed_at)::timestamptz AS date, +WITH dates AS ( + SELECT generate_series( + date_trunc('day', $1::timestamptz), + date_trunc('day', $2::timestamptz), + '1 day'::interval + )::timestamptz AS date +), +latest_status_before_range AS ( + -- Get the last status change for each user before our date range + SELECT DISTINCT ON (user_id) + user_id, new_status, - user_id + changed_at FROM user_status_changes - WHERE changed_at >= $1::timestamptz - AND changed_at < $2::timestamptz - ORDER BY - date_trunc('day', changed_at), + WHERE changed_at < (SELECT MIN(date) FROM dates) + ORDER BY user_id, changed_at DESC +), +all_status_changes AS ( + -- Combine status changes before and during our range + SELECT + user_id, + new_status, + changed_at + FROM latest_status_before_range + + UNION ALL + + SELECT user_id, - changed_at DESC -- This ensures we get the last status for each day + new_status, + changed_at + FROM user_status_changes + WHERE changed_at < $2::timestamptz ), daily_counts AS ( - -- Then count unique users per status per day SELECT - date, - new_status, - COUNT(*) AS count - FROM last_status_per_day - GROUP BY - date, - new_status + d.date, + asc1.new_status, + -- For each date and status, count users whose most recent status change + -- (up to that date) matches this status + COUNT(*) FILTER ( + WHERE asc1.changed_at = ( + SELECT MAX(changed_at) + FROM all_status_changes asc2 + WHERE asc2.user_id = asc1.user_id + AND asc2.changed_at <= d.date + ) + )::bigint AS count + FROM dates d + CROSS JOIN all_status_changes asc1 + GROUP BY d.date, asc1.new_status ) SELECT - date::timestamptz AS changed_at, + date AS changed_at, new_status, - count::bigint + count FROM daily_counts +WHERE count > 0 ORDER BY new_status ASC, date ASC diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 513ff7eeec1ec..25f46617efeba 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -773,36 +773,64 @@ JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.wor GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; -- name: GetUserStatusChanges :many -WITH last_status_per_day AS ( - -- First get the last status change for each user for each day - SELECT DISTINCT ON (date_trunc('day', changed_at), user_id) - date_trunc('day', changed_at)::timestamptz AS date, +WITH dates AS ( + SELECT generate_series( + date_trunc('day', @start_time::timestamptz), + date_trunc('day', @end_time::timestamptz), + '1 day'::interval + )::timestamptz AS date +), +latest_status_before_range AS ( + -- Get the last status change for each user before our date range + SELECT DISTINCT ON (user_id) + user_id, new_status, - user_id + changed_at FROM user_status_changes - WHERE changed_at >= @start_time::timestamptz - AND changed_at < @end_time::timestamptz - ORDER BY - date_trunc('day', changed_at), + WHERE changed_at < (SELECT MIN(date) FROM dates) + ORDER BY user_id, changed_at DESC +), +all_status_changes AS ( + -- Combine status changes before and during our range + SELECT + user_id, + new_status, + changed_at + FROM latest_status_before_range + + UNION ALL + + SELECT user_id, - changed_at DESC -- This ensures we get the last status for each day + new_status, + changed_at + FROM user_status_changes + WHERE changed_at < @end_time::timestamptz ), daily_counts AS ( - -- Then count unique users per status per day SELECT - date, - new_status, - COUNT(*) AS count - FROM last_status_per_day - GROUP BY - date, - new_status + d.date, + asc1.new_status, + -- For each date and status, count users whose most recent status change + -- (up to that date) matches this status + COUNT(*) FILTER ( + WHERE asc1.changed_at = ( + SELECT MAX(changed_at) + FROM all_status_changes asc2 + WHERE asc2.user_id = asc1.user_id + AND asc2.changed_at <= d.date + ) + )::bigint AS count + FROM dates d + CROSS JOIN all_status_changes asc1 + GROUP BY d.date, asc1.new_status ) SELECT - date::timestamptz AS changed_at, + date AS changed_at, new_status, - count::bigint + count FROM daily_counts +WHERE count > 0 ORDER BY new_status ASC, date ASC; From 1b3976db9dc9678440b0b448cbc172918b782fe9 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 10:27:40 +0000 Subject: [PATCH 11/22] ensure we use the same time zone as the start_time param --- coderd/database/queries/insights.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 25f46617efeba..18ea61de32773 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -774,11 +774,11 @@ GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.de -- name: GetUserStatusChanges :many WITH dates AS ( - SELECT generate_series( + SELECT (generate_series( date_trunc('day', @start_time::timestamptz), date_trunc('day', @end_time::timestamptz), '1 day'::interval - )::timestamptz AS date + ) AT TIME ZONE (extract(timezone FROM @start_time::timestamptz)::text || ' minutes'))::timestamptz AS date ), latest_status_before_range AS ( -- Get the last status change for each user before our date range From e2d0d15a75c9508f44d5b21a42c7dc09d5835335 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 10:37:38 +0000 Subject: [PATCH 12/22] ensure we use the same time zone as the start_time param --- coderd/database/queries.sql.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 40c0d8d941803..09ed1db3a14c0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3096,11 +3096,11 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate const getUserStatusChanges = `-- name: GetUserStatusChanges :many WITH dates AS ( - SELECT generate_series( + SELECT (generate_series( date_trunc('day', $1::timestamptz), date_trunc('day', $2::timestamptz), '1 day'::interval - )::timestamptz AS date + ) AT TIME ZONE (extract(timezone FROM $1::timestamptz)::text || ' minutes'))::timestamptz AS date ), latest_status_before_range AS ( -- Get the last status change for each user before our date range From aed413296f3d647f80e13c4b19f641902af3b644 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 14:29:32 +0000 Subject: [PATCH 13/22] make gen --- site/src/api/typesGenerated.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3b4df942ed28d..212995bc79de3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -777,12 +777,12 @@ export interface GenerateAPIKeyResponse { // From codersdk/insights.go export interface GetUserStatusChangesRequest { - readonly offset: string; + readonly offset: string; } // From codersdk/insights.go export interface GetUserStatusChangesResponse { - readonly status_counts: Record; + readonly status_counts: Record; } // From codersdk/users.go @@ -2334,8 +2334,8 @@ export type UserStatus = "active" | "dormant" | "suspended"; // From codersdk/insights.go export interface UserStatusChangeCount { - readonly date: string; - readonly count: number; + readonly date: string; + readonly count: number; } export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; From 9c658566aa58a273aa164fd8c861d1216f4700cc Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 24 Dec 2024 17:32:20 +0000 Subject: [PATCH 14/22] update field names and fix tests --- coderd/database/dbmem/dbmem.go | 11 ++++++----- coderd/database/querier_test.go | 20 ++++++++++---------- coderd/database/queries.sql.go | 18 +++++++++--------- coderd/database/queries/insights.sql | 10 +++++----- coderd/insights.go | 4 ++-- 5 files changed, 32 insertions(+), 31 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ca6a41e27266d..fa51ce181ace5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5680,17 +5680,18 @@ func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUs if change.ChangedAt.Before(arg.StartTime) || change.ChangedAt.After(arg.EndTime) { continue } + date := time.Date(change.ChangedAt.Year(), change.ChangedAt.Month(), change.ChangedAt.Day(), 0, 0, 0, 0, time.UTC) if !slices.ContainsFunc(result, func(r database.GetUserStatusChangesRow) bool { - return r.ChangedAt.Equal(change.ChangedAt) && r.NewStatus == change.NewStatus + return r.Status == change.NewStatus && r.Date.Equal(date) }) { result = append(result, database.GetUserStatusChangesRow{ - NewStatus: change.NewStatus, - ChangedAt: change.ChangedAt, - Count: 1, + Status: change.NewStatus, + Date: date, + Count: 1, }) } else { for i, r := range result { - if r.ChangedAt.Equal(change.ChangedAt) && r.NewStatus == change.NewStatus { + if r.Status == change.NewStatus && r.Date.Equal(date) { result[i].Count++ break } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 1c5d4778f6dcb..36ce5401cfc86 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2328,7 +2328,7 @@ func TestGetUserStatusChanges(t *testing.T) { // We should have an entry for each status change require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") for _, row := range userStatusChanges { - require.Equal(t, row.NewStatus, tc.status, "should have the correct status") + require.Equal(t, row.Status, tc.status, "should have the correct status") } }) } @@ -2422,10 +2422,10 @@ func TestGetUserStatusChanges(t *testing.T) { // We should have an entry for each status (active, dormant, suspended) for each day require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") for _, row := range userStatusChanges { - if row.ChangedAt.Before(firstTransitionTime) { - require.Equal(t, row.NewStatus, tc.initialStatus, "should have the initial status") + if row.Date.Before(firstTransitionTime) { + require.Equal(t, row.Status, tc.initialStatus, "should have the initial status") } else { - require.Equal(t, row.NewStatus, tc.targetStatus, "should have the target status") + require.Equal(t, row.Status, tc.targetStatus, "should have the target status") } } }) @@ -2638,12 +2638,12 @@ func TestGetUserStatusChanges(t *testing.T) { // Expected counts before, between and after the transitions should match: for _, row := range userStatusChanges { switch { - case row.ChangedAt.Before(firstTransitionTime): - require.Equal(t, row.Count, tc.expectedCounts["initial"][row.NewStatus], "should have the correct count before the first transition") - case row.ChangedAt.Before(secondTransitionTime): - require.Equal(t, row.Count, tc.expectedCounts["between"][row.NewStatus], "should have the correct count between the transitions") - case row.ChangedAt.Before(now): - require.Equal(t, row.Count, tc.expectedCounts["final"][row.NewStatus], "should have the correct count after the second transition") + case row.Date.Before(firstTransitionTime): + require.Equal(t, row.Count, tc.expectedCounts["initial"][row.Status], "should have the correct count before the first transition") + case row.Date.Before(secondTransitionTime): + require.Equal(t, row.Count, tc.expectedCounts["between"][row.Status], "should have the correct count between the transitions") + case row.Date.Before(now): + require.Equal(t, row.Count, tc.expectedCounts["final"][row.Status], "should have the correct count after the second transition") } } }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7ebe377b948b5..3efa91a78b1ff 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3096,11 +3096,11 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate const getUserStatusChanges = `-- name: GetUserStatusChanges :many WITH dates AS ( - SELECT (generate_series( + SELECT generate_series( date_trunc('day', $1::timestamptz), date_trunc('day', $2::timestamptz), '1 day'::interval - ) AT TIME ZONE (extract(timezone FROM $1::timestamptz)::text || ' minutes'))::timestamptz AS date + )::timestamptz AS date ), latest_status_before_range AS ( -- Get the last status change for each user before our date range @@ -3148,13 +3148,13 @@ daily_counts AS ( GROUP BY d.date, asc1.new_status ) SELECT - date AS changed_at, - new_status, + date::timestamptz AS date, + new_status AS status, count FROM daily_counts WHERE count > 0 ORDER BY - new_status ASC, + status ASC, date ASC ` @@ -3164,9 +3164,9 @@ type GetUserStatusChangesParams struct { } type GetUserStatusChangesRow struct { - ChangedAt time.Time `db:"changed_at" json:"changed_at"` - NewStatus UserStatus `db:"new_status" json:"new_status"` - Count int64 `db:"count" json:"count"` + Date time.Time `db:"date" json:"date"` + Status UserStatus `db:"status" json:"status"` + Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]GetUserStatusChangesRow, error) { @@ -3178,7 +3178,7 @@ func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatus var items []GetUserStatusChangesRow for rows.Next() { var i GetUserStatusChangesRow - if err := rows.Scan(&i.ChangedAt, &i.NewStatus, &i.Count); err != nil { + if err := rows.Scan(&i.Date, &i.Status, &i.Count); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 18ea61de32773..698acbfba6605 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -774,11 +774,11 @@ GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.de -- name: GetUserStatusChanges :many WITH dates AS ( - SELECT (generate_series( + SELECT generate_series( date_trunc('day', @start_time::timestamptz), date_trunc('day', @end_time::timestamptz), '1 day'::interval - ) AT TIME ZONE (extract(timezone FROM @start_time::timestamptz)::text || ' minutes'))::timestamptz AS date + )::timestamptz AS date ), latest_status_before_range AS ( -- Get the last status change for each user before our date range @@ -826,11 +826,11 @@ daily_counts AS ( GROUP BY d.date, asc1.new_status ) SELECT - date AS changed_at, - new_status, + date::timestamptz AS date, + new_status AS status, count FROM daily_counts WHERE count > 0 ORDER BY - new_status ASC, + status ASC, date ASC; diff --git a/coderd/insights.go b/coderd/insights.go index dfa914425f965..3cd9a364a2efe 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -341,12 +341,12 @@ func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http } for _, row := range rows { - status := codersdk.UserStatus(row.NewStatus) + status := codersdk.UserStatus(row.Status) if _, ok := resp.StatusCounts[status]; !ok { resp.StatusCounts[status] = make([]codersdk.UserStatusChangeCount, 0) } resp.StatusCounts[status] = append(resp.StatusCounts[status], codersdk.UserStatusChangeCount{ - Date: row.ChangedAt, + Date: row.Date, Count: row.Count, }) } From d6c5a4f169640aaab28aa251c5f3d76b7fa0539d Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 27 Dec 2024 12:17:23 +0000 Subject: [PATCH 15/22] exclude deleted users from the user status graph --- coderd/database/dump.sql | 27 +++++++++++++++++++ coderd/database/foreign_key_constraint.go | 1 + .../000280_user_status_changes.down.sql | 6 ++--- .../000280_user_status_changes.up.sql | 21 +++++++++++++++ coderd/database/models.go | 7 +++++ coderd/database/queries.sql.go | 8 +++++- coderd/database/queries/insights.sql | 8 +++++- coderd/database/unique_constraint.go | 1 + 8 files changed, 73 insertions(+), 6 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d3835897f84d3..98ffe2c3e1c3f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -429,6 +429,17 @@ BEGIN NEW.updated_at ); END IF; + + IF OLD.deleted = FALSE AND NEW.deleted = TRUE THEN + INSERT INTO user_deleted ( + user_id, + deleted_at + ) VALUES ( + NEW.id, + NEW.updated_at + ); + END IF; + RETURN NEW; END; $$; @@ -1387,6 +1398,14 @@ CREATE VIEW template_with_names AS COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; +CREATE TABLE user_deleted ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + deleted_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +COMMENT ON TABLE user_deleted IS 'Tracks when users were deleted'; + CREATE TABLE user_links ( user_id uuid NOT NULL, login_type login_type NOT NULL, @@ -1998,6 +2017,9 @@ ALTER TABLE ONLY template_versions ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_deleted + ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); @@ -2114,6 +2136,8 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id); CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); +CREATE INDEX idx_user_deleted_deleted_at ON user_deleted USING btree (deleted_at); + CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); @@ -2383,6 +2407,9 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_deleted + ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 18c82b83750fa..52f98a679a71b 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -47,6 +47,7 @@ const ( ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyUserDeletedUserID ForeignKeyConstraint = "user_deleted_user_id_fkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000280_user_status_changes.down.sql b/coderd/database/migrations/000280_user_status_changes.down.sql index 30514ef467ebe..fbe85a6be0fe5 100644 --- a/coderd/database/migrations/000280_user_status_changes.down.sql +++ b/coderd/database/migrations/000280_user_status_changes.down.sql @@ -1,11 +1,9 @@ --- Drop the trigger first DROP TRIGGER IF EXISTS user_status_change_trigger ON users; --- Drop the trigger function DROP FUNCTION IF EXISTS record_user_status_change(); --- Drop the indexes DROP INDEX IF EXISTS idx_user_status_changes_changed_at; +DROP INDEX IF EXISTS idx_user_deleted_deleted_at; --- Drop the table DROP TABLE IF EXISTS user_status_changes; +DROP TABLE IF EXISTS user_deleted; diff --git a/coderd/database/migrations/000280_user_status_changes.up.sql b/coderd/database/migrations/000280_user_status_changes.up.sql index f16164862b3e5..04d8472e55460 100644 --- a/coderd/database/migrations/000280_user_status_changes.up.sql +++ b/coderd/database/migrations/000280_user_status_changes.up.sql @@ -21,6 +21,16 @@ SELECT FROM users WHERE NOT deleted; +CREATE TABLE user_deleted ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + deleted_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE user_deleted IS 'Tracks when users were deleted'; + +CREATE INDEX idx_user_deleted_deleted_at ON user_deleted(deleted_at); + CREATE OR REPLACE FUNCTION record_user_status_change() RETURNS trigger AS $$ BEGIN IF TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status THEN @@ -34,6 +44,17 @@ BEGIN NEW.updated_at ); END IF; + + IF OLD.deleted = FALSE AND NEW.deleted = TRUE THEN + INSERT INTO user_deleted ( + user_id, + deleted_at + ) VALUES ( + NEW.id, + NEW.updated_at + ); + END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/coderd/database/models.go b/coderd/database/models.go index c38722ae1c208..ecdaa29d7ce3e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2883,6 +2883,13 @@ type User struct { OneTimePasscodeExpiresAt sql.NullTime `db:"one_time_passcode_expires_at" json:"one_time_passcode_expires_at"` } +// Tracks when users were deleted +type UserDeleted struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` +} + type UserLink struct { UserID uuid.UUID `db:"user_id" json:"user_id"` LoginType LoginType `db:"login_type" json:"login_type"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3efa91a78b1ff..cbcc8792e632f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3134,7 +3134,7 @@ daily_counts AS ( d.date, asc1.new_status, -- For each date and status, count users whose most recent status change - -- (up to that date) matches this status + -- (up to that date) matches this status AND who weren't deleted by that date COUNT(*) FILTER ( WHERE asc1.changed_at = ( SELECT MAX(changed_at) @@ -3142,6 +3142,12 @@ daily_counts AS ( WHERE asc2.user_id = asc1.user_id AND asc2.changed_at <= d.date ) + AND NOT EXISTS ( + SELECT 1 + FROM user_deleted ud + WHERE ud.user_id = asc1.user_id + AND ud.deleted_at <= d.date + ) )::bigint AS count FROM dates d CROSS JOIN all_status_changes asc1 diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 698acbfba6605..04e74bf7f166e 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -812,7 +812,7 @@ daily_counts AS ( d.date, asc1.new_status, -- For each date and status, count users whose most recent status change - -- (up to that date) matches this status + -- (up to that date) matches this status AND who weren't deleted by that date COUNT(*) FILTER ( WHERE asc1.changed_at = ( SELECT MAX(changed_at) @@ -820,6 +820,12 @@ daily_counts AS ( WHERE asc2.user_id = asc1.user_id AND asc2.changed_at <= d.date ) + AND NOT EXISTS ( + SELECT 1 + FROM user_deleted ud + WHERE ud.user_id = asc1.user_id + AND ud.deleted_at <= d.date + ) )::bigint AS count FROM dates d CROSS JOIN all_status_changes asc1 diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b59cb0cbc8091..f253aa98ec266 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -62,6 +62,7 @@ const ( UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); From fa66382836f476121d6de96b7535a8ca7cf7faa4 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 2 Jan 2025 17:18:42 +0000 Subject: [PATCH 16/22] GetUserStatusChanges now passes all querier tests --- Makefile | 8 +- coderd/database/querier_test.go | 803 ++++++++++++++++----------- coderd/database/queries.sql.go | 113 ++-- coderd/database/queries/insights.sql | 114 ++-- 4 files changed, 604 insertions(+), 434 deletions(-) diff --git a/Makefile b/Makefile index 9347eb15a2b14..acd948fd24891 100644 --- a/Makefile +++ b/Makefile @@ -500,6 +500,7 @@ lint/helm: # All files generated by the database should be added here, and this can be used # as a target for jobs that need to run after the database is generated. DB_GEN_FILES := \ + coderd/database/dump.sql \ coderd/database/querier.go \ coderd/database/unique_constraint.go \ coderd/database/dbmem/dbmem.go \ @@ -519,8 +520,6 @@ GEN_FILES := \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ vpn/vpn.pb.go \ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ site/src/api/typesGenerated.ts \ coderd/rbac/object_gen.go \ codersdk/rbacresources_gen.go \ @@ -540,9 +539,12 @@ GEN_FILES := \ coderd/database/pubsub/psmock/psmock.go # all gen targets should be added here and to gen/mark-fresh -gen: $(GEN_FILES) +gen: gen/db $(GEN_FILES) .PHONY: gen +gen/db: $(DB_GEN_FILES) +.PHONY: gen/db + # Mark all generated files as fresh so make thinks they're up-to-date. This is # used during releases so we don't run generation scripts. gen/mark-fresh: diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 36ce5401cfc86..6ace33f74a96c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/json" "fmt" + "maps" "sort" "testing" "time" @@ -2258,397 +2259,525 @@ func TestGroupRemovalTrigger(t *testing.T) { func TestGetUserStatusChanges(t *testing.T) { t.Parallel() - now := dbtime.Now() - createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago - firstTransitionTime := createdAt.Add(2 * 24 * time.Hour) // 3 days ago - secondTransitionTime := firstTransitionTime.Add(2 * 24 * time.Hour) // 1 days ago - t.Run("No Users", func(t *testing.T) { - t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - - end := dbtime.Now() - start := end.Add(-30 * 24 * time.Hour) + if !dbtestutil.WillUsePostgres() { + t.SkipNow() + } - counts, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ - StartTime: start, - EndTime: end, - }) - require.NoError(t, err) - require.Empty(t, counts, "should return no results when there are no users") - }) + timezones := []string{ + "Canada/Newfoundland", + "Africa/Johannesburg", + "America/New_York", + "Europe/London", + "Asia/Tokyo", + "Australia/Sydney", + } - t.Run("Single User/Single State", func(t *testing.T) { - t.Parallel() + for _, tz := range timezones { + tz := tz + t.Run(tz, func(t *testing.T) { + t.Parallel() - testCases := []struct { - name string - status database.UserStatus - }{ - { - name: "Active Only", - status: database.UserStatusActive, - }, - { - name: "Dormant Only", - status: database.UserStatusDormant, - }, - { - name: "Suspended Only", - status: database.UserStatusSuspended, - }, - // { - // name: "Deleted Only", - // status: database.UserStatusDeleted, - // }, - } + location, err := time.LoadLocation(tz) + if err != nil { + t.Fatalf("failed to load location: %v", err) + } + today := dbtime.Now().In(location) + createdAt := today.Add(-5 * 24 * time.Hour) + firstTransitionTime := createdAt.Add(2 * 24 * time.Hour) + secondTransitionTime := firstTransitionTime.Add(2 * 24 * time.Hour) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { + t.Run("No Users", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - // Create a user that's been in the specified status for the past 30 days - dbgen.User(t, db, database.User{ - Status: tc.status, - CreatedAt: createdAt, - UpdatedAt: createdAt, - }) + end := dbtime.Now() + start := end.Add(-30 * 24 * time.Hour) - // Query for the last 30 days - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ - StartTime: createdAt, - EndTime: now, + counts, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: start, + EndTime: end, }) require.NoError(t, err) - require.NotEmpty(t, userStatusChanges, "should return results") - - // We should have an entry for each status change - require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") - for _, row := range userStatusChanges { - require.Equal(t, row.Status, tc.status, "should have the correct status") - } + require.Empty(t, counts, "should return no results when there are no users") }) - } - }) - - t.Run("Single Transition", func(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - initialStatus database.UserStatus - targetStatus database.UserStatus - }{ - { - name: "Active to Dormant", - initialStatus: database.UserStatusActive, - targetStatus: database.UserStatusDormant, - }, - { - name: "Active to Suspended", - initialStatus: database.UserStatusActive, - targetStatus: database.UserStatusSuspended, - }, - // { - // name: "Active to Deleted", - // initialStatus: database.UserStatusActive, - // targetStatus: database.UserStatusDeleted, - // }, - { - name: "Dormant to Active", - initialStatus: database.UserStatusDormant, - targetStatus: database.UserStatusActive, - }, - { - name: "Dormant to Suspended", - initialStatus: database.UserStatusDormant, - targetStatus: database.UserStatusSuspended, - }, - // { - // name: "Dormant to Deleted", - // initialStatus: database.UserStatusDormant, - // targetStatus: database.UserStatusDeleted, - // }, - { - name: "Suspended to Active", - initialStatus: database.UserStatusSuspended, - targetStatus: database.UserStatusActive, - }, - { - name: "Suspended to Dormant", - initialStatus: database.UserStatusSuspended, - targetStatus: database.UserStatusDormant, - }, - // { - // name: "Suspended to Deleted", - // initialStatus: database.UserStatusSuspended, - // targetStatus: database.UserStatusDeleted, - // }, - } - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { + t.Run("One User/Creation Only", func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - // Create a user that starts with initial status - user := dbgen.User(t, db, database.User{ - Status: tc.initialStatus, - CreatedAt: createdAt, - UpdatedAt: createdAt, - }) - - // After 2 days, change status to target status - user, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ - ID: user.ID, - Status: tc.targetStatus, - UpdatedAt: firstTransitionTime, - }) - require.NoError(t, err) + testCases := []struct { + name string + status database.UserStatus + }{ + { + name: "Active Only", + status: database.UserStatusActive, + }, + { + name: "Dormant Only", + status: database.UserStatusDormant, + }, + { + name: "Suspended Only", + status: database.UserStatusSuspended, + }, + // { + // name: "Deleted Only", + // status: database.UserStatusDeleted, + // }, + } - // Query for the last 5 days - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ - StartTime: createdAt, - EndTime: now, - }) - require.NoError(t, err) - require.NotEmpty(t, userStatusChanges, "should return results") - - // We should have an entry for each status (active, dormant, suspended) for each day - require.Len(t, userStatusChanges, 5, "should have 1 user * 5 days = 5 rows") - for _, row := range userStatusChanges { - if row.Date.Before(firstTransitionTime) { - require.Equal(t, row.Status, tc.initialStatus, "should have the initial status") - } else { - require.Equal(t, row.Status, tc.targetStatus, "should have the target status") - } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a user that's been in the specified status for the past 30 days + dbgen.User(t, db, database.User{ + Status: tc.status, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // Query for the last 30 days + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: createdAt, + EndTime: today, + }) + require.NoError(t, err) + require.NotEmpty(t, userStatusChanges, "should return results") + + require.Len(t, userStatusChanges, 2, "should have 1 entry per status change plus and 1 entry for the end of the range = 2 entries") + + require.Equal(t, userStatusChanges[0].Status, tc.status, "should have the correct status") + require.Equal(t, userStatusChanges[0].Count, int64(1), "should have 1 user") + require.True(t, userStatusChanges[0].Date.Equal(createdAt), "should have the correct date") + + require.Equal(t, userStatusChanges[1].Status, tc.status, "should have the correct status") + require.Equal(t, userStatusChanges[1].Count, int64(1), "should have 1 user") + require.True(t, userStatusChanges[1].Date.Equal(today), "should have the correct date") + }) } }) - } - }) - t.Run("Two Users Transitioning", func(t *testing.T) { - t.Parallel() - - type transition struct { - from database.UserStatus - to database.UserStatus - } - - type testCase struct { - name string - user1Transition transition - user2Transition transition - expectedCounts map[string]map[database.UserStatus]int64 - } + t.Run("One User/One Transition", func(t *testing.T) { + t.Parallel() - testCases := []testCase{ - { - name: "Active->Dormant and Dormant->Suspended", - user1Transition: transition{ - from: database.UserStatusActive, - to: database.UserStatusDormant, - }, - user2Transition: transition{ - from: database.UserStatusDormant, - to: database.UserStatusSuspended, - }, - expectedCounts: map[string]map[database.UserStatus]int64{ - "initial": { - database.UserStatusActive: 1, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 0, - }, - "between": { - database.UserStatusActive: 0, - database.UserStatusDormant: 2, - database.UserStatusSuspended: 0, - }, - "final": { - database.UserStatusActive: 0, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 1, - }, - }, - }, - { - name: "Suspended->Active and Active->Dormant", - user1Transition: transition{ - from: database.UserStatusSuspended, - to: database.UserStatusActive, - }, - user2Transition: transition{ - from: database.UserStatusActive, - to: database.UserStatusDormant, - }, - expectedCounts: map[string]map[database.UserStatus]int64{ - "initial": { - database.UserStatusActive: 1, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 1, - }, - "between": { - database.UserStatusActive: 2, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 0, + testCases := []struct { + name string + initialStatus database.UserStatus + targetStatus database.UserStatus + expectedCounts map[time.Time]map[database.UserStatus]int64 + }{ + { + name: "Active to Dormant", + initialStatus: database.UserStatusActive, + targetStatus: database.UserStatusDormant, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + }, + firstTransitionTime: { + database.UserStatusDormant: 1, + database.UserStatusActive: 0, + }, + today: { + database.UserStatusDormant: 1, + database.UserStatusActive: 0, + }, + }, }, - "final": { - database.UserStatusActive: 1, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 0, + { + name: "Active to Suspended", + initialStatus: database.UserStatusActive, + targetStatus: database.UserStatusSuspended, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusActive: 1, + database.UserStatusSuspended: 0, + }, + firstTransitionTime: { + database.UserStatusSuspended: 1, + database.UserStatusActive: 0, + }, + today: { + database.UserStatusSuspended: 1, + database.UserStatusActive: 0, + }, + }, }, - }, - }, - { - name: "Dormant->Active and Suspended->Dormant", - user1Transition: transition{ - from: database.UserStatusDormant, - to: database.UserStatusActive, - }, - user2Transition: transition{ - from: database.UserStatusSuspended, - to: database.UserStatusDormant, - }, - expectedCounts: map[string]map[database.UserStatus]int64{ - "initial": { - database.UserStatusActive: 0, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 1, + { + name: "Dormant to Active", + initialStatus: database.UserStatusDormant, + targetStatus: database.UserStatusActive, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusDormant: 1, + database.UserStatusActive: 0, + }, + firstTransitionTime: { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + }, + today: { + database.UserStatusActive: 1, + database.UserStatusDormant: 0, + }, + }, }, - "between": { - database.UserStatusActive: 1, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 1, + { + name: "Dormant to Suspended", + initialStatus: database.UserStatusDormant, + targetStatus: database.UserStatusSuspended, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + firstTransitionTime: { + database.UserStatusSuspended: 1, + database.UserStatusDormant: 0, + }, + today: { + database.UserStatusSuspended: 1, + database.UserStatusDormant: 0, + }, + }, }, - "final": { - database.UserStatusActive: 1, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 0, + { + name: "Suspended to Active", + initialStatus: database.UserStatusSuspended, + targetStatus: database.UserStatusActive, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusSuspended: 1, + database.UserStatusActive: 0, + }, + firstTransitionTime: { + database.UserStatusActive: 1, + database.UserStatusSuspended: 0, + }, + today: { + database.UserStatusActive: 1, + database.UserStatusSuspended: 0, + }, + }, }, - }, - }, - { - name: "Active->Suspended and Suspended->Active", - user1Transition: transition{ - from: database.UserStatusActive, - to: database.UserStatusSuspended, - }, - user2Transition: transition{ - from: database.UserStatusSuspended, - to: database.UserStatusActive, - }, - expectedCounts: map[string]map[database.UserStatus]int64{ - "initial": { - database.UserStatusActive: 1, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 1, + { + name: "Suspended to Dormant", + initialStatus: database.UserStatusSuspended, + targetStatus: database.UserStatusDormant, + expectedCounts: map[time.Time]map[database.UserStatus]int64{ + createdAt: { + database.UserStatusSuspended: 1, + database.UserStatusDormant: 0, + }, + firstTransitionTime: { + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + today: { + database.UserStatusDormant: 1, + database.UserStatusSuspended: 0, + }, + }, }, - "between": { - database.UserStatusActive: 0, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 2, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a user that starts with initial status + user := dbgen.User(t, db, database.User{ + Status: tc.initialStatus, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // After 2 days, change status to target status + user, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user.ID, + Status: tc.targetStatus, + UpdatedAt: firstTransitionTime, + }) + require.NoError(t, err) + + // Query for the last 5 days + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: createdAt, + EndTime: today, + }) + require.NoError(t, err) + require.NotEmpty(t, userStatusChanges, "should return results") + + gotCounts := map[time.Time]map[database.UserStatus]int64{} + for _, row := range userStatusChanges { + gotDateInLocation := row.Date.In(location) + if _, ok := gotCounts[gotDateInLocation]; !ok { + gotCounts[gotDateInLocation] = map[database.UserStatus]int64{} + } + if _, ok := gotCounts[gotDateInLocation][row.Status]; !ok { + gotCounts[gotDateInLocation][row.Status] = 0 + } + gotCounts[gotDateInLocation][row.Status] += row.Count + } + require.Equal(t, tc.expectedCounts, gotCounts) + }) + } + }) + + t.Run("Two Users/One Transition", func(t *testing.T) { + t.Parallel() + + type transition struct { + from database.UserStatus + to database.UserStatus + } + + type testCase struct { + name string + user1Transition transition + user2Transition transition + } + + testCases := []testCase{ + { + name: "Active->Dormant and Dormant->Suspended", + user1Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusDormant, + }, + user2Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusSuspended, + }, }, - "final": { - database.UserStatusActive: 1, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 1, + { + name: "Suspended->Active and Active->Dormant", + user1Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusActive, + }, + user2Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusDormant, + }, }, - }, - }, - { - name: "Dormant->Suspended and Dormant->Active", - user1Transition: transition{ - from: database.UserStatusDormant, - to: database.UserStatusSuspended, - }, - user2Transition: transition{ - from: database.UserStatusDormant, - to: database.UserStatusActive, - }, - expectedCounts: map[string]map[database.UserStatus]int64{ - "initial": { - database.UserStatusActive: 0, - database.UserStatusDormant: 2, - database.UserStatusSuspended: 0, + { + name: "Dormant->Active and Suspended->Dormant", + user1Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusActive, + }, + user2Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusDormant, + }, }, - "between": { - database.UserStatusActive: 0, - database.UserStatusDormant: 1, - database.UserStatusSuspended: 1, + { + name: "Active->Suspended and Suspended->Active", + user1Transition: transition{ + from: database.UserStatusActive, + to: database.UserStatusSuspended, + }, + user2Transition: transition{ + from: database.UserStatusSuspended, + to: database.UserStatusActive, + }, }, - "final": { - database.UserStatusActive: 1, - database.UserStatusDormant: 0, - database.UserStatusSuspended: 1, + { + name: "Dormant->Suspended and Dormant->Active", + user1Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusSuspended, + }, + user2Transition: transition{ + from: database.UserStatusDormant, + to: database.UserStatusActive, + }, }, - }, - }, - } + } - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + user1 := dbgen.User(t, db, database.User{ + Status: tc.user1Transition.from, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + user2 := dbgen.User(t, db, database.User{ + Status: tc.user2Transition.from, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }) + + // First transition at 2 days + user1, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user1.ID, + Status: tc.user1Transition.to, + UpdatedAt: firstTransitionTime, + }) + require.NoError(t, err) + + // Second transition at 4 days + user2, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ + ID: user2.ID, + Status: tc.user2Transition.to, + UpdatedAt: secondTransitionTime, + }) + require.NoError(t, err) + + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: createdAt, + EndTime: today, + }) + require.NoError(t, err) + require.NotEmpty(t, userStatusChanges) + gotCounts := map[time.Time]map[database.UserStatus]int64{ + createdAt.In(location): {}, + firstTransitionTime.In(location): {}, + secondTransitionTime.In(location): {}, + today.In(location): {}, + } + for _, row := range userStatusChanges { + dateInLocation := row.Date.In(location) + switch { + case dateInLocation.Equal(createdAt.In(location)): + gotCounts[createdAt][row.Status] = row.Count + case dateInLocation.Equal(firstTransitionTime.In(location)): + gotCounts[firstTransitionTime][row.Status] = row.Count + case dateInLocation.Equal(secondTransitionTime.In(location)): + gotCounts[secondTransitionTime][row.Status] = row.Count + case dateInLocation.Equal(today.In(location)): + gotCounts[today][row.Status] = row.Count + default: + t.Fatalf("unexpected date %s", row.Date) + } + } + + expectedCounts := map[time.Time]map[database.UserStatus]int64{} + for _, status := range []database.UserStatus{ + tc.user1Transition.from, + tc.user1Transition.to, + tc.user2Transition.from, + tc.user2Transition.to, + } { + if _, ok := expectedCounts[createdAt]; !ok { + expectedCounts[createdAt] = map[database.UserStatus]int64{} + } + expectedCounts[createdAt][status] = 0 + } + + expectedCounts[createdAt][tc.user1Transition.from]++ + expectedCounts[createdAt][tc.user2Transition.from]++ + + expectedCounts[firstTransitionTime] = map[database.UserStatus]int64{} + maps.Copy(expectedCounts[firstTransitionTime], expectedCounts[createdAt]) + expectedCounts[firstTransitionTime][tc.user1Transition.from]-- + expectedCounts[firstTransitionTime][tc.user1Transition.to]++ + + expectedCounts[secondTransitionTime] = map[database.UserStatus]int64{} + maps.Copy(expectedCounts[secondTransitionTime], expectedCounts[firstTransitionTime]) + expectedCounts[secondTransitionTime][tc.user2Transition.from]-- + expectedCounts[secondTransitionTime][tc.user2Transition.to]++ + + expectedCounts[today] = map[database.UserStatus]int64{} + maps.Copy(expectedCounts[today], expectedCounts[secondTransitionTime]) + + require.Equal(t, expectedCounts[createdAt], gotCounts[createdAt]) + require.Equal(t, expectedCounts[firstTransitionTime], gotCounts[firstTransitionTime]) + require.Equal(t, expectedCounts[secondTransitionTime], gotCounts[secondTransitionTime]) + require.Equal(t, expectedCounts[today], gotCounts[today]) + }) + } + }) + + t.Run("User precedes and survives query range", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - now := dbtime.Now() - createdAt := now.Add(-5 * 24 * time.Hour) // 5 days ago - - user1 := dbgen.User(t, db, database.User{ - Status: tc.user1Transition.from, + _ = dbgen.User(t, db, database.User{ + Status: database.UserStatusActive, CreatedAt: createdAt, UpdatedAt: createdAt, }) - user2 := dbgen.User(t, db, database.User{ - Status: tc.user2Transition.from, + + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: createdAt.Add(time.Hour * 24), + EndTime: today, + }) + require.NoError(t, err) + + require.Len(t, userStatusChanges, 2) + require.Equal(t, userStatusChanges[0].Count, int64(1)) + require.Equal(t, userStatusChanges[0].Status, database.UserStatusActive) + require.Equal(t, userStatusChanges[1].Count, int64(1)) + require.Equal(t, userStatusChanges[1].Status, database.UserStatusActive) + }) + + t.Run("User deleted before query range", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + user := dbgen.User(t, db, database.User{ + Status: database.UserStatusActive, CreatedAt: createdAt, UpdatedAt: createdAt, }) - // First transition at 2 days - user1, err := db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ - ID: user1.ID, - Status: tc.user1Transition.to, - UpdatedAt: firstTransitionTime, + err = db.UpdateUserDeletedByID(ctx, user.ID) + require.NoError(t, err) + + userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + StartTime: today.Add(time.Hour * 24), + EndTime: today.Add(time.Hour * 48), }) require.NoError(t, err) + require.Empty(t, userStatusChanges) + }) + + t.Run("User deleted during query range", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) - // Second transition at 4 days - user2, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ - ID: user2.ID, - Status: tc.user2Transition.to, - UpdatedAt: secondTransitionTime, + user := dbgen.User(t, db, database.User{ + Status: database.UserStatusActive, + CreatedAt: createdAt, + UpdatedAt: createdAt, }) + + err := db.UpdateUserDeletedByID(ctx, user.ID) require.NoError(t, err) - // Query for the last 5 days userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ StartTime: createdAt, - EndTime: now, + EndTime: today.Add(time.Hour * 24), }) require.NoError(t, err) - require.NotEmpty(t, userStatusChanges) - - // Expected counts before, between and after the transitions should match: - for _, row := range userStatusChanges { - switch { - case row.Date.Before(firstTransitionTime): - require.Equal(t, row.Count, tc.expectedCounts["initial"][row.Status], "should have the correct count before the first transition") - case row.Date.Before(secondTransitionTime): - require.Equal(t, row.Count, tc.expectedCounts["between"][row.Status], "should have the correct count between the transitions") - case row.Date.Before(now): - require.Equal(t, row.Count, tc.expectedCounts["final"][row.Status], "should have the correct count after the second transition") - } - } + require.Equal(t, userStatusChanges[0].Count, int64(1)) + require.Equal(t, userStatusChanges[0].Status, database.UserStatusActive) + require.Equal(t, userStatusChanges[1].Count, int64(0)) + require.Equal(t, userStatusChanges[1].Status, database.UserStatusActive) + require.Equal(t, userStatusChanges[2].Count, int64(0)) + require.Equal(t, userStatusChanges[2].Status, database.UserStatusActive) }) - } - }) + }) + } } func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cbcc8792e632f..38a87874d7f18 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3096,24 +3096,49 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate const getUserStatusChanges = `-- name: GetUserStatusChanges :many WITH dates AS ( - SELECT generate_series( - date_trunc('day', $1::timestamptz), - date_trunc('day', $2::timestamptz), - '1 day'::interval - )::timestamptz AS date + SELECT $1::timestamptz AS date + + UNION + + SELECT DISTINCT changed_at AS date + FROM user_status_changes + WHERE changed_at > $1::timestamptz + AND changed_at < $2::timestamptz + + UNION + + SELECT DISTINCT deleted_at AS date + FROM user_deleted + WHERE deleted_at > $1::timestamptz + AND deleted_at < $2::timestamptz + + UNION + + SELECT $2::timestamptz AS date ), latest_status_before_range AS ( - -- Get the last status change for each user before our date range - SELECT DISTINCT ON (user_id) - user_id, - new_status, - changed_at - FROM user_status_changes - WHERE changed_at < (SELECT MIN(date) FROM dates) - ORDER BY user_id, changed_at DESC + SELECT + DISTINCT usc.user_id, + usc.new_status, + usc.changed_at + FROM user_status_changes usc + LEFT JOIN user_deleted ud ON usc.user_id = ud.user_id + WHERE usc.changed_at < $1::timestamptz + AND (ud.user_id IS NULL OR ud.deleted_at > $1::timestamptz) + ORDER BY usc.user_id, usc.changed_at DESC +), +status_changes_during_range AS ( + SELECT + usc.user_id, + usc.new_status, + usc.changed_at + FROM user_status_changes usc + LEFT JOIN user_deleted ud ON usc.user_id = ud.user_id + WHERE usc.changed_at >= $1::timestamptz + AND usc.changed_at <= $2::timestamptz + AND (ud.user_id IS NULL OR usc.changed_at < ud.deleted_at) ), all_status_changes AS ( - -- Combine status changes before and during our range SELECT user_id, new_status, @@ -3126,42 +3151,36 @@ all_status_changes AS ( user_id, new_status, changed_at - FROM user_status_changes - WHERE changed_at < $2::timestamptz + FROM status_changes_during_range ), -daily_counts AS ( - SELECT - d.date, - asc1.new_status, - -- For each date and status, count users whose most recent status change - -- (up to that date) matches this status AND who weren't deleted by that date - COUNT(*) FILTER ( - WHERE asc1.changed_at = ( - SELECT MAX(changed_at) - FROM all_status_changes asc2 - WHERE asc2.user_id = asc1.user_id - AND asc2.changed_at <= d.date - ) - AND NOT EXISTS ( - SELECT 1 - FROM user_deleted ud - WHERE ud.user_id = asc1.user_id - AND ud.deleted_at <= d.date - ) - )::bigint AS count - FROM dates d - CROSS JOIN all_status_changes asc1 - GROUP BY d.date, asc1.new_status +ranked_status_change_per_user_per_date AS ( + SELECT + d.date, + asc1.user_id, + ROW_NUMBER() OVER (PARTITION BY d.date, asc1.user_id ORDER BY asc1.changed_at DESC) AS rn, + asc1.new_status + FROM dates d + LEFT JOIN all_status_changes asc1 ON asc1.changed_at <= d.date ) SELECT - date::timestamptz AS date, - new_status AS status, - count -FROM daily_counts -WHERE count > 0 -ORDER BY - status ASC, - date ASC + date, + statuses.new_status AS status, + COUNT(rscpupd.user_id) FILTER ( + WHERE rscpupd.rn = 1 + AND ( + rscpupd.new_status = statuses.new_status + AND ( + -- Include users who haven't been deleted + NOT EXISTS (SELECT 1 FROM user_deleted WHERE user_id = rscpupd.user_id) + OR + -- Or users whose deletion date is after the current date we're looking at + rscpupd.date < (SELECT deleted_at FROM user_deleted WHERE user_id = rscpupd.user_id) + ) + ) + ) AS count +FROM ranked_status_change_per_user_per_date rscpupd +CROSS JOIN (select distinct new_status FROM all_status_changes) statuses +GROUP BY date, statuses.new_status ` type GetUserStatusChangesParams struct { diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 04e74bf7f166e..c721ebcc79227 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -774,24 +774,49 @@ GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.de -- name: GetUserStatusChanges :many WITH dates AS ( - SELECT generate_series( - date_trunc('day', @start_time::timestamptz), - date_trunc('day', @end_time::timestamptz), - '1 day'::interval - )::timestamptz AS date + SELECT @start_time::timestamptz AS date + + UNION + + SELECT DISTINCT changed_at AS date + FROM user_status_changes + WHERE changed_at > @start_time::timestamptz + AND changed_at < @end_time::timestamptz + + UNION + + SELECT DISTINCT deleted_at AS date + FROM user_deleted + WHERE deleted_at > @start_time::timestamptz + AND deleted_at < @end_time::timestamptz + + UNION + + SELECT @end_time::timestamptz AS date ), latest_status_before_range AS ( - -- Get the last status change for each user before our date range - SELECT DISTINCT ON (user_id) - user_id, - new_status, - changed_at - FROM user_status_changes - WHERE changed_at < (SELECT MIN(date) FROM dates) - ORDER BY user_id, changed_at DESC + SELECT + DISTINCT usc.user_id, + usc.new_status, + usc.changed_at + FROM user_status_changes usc + LEFT JOIN user_deleted ud ON usc.user_id = ud.user_id + WHERE usc.changed_at < @start_time::timestamptz + AND (ud.user_id IS NULL OR ud.deleted_at > @start_time::timestamptz) + ORDER BY usc.user_id, usc.changed_at DESC +), +status_changes_during_range AS ( + SELECT + usc.user_id, + usc.new_status, + usc.changed_at + FROM user_status_changes usc + LEFT JOIN user_deleted ud ON usc.user_id = ud.user_id + WHERE usc.changed_at >= @start_time::timestamptz + AND usc.changed_at <= @end_time::timestamptz + AND (ud.user_id IS NULL OR usc.changed_at < ud.deleted_at) ), all_status_changes AS ( - -- Combine status changes before and during our range SELECT user_id, new_status, @@ -804,39 +829,34 @@ all_status_changes AS ( user_id, new_status, changed_at - FROM user_status_changes - WHERE changed_at < @end_time::timestamptz + FROM status_changes_during_range ), -daily_counts AS ( - SELECT - d.date, - asc1.new_status, - -- For each date and status, count users whose most recent status change - -- (up to that date) matches this status AND who weren't deleted by that date - COUNT(*) FILTER ( - WHERE asc1.changed_at = ( - SELECT MAX(changed_at) - FROM all_status_changes asc2 - WHERE asc2.user_id = asc1.user_id - AND asc2.changed_at <= d.date - ) - AND NOT EXISTS ( - SELECT 1 - FROM user_deleted ud - WHERE ud.user_id = asc1.user_id - AND ud.deleted_at <= d.date - ) - )::bigint AS count - FROM dates d - CROSS JOIN all_status_changes asc1 - GROUP BY d.date, asc1.new_status +ranked_status_change_per_user_per_date AS ( + SELECT + d.date, + asc1.user_id, + ROW_NUMBER() OVER (PARTITION BY d.date, asc1.user_id ORDER BY asc1.changed_at DESC) AS rn, + asc1.new_status + FROM dates d + LEFT JOIN all_status_changes asc1 ON asc1.changed_at <= d.date ) SELECT - date::timestamptz AS date, - new_status AS status, - count -FROM daily_counts -WHERE count > 0 -ORDER BY - status ASC, - date ASC; + date, + statuses.new_status AS status, + COUNT(rscpupd.user_id) FILTER ( + WHERE rscpupd.rn = 1 + AND ( + rscpupd.new_status = statuses.new_status + AND ( + -- Include users who haven't been deleted + NOT EXISTS (SELECT 1 FROM user_deleted WHERE user_id = rscpupd.user_id) + OR + -- Or users whose deletion date is after the current date we're looking at + rscpupd.date < (SELECT deleted_at FROM user_deleted WHERE user_id = rscpupd.user_id) + ) + ) + ) AS count +FROM ranked_status_change_per_user_per_date rscpupd +CROSS JOIN (select distinct new_status FROM all_status_changes) statuses +GROUP BY date, statuses.new_status; + From a8c125ccbac53e9eb61ce12a64e8a04b19965e60 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 2 Jan 2025 17:31:57 +0000 Subject: [PATCH 17/22] renumber migrations --- ...tatus_changes.down.sql => 000281_user_status_changes.down.sql} | 0 ...er_status_changes.up.sql => 000281_user_status_changes.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000280_user_status_changes.down.sql => 000281_user_status_changes.down.sql} (100%) rename coderd/database/migrations/{000280_user_status_changes.up.sql => 000281_user_status_changes.up.sql} (100%) diff --git a/coderd/database/migrations/000280_user_status_changes.down.sql b/coderd/database/migrations/000281_user_status_changes.down.sql similarity index 100% rename from coderd/database/migrations/000280_user_status_changes.down.sql rename to coderd/database/migrations/000281_user_status_changes.down.sql diff --git a/coderd/database/migrations/000280_user_status_changes.up.sql b/coderd/database/migrations/000281_user_status_changes.up.sql similarity index 100% rename from coderd/database/migrations/000280_user_status_changes.up.sql rename to coderd/database/migrations/000281_user_status_changes.up.sql From 726bcba029f2af5e9fcde279eb5f8e137062aa0f Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 3 Jan 2025 06:11:55 +0000 Subject: [PATCH 18/22] add partial fixture for CI --- .../000281_user_status_changes.up.sql | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql b/coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql new file mode 100644 index 0000000000000..3f7bcc21b16f0 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql @@ -0,0 +1,44 @@ +INSERT INTO + users ( + id, + email, + username, + hashed_password, + created_at, + updated_at, + status, + rbac_roles, + login_type, + avatar_url, + deleted, + last_seen_at, + quiet_hours_schedule, + theme_preference, + name, + github_com_user_id, + hashed_one_time_passcode, + one_time_passcode_expires_at + ) + VALUES ( + '5755e622-fadd-44ca-98da-5df070491844', -- uuid + 'test@example.com', + 'testuser', + 'hashed_password', + '2024-01-01 00:00:00', + '2024-01-01 00:00:00', + 'active', + '{}', + 'password', + '', + false, + '2024-01-01 00:00:00', + '', + '', + '', + 123, + NULL, + NULL + ); + +UPDATE users SET status = 'dormant', updated_at = '2024-01-01 01:00:00' WHERE id = '5755e622-fadd-44ca-98da-5df070491844'; +UPDATE users SET deleted = true, updated_at = '2024-01-01 02:00:00' WHERE id = '5755e622-fadd-44ca-98da-5df070491844'; From 8bcbe03ff0e584c55dce50c3095fa07d572142da Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 3 Jan 2025 06:22:27 +0000 Subject: [PATCH 19/22] fix migration numbers --- ...tatus_changes.down.sql => 000282_user_status_changes.down.sql} | 0 ...er_status_changes.up.sql => 000282_user_status_changes.up.sql} | 0 ...er_status_changes.up.sql => 000282_user_status_changes.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000281_user_status_changes.down.sql => 000282_user_status_changes.down.sql} (100%) rename coderd/database/migrations/{000281_user_status_changes.up.sql => 000282_user_status_changes.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000281_user_status_changes.up.sql => 000282_user_status_changes.up.sql} (100%) diff --git a/coderd/database/migrations/000281_user_status_changes.down.sql b/coderd/database/migrations/000282_user_status_changes.down.sql similarity index 100% rename from coderd/database/migrations/000281_user_status_changes.down.sql rename to coderd/database/migrations/000282_user_status_changes.down.sql diff --git a/coderd/database/migrations/000281_user_status_changes.up.sql b/coderd/database/migrations/000282_user_status_changes.up.sql similarity index 100% rename from coderd/database/migrations/000281_user_status_changes.up.sql rename to coderd/database/migrations/000282_user_status_changes.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql b/coderd/database/migrations/testdata/fixtures/000282_user_status_changes.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000281_user_status_changes.up.sql rename to coderd/database/migrations/testdata/fixtures/000282_user_status_changes.up.sql From 494f1651ed6b4fe2eb3caa4159c874133143d60c Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 3 Jan 2025 06:51:17 +0000 Subject: [PATCH 20/22] rename and document sql function --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmem/dbmem.go | 8 +-- coderd/database/dbmetrics/querymetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 14 ++--- coderd/database/querier.go | 30 ++++++++- coderd/database/querier_test.go | 16 ++--- coderd/database/queries.sql.go | 62 ++++++++++++++----- coderd/database/queries/insights.sql | 50 ++++++++++++--- coderd/insights.go | 6 +- codersdk/insights.go | 12 ++-- docs/reference/api/insights.md | 6 +- docs/reference/api/schemas.md | 2 +- site/src/api/api.ts | 2 +- site/src/api/typesGenerated.ts | 4 +- .../GeneralSettingsPageView.tsx | 4 +- 18 files changed, 165 insertions(+), 73 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4ca5d46a83914..67ee167ab73f8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1426,7 +1426,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.GetUserStatusChangesResponse" + "$ref": "#/definitions/codersdk.GetUserStatusCountsOverTimeResponse" } } } @@ -11149,7 +11149,7 @@ const docTemplate = `{ } } }, - "codersdk.GetUserStatusChangesResponse": { + "codersdk.GetUserStatusCountsOverTimeResponse": { "type": "object", "properties": { "status_counts": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index cebe74446f9a5..731e552ed3759 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1243,7 +1243,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.GetUserStatusChangesResponse" + "$ref": "#/definitions/codersdk.GetUserStatusCountsOverTimeResponse" } } } @@ -10000,7 +10000,7 @@ } } }, - "codersdk.GetUserStatusChangesResponse": { + "codersdk.GetUserStatusCountsOverTimeResponse": { "type": "object", "properties": { "status_counts": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 78d620bd82020..3ffc7779044f0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2413,11 +2413,11 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } -func (q *querier) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { +func (q *querier) GetUserStatusCountsOverTime(ctx context.Context, arg database.GetUserStatusCountsOverTimeParams) ([]database.GetUserStatusCountsOverTimeRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err } - return q.db.GetUserStatusChanges(ctx, arg) + return q.db.GetUserStatusCountsOverTime(ctx, arg) } func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e1258a8eed087..b0862f83aa737 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1490,8 +1490,8 @@ func (s *MethodTestSuite) TestUser() { rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, ) })) - s.Run("GetUserStatusChanges", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetUserStatusChangesParams{ + s.Run("GetUserStatusCountsOverTime", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.GetUserStatusCountsOverTimeParams{ StartTime: time.Now().Add(-time.Hour * 24 * 30), EndTime: time.Now(), }).Asserts(rbac.ResourceUser, policy.ActionRead) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index fa51ce181ace5..8a646d21799a7 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5666,7 +5666,7 @@ func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID u return out, nil } -func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { +func (q *FakeQuerier) GetUserStatusCountsOverTime(_ context.Context, arg database.GetUserStatusCountsOverTimeParams) ([]database.GetUserStatusCountsOverTimeRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5675,16 +5675,16 @@ func (q *FakeQuerier) GetUserStatusChanges(_ context.Context, arg database.GetUs return nil, err } - result := make([]database.GetUserStatusChangesRow, 0) + result := make([]database.GetUserStatusCountsOverTimeRow, 0) for _, change := range q.userStatusChanges { if change.ChangedAt.Before(arg.StartTime) || change.ChangedAt.After(arg.EndTime) { continue } date := time.Date(change.ChangedAt.Year(), change.ChangedAt.Month(), change.ChangedAt.Day(), 0, 0, 0, 0, time.UTC) - if !slices.ContainsFunc(result, func(r database.GetUserStatusChangesRow) bool { + if !slices.ContainsFunc(result, func(r database.GetUserStatusCountsOverTimeRow) bool { return r.Status == change.NewStatus && r.Date.Equal(date) }) { - result = append(result, database.GetUserStatusChangesRow{ + result = append(result, database.GetUserStatusCountsOverTimeRow{ Status: change.NewStatus, Date: date, Count: 1, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c1662346adf63..78134fae00866 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1337,10 +1337,10 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } -func (m queryMetricsStore) GetUserStatusChanges(ctx context.Context, arg database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { +func (m queryMetricsStore) GetUserStatusCountsOverTime(ctx context.Context, arg database.GetUserStatusCountsOverTimeParams) ([]database.GetUserStatusCountsOverTimeRow, error) { start := time.Now() - r0, r1 := m.s.GetUserStatusChanges(ctx, arg) - m.queryLatencies.WithLabelValues("GetUserStatusChanges").Observe(time.Since(start).Seconds()) + r0, r1 := m.s.GetUserStatusCountsOverTime(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserStatusCountsOverTime").Observe(time.Since(start).Seconds()) return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index eedac4ced468f..ec1abc0361a88 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2811,19 +2811,19 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), arg0, arg1) } -// GetUserStatusChanges mocks base method. -func (m *MockStore) GetUserStatusChanges(arg0 context.Context, arg1 database.GetUserStatusChangesParams) ([]database.GetUserStatusChangesRow, error) { +// GetUserStatusCountsOverTime mocks base method. +func (m *MockStore) GetUserStatusCountsOverTime(arg0 context.Context, arg1 database.GetUserStatusCountsOverTimeParams) ([]database.GetUserStatusCountsOverTimeRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserStatusChanges", arg0, arg1) - ret0, _ := ret[0].([]database.GetUserStatusChangesRow) + ret := m.ctrl.Call(m, "GetUserStatusCountsOverTime", arg0, arg1) + ret0, _ := ret[0].([]database.GetUserStatusCountsOverTimeRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetUserStatusChanges indicates an expected call of GetUserStatusChanges. -func (mr *MockStoreMockRecorder) GetUserStatusChanges(arg0, arg1 any) *gomock.Call { +// GetUserStatusCountsOverTime indicates an expected call of GetUserStatusCountsOverTime. +func (mr *MockStoreMockRecorder) GetUserStatusCountsOverTime(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusChanges", reflect.TypeOf((*MockStore)(nil).GetUserStatusChanges), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatusCountsOverTime", reflect.TypeOf((*MockStore)(nil).GetUserStatusCountsOverTime), arg0, arg1) } // GetUserWorkspaceBuildParameters mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 373a5f28b66df..550fe194912ca 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -285,7 +285,35 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) - GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]GetUserStatusChangesRow, error) + // GetUserStatusCountsOverTime returns the count of users in each status over time. + // The time range is inclusively defined by the start_time and end_time parameters. + // + // Bucketing: + // Between the start_time and end_time, we include each timestamp where a user's status changed or they were deleted. + // We do not bucket these results by day or some other time unit. This is because such bucketing would hide potentially + // important patterns. If a user was active for 23 hours and 59 minutes, and then suspended, a daily bucket would hide this. + // A daily bucket would also have required us to carefully manage the timezone of the bucket based on the timezone of the user. + // + // Accumulation: + // We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such, + // the result shows the total number of users in each status on any particular day. + // dates_of_interest defines all points in time that are relevant to the query. + // It includes the start_time, all status changes, all deletions, and the end_time. + // latest_status_before_range defines the status of each user before the start_time. + // We do not include users who were deleted before the start_time. We use this to ensure that + // we correctly count users prior to the start_time for a complete graph. + // status_changes_during_range defines the status of each user during the start_time and end_time. + // If a user is deleted during the time range, we count status changes prior to the deletion. + // Theoretically, it should probably not be possible to update the status of a deleted user, but we + // need to ensure that this is enforced, so that a change in business logic later does not break this graph. + // relevant_status_changes defines the status of each user at any point in time. + // It includes the status of each user before the start_time, and the status of each user during the start_time and end_time. + // statuses defines all the distinct statuses that were present just before and during the time range. + // This is used to ensure that we have a series for every relevant status. + // We only want to count the latest status change for each user on each date and then filter them by the relevant status. + // We use the row_number function to ensure that we only count the latest status change for each user on each date. + // We then filter the status changes by the relevant status in the final select statement below. + GetUserStatusCountsOverTime(ctx context.Context, arg GetUserStatusCountsOverTimeParams) ([]GetUserStatusCountsOverTimeRow, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 6ace33f74a96c..8d0d7edf312f6 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2256,7 +2256,7 @@ func TestGroupRemovalTrigger(t *testing.T) { }, db2sdk.List(extraUserGroups, onlyGroupIDs)) } -func TestGetUserStatusChanges(t *testing.T) { +func TestGetUserStatusCountsOverTime(t *testing.T) { t.Parallel() if !dbtestutil.WillUsePostgres() { @@ -2294,7 +2294,7 @@ func TestGetUserStatusChanges(t *testing.T) { end := dbtime.Now() start := end.Add(-30 * 24 * time.Hour) - counts, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + counts, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: start, EndTime: end, }) @@ -2342,7 +2342,7 @@ func TestGetUserStatusChanges(t *testing.T) { }) // Query for the last 30 days - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: createdAt, EndTime: today, }) @@ -2510,7 +2510,7 @@ func TestGetUserStatusChanges(t *testing.T) { require.NoError(t, err) // Query for the last 5 days - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: createdAt, EndTime: today, }) @@ -2639,7 +2639,7 @@ func TestGetUserStatusChanges(t *testing.T) { }) require.NoError(t, err) - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: createdAt, EndTime: today, }) @@ -2715,7 +2715,7 @@ func TestGetUserStatusChanges(t *testing.T) { UpdatedAt: createdAt, }) - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: createdAt.Add(time.Hour * 24), EndTime: today, }) @@ -2742,7 +2742,7 @@ func TestGetUserStatusChanges(t *testing.T) { err = db.UpdateUserDeletedByID(ctx, user.ID) require.NoError(t, err) - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: today.Add(time.Hour * 24), EndTime: today.Add(time.Hour * 48), }) @@ -2764,7 +2764,7 @@ func TestGetUserStatusChanges(t *testing.T) { err := db.UpdateUserDeletedByID(ctx, user.ID) require.NoError(t, err) - userStatusChanges, err := db.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + userStatusChanges, err := db.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: createdAt, EndTime: today.Add(time.Hour * 24), }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 38a87874d7f18..ffd0dd6e127c0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3094,8 +3094,9 @@ func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLate return items, nil } -const getUserStatusChanges = `-- name: GetUserStatusChanges :many -WITH dates AS ( +const getUserStatusCountsOverTime = `-- name: GetUserStatusCountsOverTime :many +WITH +dates_of_interest AS ( SELECT $1::timestamptz AS date UNION @@ -3138,7 +3139,7 @@ status_changes_during_range AS ( AND usc.changed_at <= $2::timestamptz AND (ud.user_id IS NULL OR usc.changed_at < ud.deleted_at) ), -all_status_changes AS ( +relevant_status_changes AS ( SELECT user_id, new_status, @@ -3153,14 +3154,17 @@ all_status_changes AS ( changed_at FROM status_changes_during_range ), +statuses AS ( + SELECT DISTINCT new_status FROM relevant_status_changes +), ranked_status_change_per_user_per_date AS ( SELECT d.date, - asc1.user_id, - ROW_NUMBER() OVER (PARTITION BY d.date, asc1.user_id ORDER BY asc1.changed_at DESC) AS rn, - asc1.new_status - FROM dates d - LEFT JOIN all_status_changes asc1 ON asc1.changed_at <= d.date + rsc1.user_id, + ROW_NUMBER() OVER (PARTITION BY d.date, rsc1.user_id ORDER BY rsc1.changed_at DESC) AS rn, + rsc1.new_status + FROM dates_of_interest d + LEFT JOIN relevant_status_changes rsc1 ON rsc1.changed_at <= d.date ) SELECT date, @@ -3179,30 +3183,58 @@ SELECT ) ) AS count FROM ranked_status_change_per_user_per_date rscpupd -CROSS JOIN (select distinct new_status FROM all_status_changes) statuses +CROSS JOIN statuses GROUP BY date, statuses.new_status ` -type GetUserStatusChangesParams struct { +type GetUserStatusCountsOverTimeParams struct { StartTime time.Time `db:"start_time" json:"start_time"` EndTime time.Time `db:"end_time" json:"end_time"` } -type GetUserStatusChangesRow struct { +type GetUserStatusCountsOverTimeRow struct { Date time.Time `db:"date" json:"date"` Status UserStatus `db:"status" json:"status"` Count int64 `db:"count" json:"count"` } -func (q *sqlQuerier) GetUserStatusChanges(ctx context.Context, arg GetUserStatusChangesParams) ([]GetUserStatusChangesRow, error) { - rows, err := q.db.QueryContext(ctx, getUserStatusChanges, arg.StartTime, arg.EndTime) +// GetUserStatusCountsOverTime returns the count of users in each status over time. +// The time range is inclusively defined by the start_time and end_time parameters. +// +// Bucketing: +// Between the start_time and end_time, we include each timestamp where a user's status changed or they were deleted. +// We do not bucket these results by day or some other time unit. This is because such bucketing would hide potentially +// important patterns. If a user was active for 23 hours and 59 minutes, and then suspended, a daily bucket would hide this. +// A daily bucket would also have required us to carefully manage the timezone of the bucket based on the timezone of the user. +// +// Accumulation: +// We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such, +// the result shows the total number of users in each status on any particular day. +// dates_of_interest defines all points in time that are relevant to the query. +// It includes the start_time, all status changes, all deletions, and the end_time. +// latest_status_before_range defines the status of each user before the start_time. +// We do not include users who were deleted before the start_time. We use this to ensure that +// we correctly count users prior to the start_time for a complete graph. +// status_changes_during_range defines the status of each user during the start_time and end_time. +// If a user is deleted during the time range, we count status changes prior to the deletion. +// Theoretically, it should probably not be possible to update the status of a deleted user, but we +// need to ensure that this is enforced, so that a change in business logic later does not break this graph. +// relevant_status_changes defines the status of each user at any point in time. +// It includes the status of each user before the start_time, and the status of each user during the start_time and end_time. +// statuses defines all the distinct statuses that were present just before and during the time range. +// This is used to ensure that we have a series for every relevant status. +// We only want to count the latest status change for each user on each date and then filter them by the relevant status. +// We use the row_number function to ensure that we only count the latest status change for each user on each date. +// We then filter the status changes by the relevant status in the final select statement below. +func (q *sqlQuerier) GetUserStatusCountsOverTime(ctx context.Context, arg GetUserStatusCountsOverTimeParams) ([]GetUserStatusCountsOverTimeRow, error) { + rows, err := q.db.QueryContext(ctx, getUserStatusCountsOverTime, arg.StartTime, arg.EndTime) if err != nil { return nil, err } defer rows.Close() - var items []GetUserStatusChangesRow + var items []GetUserStatusCountsOverTimeRow for rows.Next() { - var i GetUserStatusChangesRow + var i GetUserStatusCountsOverTimeRow if err := rows.Scan(&i.Date, &i.Status, &i.Count); err != nil { return nil, err } diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index c721ebcc79227..d599e2989a56e 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -772,8 +772,23 @@ FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; --- name: GetUserStatusChanges :many -WITH dates AS ( +-- name: GetUserStatusCountsOverTime :many +-- GetUserStatusCountsOverTime returns the count of users in each status over time. +-- The time range is inclusively defined by the start_time and end_time parameters. +-- +-- Bucketing: +-- Between the start_time and end_time, we include each timestamp where a user's status changed or they were deleted. +-- We do not bucket these results by day or some other time unit. This is because such bucketing would hide potentially +-- important patterns. If a user was active for 23 hours and 59 minutes, and then suspended, a daily bucket would hide this. +-- A daily bucket would also have required us to carefully manage the timezone of the bucket based on the timezone of the user. +-- +-- Accumulation: +-- We do not start counting from 0 at the start_time. We check the last status change before the start_time for each user. As such, +-- the result shows the total number of users in each status on any particular day. +WITH +-- dates_of_interest defines all points in time that are relevant to the query. +-- It includes the start_time, all status changes, all deletions, and the end_time. +dates_of_interest AS ( SELECT @start_time::timestamptz AS date UNION @@ -794,6 +809,9 @@ WITH dates AS ( SELECT @end_time::timestamptz AS date ), +-- latest_status_before_range defines the status of each user before the start_time. +-- We do not include users who were deleted before the start_time. We use this to ensure that +-- we correctly count users prior to the start_time for a complete graph. latest_status_before_range AS ( SELECT DISTINCT usc.user_id, @@ -805,6 +823,10 @@ latest_status_before_range AS ( AND (ud.user_id IS NULL OR ud.deleted_at > @start_time::timestamptz) ORDER BY usc.user_id, usc.changed_at DESC ), +-- status_changes_during_range defines the status of each user during the start_time and end_time. +-- If a user is deleted during the time range, we count status changes prior to the deletion. +-- Theoretically, it should probably not be possible to update the status of a deleted user, but we +-- need to ensure that this is enforced, so that a change in business logic later does not break this graph. status_changes_during_range AS ( SELECT usc.user_id, @@ -816,7 +838,9 @@ status_changes_during_range AS ( AND usc.changed_at <= @end_time::timestamptz AND (ud.user_id IS NULL OR usc.changed_at < ud.deleted_at) ), -all_status_changes AS ( +-- relevant_status_changes defines the status of each user at any point in time. +-- It includes the status of each user before the start_time, and the status of each user during the start_time and end_time. +relevant_status_changes AS ( SELECT user_id, new_status, @@ -831,14 +855,22 @@ all_status_changes AS ( changed_at FROM status_changes_during_range ), +-- statuses defines all the distinct statuses that were present just before and during the time range. +-- This is used to ensure that we have a series for every relevant status. +statuses AS ( + SELECT DISTINCT new_status FROM relevant_status_changes +), +-- We only want to count the latest status change for each user on each date and then filter them by the relevant status. +-- We use the row_number function to ensure that we only count the latest status change for each user on each date. +-- We then filter the status changes by the relevant status in the final select statement below. ranked_status_change_per_user_per_date AS ( SELECT d.date, - asc1.user_id, - ROW_NUMBER() OVER (PARTITION BY d.date, asc1.user_id ORDER BY asc1.changed_at DESC) AS rn, - asc1.new_status - FROM dates d - LEFT JOIN all_status_changes asc1 ON asc1.changed_at <= d.date + rsc1.user_id, + ROW_NUMBER() OVER (PARTITION BY d.date, rsc1.user_id ORDER BY rsc1.changed_at DESC) AS rn, + rsc1.new_status + FROM dates_of_interest d + LEFT JOIN relevant_status_changes rsc1 ON rsc1.changed_at <= d.date ) SELECT date, @@ -857,6 +889,6 @@ SELECT ) ) AS count FROM ranked_status_change_per_user_per_date rscpupd -CROSS JOIN (select distinct new_status FROM all_status_changes) statuses +CROSS JOIN statuses GROUP BY date, statuses.new_status; diff --git a/coderd/insights.go b/coderd/insights.go index 3cd9a364a2efe..10205439e2b4a 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -298,7 +298,7 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Insights // @Param tz_offset query int true "Time-zone offset (e.g. -2)" -// @Success 200 {object} codersdk.GetUserStatusChangesResponse +// @Success 200 {object} codersdk.GetUserStatusCountsOverTimeResponse // @Router /insights/user-status-counts-over-time [get] func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -324,7 +324,7 @@ func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http // Always return 60 days of data (2 months). sixtyDaysAgo := nextHourInLoc.In(loc).Truncate(24*time.Hour).AddDate(0, 0, -60) - rows, err := api.Database.GetUserStatusChanges(ctx, database.GetUserStatusChangesParams{ + rows, err := api.Database.GetUserStatusCountsOverTime(ctx, database.GetUserStatusCountsOverTimeParams{ StartTime: sixtyDaysAgo, EndTime: nextHourInLoc, }) @@ -336,7 +336,7 @@ func (api *API) insightsUserStatusCountsOverTime(rw http.ResponseWriter, r *http return } - resp := codersdk.GetUserStatusChangesResponse{ + resp := codersdk.GetUserStatusCountsOverTimeResponse{ StatusCounts: make(map[codersdk.UserStatus][]codersdk.UserStatusChangeCount), } diff --git a/codersdk/insights.go b/codersdk/insights.go index b3a3cc728378a..3e285b5de0a58 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -283,7 +283,7 @@ func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsReque return result, json.NewDecoder(resp.Body).Decode(&result) } -type GetUserStatusChangesResponse struct { +type GetUserStatusCountsOverTimeResponse struct { StatusCounts map[UserStatus][]UserStatusChangeCount `json:"status_counts"` } @@ -292,24 +292,24 @@ type UserStatusChangeCount struct { Count int64 `json:"count" example:"10"` } -type GetUserStatusChangesRequest struct { +type GetUserStatusCountsOverTimeRequest struct { Offset time.Time `json:"offset" format:"date-time"` } -func (c *Client) GetUserStatusChanges(ctx context.Context, req GetUserStatusChangesRequest) (GetUserStatusChangesResponse, error) { +func (c *Client) GetUserStatusCountsOverTime(ctx context.Context, req GetUserStatusCountsOverTimeRequest) (GetUserStatusCountsOverTimeResponse, error) { qp := url.Values{} qp.Add("offset", req.Offset.Format(insightsTimeLayout)) reqURL := fmt.Sprintf("/api/v2/insights/user-status-counts-over-time?%s", qp.Encode()) resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) if err != nil { - return GetUserStatusChangesResponse{}, xerrors.Errorf("make request: %w", err) + return GetUserStatusCountsOverTimeResponse{}, xerrors.Errorf("make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return GetUserStatusChangesResponse{}, ReadBodyAsError(resp) + return GetUserStatusCountsOverTimeResponse{}, ReadBodyAsError(resp) } - var result GetUserStatusChangesResponse + var result GetUserStatusCountsOverTimeResponse return result, json.NewDecoder(resp.Body).Decode(&result) } diff --git a/docs/reference/api/insights.md b/docs/reference/api/insights.md index 8d6f5640f9e60..71ae63c2cf4a0 100644 --- a/docs/reference/api/insights.md +++ b/docs/reference/api/insights.md @@ -289,8 +289,8 @@ curl -X GET http://coder-server:8080/api/v2/insights/user-status-counts-over-tim ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GetUserStatusChangesResponse](schemas.md#codersdkgetuserstatuschangesresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GetUserStatusCountsOverTimeResponse](schemas.md#codersdkgetuserstatuscountsovertimeresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8fa040f99fe75..baf608bfe4478 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2882,7 +2882,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | ----- | ------ | -------- | ------------ | ----------- | | `key` | string | false | | | -## codersdk.GetUserStatusChangesResponse +## codersdk.GetUserStatusCountsOverTimeResponse ```json { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 16c0506774295..74ca5ce685007 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2099,7 +2099,7 @@ class ApiMethods { getInsightsUserStatusCountsOverTime = async ( offset = Math.trunc(new Date().getTimezoneOffset() / 60), - ): Promise => { + ): Promise => { const searchParams = new URLSearchParams({ offset: offset.toString(), }); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 212995bc79de3..8e38afb315ae6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -776,12 +776,12 @@ export interface GenerateAPIKeyResponse { } // From codersdk/insights.go -export interface GetUserStatusChangesRequest { +export interface GetUserStatusCountsOverTimeRequest { readonly offset: string; } // From codersdk/insights.go -export interface GetUserStatusChangesResponse { +export interface GetUserStatusCountsOverTimeResponse { readonly status_counts: Record; } diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index bf663fecaa945..3680e823516a6 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -2,7 +2,7 @@ import AlertTitle from "@mui/material/AlertTitle"; import type { DAUsResponse, Experiments, - GetUserStatusChangesResponse, + GetUserStatusCountsOverTimeResponse, SerpentOption, } from "api/typesGenerated"; import { @@ -25,7 +25,7 @@ export type GeneralSettingsPageViewProps = { deploymentOptions: SerpentOption[]; deploymentDAUs?: DAUsResponse; deploymentDAUsError: unknown; - userStatusCountsOverTime?: GetUserStatusChangesResponse; + userStatusCountsOverTime?: GetUserStatusCountsOverTimeResponse; activeUserLimit?: number; readonly invalidExperiments: Experiments | string[]; readonly safeExperiments: Experiments | string[]; From dbebf0b820aaa9d42a65fd707dec05b76f68bde0 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 3 Jan 2025 07:00:50 +0000 Subject: [PATCH 21/22] revert backend changes for now. so that we can review and merge the db side --- site/.storybook/preview.jsx | 8 + site/package.json | 21 +- site/pnpm-lock.yaml | 645 ++++++++---------- site/src/api/api.ts | 13 - site/src/api/queries/insights.ts | 7 - site/src/api/queries/users.ts | 1 + site/src/api/typesGenerated.ts | 26 +- .../ActiveUserChart.stories.tsx | 69 +- .../ActiveUserChart/ActiveUserChart.tsx | 121 +--- site/src/components/Breadcrumb/Breadcrumb.tsx | 2 +- site/src/components/Button/Button.tsx | 39 +- .../components/DropdownMenu/DropdownMenu.tsx | 6 +- site/src/components/Loader/Loader.tsx | 2 +- .../components/Spinner/Spinner.stories.tsx | 20 + site/src/components/Spinner/Spinner.tsx | 93 ++- .../components/deprecated/Spinner/Spinner.tsx | 24 + site/src/contexts/ProxyContext.tsx | 6 +- site/src/index.css | 13 + .../dashboard/Navbar/DeploymentDropdown.tsx | 20 +- .../dashboard/Navbar/MobileMenu.stories.tsx | 146 ++++ .../modules/dashboard/Navbar/MobileMenu.tsx | 339 +++++++++ .../modules/dashboard/Navbar/Navbar.test.tsx | 15 +- site/src/modules/dashboard/Navbar/Navbar.tsx | 3 +- .../dashboard/Navbar/NavbarView.stories.tsx | 4 - .../dashboard/Navbar/NavbarView.test.tsx | 33 +- .../modules/dashboard/Navbar/NavbarView.tsx | 281 +++----- .../modules/dashboard/Navbar/ProxyMenu.tsx | 31 +- .../Navbar/UserDropdown/UserDropdown.tsx | 45 +- .../modules/dashboard/Navbar/proxyUtils.tsx | 12 + .../management/DeploymentSettingsLayout.tsx | 2 +- .../management/OrganizationSettingsLayout.tsx | 2 +- .../AuditLogRow/AuditLogDiff/auditUtils.ts | 26 + .../AuditPage/AuditLogRow/AuditLogRow.tsx | 13 +- .../GeneralSettingsPage.tsx | 7 +- .../GeneralSettingsPageView.stories.tsx | 174 ++--- .../GeneralSettingsPageView.tsx | 82 +-- .../TemplateInsightsPage.tsx | 12 +- site/tailwind.config.js | 14 + 38 files changed, 1306 insertions(+), 1071 deletions(-) create mode 100644 site/src/components/Spinner/Spinner.stories.tsx create mode 100644 site/src/components/deprecated/Spinner/Spinner.tsx create mode 100644 site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx create mode 100644 site/src/modules/dashboard/Navbar/MobileMenu.tsx create mode 100644 site/src/modules/dashboard/Navbar/proxyUtils.tsx diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 9953c0533e5d6..17e6113508fcc 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -64,6 +64,14 @@ export const parameters = { }, type: "tablet", }, + iphone12: { + name: "iPhone 12", + styles: { + height: "844px", + width: "390px", + }, + type: "mobile", + }, terminal: { name: "Terminal", styles: { diff --git a/site/package.json b/site/package.json index 24895dfd61f2a..4ced71129eafb 100644 --- a/site/package.json +++ b/site/package.json @@ -34,7 +34,6 @@ "update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis" }, "dependencies": { - "@alwaysmeticulous/recorder-loader": "2.137.0", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@emotion/cache": "11.14.0", @@ -45,15 +44,15 @@ "@fontsource-variable/inter": "5.0.15", "@fontsource/ibm-plex-mono": "5.1.0", "@monaco-editor/react": "4.6.0", - "@mui/icons-material": "5.16.7", - "@mui/lab": "5.0.0-alpha.173", - "@mui/material": "5.16.7", - "@mui/system": "5.16.7", - "@mui/utils": "5.16.6", - "@mui/x-tree-view": "7.18.0", + "@mui/icons-material": "5.16.13", + "@mui/lab": "5.0.0-alpha.175", + "@mui/material": "5.16.13", + "@mui/system": "5.16.13", + "@mui/utils": "5.16.13", + "@mui/x-tree-view": "7.23.2", "@radix-ui/react-avatar": "1.1.2", "@radix-ui/react-collapsible": "1.1.2", - "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dropdown-menu": "2.1.4", "@radix-ui/react-label": "2.1.0", "@radix-ui/react-popover": "1.1.3", @@ -103,7 +102,7 @@ "react-markdown": "9.0.1", "react-query": "npm:@tanstack/react-query@4.35.3", "react-router-dom": "6.26.2", - "react-syntax-highlighter": "15.5.0", + "react-syntax-highlighter": "15.6.1", "react-virtualized-auto-sizer": "1.0.24", "react-window": "1.8.10", "remark-gfm": "4.0.0", @@ -131,7 +130,7 @@ "@storybook/addon-links": "8.4.6", "@storybook/addon-mdx-gfm": "8.4.6", "@storybook/addon-themes": "8.4.6", - "@storybook/preview-api": "8.4.6", + "@storybook/preview-api": "8.4.7", "@storybook/react": "8.4.6", "@storybook/react-vite": "8.4.6", "@storybook/test": "8.4.6", @@ -147,7 +146,7 @@ "@types/file-saver": "2.0.7", "@types/jest": "29.5.14", "@types/lodash": "4.17.13", - "@types/node": "20.17.6", + "@types/node": "20.17.11", "@types/react": "18.3.12", "@types/react-color": "3.0.12", "@types/react-date-range": "1.4.4", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 95d5567a1b96d..bbf6f1ce2f105 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -12,9 +12,6 @@ importers: .: dependencies: - '@alwaysmeticulous/recorder-loader': - specifier: 2.137.0 - version: 2.137.0 '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -46,23 +43,23 @@ importers: specifier: 4.6.0 version: 4.6.0(monaco-editor@0.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/icons-material': - specifier: 5.16.7 - version: 5.16.7(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + specifier: 5.16.13 + version: 5.16.13(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/lab': - specifier: 5.0.0-alpha.173 - version: 5.0.0-alpha.173(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 5.0.0-alpha.175 + version: 5.0.0-alpha.175(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/material': - specifier: 5.16.7 - version: 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 5.16.13 + version: 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/system': - specifier: 5.16.7 - version: 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + specifier: 5.16.13 + version: 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@mui/utils': - specifier: 5.16.6 - version: 5.16.6(@types/react@18.3.12)(react@18.3.1) + specifier: 5.16.13 + version: 5.16.13(@types/react@18.3.12)(react@18.3.1) '@mui/x-tree-view': - specifier: 7.18.0 - version: 7.18.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.23.2 + version: 7.23.2(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -70,8 +67,8 @@ importers: specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': - specifier: 1.1.2 - version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 1.1.4 + version: 1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: 2.1.4 version: 2.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -220,8 +217,8 @@ importers: specifier: 6.26.2 version: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-syntax-highlighter: - specifier: 15.5.0 - version: 15.5.0(react@18.3.1) + specifier: 15.6.1 + version: 15.6.1(react@18.3.1) react-virtualized-auto-sizer: specifier: 1.0.24 version: 1.0.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -245,7 +242,7 @@ importers: version: 2.5.4 tailwindcss-animate: specifier: 1.0.7 - version: 1.0.7(tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3))) + version: 1.0.7(tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3))) tzdata: specifier: 1.0.40 version: 1.0.40 @@ -299,14 +296,14 @@ importers: specifier: 8.4.6 version: 8.4.6(storybook@8.4.6(prettier@3.4.1)) '@storybook/preview-api': - specifier: 8.4.6 - version: 8.4.6(storybook@8.4.6(prettier@3.4.1)) + specifier: 8.4.7 + version: 8.4.7(storybook@8.4.6(prettier@3.4.1)) '@storybook/react': specifier: 8.4.6 version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3) '@storybook/react-vite': specifier: 8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.24.3)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6)) + version: 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.24.3)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11)) '@storybook/test': specifier: 8.4.6 version: 8.4.6(storybook@8.4.6(prettier@3.4.1)) @@ -318,7 +315,7 @@ importers: version: 0.2.37(@swc/core@1.3.38) '@testing-library/jest-dom': specifier: 6.4.6 - version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.14)(jest@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3))) + version: 6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.14)(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3))) '@testing-library/react': specifier: 14.3.1 version: 14.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -347,8 +344,8 @@ importers: specifier: 4.17.13 version: 4.17.13 '@types/node': - specifier: 20.17.6 - version: 20.17.6 + specifier: 20.17.11 + version: 20.17.11 '@types/react': specifier: 18.3.12 version: 18.3.12 @@ -384,7 +381,7 @@ importers: version: 9.0.2 '@vitejs/plugin-react': specifier: 4.3.3 - version: 4.3.3(vite@5.4.10(@types/node@20.17.6)) + version: 4.3.3(vite@5.4.10(@types/node@20.17.11)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.47) @@ -399,7 +396,7 @@ importers: version: 4.21.0 jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + version: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) jest-canvas-mock: specifier: 2.5.2 version: 2.5.2 @@ -438,16 +435,16 @@ importers: version: 8.4.6(prettier@3.4.1) storybook-addon-remix-react-router: specifier: 3.0.2 - version: 3.0.2(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/preview-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 3.0.2(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/preview-api@8.4.7(storybook@8.4.6(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) storybook-react-context: specifier: 0.7.0 version: 0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)) tailwindcss: specifier: 3.4.13 - version: 3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + version: 3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) ts-node: specifier: 10.9.1 - version: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3) + version: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3) ts-proto: specifier: 1.164.0 version: 1.164.0 @@ -459,10 +456,10 @@ importers: version: 5.6.3 vite: specifier: 5.4.10 - version: 5.4.10(@types/node@20.17.6) + version: 5.4.10(@types/node@20.17.11) vite-plugin-checker: specifier: 0.8.0 - version: 0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6)) + version: 0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11)) vite-plugin-turbosnap: specifier: 1.0.3 version: 1.0.3 @@ -480,9 +477,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@alwaysmeticulous/recorder-loader@2.137.0': - resolution: {integrity: sha512-ux/xGYCNsOe8BzquEg7k7YSNJiw/0Sg2Pd/7fppYiVr5xEefpPeIhh3qwuupZgx6sB2t5KpKQdodNWVmGeyh/w==} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -674,10 +668,6 @@ packages: resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.4': - resolution: {integrity: sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.6': resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} engines: {node: '>=6.9.0'} @@ -1153,18 +1143,33 @@ packages: '@floating-ui/core@1.6.7': resolution: {integrity: sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==} + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + '@floating-ui/dom@1.6.10': resolution: {integrity: sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==} + '@floating-ui/dom@1.6.12': + resolution: {integrity: sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==} + '@floating-ui/react-dom@2.1.1': resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.7': resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@fontsource-variable/inter@5.0.15': resolution: {integrity: sha512-CdQPQQgOVxg6ifmbrqYZeUqtQf7p2wPn6EvJ4M+vdNnsmYZgYwPPPQDNlIOU7LCUlSGaN26v6H0uA030WKn61g==} @@ -1363,41 +1368,41 @@ packages: resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==} engines: {node: '>=18'} - '@mui/base@5.0.0-beta.40': - resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} + '@mui/base@5.0.0-beta.40-0': + resolution: {integrity: sha512-hG3atoDUxlvEy+0mqdMpWd04wca8HKr2IHjW/fAjlkCHQolSLazhZM46vnHjOf15M4ESu25mV/3PgjczyjVM4w==} engines: {node: '>=12.0.0'} peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/core-downloads-tracker@5.16.7': - resolution: {integrity: sha512-RtsCt4Geed2/v74sbihWzzRs+HsIQCfclHeORh5Ynu2fS4icIKozcSubwuG7vtzq2uW3fOR1zITSP84TNt2GoQ==} + '@mui/core-downloads-tracker@5.16.13': + resolution: {integrity: sha512-xe5RwI0Q2O709Bd2Y7l1W1NIwNmln0y+xaGk5VgX3vDJbkQEqzdfTFZ73e0CkEZgJwyiWgk5HY0l8R4nysOxjw==} - '@mui/icons-material@5.16.7': - resolution: {integrity: sha512-UrGwDJCXEszbDI7yV047BYU5A28eGJ79keTCP4cc74WyncuVrnurlmIRxaHL8YK+LI1Kzq+/JM52IAkNnv4u+Q==} + '@mui/icons-material@5.16.13': + resolution: {integrity: sha512-aWyOgGDEqj37m3K4F6qUfn7JrEccwiDynJtGQMFbxp94EqyGwO13TKcZ4O8aHdwW3tG63hpbION8KyUoBWI4JQ==} engines: {node: '>=12.0.0'} peerDependencies: '@mui/material': ^5.0.0 - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/lab@5.0.0-alpha.173': - resolution: {integrity: sha512-Gt5zopIWwxDgGy/MXcp6GueD84xFFugFai4hYiXY0zowJpTVnIrTQCQXV004Q7rejJ7aaCntX9hpPJqCrioshA==} + '@mui/lab@5.0.0-alpha.175': + resolution: {integrity: sha512-AvM0Nvnnj7vHc9+pkkQkoE1i+dEbr6gsMdnSfy7X4w3Ljgcj1yrjZhIt3jGTCLzyKVLa6uve5eLluOcGkvMqUA==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 '@mui/material': '>=5.15.0' - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/react': optional: true @@ -1406,15 +1411,15 @@ packages: '@types/react': optional: true - '@mui/material@5.16.7': - resolution: {integrity: sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==} + '@mui/material@5.16.13': + resolution: {integrity: sha512-FhLDkDPYDzvrWCHFsdXzRArhS4AdYufU8d69rmLL+bwhodPcbm2C7cS8Gq5VR32PsW6aKZb58gvAgvEVaiiJbA==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/react': optional: true @@ -1423,37 +1428,37 @@ packages: '@types/react': optional: true - '@mui/private-theming@5.16.6': - resolution: {integrity: sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==} + '@mui/private-theming@5.16.13': + resolution: {integrity: sha512-+s0FklvDvO7j0yBZn19DIIT3rLfub2fWvXGtMX49rG/xHfDFcP7fbWbZKHZMMP/2/IoTRDrZCbY1iP0xZlmuJA==} engines: {node: '>=12.0.0'} peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/styled-engine@5.16.6': - resolution: {integrity: sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==} + '@mui/styled-engine@5.16.13': + resolution: {integrity: sha512-2XNHEG8/o1ucSLhTA9J+HIIXjzlnEc0OV7kneeUQ5JukErPYT2zc6KYBDLjlKWrzQyvnQzbiffjjspgHUColZg==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.4.1 '@emotion/styled': ^11.3.0 - react: ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/react': optional: true '@emotion/styled': optional: true - '@mui/system@5.16.7': - resolution: {integrity: sha512-Jncvs/r/d/itkxh7O7opOunTqbbSSzMTHzZkNLM+FjAOg+cYAZHrPDlYe1ZGKUYORwwb2XexlWnpZp0kZ4AHuA==} + '@mui/system@5.16.13': + resolution: {integrity: sha512-JnO3VH3yNoAmgyr44/2jiS1tcNwshwAqAaG5fTEEjHQbkuZT/mvPYj2GC1cON0zEQ5V03xrCNl/D+gU9AXibpw==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/react': optional: true @@ -1462,40 +1467,40 @@ packages: '@types/react': optional: true - '@mui/types@7.2.15': - resolution: {integrity: sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==} + '@mui/types@7.2.20': + resolution: {integrity: sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==} peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@5.16.6': - resolution: {integrity: sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==} + '@mui/utils@5.16.13': + resolution: {integrity: sha512-35kLiShnDPByk57Mz4PP66fQUodCFiOD92HfpW6dK9lc7kjhZsKHRKeYPgWuwEHeXwYsCSFtBCW4RZh/8WT+TQ==} engines: {node: '>=12.0.0'} peerDependencies: - '@types/react': ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/x-internals@7.18.0': - resolution: {integrity: sha512-lzCHOWIR0cAIY1bGrWSprYerahbnH5C31ql/2OWCEjcngL2NAV1M6oKI2Vp4HheqzJ822c60UyWyapvyjSzY/A==} + '@mui/x-internals@7.23.0': + resolution: {integrity: sha512-bPclKpqUiJYIHqmTxSzMVZi6MH51cQsn5U+8jskaTlo3J4QiMeCYJn/gn7YbeR9GOZFp8hetyHjoQoVHKRXCig==} engines: {node: '>=14.0.0'} peerDependencies: - react: ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@mui/x-tree-view@7.18.0': - resolution: {integrity: sha512-3UJAYtBquc0SzKxEEdM68XlKOuuCl70ktZPqqI3z4wTZ0HK445XXc32t/s0VPIL94kRxWQcGPpgWFauScDwhug==} + '@mui/x-tree-view@7.23.2': + resolution: {integrity: sha512-/R/9/GSF311fVLOUCg7r+a/+AScYZezL0SJZPfsTOquL1RDPAFRZei7BZEivUzOSEELJc0cxLGapJyM6QCA7Zg==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.9.0 '@emotion/styled': ^11.8.1 '@mui/material': ^5.15.14 || ^6.0.0 '@mui/system': ^5.15.14 || ^6.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/react': optional: true @@ -1715,8 +1720,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dialog@1.1.2': - resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} + '@radix-ui/react-dialog@1.1.4': + resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1750,19 +1755,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dismissable-layer@1.1.1': - resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-dismissable-layer@1.1.2': resolution: {integrity: sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==} peerDependencies: @@ -1833,19 +1825,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-scope@1.1.0': - resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-focus-scope@1.1.1': resolution: {integrity: sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==} peerDependencies: @@ -1942,19 +1921,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.2': - resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-portal@1.1.3': resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==} peerDependencies: @@ -1981,19 +1947,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.1': - resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-presence@1.1.2': resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} peerDependencies: @@ -2461,8 +2414,8 @@ packages: '@storybook/csf@0.1.11': resolution: {integrity: sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==} - '@storybook/csf@0.1.12': - resolution: {integrity: sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw==} + '@storybook/csf@0.1.13': + resolution: {integrity: sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q==} '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -2489,6 +2442,11 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + '@storybook/preview-api@8.4.7': + resolution: {integrity: sha512-0QVQwHw+OyZGHAJEXo6Knx+6/4er7n2rTDE5RYJ9F2E2Lg42E19pfdLlq2Jhoods2Xrclo3wj6GWR//Ahi39Eg==} + peerDependencies: + storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + '@storybook/react-dom-shim@8.4.6': resolution: {integrity: sha512-f7RM8GO++fqMxbjNdEzeGS1P821jXuwRnAraejk5hyjB5SqetauFxMwoFYEYfJXPaLX2qIubnIJ78hdJ/IBaEA==} peerDependencies: @@ -2830,18 +2788,18 @@ packages: '@types/node@18.19.0': resolution: {integrity: sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==} - '@types/node@20.17.6': - resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + '@types/node@20.17.11': + resolution: {integrity: sha512-Ept5glCK35R8yeyIeYlRIZtX6SLRyqMhOFTgj5SOkMpLTdw3SEHI9fHx60xaUZ+V1aJxQJODE+7/j5ocZydYTg==} '@types/parse-json@4.0.0': resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} - '@types/prop-types@15.7.12': - resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} - '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + '@types/prop-types@15.7.14': + resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} + '@types/qs@6.9.7': resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} @@ -2860,8 +2818,10 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react-transition-group@4.4.11': - resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==} + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' '@types/react-virtualized-auto-sizer@1.0.4': resolution: {integrity: sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==} @@ -3789,6 +3749,7 @@ packages: eslint@8.52.0: resolution: {integrity: sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -4104,6 +4065,9 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlightjs-vue@1.0.0: + resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -5361,6 +5325,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.0.0: + resolution: {integrity: sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==} + react-list@0.8.17: resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==} peerDependencies: @@ -5459,8 +5426,8 @@ packages: '@types/react': optional: true - react-syntax-highlighter@15.5.0: - resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} + react-syntax-highlighter@15.6.1: + resolution: {integrity: sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==} peerDependencies: react: '>= 0.14.0' @@ -6410,8 +6377,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@alwaysmeticulous/recorder-loader@2.137.0': {} - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -6623,10 +6588,6 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/runtime@7.25.4': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.25.6': dependencies: regenerator-runtime: 0.14.1 @@ -7018,19 +6979,36 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.7 + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + '@floating-ui/dom@1.6.10': dependencies: '@floating-ui/core': 1.6.7 '@floating-ui/utils': 0.2.7 + '@floating-ui/dom@1.6.12': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + '@floating-ui/react-dom@2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.6.10 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.6.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@floating-ui/utils@0.2.7': {} + '@floating-ui/utils@0.2.8': {} + '@fontsource-variable/inter@5.0.15': {} '@fontsource/ibm-plex-mono@5.1.0': {} @@ -7063,7 +7041,7 @@ snapshots: dependencies: '@inquirer/type': 1.2.0 '@types/mute-stream': 0.0.4 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 chalk: 4.1.2 @@ -7102,27 +7080,27 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -7151,14 +7129,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-mock: 29.6.2 '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -7176,7 +7154,7 @@ snapshots: dependencies: '@jest/types': 29.6.1 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-message-util: 29.6.2 jest-mock: 29.6.2 jest-util: 29.6.2 @@ -7185,7 +7163,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -7207,7 +7185,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -7277,7 +7255,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.5 '@types/istanbul-reports': 3.0.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/yargs': 17.0.29 chalk: 4.1.2 @@ -7286,15 +7264,15 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11))': dependencies: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.6.3) - vite: 5.4.10(@types/node@20.17.6) + vite: 5.4.10(@types/node@20.17.11) optionalDependencies: typescript: 5.6.3 @@ -7359,12 +7337,12 @@ snapshots: outvariant: 1.4.2 strict-event-emitter: 0.5.1 - '@mui/base@5.0.0-beta.40(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/base@5.0.0-beta.40-0(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.15(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.20(@types/react@18.3.12) + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) '@popperjs/core': 2.11.8 clsx: 2.1.1 prop-types: 15.8.1 @@ -7373,24 +7351,24 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@mui/core-downloads-tracker@5.16.7': {} + '@mui/core-downloads-tracker@5.16.13': {} - '@mui/icons-material@5.16.7(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': + '@mui/icons-material@5.16.13(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/material': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/material': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 optionalDependencies: '@types/react': 18.3.12 - '@mui/lab@5.0.0-alpha.173(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/lab@5.0.0-alpha.175(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/base': 5.0.0-beta.40(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/material': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/system': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/types': 7.2.15(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/base': 5.0.0-beta.40-0(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/material': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + '@mui/types': 7.2.20(@types/react@18.3.12) + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 @@ -7400,39 +7378,39 @@ snapshots: '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@types/react': 18.3.12 - '@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/core-downloads-tracker': 5.16.7 - '@mui/system': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/types': 7.2.15(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/core-downloads-tracker': 5.16.13 + '@mui/system': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + '@mui/types': 7.2.20(@types/react@18.3.12) + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.11 + '@types/react-transition-group': 4.4.12(@types/react@18.3.12) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 + react-is: 19.0.0 react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.12)(react@18.3.1) '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@types/react': 18.3.12 - '@mui/private-theming@5.16.6(@types/react@18.3.12)(react@18.3.1)': + '@mui/private-theming@5.16.13(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 optionalDependencies: '@types/react': 18.3.12 - '@mui/styled-engine@5.16.6(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': + '@mui/styled-engine@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.26.0 '@emotion/cache': 11.14.0 csstype: 3.1.3 prop-types: 15.8.1 @@ -7441,13 +7419,13 @@ snapshots: '@emotion/react': 11.14.0(@types/react@18.3.12)(react@18.3.1) '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/system@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': + '@mui/system@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/private-theming': 5.16.6(@types/react@18.3.12)(react@18.3.1) - '@mui/styled-engine': 5.16.6(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) - '@mui/types': 7.2.15(@types/react@18.3.12) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/private-theming': 5.16.13(@types/react@18.3.12)(react@18.3.1) + '@mui/styled-engine': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.20(@types/react@18.3.12) + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 @@ -7457,38 +7435,38 @@ snapshots: '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) '@types/react': 18.3.12 - '@mui/types@7.2.15(@types/react@18.3.12)': + '@mui/types@7.2.20(@types/react@18.3.12)': optionalDependencies: '@types/react': 18.3.12 - '@mui/utils@5.16.6(@types/react@18.3.12)(react@18.3.1)': + '@mui/utils@5.16.13(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.4 - '@mui/types': 7.2.15(@types/react@18.3.12) - '@types/prop-types': 15.7.12 + '@babel/runtime': 7.26.0 + '@mui/types': 7.2.20(@types/react@18.3.12) + '@types/prop-types': 15.7.14 clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 - react-is: 18.3.1 + react-is: 19.0.0 optionalDependencies: '@types/react': 18.3.12 - '@mui/x-internals@7.18.0(@types/react@18.3.12)(react@18.3.1)': + '@mui/x-internals@7.23.0(@types/react@18.3.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.6 - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) + '@babel/runtime': 7.26.0 + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) react: 18.3.1 transitivePeerDependencies: - '@types/react' - '@mui/x-tree-view@7.18.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/x-tree-view@7.23.2(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.6 - '@mui/material': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mui/system': 5.16.7(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) - '@mui/utils': 5.16.6(@types/react@18.3.12)(react@18.3.1) - '@mui/x-internals': 7.18.0(@types/react@18.3.12)(react@18.3.1) - '@types/react-transition-group': 4.4.11 + '@babel/runtime': 7.26.0 + '@mui/material': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mui/system': 5.16.13(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) + '@mui/utils': 5.16.13(@types/react@18.3.12)(react@18.3.1) + '@mui/x-internals': 7.23.0(@types/react@18.3.12)(react@18.3.1) + '@types/react-transition-group': 4.4.12(@types/react@18.3.12) clsx: 2.1.1 prop-types: 15.8.1 react: 18.3.1 @@ -7691,24 +7669,24 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.12)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) aria-hidden: 1.2.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.6.0(@types/react@18.3.12)(react@18.3.1) + react-remove-scroll: 2.6.2(@types/react@18.3.12)(react@18.3.1) optionalDependencies: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 @@ -7733,19 +7711,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.1 - '@radix-ui/react-dismissable-layer@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -7812,17 +7777,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.1 - '@radix-ui/react-focus-scope@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) @@ -7935,16 +7889,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-portal@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.1 - '@radix-ui/react-portal@1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -7966,16 +7910,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-presence@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.12 - '@types/react-dom': 18.3.1 - '@radix-ui/react-presence@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) @@ -8367,13 +8301,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.4.6(storybook@8.4.6(prettier@3.4.1))(vite@5.4.10(@types/node@20.17.6))': + '@storybook/builder-vite@8.4.6(storybook@8.4.6(prettier@3.4.1))(vite@5.4.10(@types/node@20.17.11))': dependencies: '@storybook/csf-plugin': 8.4.6(storybook@8.4.6(prettier@3.4.1)) browser-assert: 1.2.1 storybook: 8.4.6(prettier@3.4.1) ts-dedent: 2.2.0 - vite: 5.4.10(@types/node@20.17.6) + vite: 5.4.10(@types/node@20.17.11) '@storybook/channels@8.1.11': dependencies: @@ -8393,7 +8327,7 @@ snapshots: '@storybook/core-events@8.1.11': dependencies: - '@storybook/csf': 0.1.12 + '@storybook/csf': 0.1.13 ts-dedent: 2.2.0 '@storybook/core@8.4.6(prettier@3.4.1)': @@ -8425,7 +8359,7 @@ snapshots: dependencies: type-fest: 2.19.0 - '@storybook/csf@0.1.12': + '@storybook/csf@0.1.13': dependencies: type-fest: 2.19.0 @@ -8450,17 +8384,21 @@ snapshots: dependencies: storybook: 8.4.6(prettier@3.4.1) + '@storybook/preview-api@8.4.7(storybook@8.4.6(prettier@3.4.1))': + dependencies: + storybook: 8.4.6(prettier@3.4.1) + '@storybook/react-dom-shim@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) storybook: 8.4.6(prettier@3.4.1) - '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.24.3)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6))': + '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.24.3)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11)) '@rollup/pluginutils': 5.0.5(rollup@4.24.3) - '@storybook/builder-vite': 8.4.6(storybook@8.4.6(prettier@3.4.1))(vite@5.4.10(@types/node@20.17.6)) + '@storybook/builder-vite': 8.4.6(storybook@8.4.6(prettier@3.4.1))(vite@5.4.10(@types/node@20.17.11)) '@storybook/react': 8.4.6(@storybook/test@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1))(typescript@5.6.3) find-up: 5.0.0 magic-string: 0.30.5 @@ -8470,7 +8408,7 @@ snapshots: resolve: 1.22.8 storybook: 8.4.6(prettier@3.4.1) tsconfig-paths: 4.2.0 - vite: 5.4.10(@types/node@20.17.6) + vite: 5.4.10(@types/node@20.17.11) transitivePeerDependencies: - '@storybook/test' - rollup @@ -8597,7 +8535,7 @@ snapshots: '@testing-library/dom@9.3.3': dependencies: '@babel/code-frame': 7.25.7 - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.0 '@types/aria-query': 5.0.3 aria-query: 5.1.3 chalk: 4.1.2 @@ -8605,7 +8543,7 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.14)(jest@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)))': + '@testing-library/jest-dom@6.4.6(@jest/globals@29.7.0)(@types/jest@29.5.14)(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)))': dependencies: '@adobe/css-tools': 4.4.0 '@babel/runtime': 7.24.7 @@ -8618,7 +8556,7 @@ snapshots: optionalDependencies: '@jest/globals': 29.7.0 '@types/jest': 29.5.14 - jest: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + jest: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) '@testing-library/jest-dom@6.5.0': dependencies: @@ -8698,7 +8636,7 @@ snapshots: '@types/body-parser@1.19.2': dependencies: '@types/connect': 3.4.35 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/chroma-js@2.4.0': {} @@ -8710,7 +8648,7 @@ snapshots: '@types/connect@3.4.35': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/cookie@0.6.0': {} @@ -8724,7 +8662,7 @@ snapshots: '@types/express-serve-static-core@4.17.35': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 '@types/send': 0.17.1 @@ -8740,7 +8678,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/hast@2.3.8': dependencies: @@ -8784,7 +8722,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/tough-cookie': 4.0.2 parse5: 7.1.2 @@ -8804,22 +8742,22 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/node@18.19.0': dependencies: undici-types: 5.26.5 - '@types/node@20.17.6': + '@types/node@20.17.11': dependencies: undici-types: 6.19.8 '@types/parse-json@4.0.0': {} - '@types/prop-types@15.7.12': {} - '@types/prop-types@15.7.13': {} + '@types/prop-types@15.7.14': {} + '@types/qs@6.9.7': {} '@types/range-parser@1.2.4': {} @@ -8842,7 +8780,7 @@ snapshots: dependencies: '@types/react': 18.3.12 - '@types/react-transition-group@4.4.11': + '@types/react-transition-group@4.4.12(@types/react@18.3.12)': dependencies: '@types/react': 18.3.12 @@ -8870,13 +8808,13 @@ snapshots: '@types/send@0.17.1': dependencies: '@types/mime': 1.3.2 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/serve-static@1.15.2': dependencies: '@types/http-errors': 2.0.1 '@types/mime': 3.0.1 - '@types/node': 20.17.6 + '@types/node': 20.17.11 '@types/ssh2@1.15.1': dependencies: @@ -8919,14 +8857,14 @@ snapshots: '@ungap/structured-clone@1.2.1': optional: true - '@vitejs/plugin-react@4.3.3(vite@5.4.10(@types/node@20.17.6))': + '@vitejs/plugin-react@4.3.3(vite@5.4.10(@types/node@20.17.11))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.10(@types/node@20.17.6) + vite: 5.4.10(@types/node@20.17.11) transitivePeerDependencies: - supports-color @@ -9463,13 +9401,13 @@ snapshots: nan: 2.20.0 optional: true - create-jest@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + create-jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -9640,7 +9578,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.26.0 csstype: 3.1.3 domexception@4.0.0: @@ -10194,6 +10132,8 @@ snapshots: highlight.js@10.7.3: {} + highlightjs-vue@1.0.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -10475,7 +10415,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3(babel-plugin-macros@3.1.0) @@ -10495,16 +10435,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + jest-cli@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + create-jest: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -10514,7 +10454,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + jest-config@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 @@ -10539,8 +10479,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.17.6 - ts-node: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3) + '@types/node': 20.17.11 + ts-node: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10577,7 +10517,7 @@ snapshots: '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 '@types/jsdom': 20.0.1 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-mock: 29.6.2 jest-util: 29.6.2 jsdom: 20.0.3(canvas@3.0.0-rc2) @@ -10593,7 +10533,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -10605,7 +10545,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.17.6 + '@types/node': 20.17.11 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -10661,13 +10601,13 @@ snapshots: jest-mock@29.6.2: dependencies: '@jest/types': 29.6.1 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-util: 29.6.2 jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -10702,7 +10642,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -10730,7 +10670,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 @@ -10776,7 +10716,7 @@ snapshots: jest-util@29.6.2: dependencies: '@jest/types': 29.6.1 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -10785,7 +10725,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -10804,7 +10744,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.17.6 + '@types/node': 20.17.11 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -10818,17 +10758,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.6)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + jest-cli: 29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -11602,13 +11542,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.47 - postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: lilconfig: 3.1.2 yaml: 2.6.0 optionalDependencies: postcss: 8.4.47 - ts-node: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3) + ts-node: 10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3) postcss-nested@6.2.0(postcss@8.4.47): dependencies: @@ -11701,7 +11641,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.17.6 + '@types/node': 20.17.11 long: 5.2.3 proxy-addr@2.0.7: @@ -11803,7 +11743,7 @@ snapshots: react-error-boundary@3.1.4(react@18.3.1): dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.26.0 react: 18.3.1 react-fast-compare@2.0.4: {} @@ -11827,6 +11767,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.0.0: {} + react-list@0.8.17(react@18.3.1): dependencies: prop-types: 15.8.1 @@ -11854,7 +11796,7 @@ snapshots: react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 - react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@18.3.1) tslib: 2.6.2 optionalDependencies: '@types/react': 18.3.12 @@ -11870,10 +11812,10 @@ snapshots: react-remove-scroll@2.5.5(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 - react-remove-scroll-bar: 2.3.6(@types/react@18.3.12)(react@18.3.1) - react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + react-remove-scroll-bar: 2.3.8(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@18.3.1) tslib: 2.6.2 - use-callback-ref: 1.3.2(@types/react@18.3.12)(react@18.3.1) + use-callback-ref: 1.3.3(@types/react@18.3.12)(react@18.3.1) use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) optionalDependencies: '@types/react': 18.3.12 @@ -11893,7 +11835,7 @@ snapshots: dependencies: react: 18.3.1 react-remove-scroll-bar: 2.3.8(@types/react@18.3.12)(react@18.3.1) - react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@18.3.1) tslib: 2.6.2 use-callback-ref: 1.3.3(@types/react@18.3.12)(react@18.3.1) use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) @@ -11929,10 +11871,11 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-syntax-highlighter@15.5.0(react@18.3.1): + react-syntax-highlighter@15.6.1(react@18.3.1): dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.26.0 highlight.js: 10.7.3 + highlightjs-vue: 1.0.0 lowlight: 1.20.0 prismjs: 1.29.0 react: 18.3.1 @@ -11940,7 +11883,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.26.0 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -12282,14 +12225,14 @@ snapshots: dependencies: internal-slot: 1.0.6 - storybook-addon-remix-react-router@3.0.2(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/preview-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + storybook-addon-remix-react-router@3.0.2(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.4.6(prettier@3.4.1)))(@storybook/preview-api@8.4.7(storybook@8.4.6(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.4.6(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@storybook/blocks': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)) '@storybook/channels': 8.1.11 '@storybook/components': 8.4.6(storybook@8.4.6(prettier@3.4.1)) '@storybook/core-events': 8.1.11 '@storybook/manager-api': 8.4.6(storybook@8.4.6(prettier@3.4.1)) - '@storybook/preview-api': 8.4.6(storybook@8.4.6(prettier@3.4.1)) + '@storybook/preview-api': 8.4.7(storybook@8.4.6(prettier@3.4.1)) '@storybook/theming': 8.4.6(storybook@8.4.6(prettier@3.4.1)) compare-versions: 6.1.0 react-inspector: 6.0.2(react@18.3.1) @@ -12300,7 +12243,7 @@ snapshots: storybook-react-context@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.6(prettier@3.4.1)): dependencies: - '@storybook/preview-api': 8.4.6(storybook@8.4.6(prettier@3.4.1)) + '@storybook/preview-api': 8.4.7(storybook@8.4.6(prettier@3.4.1)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -12407,11 +12350,11 @@ snapshots: tailwind-merge@2.5.4: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3))): dependencies: - tailwindcss: 3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + tailwindcss: 3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) - tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)): + tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -12430,7 +12373,7 @@ snapshots: postcss: 8.4.47 postcss-import: 15.1.0(postcss@8.4.47) postcss-js: 4.0.1(postcss@8.4.47) - postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3)) + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3)) postcss-nested: 6.2.0(postcss@8.4.47) postcss-selector-parser: 6.1.2 resolve: 1.22.8 @@ -12529,14 +12472,14 @@ snapshots: '@ts-morph/common': 0.12.3 code-block-writer: 11.0.3 - ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.6)(typescript@5.6.3): + ts-node@10.9.1(@swc/core@1.3.38)(@types/node@20.17.11)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.6 + '@types/node': 20.17.11 acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 @@ -12750,7 +12693,7 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 - vite-plugin-checker@0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6)): + vite-plugin-checker@0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.11)): dependencies: '@babel/code-frame': 7.25.7 ansi-escapes: 4.3.2 @@ -12762,7 +12705,7 @@ snapshots: npm-run-path: 4.0.1 strip-ansi: 6.0.1 tiny-invariant: 1.3.3 - vite: 5.4.10(@types/node@20.17.6) + vite: 5.4.10(@types/node@20.17.11) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.12 @@ -12775,13 +12718,13 @@ snapshots: vite-plugin-turbosnap@1.0.3: {} - vite@5.4.10(@types/node@20.17.6): + vite@5.4.10(@types/node@20.17.11): dependencies: esbuild: 0.21.5 postcss: 8.4.47 rollup: 4.24.3 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 20.17.11 fsevents: 2.3.3 vscode-jsonrpc@6.0.0: {} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 74ca5ce685007..6b0e685b177eb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2097,19 +2097,6 @@ class ApiMethods { return response.data; }; - getInsightsUserStatusCountsOverTime = async ( - offset = Math.trunc(new Date().getTimezoneOffset() / 60), - ): Promise => { - const searchParams = new URLSearchParams({ - offset: offset.toString(), - }); - const response = await this.axios.get( - `/api/v2/insights/user-status-counts-over-time?${searchParams}`, - ); - - return response.data; - }; - getHealth = async (force = false) => { const params = new URLSearchParams({ force: force.toString() }); const response = await this.axios.get( diff --git a/site/src/api/queries/insights.ts b/site/src/api/queries/insights.ts index 8f56b5982cd84..a7044a2f2469f 100644 --- a/site/src/api/queries/insights.ts +++ b/site/src/api/queries/insights.ts @@ -20,10 +20,3 @@ export const insightsUserActivity = (params: InsightsParams) => { queryFn: () => API.getInsightsUserActivity(params), }; }; - -export const userStatusCountsOverTime = () => { - return { - queryKey: ["userStatusCountsOverTime"], - queryFn: () => API.getInsightsUserStatusCountsOverTime(), - }; -}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 833d88e6baeef..77d879abe3258 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -9,6 +9,7 @@ import type { UpdateUserProfileRequest, User, UsersRequest, + ValidateUserPasswordRequest, } from "api/typesGenerated"; import { type MetadataState, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8e38afb315ae6..c605268c9d920 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -775,16 +775,6 @@ export interface GenerateAPIKeyResponse { readonly key: string; } -// From codersdk/insights.go -export interface GetUserStatusCountsOverTimeRequest { - readonly offset: string; -} - -// From codersdk/insights.go -export interface GetUserStatusCountsOverTimeResponse { - readonly status_counts: Record; -} - // From codersdk/users.go export interface GetUsersResponse { readonly users: readonly User[]; @@ -1558,15 +1548,9 @@ export interface ResolveAutostartResponse { } // From codersdk/audit.go -export type ResourceType = "api_key" | "convert_login" | "custom_role" | "git_ssh_key" | "group" | "health_settings" | "license" | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" | "template" | "template_version" | "user" | "workspace" | "workspace_build" | "workspace_proxy"; +export type ResourceType = "api_key" | "convert_login" | "custom_role" | "git_ssh_key" | "group" | "health_settings" | "idp_sync_settings_group" | "idp_sync_settings_organization" | "idp_sync_settings_role" | "license" | "notification_template" | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" | "organization_member" | "template" | "template_version" | "user" | "workspace" | "workspace_build" | "workspace_proxy"; -// From codersdk/audit.go -export const ResourceTypeNotificationTemplate = "notification_template"; - -// From codersdk/audit.go -export const ResourceTypeOrganizationMember = "organization_member"; - -export const ResourceTypes: ResourceType[] = ["api_key", "convert_login", "custom_role", "git_ssh_key", "group", "health_settings", "license", "notifications_settings", "oauth2_provider_app", "oauth2_provider_app_secret", "organization", "template", "template_version", "user", "workspace", "workspace_build", "workspace_proxy"]; +export const ResourceTypes: ResourceType[] = ["api_key", "convert_login", "custom_role", "git_ssh_key", "group", "health_settings", "idp_sync_settings_group", "idp_sync_settings_organization", "idp_sync_settings_role", "license", "notification_template", "notifications_settings", "oauth2_provider_app", "oauth2_provider_app_secret", "organization", "organization_member", "template", "template_version", "user", "workspace", "workspace_build", "workspace_proxy"]; // From codersdk/client.go export interface Response { @@ -2332,12 +2316,6 @@ export interface UserRoles { // From codersdk/users.go export type UserStatus = "active" | "dormant" | "suspended"; -// From codersdk/insights.go -export interface UserStatusChangeCount { - readonly date: string; - readonly count: number; -} - export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; // From codersdk/users.go diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx index b77886b63fd2a..4f28d7243a0bf 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx @@ -5,19 +5,14 @@ const meta: Meta = { title: "components/ActiveUserChart", component: ActiveUserChart, args: { - series: [ - { - label: "Daily", - data: [ - { date: "1/1/2024", amount: 5 }, - { date: "1/2/2024", amount: 6 }, - { date: "1/3/2024", amount: 7 }, - { date: "1/4/2024", amount: 8 }, - { date: "1/5/2024", amount: 9 }, - { date: "1/6/2024", amount: 10 }, - { date: "1/7/2024", amount: 11 }, - ], - }, + data: [ + { date: "1/1/2024", amount: 5 }, + { date: "1/2/2024", amount: 6 }, + { date: "1/3/2024", amount: 7 }, + { date: "1/4/2024", amount: 8 }, + { date: "1/5/2024", amount: 9 }, + { date: "1/6/2024", amount: 10 }, + { date: "1/7/2024", amount: 11 }, ], interval: "day", }, @@ -27,51 +22,3 @@ export default meta; type Story = StoryObj; export const Example: Story = {}; - -export const MultipleSeries: Story = { - args: { - series: [ - { - label: "Active", - data: [ - { date: "1/1/2024", amount: 150 }, - { date: "1/2/2024", amount: 165 }, - { date: "1/3/2024", amount: 180 }, - { date: "1/4/2024", amount: 155 }, - { date: "1/5/2024", amount: 190 }, - { date: "1/6/2024", amount: 200 }, - { date: "1/7/2024", amount: 210 }, - ], - color: "green", - }, - { - label: "Dormant", - data: [ - { date: "1/1/2024", amount: 80 }, - { date: "1/2/2024", amount: 82 }, - { date: "1/3/2024", amount: 85 }, - { date: "1/4/2024", amount: 88 }, - { date: "1/5/2024", amount: 90 }, - { date: "1/6/2024", amount: 92 }, - { date: "1/7/2024", amount: 95 }, - ], - color: "grey", - }, - { - label: "Suspended", - data: [ - { date: "1/1/2024", amount: 20 }, - { date: "1/2/2024", amount: 22 }, - { date: "1/3/2024", amount: 25 }, - { date: "1/4/2024", amount: 23 }, - { date: "1/5/2024", amount: 28 }, - { date: "1/6/2024", amount: 30 }, - { date: "1/7/2024", amount: 32 }, - ], - color: "red", - }, - ], - interval: "day", - userLimit: 100, - }, -}; diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx index 10acb6ec9fc90..41345ea8f03f8 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -1,7 +1,5 @@ import "chartjs-adapter-date-fns"; import { useTheme } from "@emotion/react"; -import LaunchOutlined from "@mui/icons-material/LaunchOutlined"; -import Button from "@mui/material/Button"; import { CategoryScale, Chart as ChartJS, @@ -16,7 +14,6 @@ import { Tooltip, defaults, } from "chart.js"; -import annotationPlugin from "chartjs-plugin-annotation"; import { HelpTooltip, HelpTooltipContent, @@ -38,51 +35,32 @@ ChartJS.register( Title, Tooltip, Legend, - annotationPlugin, ); -export interface DataSeries { - label?: string; - data: readonly { date: string; amount: number }[]; - color?: string; // Optional custom color -} - export interface ActiveUserChartProps { - series: DataSeries[]; - userLimit?: number; + data: readonly { date: string; amount: number }[]; interval: "day" | "week"; } export const ActiveUserChart: FC = ({ - series, - userLimit, + data, interval, }) => { const theme = useTheme(); + const labels = data.map((val) => dayjs(val.date).format("YYYY-MM-DD")); + const chartData = data.map((val) => val.amount); + defaults.font.family = theme.typography.fontFamily as string; defaults.color = theme.palette.text.secondary; const options: ChartOptions<"line"> = { responsive: true, animation: false, - interaction: { - mode: "index", - }, plugins: { - legend: - series.length > 1 - ? { - display: false, - position: "top" as const, - labels: { - usePointStyle: true, - pointStyle: "line", - }, - } - : { - display: false, - }, + legend: { + display: false, + }, tooltip: { displayColors: false, callbacks: { @@ -92,24 +70,6 @@ export const ActiveUserChart: FC = ({ }, }, }, - annotation: { - annotations: [ - { - type: "line", - scaleID: "y", - value: userLimit, - borderColor: "white", - borderWidth: 2, - label: { - content: "Active User limit", - color: theme.palette.primary.contrastText, - display: true, - textStrokeWidth: 2, - textStrokeColor: theme.palette.background.paper, - }, - }, - ], - }, }, scales: { y: { @@ -118,12 +78,11 @@ export const ActiveUserChart: FC = ({ ticks: { precision: 0, }, - stacked: true, }, x: { grid: { color: theme.palette.divider }, ticks: { - stepSize: series[0].data.length > 10 ? 2 : undefined, + stepSize: data.length > 10 ? 2 : undefined, }, type: "time", time: { @@ -138,16 +97,16 @@ export const ActiveUserChart: FC = ({ - dayjs(val.date).format("YYYY-MM-DD"), - ), - datasets: series.map((s) => ({ - label: s.label, - data: s.data.map((val) => val.amount), - pointBackgroundColor: s.color || theme.roles.active.outline, - pointBorderColor: s.color || theme.roles.active.outline, - borderColor: s.color || theme.roles.active.outline, - })), + labels: labels, + datasets: [ + { + label: `${interval === "day" ? "Daily" : "Weekly"} Active Users`, + data: chartData, + pointBackgroundColor: theme.roles.active.outline, + pointBorderColor: theme.roles.active.outline, + borderColor: theme.roles.active.outline, + }, + ], }} options={options} /> @@ -161,13 +120,11 @@ type ActiveUsersTitleProps = { export const ActiveUsersTitle: FC = ({ interval }) => { return (
- {interval === "day" ? "Daily" : "Weekly"} User Activity + {interval === "day" ? "Daily" : "Weekly"} Active Users - - How do we calculate user activity? - + How do we calculate active users? When a connection is initiated to a user's workspace they are considered an active user. e.g. apps, web terminal, SSH. This is for @@ -179,39 +136,3 @@ export const ActiveUsersTitle: FC = ({ interval }) => {
); }; - -export type UserStatusTitleProps = { - interval: "day" | "week"; -}; - -export const UserStatusTitle: FC = ({ interval }) => { - return ( -
- {interval === "day" ? "Daily" : "Weekly"} User Status - - - - What are user statuses? - - - Active users count towards your license consumption. Dormant or - suspended users do not. Any user who has logged into the coder - platform within the last 90 days is considered active. - - - - - -
- ); -}; diff --git a/site/src/components/Breadcrumb/Breadcrumb.tsx b/site/src/components/Breadcrumb/Breadcrumb.tsx index cd6625a42cca3..35f90d30a5d7b 100644 --- a/site/src/components/Breadcrumb/Breadcrumb.tsx +++ b/site/src/components/Breadcrumb/Breadcrumb.tsx @@ -28,7 +28,7 @@ export const BreadcrumbList = forwardRef<
    = forwardRef< - HTMLButtonElement, - ButtonProps ->(({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); -}); +export const Button = forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index 8008ea9d6c27e..c924317b20f87 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -7,10 +7,12 @@ */ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { Check, ChevronRight, Circle } from "lucide-react"; +import { Button } from "components/Button/Button"; +import { Check, ChevronDownIcon, ChevronRight, Circle } from "lucide-react"; import { type ComponentPropsWithoutRef, type ElementRef, + type FC, type HTMLAttributes, forwardRef, } from "react"; @@ -196,7 +198,7 @@ export const DropdownMenuSeparator = forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/site/src/components/Loader/Loader.tsx b/site/src/components/Loader/Loader.tsx index 589cbd72b2331..0121b352eaeb1 100644 --- a/site/src/components/Loader/Loader.tsx +++ b/site/src/components/Loader/Loader.tsx @@ -1,5 +1,5 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { Spinner } from "components/Spinner/Spinner"; +import { Spinner } from "components/deprecated/Spinner/Spinner"; import type { FC, HTMLAttributes } from "react"; interface LoaderProps extends HTMLAttributes { diff --git a/site/src/components/Spinner/Spinner.stories.tsx b/site/src/components/Spinner/Spinner.stories.tsx new file mode 100644 index 0000000000000..f1cd9e1b24ff2 --- /dev/null +++ b/site/src/components/Spinner/Spinner.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlusIcon } from "lucide-react"; +import { Spinner } from "./Spinner"; + +const meta: Meta = { + title: "components/Spinner", + component: Spinner, + args: { + children: , + }, +}; + +export default meta; +type Story = StoryObj; + +export const Idle: Story = {}; + +export const Loading: Story = { + args: { loading: true }, +}; diff --git a/site/src/components/Spinner/Spinner.tsx b/site/src/components/Spinner/Spinner.tsx index 5b20e2e54c5ef..8e331a1a6dd20 100644 --- a/site/src/components/Spinner/Spinner.tsx +++ b/site/src/components/Spinner/Spinner.tsx @@ -1,22 +1,77 @@ -import CircularProgress, { - type CircularProgressProps, -} from "@mui/material/CircularProgress"; -import isChromatic from "chromatic/isChromatic"; -import type { FC } from "react"; - /** - * Spinner component used to indicate loading states. This component abstracts - * the MUI CircularProgress to provide better control over its rendering, - * especially in snapshot tests with Chromatic. + * This component was inspired by + * https://www.radix-ui.com/themes/docs/components/spinner and developed using + * https://v0.dev/ help. */ -export const Spinner: FC = (props) => { - /** - * During Chromatic snapshots, we render the spinner as determinate to make it - * static without animations, using a deterministic value (75%). - */ - if (isChromatic()) { - props.variant = "determinate"; - props.value = 75; + +import isChromatic from "chromatic/isChromatic"; +import { type VariantProps, cva } from "class-variance-authority"; +import type { ReactNode } from "react"; +import { cn } from "utils/cn"; + +const leaves = 8; + +const spinnerVariants = cva("", { + variants: { + size: { + lg: "size-icon-lg", + sm: "size-icon-sm", + }, + }, + defaultVariants: { + size: "lg", + }, +}); + +type SpinnerProps = React.SVGProps & + VariantProps & { + children?: ReactNode; + loading?: boolean; + }; + +export function Spinner({ + className, + size, + loading, + children, + ...props +}: SpinnerProps) { + if (!loading) { + return children; } - return ; -}; + + return ( + + Loading spinner + {[...Array(leaves)].map((_, i) => { + const rotation = i * (360 / leaves); + + return ( + + ); + })} + + ); +} diff --git a/site/src/components/deprecated/Spinner/Spinner.tsx b/site/src/components/deprecated/Spinner/Spinner.tsx new file mode 100644 index 0000000000000..35fc7e9e177b0 --- /dev/null +++ b/site/src/components/deprecated/Spinner/Spinner.tsx @@ -0,0 +1,24 @@ +import CircularProgress, { + type CircularProgressProps, +} from "@mui/material/CircularProgress"; +import isChromatic from "chromatic/isChromatic"; +import type { FC } from "react"; + +/** + * Spinner component used to indicate loading states. This component abstracts + * the MUI CircularProgress to provide better control over its rendering, + * especially in snapshot tests with Chromatic. + * + * @deprecated prefer `components.Spinner` + */ +export const Spinner: FC = (props) => { + /** + * During Chromatic snapshots, we render the spinner as determinate to make it + * static without animations, using a deterministic value (75%). + */ + if (isChromatic()) { + props.variant = "determinate"; + props.value = 75; + } + return ; +}; diff --git a/site/src/contexts/ProxyContext.tsx b/site/src/contexts/ProxyContext.tsx index fb43291dd48a6..1aa749e83edf4 100644 --- a/site/src/contexts/ProxyContext.tsx +++ b/site/src/contexts/ProxyContext.tsx @@ -15,6 +15,8 @@ import { import { useQuery } from "react-query"; import { type ProxyLatencyReport, useProxyLatency } from "./useProxyLatency"; +export type Proxies = readonly Region[] | readonly WorkspaceProxy[]; +export type ProxyLatencies = Record; export interface ProxyContextValue { // proxy is **always** the workspace proxy that should be used. // The 'proxy.selectedProxy' field is the proxy being used and comes from either: @@ -43,7 +45,7 @@ export interface ProxyContextValue { // WorkspaceProxy[] is returned if the user is an admin. WorkspaceProxy extends Region with // more information about the proxy and the status. More information includes the error message if // the proxy is unhealthy. - proxies?: readonly Region[] | readonly WorkspaceProxy[]; + proxies?: Proxies; // isFetched is true when the 'proxies' api call is complete. isFetched: boolean; isLoading: boolean; @@ -51,7 +53,7 @@ export interface ProxyContextValue { // proxyLatencies is a map of proxy id to latency report. If the proxyLatencies[proxy.id] is undefined // then the latency has not been fetched yet. Calculations happen async for each proxy in the list. // Refer to the returned report for a given proxy for more information. - proxyLatencies: Record; + proxyLatencies: ProxyLatencies; // refetchProxyLatencies will trigger refreshing of the proxy latencies. By default the latencies // are loaded once. refetchProxyLatencies: () => Date; diff --git a/site/src/index.css b/site/src/index.css index c97e827b98a0f..5f690b5616bca 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -70,4 +70,17 @@ * { @apply border-border; } + + /* + By default, Radix adds a margin to the `body` element when a dropdown is displayed, + causing some shifting when the dropdown has a full-width size, as is the case with the mobile menu. + To prevent this, we need to apply the styles below. + + There’s a related issue on GitHub: Radix UI Primitives Issue #3251 + https://github.com/radix-ui/primitives/issues/3251 + */ + html body[data-scroll-locked] { + --removed-body-scroll-bar-size: 0 !important; + margin-right: 0 !important; + } } diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index dc4b1b4d92fde..d1a75c02cd315 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -1,7 +1,6 @@ import { type Interpolation, type Theme, css, useTheme } from "@emotion/react"; -import Button from "@mui/material/Button"; import MenuItem from "@mui/material/MenuItem"; -import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { Button } from "components/Button/Button"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Popover, @@ -9,6 +8,7 @@ import { PopoverTrigger, usePopover, } from "components/deprecated/Popover/Popover"; +import { ChevronDownIcon } from "lucide-react"; import { linkToAuditing } from "modules/navigation"; import type { FC } from "react"; import { NavLink } from "react-router-dom"; @@ -16,7 +16,6 @@ import { NavLink } from "react-router-dom"; interface DeploymentDropdownProps { canViewDeployment: boolean; canViewOrganizations: boolean; - canViewAllUsers: boolean; canViewAuditLog: boolean; canViewHealth: boolean; } @@ -24,7 +23,6 @@ interface DeploymentDropdownProps { export const DeploymentDropdown: FC = ({ canViewDeployment, canViewOrganizations, - canViewAllUsers, canViewAuditLog, canViewHealth, }) => { @@ -34,7 +32,6 @@ export const DeploymentDropdown: FC = ({ !canViewAuditLog && !canViewOrganizations && !canViewDeployment && - !canViewAllUsers && !canViewHealth ) { return null; @@ -43,17 +40,9 @@ export const DeploymentDropdown: FC = ({ return ( - @@ -70,7 +59,6 @@ export const DeploymentDropdown: FC = ({ diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx new file mode 100644 index 0000000000000..19c66c14b38a7 --- /dev/null +++ b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn, userEvent, within } from "@storybook/test"; +import { PointerEventsCheckLevel } from "@testing-library/user-event"; +import type { FC } from "react"; +import { chromaticWithTablet } from "testHelpers/chromatic"; +import { + MockPrimaryWorkspaceProxy, + MockProxyLatencies, + MockSupportLinks, + MockUser, + MockUser2, + MockWorkspaceProxies, +} from "testHelpers/entities"; +import { MobileMenu } from "./MobileMenu"; + +const meta: Meta = { + title: "modules/dashboard/MobileMenu", + parameters: { + layout: "fullscreen", + viewport: { + defaultViewport: "iphone12", + }, + }, + component: MobileMenu, + args: { + proxyContextValue: { + proxy: { + preferredPathAppURL: "", + preferredWildcardHostname: "", + proxy: MockPrimaryWorkspaceProxy, + }, + isLoading: false, + isFetched: true, + setProxy: fn(), + clearProxy: fn(), + refetchProxyLatencies: fn(), + proxyLatencies: MockProxyLatencies, + proxies: MockWorkspaceProxies, + }, + user: MockUser, + supportLinks: MockSupportLinks, + docsHref: "https://coder.com/docs", + onSignOut: fn(), + isDefaultOpen: true, + canViewAuditLog: true, + canViewDeployment: true, + canViewHealth: true, + canViewOrganizations: true, + }, + decorators: [withNavbarMock], +}; + +export default meta; +type Story = StoryObj; + +export const Closed: Story = { + args: { + isDefaultOpen: false, + }, +}; + +export const Admin: Story = { + play: openAdminSettings, +}; + +export const Auditor: Story = { + args: { + user: MockUser2, + canViewAuditLog: true, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: false, + }, + play: openAdminSettings, +}; + +export const OrgAdmin: Story = { + args: { + user: MockUser2, + canViewAuditLog: true, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: true, + }, + play: openAdminSettings, +}; + +export const Member: Story = { + args: { + user: MockUser2, + canViewAuditLog: false, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: false, + }, +}; + +export const ProxySettings: Story = { + play: async ({ canvasElement }) => { + const user = setupUser(); + const body = within(canvasElement.ownerDocument.body); + const menuItem = await body.findByRole("menuitem", { + name: /workspace proxy settings/i, + }); + await user.click(menuItem); + }, +}; + +export const UserSettings: Story = { + play: async ({ canvasElement }) => { + const user = setupUser(); + const body = within(canvasElement.ownerDocument.body); + const menuItem = await body.findByRole("menuitem", { + name: /user settings/i, + }); + await user.click(menuItem); + }, +}; + +function withNavbarMock(Story: FC) { + return ( +
    + +
    + ); +} + +function setupUser() { + // It seems the dropdown component is disabling pointer events, which is + // causing Testing Library to throw an error. As a workaround, we can + // disable the pointer events check. + return userEvent.setup({ + pointerEventsCheck: PointerEventsCheckLevel.Never, + }); +} + +async function openAdminSettings({ + canvasElement, +}: { canvasElement: HTMLElement }) { + const user = setupUser(); + const body = within(canvasElement.ownerDocument.body); + const menuItem = await body.findByRole("menuitem", { + name: /admin settings/i, + }); + await user.click(menuItem); +} diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx new file mode 100644 index 0000000000000..e04bb7328d78c --- /dev/null +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -0,0 +1,339 @@ +import type * as TypesGen from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Button } from "components/Button/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "components/Collapsible/Collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Latency } from "components/Latency/Latency"; +import type { ProxyContextValue } from "contexts/ProxyContext"; +import { + ChevronRightIcon, + CircleHelpIcon, + MenuIcon, + XIcon, +} from "lucide-react"; +import { type FC, useState } from "react"; +import { Link } from "react-router-dom"; +import { cn } from "utils/cn"; +import { sortProxiesByLatency } from "./proxyUtils"; + +const itemStyles = { + default: "px-9 h-10 no-underline", + sub: "pl-12", + open: "text-content-primary", +}; + +type MobileMenuPermissions = { + canViewDeployment: boolean; + canViewOrganizations: boolean; + canViewAuditLog: boolean; + canViewHealth: boolean; +}; + +type MobileMenuProps = MobileMenuPermissions & { + proxyContextValue?: ProxyContextValue; + user?: TypesGen.User; + supportLinks?: readonly TypesGen.LinkConfig[]; + docsHref: string; + onSignOut: () => void; + isDefaultOpen?: boolean; // Useful for storybook +}; + +export const MobileMenu: FC = ({ + isDefaultOpen, + proxyContextValue, + user, + supportLinks, + docsHref, + onSignOut, + ...permissions +}) => { + const [open, setOpen] = useState(isDefaultOpen); + const hasSomePermission = Object.values(permissions).some((p) => p); + + return ( + + {open && ( +
    + )} + + + + + + + {hasSomePermission && ( + <> + + + + )} + + + + Docs + + + + + + + ); +}; + +type ProxySettingsSubProps = { + proxyContextValue?: ProxyContextValue; +}; + +const ProxySettingsSub: FC = ({ proxyContextValue }) => { + const selectedProxy = proxyContextValue?.proxy.proxy; + const latency = selectedProxy + ? proxyContextValue?.proxyLatencies[selectedProxy?.id] + : undefined; + const [open, setOpen] = useState(false); + + if (!selectedProxy) { + return null; + } + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + Workspace proxy settings: + + {selectedProxy.name} + {latency && } + + + + + + {proxyContextValue.proxies && + sortProxiesByLatency( + proxyContextValue.proxies, + proxyContextValue.proxyLatencies, + ).map((p) => { + const latency = proxyContextValue.proxyLatencies[p.id]; + return ( + { + e.preventDefault(); + + if (!p.healthy) { + displayError("Please select a healthy workspace proxy."); + return; + } + + proxyContextValue.setProxy(p); + setOpen(false); + }} + > + {p.name} + {p.display_name || p.name} + {latency ? ( + + ) : ( + + )} + + ); + })} + + + Proxy settings + + { + proxyContextValue.refetchProxyLatencies(); + }} + > + Refresh latencies + + + + ); +}; + +const AdminSettingsSub: FC = ({ + canViewDeployment, + canViewOrganizations, + canViewAuditLog, + canViewHealth, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + Admin settings + + + + + {canViewDeployment && ( + + Deployment + + )} + {canViewOrganizations && ( + + + Organizations + + + + )} + {canViewAuditLog && ( + + Audit logs + + )} + {canViewHealth && ( + + Healthcheck + + )} + + + ); +}; + +type UserSettingsSubProps = { + user?: TypesGen.User; + supportLinks?: readonly TypesGen.LinkConfig[]; + onSignOut: () => void; +}; + +const UserSettingsSub: FC = ({ + user, + supportLinks, + onSignOut, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + { + e.preventDefault(); + setOpen((prev) => !prev); + }} + > + + User settings + + + + + + Account + + + Sign out + + {supportLinks && ( + <> + + {supportLinks?.map((l) => ( + + + {l.name} + + + ))} + + )} + + + ); +}; diff --git a/site/src/modules/dashboard/Navbar/Navbar.test.tsx b/site/src/modules/dashboard/Navbar/Navbar.test.tsx index e01a1506f4c9c..aa9a2c0400e10 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.test.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.test.tsx @@ -7,7 +7,6 @@ import { MockMemberPermissions, } from "testHelpers/entities"; import { server } from "testHelpers/server"; -import { Language } from "./NavbarView"; /** * The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their @@ -24,13 +23,7 @@ describe("Navbar", () => { render(); const deploymentMenu = await screen.findByText("Admin settings"); await userEvent.click(deploymentMenu); - await waitFor( - () => { - const link = screen.getByText(Language.audit); - expect(link).toBeDefined(); - }, - { timeout: 2000 }, - ); + await screen.findByText("Audit Logs"); }); it("does not show Audit Log link when not entitled", async () => { @@ -41,8 +34,7 @@ describe("Navbar", () => { await userEvent.click(deploymentMenu); await waitFor( () => { - const link = screen.queryByText(Language.audit); - expect(link).toBe(null); + expect(screen.queryByText("Audit Logs")).not.toBeInTheDocument(); }, { timeout: 2000 }, ); @@ -64,8 +56,7 @@ describe("Navbar", () => { render(); await waitFor( () => { - const link = screen.queryByText("Deployment"); - expect(link).toBe(null); + expect(screen.queryByText("Deployment")).not.toBeInTheDocument(); }, { timeout: 2000 }, ); diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 5a3e86832ee43..5c3ccb72ff97e 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -20,7 +20,6 @@ export const Navbar: FC = () => { const canViewDeployment = Boolean(permissions.viewDeploymentValues); const canViewOrganizations = Boolean(permissions.editAnyOrganization) && showOrganizations; - const canViewAllUsers = Boolean(permissions.viewAllUsers); const proxyContextValue = useProxy(); const canViewHealth = canViewDeployment; @@ -33,10 +32,10 @@ export const Navbar: FC = () => { onSignOut={signOut} canViewDeployment={canViewDeployment} canViewOrganizations={canViewOrganizations} - canViewAllUsers={canViewAllUsers} canViewHealth={canViewHealth} canViewAuditLog={canViewAuditLog} proxyContextValue={proxyContextValue} + docsHref={appearance.docs_url} /> ); }; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 6ac0e51087dfa..ae13c7fcc9129 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -11,7 +11,6 @@ const meta: Meta = { component: NavbarView, args: { user: MockUser, - canViewAllUsers: true, canViewAuditLog: true, canViewDeployment: true, canViewHealth: true, @@ -35,7 +34,6 @@ export const ForAdmin: Story = { export const ForAuditor: Story = { args: { user: MockUser2, - canViewAllUsers: false, canViewAuditLog: true, canViewDeployment: false, canViewHealth: false, @@ -52,7 +50,6 @@ export const ForAuditor: Story = { export const ForOrgAdmin: Story = { args: { user: MockUser2, - canViewAllUsers: false, canViewAuditLog: true, canViewDeployment: false, canViewHealth: false, @@ -69,7 +66,6 @@ export const ForOrgAdmin: Story = { export const ForMember: Story = { args: { user: MockUser2, - canViewAllUsers: false, canViewAuditLog: false, canViewDeployment: false, canViewHealth: false, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index 3dd4251385e20..7b51561ddea5a 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { MockPrimaryWorkspaceProxy, MockUser } from "testHelpers/entities"; import { renderWithAuth } from "testHelpers/renderHelpers"; -import { NavbarView, Language as navLanguage } from "./NavbarView"; +import { NavbarView } from "./NavbarView"; const proxyContextValue: ProxyContextValue = { proxy: { @@ -25,76 +25,75 @@ describe("NavbarView", () => { it("workspaces nav link has the correct href", async () => { renderWithAuth( , ); - const workspacesLink = await screen.findByText(navLanguage.workspaces); - expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces"); + const workspacesLink = + await screen.findByText(/workspaces/i); + expect(workspacesLink.href).toContain("/workspaces"); }); it("templates nav link has the correct href", async () => { renderWithAuth( , ); - const templatesLink = await screen.findByText(navLanguage.templates); - expect((templatesLink as HTMLAnchorElement).href).toContain("/templates"); + const templatesLink = + await screen.findByText(/templates/i); + expect(templatesLink.href).toContain("/templates"); }); it("audit nav link has the correct href", async () => { renderWithAuth( , ); const deploymentMenu = await screen.findByText("Admin settings"); await userEvent.click(deploymentMenu); - const auditLink = await screen.findByText(navLanguage.audit); - expect((auditLink as HTMLAnchorElement).href).toContain("/audit"); + const auditLink = await screen.findByText(/audit logs/i); + expect(auditLink.href).toContain("/audit"); }); it("deployment nav link has the correct href", async () => { renderWithAuth( , ); const deploymentMenu = await screen.findByText("Admin settings"); await userEvent.click(deploymentMenu); - const deploymentSettingsLink = await screen.findByText( - navLanguage.deployment, - ); - expect((deploymentSettingsLink as HTMLAnchorElement).href).toContain( - "/deployment/general", - ); + const deploymentSettingsLink = + await screen.findByText(/deployment/i); + expect(deploymentSettingsLink.href).toContain("/deployment/general"); }); }); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 662e39ca9d02c..ec3a1c690bb60 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,239 +1,134 @@ -import { type Interpolation, type Theme, css, useTheme } from "@emotion/react"; -import MenuIcon from "@mui/icons-material/Menu"; -import Drawer from "@mui/material/Drawer"; -import IconButton from "@mui/material/IconButton"; import type * as TypesGen from "api/typesGenerated"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import type { ProxyContextValue } from "contexts/ProxyContext"; -import { type FC, useState } from "react"; +import type { FC } from "react"; import { NavLink, useLocation } from "react-router-dom"; -import { navHeight } from "theme/constants"; +import { cn } from "utils/cn"; import { DeploymentDropdown } from "./DeploymentDropdown"; +import { MobileMenu } from "./MobileMenu"; import { ProxyMenu } from "./ProxyMenu"; import { UserDropdown } from "./UserDropdown/UserDropdown"; export interface NavbarViewProps { logo_url?: string; user?: TypesGen.User; + docsHref: string; buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: readonly TypesGen.LinkConfig[]; onSignOut: () => void; canViewDeployment: boolean; canViewOrganizations: boolean; - canViewAllUsers: boolean; canViewAuditLog: boolean; canViewHealth: boolean; proxyContextValue?: ProxyContextValue; } -export const Language = { - workspaces: "Workspaces", - templates: "Templates", - users: "Users", - audit: "Audit Logs", - deployment: "Deployment", -}; - -interface NavItemsProps { - className?: string; -} - -const NavItems: FC = ({ className }) => { - const location = useLocation(); - const theme = useTheme(); - - return ( - - ); +const linkStyles = { + default: + "text-sm font-medium text-content-secondary no-underline block h-full px-2 flex items-center hover:text-content-primary transition-colors", + active: "text-content-primary", }; export const NavbarView: FC = ({ user, logo_url, + docsHref, buildInfo, supportLinks, onSignOut, canViewDeployment, canViewOrganizations, - canViewAllUsers, canViewHealth, canViewAuditLog, proxyContextValue, }) => { - const theme = useTheme(); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - return ( - + + +
    ); }; -const styles = { - desktopNavItems: (theme) => css` - display: none; - - ${theme.breakpoints.up("md")} { - display: flex; - } - `, - mobileMenuButton: (theme) => css` - ${theme.breakpoints.up("md")} { - display: none; - } - `, - navMenus: (theme) => ({ - display: "flex", - gap: 16, - alignItems: "center", - paddingRight: 16, - - [theme.breakpoints.up("md")]: { - marginLeft: "auto", - }, - }), - wrapper: (theme) => css` - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - - ${theme.breakpoints.up("md")} { - justify-content: flex-start; - } - `, - drawerHeader: { - padding: 16, - paddingTop: 32, - paddingBottom: 32, - }, - logo: (theme) => css` - align-items: center; - display: flex; - height: ${navHeight}px; - color: ${theme.palette.text.primary}; - padding: 16px; - - // svg is for the Coder logo, img is for custom images - & svg, - & img { - height: 100%; - object-fit: contain; - } - `, - drawerLogo: { - padding: 0, - maxHeight: 40, - }, - link: (theme) => css` - align-items: center; - color: ${theme.palette.text.secondary}; - display: flex; - flex: 1; - font-size: 16px; - padding: 12px 16px; - text-decoration: none; - transition: background-color 0.15s ease-in-out; - - &.active { - color: ${theme.palette.text.primary}; - font-weight: 500; - } +interface NavItemsProps { + className?: string; +} - &:hover { - background-color: ${theme.experimental.l2.hover.background}; - } +const NavItems: FC = ({ className }) => { + const location = useLocation(); - ${theme.breakpoints.up("md")} { - height: ${navHeight}px; - padding: 0 24px; - } - `, -} satisfies Record>; + return ( + + ); +}; diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx index 2d14ce3daee56..5345d3db9cdae 100644 --- a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx @@ -1,6 +1,4 @@ import { useTheme } from "@emotion/react"; -import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; -import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; @@ -8,13 +6,15 @@ import Skeleton from "@mui/material/Skeleton"; import { visuallyHidden } from "@mui/utils"; import type * as TypesGen from "api/typesGenerated"; import { Abbr } from "components/Abbr/Abbr"; +import { Button } from "components/Button/Button"; import { displayError } from "components/GlobalSnackbar/utils"; import { Latency } from "components/Latency/Latency"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { ChevronDownIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { BUTTON_SM_HEIGHT } from "theme/constants"; +import { sortProxiesByLatency } from "./proxyUtils"; interface ProxyMenuProps { proxyContextValue: ProxyContextValue; @@ -62,7 +62,7 @@ export const ProxyMenu: FC = ({ proxyContextValue }) => { return ( ); @@ -71,13 +71,10 @@ export const ProxyMenu: FC = ({ proxyContextValue }) => { return ( <> = ({ proxyContextValue }) => { ]} {proxyContextValue.proxies && - [...proxyContextValue.proxies] - .sort((a, b) => { - const latencyA = - latencies?.[a.id]?.latencyMS ?? Number.POSITIVE_INFINITY; - const latencyB = - latencies?.[b.id]?.latencyMS ?? Number.POSITIVE_INFINITY; - return latencyA - latencyB; - }) - .map((proxy) => ( + sortProxiesByLatency(proxyContextValue.proxies, latencies).map( + (proxy) => ( = ({ proxyContextValue }) => { /> - ))} + ), + )} diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 6fb7428bb0dc1..6fc41fe7232ec 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -1,15 +1,12 @@ -import { type Interpolation, type Theme, css, useTheme } from "@emotion/react"; -import Badge from "@mui/material/Badge"; +import { useTheme } from "@emotion/react"; import type * as TypesGen from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; -import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Popover, PopoverContent, PopoverTrigger, } from "components/deprecated/Popover/Popover"; import { type FC, useState } from "react"; -import { navHeight } from "theme/constants"; import { UserDropdownContent } from "./UserDropdownContent"; export interface UserDropdownProps { @@ -31,20 +28,11 @@ export const UserDropdown: FC = ({ return ( - @@ -68,24 +56,3 @@ export const UserDropdown: FC = ({ ); }; - -const styles = { - button: css` - background: none; - border: 0; - cursor: pointer; - height: ${navHeight}px; - padding: 12px 0; - - &:hover { - background-color: transparent; - } - `, - - badgeContainer: { - display: "flex", - alignItems: "center", - minWidth: 0, - maxWidth: 300, - }, -} satisfies Record>; diff --git a/site/src/modules/dashboard/Navbar/proxyUtils.tsx b/site/src/modules/dashboard/Navbar/proxyUtils.tsx new file mode 100644 index 0000000000000..57afadb7fbdd9 --- /dev/null +++ b/site/src/modules/dashboard/Navbar/proxyUtils.tsx @@ -0,0 +1,12 @@ +import type { Proxies, ProxyLatencies } from "contexts/ProxyContext"; + +export function sortProxiesByLatency( + proxies: Proxies, + latencies: ProxyLatencies, +) { + return proxies.toSorted((a, b) => { + const latencyA = latencies?.[a.id]?.latencyMS ?? Number.POSITIVE_INFINITY; + const latencyB = latencies?.[b.id]?.latencyMS ?? Number.POSITIVE_INFINITY; + return latencyA - latencyB; + }); +} diff --git a/site/src/modules/management/DeploymentSettingsLayout.tsx b/site/src/modules/management/DeploymentSettingsLayout.tsx index 2a0a999ac9f3d..65c2e70ea3333 100644 --- a/site/src/modules/management/DeploymentSettingsLayout.tsx +++ b/site/src/modules/management/DeploymentSettingsLayout.tsx @@ -39,7 +39,7 @@ const DeploymentSettingsLayout: FC = () => {
    -
    +
    diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 484a6dd8a65e8..aa586e877d6e0 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -109,7 +109,7 @@ const OrganizationSettingsLayout: FC = () => {
    -
    +
    diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts index 7c0696a9afcbb..f07a69985d0d8 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts @@ -23,3 +23,29 @@ export const determineGroupDiff = (auditLogDiff: AuditDiff): AuditDiff => { }, }; }; + +/** + * + * @param auditLogDiff + * @returns a diff with the 'mappings' as a JSON string. Otherwise, it is [Object object] + */ +export const determineIdPSyncMappingDiff = ( + auditLogDiff: AuditDiff, +): AuditDiff => { + const old = auditLogDiff.mapping?.old as Record | undefined; + const new_ = auditLogDiff.mapping?.new as + | Record + | undefined; + if (!old || !new_) { + return auditLogDiff; + } + + return { + ...auditLogDiff, + mapping: { + old: JSON.stringify(old), + new: JSON.stringify(new_), + secret: auditLogDiff.mapping?.secret, + }, + }; +}; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx index db8e7e4537cc4..909fb7cf5646e 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx @@ -16,7 +16,10 @@ import type { ThemeRole } from "theme/roles"; import userAgentParser from "ua-parser-js"; import { AuditLogDescription } from "./AuditLogDescription/AuditLogDescription"; import { AuditLogDiff } from "./AuditLogDiff/AuditLogDiff"; -import { determineGroupDiff } from "./AuditLogDiff/auditUtils"; +import { + determineGroupDiff, + determineIdPSyncMappingDiff, +} from "./AuditLogDiff/auditUtils"; const httpStatusColor = (httpStatus: number): ThemeRole => { // Treat server errors (500) as errors @@ -59,6 +62,14 @@ export const AuditLogRow: FC = ({ auditDiff = determineGroupDiff(auditLog.diff); } + if ( + auditLog.resource_type === "idp_sync_settings_organization" || + auditLog.resource_type === "idp_sync_settings_group" || + auditLog.resource_type === "idp_sync_settings_role" + ) { + auditDiff = determineIdPSyncMappingDiff(auditLog.diff); + } + const toggle = () => { if (shouldDisplayDiff) { setIsDiffOpen((v) => !v); diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index 3de614a42ac39..2b094cbf89b26 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -1,6 +1,6 @@ import { deploymentDAUs } from "api/queries/deployment"; +import { entitlements } from "api/queries/entitlements"; import { availableExperiments, experiments } from "api/queries/experiments"; -import { userStatusCountsOverTime } from "api/queries/insights"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useDeploymentSettings } from "modules/management/DeploymentSettingsProvider"; import type { FC } from "react"; @@ -15,8 +15,9 @@ const GeneralSettingsPage: FC = () => { const safeExperimentsQuery = useQuery(availableExperiments()); const { metadata } = useEmbeddedMetadata(); + const entitlementsQuery = useQuery(entitlements(metadata.entitlements)); const enabledExperimentsQuery = useQuery(experiments(metadata.experiments)); - const userStatusCountsOverTimeQuery = useQuery(userStatusCountsOverTime()); + const safeExperiments = safeExperimentsQuery.data?.safe ?? []; const invalidExperiments = enabledExperimentsQuery.data?.filter((exp) => { @@ -32,9 +33,9 @@ const GeneralSettingsPage: FC = () => { deploymentOptions={deploymentConfig.options} deploymentDAUs={deploymentDAUsQuery.data} deploymentDAUsError={deploymentDAUsQuery.error} + entitlements={entitlementsQuery.data} invalidExperiments={invalidExperiments} safeExperiments={safeExperiments} - userStatusCountsOverTime={userStatusCountsOverTimeQuery.data} /> ); diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 78291ee03b4d8..05ed426d5dcc9 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -1,5 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { MockDeploymentDAUResponse, mockApiError } from "testHelpers/entities"; +import { + MockDeploymentDAUResponse, + MockEntitlementsWithUserLimit, + mockApiError, +} from "testHelpers/entities"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; const meta: Meta = { @@ -38,100 +42,7 @@ const meta: Meta = { deploymentDAUs: MockDeploymentDAUResponse, invalidExperiments: [], safeExperiments: [], - userStatusCountsOverTime: { - status_counts: { - active: [ - { - date: "1/1/2024", - count: 1, - }, - { - date: "1/2/2024", - count: 8, - }, - { - date: "1/3/2024", - count: 8, - }, - { - date: "1/4/2024", - count: 6, - }, - { - date: "1/5/2024", - count: 6, - }, - { - date: "1/6/2024", - count: 6, - }, - { - date: "1/7/2024", - count: 6, - }, - ], - dormant: [ - { - date: "1/1/2024", - count: 0, - }, - { - date: "1/2/2024", - count: 3, - }, - { - date: "1/3/2024", - count: 3, - }, - { - date: "1/4/2024", - count: 3, - }, - { - date: "1/5/2024", - count: 3, - }, - { - date: "1/6/2024", - count: 3, - }, - { - date: "1/7/2024", - count: 3, - }, - ], - suspended: [ - { - date: "1/1/2024", - count: 0, - }, - { - date: "1/2/2024", - count: 0, - }, - { - date: "1/3/2024", - count: 0, - }, - { - date: "1/4/2024", - count: 2, - }, - { - date: "1/5/2024", - count: 2, - }, - { - date: "1/6/2024", - count: 2, - }, - { - date: "1/7/2024", - count: 2, - }, - ], - }, - }, + entitlements: undefined, }, }; @@ -227,26 +138,73 @@ export const invalidExperimentsEnabled: Story = { }, }; -export const UnlicensedInstallation: Story = { - args: {}, -}; - -export const LicensedWithNoUserLimit: Story = { - args: {}, +export const WithLicenseUtilization: Story = { + args: { + entitlements: { + ...MockEntitlementsWithUserLimit, + features: { + ...MockEntitlementsWithUserLimit.features, + user_limit: { + ...MockEntitlementsWithUserLimit.features.user_limit, + enabled: true, + actual: 75, + limit: 100, + entitlement: "entitled", + }, + }, + }, + }, }; -export const LicensedWithPlentyOfSpareLicenses: Story = { +export const HighLicenseUtilization: Story = { args: { - activeUserLimit: 100, + entitlements: { + ...MockEntitlementsWithUserLimit, + features: { + ...MockEntitlementsWithUserLimit.features, + user_limit: { + ...MockEntitlementsWithUserLimit.features.user_limit, + enabled: true, + actual: 95, + limit: 100, + entitlement: "entitled", + }, + }, + }, }, }; -export const TotalUsersExceedsLicenseButNotActiveUsers: Story = { +export const ExceedsLicenseUtilization: Story = { args: { - activeUserLimit: 8, + entitlements: { + ...MockEntitlementsWithUserLimit, + features: { + ...MockEntitlementsWithUserLimit.features, + user_limit: { + ...MockEntitlementsWithUserLimit.features.user_limit, + enabled: true, + actual: 100, + limit: 95, + entitlement: "entitled", + }, + }, + }, }, }; - -export const ManyUsers: Story = { - args: {}, +export const NoLicenseLimit: Story = { + args: { + entitlements: { + ...MockEntitlementsWithUserLimit, + features: { + ...MockEntitlementsWithUserLimit.features, + user_limit: { + ...MockEntitlementsWithUserLimit.features.user_limit, + enabled: false, + actual: 0, + limit: 0, + entitlement: "entitled", + }, + }, + }, + }, }; diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 3680e823516a6..df5550d70e965 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,15 +1,14 @@ import AlertTitle from "@mui/material/AlertTitle"; +import LinearProgress from "@mui/material/LinearProgress"; import type { DAUsResponse, + Entitlements, Experiments, - GetUserStatusCountsOverTimeResponse, SerpentOption, } from "api/typesGenerated"; import { ActiveUserChart, ActiveUsersTitle, - type DataSeries, - UserStatusTitle, } from "components/ActiveUserChart/ActiveUserChart"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; @@ -25,8 +24,7 @@ export type GeneralSettingsPageViewProps = { deploymentOptions: SerpentOption[]; deploymentDAUs?: DAUsResponse; deploymentDAUsError: unknown; - userStatusCountsOverTime?: GetUserStatusCountsOverTimeResponse; - activeUserLimit?: number; + entitlements: Entitlements | undefined; readonly invalidExperiments: Experiments | string[]; readonly safeExperiments: Experiments | string[]; }; @@ -35,29 +33,16 @@ export const GeneralSettingsPageView: FC = ({ deploymentOptions, deploymentDAUs, deploymentDAUsError, - userStatusCountsOverTime, - activeUserLimit, + entitlements, safeExperiments, invalidExperiments, }) => { - const colors: Record = { - active: "green", - dormant: "grey", - deleted: "red", - }; - let series: DataSeries[] = []; - if (userStatusCountsOverTime?.status_counts) { - series = Object.entries(userStatusCountsOverTime.status_counts).map( - ([status, counts]) => ({ - label: status, - data: counts.map((count) => ({ - date: count.date.toString(), - amount: count.count, - })), - color: colors[status], - }), - ); - } + const licenseUtilizationPercentage = + entitlements?.features?.user_limit?.actual && + entitlements?.features?.user_limit?.limit + ? entitlements.features.user_limit.actual / + entitlements.features.user_limit.limit + : undefined; return ( <> = ({ {Boolean(deploymentDAUsError) && ( )} - {series.length && ( - }> - - - )} {deploymentDAUs && ( - }> - + }> + + +
    + )} + {licenseUtilizationPercentage && ( + + + + {Math.round(licenseUtilizationPercentage * 100)}% used ( + {entitlements!.features.user_limit.actual}/ + {entitlements!.features.user_limit.limit} users) + )} {invalidExperiments.length > 0 && ( diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 2b873c325e274..097b8fce513e7 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -258,14 +258,10 @@ const ActiveUsersPanel: FC = ({ {data && data.length > 0 && ( ({ - amount: d.active_users, - date: d.start_time, - })), - }, - ]} + data={data.map((d) => ({ + amount: d.active_users, + date: d.start_time, + }))} /> )} diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 61d35ee0338aa..884cbe88b2c9f 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -8,6 +8,11 @@ module.exports = { important: ["#root", "#storybook-root"], theme: { extend: { + size: { + "icon-lg": "1.5rem", + "icon-sm": "1.125rem", + "icon-xs": "0.875rem", + }, fontSize: { "2xs": ["0.625rem", "0.875rem"], sm: ["0.875rem", "1.5rem"], @@ -53,6 +58,15 @@ module.exports = { 5: "hsl(var(--chart-5))", }, }, + keyframes: { + loading: { + "0%": { opacity: 0.85 }, + "25%": { opacity: 0.7 }, + "50%": { opacity: 0.4 }, + "75%": { opacity: 0.3 }, + "100%": { opacity: 0.2 }, + }, + }, }, }, plugins: [require("tailwindcss-animate")], From e738a0e58a1d3bdaf6403f6e6839d48f837110d3 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 3 Jan 2025 07:05:25 +0000 Subject: [PATCH 22/22] make gen --- site/src/api/typesGenerated.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c605268c9d920..23e8be803f308 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -775,6 +775,16 @@ export interface GenerateAPIKeyResponse { readonly key: string; } +// From codersdk/insights.go +export interface GetUserStatusCountsOverTimeRequest { + readonly offset: string; +} + +// From codersdk/insights.go +export interface GetUserStatusCountsOverTimeResponse { + readonly status_counts: Record; +} + // From codersdk/users.go export interface GetUsersResponse { readonly users: readonly User[]; @@ -2316,6 +2326,12 @@ export interface UserRoles { // From codersdk/users.go export type UserStatus = "active" | "dormant" | "suspended"; +// From codersdk/insights.go +export interface UserStatusChangeCount { + readonly date: string; + readonly count: number; +} + export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; // From codersdk/users.go