From 667d3a7c9337dcc24c72172945be5dea9c530dd4 Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:27:10 +0000 Subject: [PATCH] WakaTime/Hackatime v1 imports + Settings v2 (#1062) * Imports are back!! * Settings UI v3 * Use Inertia forms for heartbeat imports * Update app/javascript/pages/Users/Settings/Data.svelte Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update Bundle * Fix broken Form/Button markup in Data.svelte settings page * Update JS deps * Greptile fixes * Remove dead code --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .env.example | 3 - DEVELOPMENT.md | 1 - Gemfile.lock | 58 +- .../api/hackatime/v1/hackatime_controller.rb | 11 - app/controllers/leaderboards_controller.rb | 11 +- .../my/heartbeat_import_sources_controller.rb | 131 --- .../my/heartbeat_imports_controller.rb | 77 +- app/controllers/my/heartbeats_controller.rb | 50 - .../my/project_repo_mappings_controller.rb | 3 +- app/controllers/sessions_controller.rb | 9 - app/controllers/settings/admin_controller.rb | 18 - app/controllers/settings/base_controller.rb | 92 +- app/controllers/settings/data_controller.rb | 10 - app/controllers/static_pages_controller.rb | 3 +- .../wakatime_mirrors_controller.rb | 47 - .../pages/Users/Settings/Access.svelte | 197 ++-- .../pages/Users/Settings/Admin.svelte | 40 - .../pages/Users/Settings/Badges.svelte | 218 ++-- .../pages/Users/Settings/Data.svelte | 1042 ++++++----------- .../pages/Users/Settings/Goals.svelte | 173 ++- .../pages/Users/Settings/Integrations.svelte | 293 ++--- .../pages/Users/Settings/Notifications.svelte | 92 +- .../pages/Users/Settings/Profile.svelte | 413 ++++--- .../pages/Users/Settings/Shell.svelte | 61 +- .../Settings/components/SectionCard.svelte | 70 ++ .../Settings/components/SubsectionNav.svelte | 65 + app/javascript/pages/Users/Settings/types.ts | 231 ++-- app/jobs/heartbeat_import_dump_job.rb | 122 ++ app/jobs/heartbeat_import_job.rb | 4 +- .../heartbeat_import_source_scheduler_job.rb | 11 - .../heartbeat_import_source_sync_day_job.rb | 149 --- app/jobs/heartbeat_import_source_sync_job.rb | 131 --- app/jobs/migrate_user_from_hackatime_job.rb | 108 -- app/jobs/mirror_fanout_enqueue_job.rb | 24 - app/jobs/sailors_log_poll_for_changes_job.rb | 3 +- app/jobs/wakatime_mirror_sync_job.rb | 86 -- app/models/hackatime/heartbeat.rb | 17 - app/models/hackatime/project_label.rb | 14 - app/models/hackatime/user.rb | 3 - app/models/hackatime_record.rb | 9 - app/models/heartbeat_import_run.rb | 88 ++ app/models/heartbeat_import_source.rb | 77 -- app/models/user.rb | 29 +- app/models/wakatime_mirror.rb | 111 -- app/services/anonymize_user_service.rb | 2 +- app/services/heartbeat_import_dump_client.rb | 116 ++ app/services/heartbeat_import_runner.rb | 269 +++-- app/services/heartbeat_import_service.rb | 66 +- app/services/wakatime_compatible_client.rb | 63 - bun.lock | 76 +- config/database.yml | 15 - config/initializers/good_job.rb | 4 - config/routes.rb | 11 - ...0312134424_create_heartbeat_import_runs.rb | 29 + db/schema.rb | 29 +- package.json | 18 +- spec/requests/api/v1/my_spec.rb | 65 +- swagger/v1/swagger.yaml | 37 +- ...eartbeat_import_sources_controller_test.rb | 110 -- .../my/heartbeat_imports_controller_test.rb | 249 +++- .../wakatime_mirrors_controller_test.rb | 46 - test/jobs/heartbeat_import_dump_job_test.rb | 130 ++ .../heartbeat_import_source_sync_job_test.rb | 187 --- test/jobs/mirror_fanout_enqueue_job_test.rb | 48 - test/jobs/wakatime_mirror_sync_job_test.rb | 344 ------ test/models/heartbeat_import_run_test.rb | 44 + test/models/heartbeat_import_source_test.rb | 50 - test/models/user_test.rb | 28 + test/models/wakatime_mirror_test.rb | 52 - .../heartbeat_import_dump_client_test.rb | 56 + .../services/heartbeat_import_service_test.rb | 72 ++ test/system/settings/access_settings_test.rb | 3 +- test/system/settings/admin_settings_test.rb | 25 - test/system/settings/badges_settings_test.rb | 3 +- test/system/settings/data_settings_test.rb | 75 +- .../settings/integrations_settings_test.rb | 3 +- test/system/settings/profile_settings_test.rb | 9 + test/system/settings/test_helpers.rb | 5 +- 78 files changed, 2748 insertions(+), 3996 deletions(-) delete mode 100644 app/controllers/my/heartbeat_import_sources_controller.rb delete mode 100644 app/controllers/settings/admin_controller.rb delete mode 100644 app/controllers/wakatime_mirrors_controller.rb delete mode 100644 app/javascript/pages/Users/Settings/Admin.svelte create mode 100644 app/javascript/pages/Users/Settings/components/SectionCard.svelte create mode 100644 app/javascript/pages/Users/Settings/components/SubsectionNav.svelte create mode 100644 app/jobs/heartbeat_import_dump_job.rb delete mode 100644 app/jobs/heartbeat_import_source_scheduler_job.rb delete mode 100644 app/jobs/heartbeat_import_source_sync_day_job.rb delete mode 100644 app/jobs/heartbeat_import_source_sync_job.rb delete mode 100644 app/jobs/migrate_user_from_hackatime_job.rb delete mode 100644 app/jobs/mirror_fanout_enqueue_job.rb delete mode 100644 app/jobs/wakatime_mirror_sync_job.rb delete mode 100644 app/models/hackatime/heartbeat.rb delete mode 100644 app/models/hackatime/project_label.rb delete mode 100644 app/models/hackatime/user.rb delete mode 100644 app/models/hackatime_record.rb create mode 100644 app/models/heartbeat_import_run.rb delete mode 100644 app/models/heartbeat_import_source.rb delete mode 100644 app/models/wakatime_mirror.rb create mode 100644 app/services/heartbeat_import_dump_client.rb delete mode 100644 app/services/wakatime_compatible_client.rb create mode 100644 db/migrate/20260312134424_create_heartbeat_import_runs.rb delete mode 100644 test/controllers/my/heartbeat_import_sources_controller_test.rb delete mode 100644 test/controllers/wakatime_mirrors_controller_test.rb create mode 100644 test/jobs/heartbeat_import_dump_job_test.rb delete mode 100644 test/jobs/heartbeat_import_source_sync_job_test.rb delete mode 100644 test/jobs/mirror_fanout_enqueue_job_test.rb delete mode 100644 test/jobs/wakatime_mirror_sync_job_test.rb create mode 100644 test/models/heartbeat_import_run_test.rb delete mode 100644 test/models/heartbeat_import_source_test.rb delete mode 100644 test/models/wakatime_mirror_test.rb create mode 100644 test/services/heartbeat_import_dump_client_test.rb create mode 100644 test/services/heartbeat_import_service_test.rb delete mode 100644 test/system/settings/admin_settings_test.rb diff --git a/.env.example b/.env.example index 2a78088..0192c65 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,6 @@ SLACK_SAILORS_LOG_SIGNING_SECRET=your_signing_secret_here SLACK_SAILORS_LOG_BOT_OAUTH_TOKEN=your_bot_oauth_token_here TELETYPE_API_KEY=your_teletype_api_key_here -# Wakatime database url used for migrating data from waka.hackclub.com -WAKATIME_DATABASE_URL=your_wakatime_database_url_here - # You can leave this alone if you're using the provided docker setup! DATABASE_URL=your_database_url_here POOL_DATABASE_URL=pg_bouncer_url_here diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ed849cb..18cf369 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,7 +21,6 @@ Edit your `.env` file to include the following: ```env # Database configurations - these work with the Docker setup DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development -WAKATIME_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development SAILORS_LOG_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development # Generate these with `rails secret` or use these for development diff --git a/Gemfile.lock b/Gemfile.lock index ca439be..d314c66 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.16) + action_text-trix (2.1.17) railties actioncable (8.1.2) actionpack (= 8.1.2) @@ -82,8 +82,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1220.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1225.0) + aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -94,8 +94,8 @@ GEM aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.213.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.216.0) + aws-sdk-core (~> 3, >= 3.243.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -153,7 +153,7 @@ GEM reline (>= 0.3.8) diff-lcs (1.6.2) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.9.0) railties (>= 5) dotenv (3.2.0) dotenv-rails (3.2.0) @@ -173,7 +173,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.6.0) + faker (3.6.1) i18n (>= 1.8.11, < 2) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) @@ -251,7 +251,7 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - inertia_rails (3.17.0) + inertia_rails (3.18.0) railties (>= 6) io-console (0.8.2) irb (1.17.0) @@ -263,8 +263,8 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.1) - json-schema (6.1.0) + json (2.19.1) + json-schema (6.2.0) addressable (~> 2.8) bigdecimal (>= 3.1, < 5) kamal (2.10.1) @@ -308,7 +308,7 @@ GEM activesupport (>= 7.1) marcel (1.1.0) matrix (0.4.3) - mcp (0.7.1) + mcp (0.8.0) json-schema (>= 4.1) memory_profiler (1.1.0) mini_magick (5.3.1) @@ -374,11 +374,11 @@ GEM pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) - posthog-ruby (3.5.4) + posthog-ruby (3.5.5) concurrent-ruby (~> 1) pp (0.6.3) prettyprint - premailer (1.28.0) + premailer (1.29.0) addressable css_parser (>= 1.19.0) htmlentities (>= 4.0.0) @@ -395,7 +395,7 @@ GEM psych (5.3.1) date stringio - public_suffix (7.0.2) + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) query_count (1.1.1) @@ -475,14 +475,14 @@ GEM rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.3) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) rswag-api (2.17.0) activesupport (>= 5.2, < 8.2) @@ -495,7 +495,7 @@ GEM rswag-ui (2.17.0) actionpack (>= 5.2, < 8.2) railties (>= 5.2, < 8.2) - rubocop (1.85.0) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -507,7 +507,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) @@ -541,10 +541,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.4.0) + sentry-rails (6.4.1) railties (>= 5.2.0) - sentry-ruby (~> 6.4.0) - sentry-ruby (6.4.0) + sentry-ruby (~> 6.4.1) + sentry-ruby (6.4.1) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) logger @@ -595,11 +595,11 @@ GEM tailwindcss-ruby (4.2.0-x86_64-linux-gnu) tailwindcss-ruby (4.2.0-x86_64-linux-musl) thor (1.5.0) - thruster (0.1.18) - thruster (0.1.18-aarch64-linux) - thruster (0.1.18-arm64-darwin) - thruster (0.1.18-x86_64-linux) - timeout (0.6.0) + thruster (0.1.19) + thruster (0.1.19-aarch64-linux) + thruster (0.1.19-arm64-darwin) + thruster (0.1.19-x86_64-linux) + timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index 6262abe..bd69b2f 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -219,7 +219,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController def handle_heartbeat(heartbeat_array) results = [] - should_enqueue_mirror_sync = false heartbeat_array.each do |heartbeat| source_type = :direct_entry @@ -252,7 +251,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController end queue_project_mapping(heartbeat[:project]) results << [ new_heartbeat.attributes, 201 ] - should_enqueue_mirror_sync ||= source_type == :direct_entry rescue => e Sentry.capture_exception(e) Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}") @@ -260,7 +258,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController end PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size }) - enqueue_mirror_sync if should_enqueue_mirror_sync results end @@ -274,14 +271,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}") end - def enqueue_mirror_sync - return unless Flipper.enabled?(:wakatime_imports_mirrors) - - MirrorFanoutEnqueueJob.perform_later(@user.id) - rescue => e - Rails.logger.error("Error enqueuing mirror sync fanout: #{e.class.name} #{e.message}") - end - def check_lockout return unless @user&.pending_deletion? render json: { error: "Account pending deletion" }, status: :forbidden diff --git a/app/controllers/leaderboards_controller.rb b/app/controllers/leaderboards_controller.rb index d7ee8b6..de613d4 100644 --- a/app/controllers/leaderboards_controller.rb +++ b/app/controllers/leaderboards_controller.rb @@ -68,16 +68,7 @@ class LeaderboardsController < ApplicationController entries_scope = leaderboard_entries_scope ids = entries_scope.distinct.pluck(:user_id) @user_on_leaderboard = current_user && ids.include?(current_user.id) - @untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard || country_scope? + @untracked_entries = 0 @total_entries = entries_scope.count end - - def calculate_untracked_entries(ids) - return 0 unless Flipper.enabled?(:hackatime_v1_import) - - range = @period_type == :last_7_days ? ((Date.current - 6.days).beginning_of_day...Date.current.end_of_day) : Date.current.all_day - ids_set = ids.to_set - - Hackatime::Heartbeat.where(time: range).distinct.pluck(:user_id).count { |uid| !ids_set.include?(uid) } - end end diff --git a/app/controllers/my/heartbeat_import_sources_controller.rb b/app/controllers/my/heartbeat_import_sources_controller.rb deleted file mode 100644 index 3f6086f..0000000 --- a/app/controllers/my/heartbeat_import_sources_controller.rb +++ /dev/null @@ -1,131 +0,0 @@ -class My::HeartbeatImportSourcesController < ApplicationController - before_action :ensure_current_user - before_action :ensure_imports_and_mirrors_enabled - - def create - if current_user.heartbeat_import_source.present? - redirect_to my_settings_data_path, alert: "Import source already configured. Update it instead." - return - end - - source = current_user.build_heartbeat_import_source(create_params) - source.provider = :wakatime_compatible - source.status = :idle - - if source.save - HeartbeatImportSourceSyncJob.perform_later(source.id) if source.sync_enabled? - redirect_to my_settings_data_path, notice: "Import source configured successfully." - else - redirect_to my_settings_data_path, alert: source.errors.full_messages.to_sentence - end - end - - def update - source = current_user.heartbeat_import_source - unless source - redirect_to my_settings_data_path, alert: "No import source is configured." - return - end - - rerun_backfill = ActiveModel::Type::Boolean.new.cast(params.dig(:heartbeat_import_source, :rerun_backfill)) - attrs = update_params - attrs = attrs.except(:encrypted_api_key) if attrs[:encrypted_api_key].blank? - - if source.update(attrs) - source.reset_backfill! if rerun_backfill - HeartbeatImportSourceSyncJob.perform_later(source.id) if source.sync_enabled? - redirect_to my_settings_data_path, notice: "Import source updated successfully." - else - redirect_to my_settings_data_path, alert: source.errors.full_messages.to_sentence - end - end - - def show - source = current_user.heartbeat_import_source - render json: { import_source: source_payload(source) } - end - - def destroy - source = current_user.heartbeat_import_source - unless source - redirect_to my_settings_data_path, alert: "No import source is configured." - return - end - - source.destroy - redirect_to my_settings_data_path, notice: "Import source removed." - end - - def sync_now - source = current_user.heartbeat_import_source - unless source - redirect_to my_settings_data_path, alert: "No import source is configured." - return - end - - unless source.sync_enabled? - redirect_to my_settings_data_path, alert: "Enable sync before running sync now." - return - end - - HeartbeatImportSourceSyncJob.perform_later(source.id) - redirect_to my_settings_data_path, notice: "Sync queued." - end - - private - - def ensure_current_user - redirect_to root_path, alert: "You must be logged in to view this page." unless current_user - end - - def ensure_imports_and_mirrors_enabled - return if Flipper.enabled?(:wakatime_imports_mirrors) - - if request.format.json? - render json: { error: "Imports and mirrors are currently disabled." }, status: :not_found - else - redirect_to my_settings_data_path, alert: "Imports and mirrors are currently disabled." - end - end - - def create_params - base_params.merge(provider: :wakatime_compatible) - end - - def update_params - base_params - end - - def base_params - params.require(:heartbeat_import_source).permit( - :endpoint_url, - :encrypted_api_key, - :sync_enabled, - :initial_backfill_start_date, - :initial_backfill_end_date - ) - end - - def source_payload(source) - return nil unless source - - { - id: source.id, - provider: source.provider, - endpoint_url: source.endpoint_url, - sync_enabled: source.sync_enabled, - status: source.status, - initial_backfill_start_date: source.initial_backfill_start_date&.iso8601, - initial_backfill_end_date: source.initial_backfill_end_date&.iso8601, - backfill_cursor_date: source.backfill_cursor_date&.iso8601, - last_synced_at: source.last_synced_at&.iso8601, - last_synced_ago: source.last_synced_at ? view_context.time_ago_in_words(source.last_synced_at) : nil, - last_error_message: source.last_error_message, - last_error_at: source.last_error_at&.iso8601, - consecutive_failures: source.consecutive_failures, - imported_count: Rails.cache.fetch("user:#{current_user.id}:wakapi_import_count", expires_in: 5.minutes) do - current_user.heartbeats.where(source_type: :wakapi_import).count - end - } - end -end diff --git a/app/controllers/my/heartbeat_imports_controller.rb b/app/controllers/my/heartbeat_imports_controller.rb index 24ebbaa..7c2d8cc 100644 --- a/app/controllers/my/heartbeat_imports_controller.rb +++ b/app/controllers/my/heartbeat_imports_controller.rb @@ -1,36 +1,41 @@ class My::HeartbeatImportsController < ApplicationController before_action :ensure_current_user - before_action :ensure_development def create - unless params[:heartbeat_file].present? - render json: { error: "pls select a file to import" }, status: :unprocessable_entity + if params[:heartbeat_file].blank? && params[:heartbeat_import].blank? + redirect_with_import_error("No import data provided.") return end - file = params[:heartbeat_file] - unless valid_json_file?(file) - render json: { error: "pls upload only json (download from the button above it)" }, status: :unprocessable_entity - return + if params[:heartbeat_file].present? + start_dev_upload! + else + start_remote_import! end - import_id = HeartbeatImportRunner.start(user: current_user, uploaded_file: file) - status = HeartbeatImportRunner.status(user: current_user, import_id: import_id) - - render json: { - import_id: import_id, - status: status - }, status: :accepted + redirect_to my_settings_data_path + rescue DevelopmentOnlyError => e + redirect_with_import_error(e.message) + rescue HeartbeatImportRunner::FeatureDisabledError => e + redirect_with_import_error(e.message) + rescue HeartbeatImportRunner::CooldownError => e + flash[:cooldown_until] = e.retry_at.iso8601 + redirect_with_import_error(e.message) + rescue HeartbeatImportRunner::ActiveImportError => e + redirect_with_import_error(e.message) + rescue HeartbeatImportRunner::InvalidProviderError, ActionController::ParameterMissing => e + redirect_with_import_error(e.message) rescue => e Sentry.capture_exception(e) Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}") - render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error + redirect_with_import_error("error reading file: #{e.message}") end def show - status = HeartbeatImportRunner.status(user: current_user, import_id: params[:id]) - if status.present? - render json: status + run = HeartbeatImportRunner.find_run(user: current_user, import_id: params[:id]) + if run.present? + run = HeartbeatImportRunner.refresh_remote_run!(run) + render json: HeartbeatImportRunner.serialize(run) else render json: { error: "Import not found" }, status: :not_found end @@ -38,10 +43,36 @@ class My::HeartbeatImportsController < ApplicationController private + class DevelopmentOnlyError < StandardError; end + def valid_json_file?(file) file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json") end + def start_dev_upload! + ensure_development + + file = params[:heartbeat_file] + unless valid_json_file?(file) + raise HeartbeatImportRunner::InvalidProviderError, "pls upload only json (download from the button above it)" + end + + HeartbeatImportRunner.start_dev_upload(user: current_user, uploaded_file: file) + end + + def start_remote_import! + heartbeat_import = remote_import_params + if heartbeat_import[:api_key].blank? + raise HeartbeatImportRunner::InvalidProviderError, "API key is required." + end + + HeartbeatImportRunner.start_remote_import( + user: current_user, + provider: heartbeat_import[:provider], + api_key: heartbeat_import[:api_key] + ) + end + def ensure_current_user return if current_user @@ -51,6 +82,14 @@ class My::HeartbeatImportsController < ApplicationController def ensure_development return if Rails.env.development? - render json: { error: "Heartbeat import is only available in development." }, status: :forbidden + raise DevelopmentOnlyError, "Heartbeat import is only available in development." + end + + def redirect_with_import_error(message) + redirect_to my_settings_data_path, inertia: { errors: { import: message } } + end + + def remote_import_params + params.require(:heartbeat_import).permit(:provider, :api_key) end end diff --git a/app/controllers/my/heartbeats_controller.rb b/app/controllers/my/heartbeats_controller.rb index 6c63388..07e211f 100644 --- a/app/controllers/my/heartbeats_controller.rb +++ b/app/controllers/my/heartbeats_controller.rb @@ -28,56 +28,6 @@ module My redirect_to my_settings_data_path, notice: "Your export is being prepared and will be emailed to you." end - def import - unless Rails.env.development? - redirect_to my_settings_path, alert: "Hey you! This is noit a dev env, STOP DOING THIS!!!!!) Also, idk why this is happning, you should not be able to see this button hmm...." - return - end - - unless params[:heartbeat_file].present? - redirect_to my_settings_path, alert: "pls select a file to import" - return - end - - file = params[:heartbeat_file] - - unless file.content_type == "application/json" || file.original_filename.ends_with?(".json") - redirect_to my_settings_path, alert: "pls upload only json (download from the button above it)" - return - end - - begin - file_content = file.read.force_encoding("UTF-8") - rescue => e - redirect_to my_settings_path, alert: "error reading file: #{e.message}" - return - end - - result = HeartbeatImportService.import_from_file(file_content, current_user) - - if result[:success] - message = "Imported #{result[:imported_count]} out of #{result[:total_count]} heartbeats in #{result[:time_taken]}s" - if result[:skipped_count] > 0 - message += " (#{result[:skipped_count]} skipped cause they were duplicates)" - end - if result[:errors].any? - error_count = result[:errors].length - if error_count <= 3 - message += ". Errors occurred: #{result[:errors].join("; ")}" - else - message += ". #{error_count} errors occurred. First few: #{result[:errors].first(2).join("; ")}..." - end - end - redirect_to root_path, notice: message - else - error_message = "Import failed: #{result[:error]}" - if result[:errors].any? && result[:errors].length > 1 - error_message += "Errors: #{result[:errors][1..2].join("; ")}" - end - redirect_to my_settings_path, alert: error_message - end - end - private def ensure_current_user diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index 74b7191..cf9740c 100644 --- a/app/controllers/my/project_repo_mappings_controller.rb +++ b/app/controllers/my/project_repo_mappings_controller.rb @@ -116,7 +116,6 @@ class My::ProjectRepoMappingsController < InertiaController .group(:repository_id) .maximum(:created_at) archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true) - labels_by_project_key = Flipper.enabled?(:hackatime_v1_import) ? current_user.project_labels.pluck(:project_key, :label).to_h : {} cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to]) @@ -131,7 +130,7 @@ class My::ProjectRepoMappingsController < InertiaController next if archived_names.key?(project_key) != archived mapping = mappings_by_name[project_key] - display_name = labels_by_project_key[project_key].presence || project_key.presence || "Unknown" + display_name = project_key.presence || "Unknown" { id: project_card_id(project_key), diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 384baf7..7d61160 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -29,10 +29,6 @@ class SessionsController < ApplicationController if @user&.persisted? session[:user_id] = @user.id - if Flipper.enabled?(:hackatime_v1_import) && @user.data_migration_jobs.empty? - MigrateUserFromHackatimeJob.perform_later(@user.id) - end - PosthogService.identify(@user) PosthogService.capture(@user, "user_signed_in", { method: "hca" }) @@ -91,11 +87,6 @@ class SessionsController < ApplicationController if @user&.persisted? session[:user_id] = @user.id - if Flipper.enabled?(:hackatime_v1_import) && @user.data_migration_jobs.empty? - # if they don't have a data migration job, add one to the queue - MigrateUserFromHackatimeJob.perform_later(@user.id) - end - PosthogService.identify(@user) PosthogService.capture(@user, "user_signed_in", { method: "slack" }) diff --git a/app/controllers/settings/admin_controller.rb b/app/controllers/settings/admin_controller.rb deleted file mode 100644 index 20f1184..0000000 --- a/app/controllers/settings/admin_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Settings::AdminController < Settings::BaseController - before_action :require_admin_section_access - - def show - render_settings_page( - active_section: "admin", - settings_update_path: my_settings_profile_path - ) - end - - private - - def require_admin_section_access - unless current_user.admin_level.in?(%w[admin superadmin]) - redirect_to my_settings_profile_path, alert: "You are not authorized to access this page" - end - end -end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 2eddcbd..10dfa5d 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -25,23 +25,19 @@ class Settings::BaseController < InertiaController "access" => "Users/Settings/Access", "goals" => "Users/Settings/Goals", "badges" => "Users/Settings/Badges", - "data" => "Users/Settings/Data", - "admin" => "Users/Settings/Admin" + "data" => "Users/Settings/Data" }.fetch(active_section.to_s, "Users/Settings/Profile") end def prepare_settings_page @is_own_settings = is_own_settings? @can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write") - @imports_and_mirrors_enabled = Flipper.enabled?(:wakatime_imports_mirrors) - + @imports_enabled = Flipper.enabled?(:imports, @user) @enabled_sailors_logs = SailorsLogNotificationPreference.where( slack_uid: @user.slack_uid, enabled: true, ).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS) - @heartbeats_migration_jobs = @user.data_migration_jobs - @projects = @user.project_repo_mappings.distinct.pluck(:project_name) heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project) goal_languages = [] @@ -62,20 +58,12 @@ class Settings::BaseController < InertiaController @general_badge_url = GithubReadmeStats.new(@user.id, "darcula").generate_badge_url @latest_api_key_token = @user.api_keys.last&.token - @mirrors = @imports_and_mirrors_enabled ? current_user.wakatime_mirrors.order(created_at: :desc) : [] - @import_source = @imports_and_mirrors_enabled ? current_user.heartbeat_import_source : nil + @latest_heartbeat_import = @user.heartbeat_import_runs.latest_first.first end def settings_page_props(active_section:, settings_update_path:) heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id) - heartbeat_import_id = nil - heartbeat_import_status = nil - - if active_section.to_s == "data" && Rails.env.development? - heartbeat_import_id = params[:heartbeat_import_id].presence - heartbeat_import_status = HeartbeatImportRunner.status(user: @user, import_id: heartbeat_import_id) if heartbeat_import_id - end { active_section: active_section, @@ -86,8 +74,7 @@ class Settings::BaseController < InertiaController access: my_settings_access_path, goals: my_settings_goals_path, badges: my_settings_badges_path, - data: my_settings_data_path, - admin: my_settings_admin_path + data: my_settings_data_path }, page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"), heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"), @@ -127,14 +114,10 @@ class Settings::BaseController < InertiaController add_email_path: add_email_auth_path, unlink_email_path: unlink_email_auth_path, rotate_api_key_path: my_settings_rotate_api_key_path, - migrate_heartbeats_path: my_settings_migrate_heartbeats_path, export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"), export_range_heartbeats_path: export_my_heartbeats_path, create_heartbeat_import_path: my_heartbeat_imports_path, - create_deletion_path: create_deletion_path, - user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user), - heartbeat_import_source_path: my_heartbeat_import_source_path, - heartbeat_import_source_sync_path: sync_my_heartbeat_import_source_path + create_deletion_path: create_deletion_path }, options: { countries: ISO3166::Country.all.map { |country| @@ -205,38 +188,22 @@ class Settings::BaseController < InertiaController config_file: { content: generated_wakatime_config(@latest_api_key_token), has_api_key: @latest_api_key_token.present?, - empty_message: "No API key is available yet. Migrate heartbeats or rotate your API key to generate one.", + empty_message: "No API key is available yet. Rotate your API key to generate one.", api_key: @latest_api_key_token, api_url: "https://#{request.host_with_port}/api/hackatime/v1" }, - migration: { - enabled: Flipper.enabled?(:hackatime_v1_import), - jobs: @heartbeats_migration_jobs.map { |job| - { - id: job.id, - status: job.status - } - } - }, data_export: { total_heartbeats: number_with_delimiter(@user.heartbeats.count), total_coding_time: @user.heartbeats.duration_simple, heartbeats_last_7_days: number_with_delimiter(heartbeats_last_7_days), is_restricted: (@user.trust_level == "red") }, - import_source: serialized_import_source(@import_source), - mirrors: serialized_mirrors(@mirrors), - admin_tools: { - visible: current_user.admin_level.in?(%w[admin superadmin]), - mirrors: serialized_mirrors(@mirrors) - }, + imports_enabled: @imports_enabled, + remote_import_cooldown_until: HeartbeatImportRunner.remote_import_cooldown_until(user: @user)&.iso8601, + latest_heartbeat_import: HeartbeatImportRunner.serialize(@latest_heartbeat_import), ui: { show_dev_import: Rails.env.development?, - show_imports_and_mirrors: @imports_and_mirrors_enabled - }, - heartbeat_import: { - import_id: heartbeat_import_id, - status: heartbeat_import_status + show_imports: @imports_enabled }, errors: { full_messages: @user.errors.full_messages, @@ -279,43 +246,4 @@ class Settings::BaseController < InertiaController def is_own_settings? params["id"] == "my" || params["id"]&.blank? end - - def serialized_import_source(source) - return nil unless source - - { - id: source.id, - provider: source.provider, - endpoint_url: source.endpoint_url, - sync_enabled: source.sync_enabled, - status: source.status, - initial_backfill_start_date: source.initial_backfill_start_date&.iso8601, - initial_backfill_end_date: source.initial_backfill_end_date&.iso8601, - backfill_cursor_date: source.backfill_cursor_date&.iso8601, - last_synced_at: source.last_synced_at&.iso8601, - last_synced_ago: (source.last_synced_at ? "#{time_ago_in_words(source.last_synced_at)} ago" : "Never"), - last_error_message: source.last_error_message, - last_error_at: source.last_error_at&.iso8601, - consecutive_failures: source.consecutive_failures, - imported_count: Rails.cache.fetch("user:#{source.user_id}:wakapi_import_count", expires_in: 5.minutes) do - source.user.heartbeats.where(source_type: :wakapi_import).count - end - } - end - - def serialized_mirrors(mirrors) - mirrors.map { |mirror| - { - id: mirror.id, - endpoint_url: mirror.endpoint_url, - enabled: mirror.enabled, - last_synced_at: mirror.last_synced_at&.iso8601, - last_synced_ago: (mirror.last_synced_at ? "#{time_ago_in_words(mirror.last_synced_at)} ago" : "Never"), - consecutive_failures: mirror.consecutive_failures, - last_error_message: mirror.last_error_message, - last_error_at: mirror.last_error_at&.iso8601, - destroy_path: user_wakatime_mirror_path(current_user, mirror) - } - } - end end diff --git a/app/controllers/settings/data_controller.rb b/app/controllers/settings/data_controller.rb index 87c089e..b5ded78 100644 --- a/app/controllers/settings/data_controller.rb +++ b/app/controllers/settings/data_controller.rb @@ -3,16 +3,6 @@ class Settings::DataController < Settings::BaseController render_data end - def migrate_heartbeats - unless Flipper.enabled?(:hackatime_v1_import) - redirect_to my_settings_data_path, alert: "Hackatime v1 import is currently disabled" - return - end - - MigrateUserFromHackatimeJob.perform_later(@user.id) - redirect_to my_settings_data_path, notice: "Heartbeats & api keys migration started" - end - private def render_data(status: :ok) diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index e793618..383d34c 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -63,11 +63,10 @@ class StaticPagesController < InertiaController cached = Rails.cache.fetch(key, expires_in: 1.minute) do hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to]) - labels = Flipper.enabled?(:hackatime_v1_import) ? current_user.project_labels : [] projects = hb.group(:project).duration_seconds.filter_map do |proj, dur| next if dur <= 0 m = @project_repo_mappings.find { |p| p.project_name == proj } - { project: labels.find { |p| p.project_key == proj }&.label || proj || "Unknown", + { project: proj || "Unknown", project_key: proj, repo_url: m&.repo_url, repository: m&.repository, has_mapping: m.present?, duration: dur } end.sort_by { |p| -p[:duration] } diff --git a/app/controllers/wakatime_mirrors_controller.rb b/app/controllers/wakatime_mirrors_controller.rb deleted file mode 100644 index c419399..0000000 --- a/app/controllers/wakatime_mirrors_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -class WakatimeMirrorsController < ApplicationController - before_action :set_user - before_action :require_current_user - before_action :ensure_imports_and_mirrors_enabled - before_action :set_mirror, only: [ :destroy ] - - def create - @mirror = @user.wakatime_mirrors.build(mirror_params) - @mirror.request_host = request.host - if @mirror.save - redirect_to my_settings_data_path, notice: "WakaTime mirror added successfully" - else - redirect_to my_settings_data_path, alert: "Failed to add WakaTime mirror: #{@mirror.errors.full_messages.join(', ')}" - end - end - - def destroy - @mirror.destroy - redirect_to my_settings_data_path, notice: "WakaTime mirror removed successfully" - end - - private - - def set_user - @user = User.find(params[:user_id]) - end - - def set_mirror - @mirror = @user.wakatime_mirrors.find(params[:id]) - end - - def mirror_params - params.require(:wakatime_mirror).permit(:endpoint_url, :encrypted_api_key) - end - - def require_current_user - unless @user == current_user - redirect_to root_path, alert: "You are not authorized to access this page" - end - end - - def ensure_imports_and_mirrors_enabled - return if Flipper.enabled?(:wakatime_imports_mirrors) - - redirect_to my_settings_data_path, alert: "Imports and mirrors are currently disabled." - end -end diff --git a/app/javascript/pages/Users/Settings/Access.svelte b/app/javascript/pages/Users/Settings/Access.svelte index 3851bbe..d6c0777 100644 --- a/app/javascript/pages/Users/Settings/Access.svelte +++ b/app/javascript/pages/Users/Settings/Access.svelte @@ -3,6 +3,7 @@ import Button from "../../../components/Button.svelte"; import Modal from "../../../components/Modal.svelte"; import Select from "../../../components/Select.svelte"; + import SectionCard from "./components/SectionCard.svelte"; import SettingsShell from "./Shell.svelte"; import type { AccessPageProps } from "./types"; @@ -18,7 +19,6 @@ paths, config_file, errors, - admin_tools, }: AccessPageProps = $props(); let csrfToken = $state(""); @@ -93,116 +93,121 @@ {heading} {subheading} {errors} - {admin_tools} > -
- Use the setup guide if you are configuring a new editor or device. -
-
- Choose how coding time appears in the extension status text.
+
+ {rotatedApiKeyError}
- Rotate your API key if you think it has been exposed. -
+ {#snippet footer()}- {rotatedApiKeyError} -
- {/if} - - {#if rotatedApiKey} -- New API key -
-{rotatedApiKey}
-
- Copy this into your
+ {config_file.empty_message}
- {config_file.empty_message}
- ~/.wakatime.cfg file.
+ {config_file.content}
+ {:else}
+ {config_file.content}
- {:else}
-
- Mirror and import controls are available under Data settings for all - users. -
-- You are not authorized to access this section. -
- {/if} -- Queue migration of heartbeats and API keys from legacy Hackatime. -
- - {#if !migration.enabled} -- Hackatime v1 import is currently disabled due to an integration issue. - We're working on reinstating imports! -
- {/if} - - {#if migration.jobs.length > 0} -- Status: {importSource.status} -
-- Last synced: {importSource.last_synced_ago || "Never"} -
-- Imported: {importSource.imported_count.toLocaleString()} -
- {#if importSource.last_error_message} -- Last error: {importSource.last_error_message} -
- {/if} -