mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Switch from hackatime heartbeats to native heartbeats
This commit is contained in:
@@ -28,11 +28,9 @@ class StaticPagesController < ApplicationController
|
||||
def activity_graph
|
||||
return unless current_user
|
||||
|
||||
@daily_durations = Hackatime::Heartbeat
|
||||
.where(user_id: current_user.slack_uid, time: 365.days.ago..)
|
||||
.group(Arel.sql("DATE_TRUNC('day', time)"))
|
||||
.duration_seconds
|
||||
.transform_keys(&:to_date)
|
||||
@daily_durations = Rails.cache.fetch("user_#{current_user.id}_daily_durations", expires_in: 1.minute) do
|
||||
current_user.heartbeats.daily_durations.to_h
|
||||
end
|
||||
|
||||
# Consider 8 hours as a "full" day of coding
|
||||
@length_of_busiest_day = 8.hours.to_i # 28800 seconds
|
||||
|
||||
@@ -21,7 +21,7 @@ class LeaderboardUpdateJob < ApplicationJob
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
valid_user_ids.each_slice(BATCH_SIZE) do |batch_user_ids|
|
||||
entries_data = Hackatime::Heartbeat.where(user_id: batch_user_ids)
|
||||
entries_data = Hackatime.where(user_id: batch_user_ids)
|
||||
.where(time: parsed_date.all_day)
|
||||
.group(:user_id)
|
||||
.duration_seconds
|
||||
|
||||
@@ -4,7 +4,7 @@ class SailorsLogPollForChangesJob < ApplicationJob
|
||||
def perform
|
||||
puts "performing SailorsLogPollForChangesJob"
|
||||
# get all users who've coded in the last minute
|
||||
users_who_coded = Hackatime::Heartbeat.where("created_at > ?", 1.minutes.ago)
|
||||
users_who_coded = Hackatime.where("created_at > ?", 1.minutes.ago)
|
||||
.where(time: 1.minutes.ago..)
|
||||
.distinct.pluck(:user_id)
|
||||
|
||||
@@ -21,7 +21,7 @@ class SailorsLogPollForChangesJob < ApplicationJob
|
||||
|
||||
logs.each do |log|
|
||||
# get all projects for the user with duration
|
||||
new_project_times = Hackatime::Heartbeat.where(user_id: log.slack_uid)
|
||||
new_project_times = Hackatime.where(user_id: log.slack_uid)
|
||||
.group(:project)
|
||||
.duration_seconds
|
||||
|
||||
|
||||
86
app/models/concerns/heartbeatable.rb
Normal file
86
app/models/concerns/heartbeatable.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
module Heartbeatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def heartbeat_timeout_duration(duration = nil)
|
||||
if duration
|
||||
@heartbeat_timeout_duration = duration
|
||||
else
|
||||
@heartbeat_timeout_duration || 2.minutes
|
||||
end
|
||||
end
|
||||
|
||||
def duration_formatted(scope = all)
|
||||
seconds = duration_seconds(scope)
|
||||
hours = seconds / 3600
|
||||
minutes = (seconds % 3600) / 60
|
||||
remaining_seconds = seconds % 60
|
||||
|
||||
format("%02d:%02d:%02d", hours, minutes, remaining_seconds)
|
||||
end
|
||||
|
||||
def duration_simple(scope = all)
|
||||
# 3 hours 10 min => "3 hrs"
|
||||
# 1 hour 10 min => "1 hr"
|
||||
# 10 min => "10 min"
|
||||
seconds = duration_seconds(scope)
|
||||
hours = seconds / 3600
|
||||
minutes = (seconds % 3600) / 60
|
||||
|
||||
if hours > 1
|
||||
"#{hours} hrs"
|
||||
elsif hours == 1
|
||||
"1 hr"
|
||||
elsif minutes > 0
|
||||
"#{minutes} min"
|
||||
else
|
||||
"0 min"
|
||||
end
|
||||
end
|
||||
|
||||
def daily_durations(start_date: 365.days.ago, end_date: Time.current)
|
||||
select(Arel.sql("DATE_TRUNC('day', to_timestamp(time)) as day_group"))
|
||||
.where(time: start_date..end_date)
|
||||
.group(Arel.sql("DATE_TRUNC('day', to_timestamp(time))"))
|
||||
.duration_seconds
|
||||
.map { |date, duration| [ date.to_date, duration ] }
|
||||
end
|
||||
|
||||
def duration_seconds(scope = all)
|
||||
if scope.group_values.any?
|
||||
group_column = scope.group_values.first
|
||||
|
||||
# Don't quote if it's a SQL function (contains parentheses)
|
||||
group_expr = group_column.to_s.include?("(") ? group_column : connection.quote_column_name(group_column)
|
||||
|
||||
capped_diffs = scope
|
||||
.select("#{group_expr} as grouped_time, CASE
|
||||
WHEN LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time) IS NULL THEN 0
|
||||
ELSE LEAST(EXTRACT(EPOCH FROM (to_timestamp(time) - to_timestamp(LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time)))), #{heartbeat_timeout_duration.to_i})
|
||||
END as diff")
|
||||
.where.not(time: nil)
|
||||
.order(time: :asc)
|
||||
.unscope(:group)
|
||||
|
||||
connection.select_all(
|
||||
"SELECT grouped_time, COALESCE(SUM(diff), 0)::integer as duration
|
||||
FROM (#{capped_diffs.to_sql}) AS diffs
|
||||
GROUP BY grouped_time"
|
||||
).each_with_object({}) do |row, hash|
|
||||
hash[row["grouped_time"]] = row["duration"].to_i
|
||||
end
|
||||
else
|
||||
# when not grouped, return a single value
|
||||
capped_diffs = scope
|
||||
.select("CASE
|
||||
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
|
||||
ELSE LEAST(EXTRACT(EPOCH FROM (to_timestamp(time) - to_timestamp(LAG(time) OVER (ORDER BY time)))), #{heartbeat_timeout_duration.to_i})
|
||||
END as diff")
|
||||
.where.not(time: nil)
|
||||
.order(time: :asc)
|
||||
|
||||
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
class Hackatime::Heartbeat < HackatimeRecord
|
||||
TIMEOUT_DURATION = 2.minutes
|
||||
include Heartbeatable
|
||||
|
||||
heartbeat_timeout_duration 2.minutes
|
||||
|
||||
def self.cached_recent_count
|
||||
Rails.cache.fetch("heartbeats_recent_count", expires_in: 5.minutes) do
|
||||
@@ -14,77 +16,4 @@ class Hackatime::Heartbeat < HackatimeRecord
|
||||
self.inheritance_column = nil
|
||||
# Prevent collision with Ruby's hash method
|
||||
self.ignored_columns += [ "hash" ]
|
||||
|
||||
def self.duration_seconds(scope = all)
|
||||
if scope.group_values.any?
|
||||
group_column = scope.group_values.first
|
||||
|
||||
# Don't quote if it's a SQL function (contains parentheses)
|
||||
group_expr = group_column.to_s.include?("(") ? group_column : connection.quote_column_name(group_column)
|
||||
|
||||
capped_diffs = scope
|
||||
.select("#{group_expr} as grouped_time, CASE
|
||||
WHEN LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time) IS NULL THEN 0
|
||||
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (PARTITION BY #{group_expr} ORDER BY time))), #{TIMEOUT_DURATION.to_i})
|
||||
END as diff")
|
||||
.where.not(time: nil)
|
||||
.order(time: :asc)
|
||||
.unscope(:group)
|
||||
|
||||
connection.select_all(
|
||||
"SELECT grouped_time, COALESCE(SUM(diff), 0)::integer as duration
|
||||
FROM (#{capped_diffs.to_sql}) AS diffs
|
||||
GROUP BY grouped_time"
|
||||
).each_with_object({}) do |row, hash|
|
||||
hash[row["grouped_time"]] = row["duration"].to_i
|
||||
end
|
||||
else
|
||||
# when not grouped, return a single value
|
||||
capped_diffs = scope
|
||||
.select("CASE
|
||||
WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0
|
||||
ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i})
|
||||
END as diff")
|
||||
.where.not(time: nil)
|
||||
.order(time: :asc)
|
||||
|
||||
connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i
|
||||
end
|
||||
end
|
||||
|
||||
def self.duration_formatted(scope = all)
|
||||
seconds = duration_seconds(scope)
|
||||
hours = seconds / 3600
|
||||
minutes = (seconds % 3600) / 60
|
||||
remaining_seconds = seconds % 60
|
||||
|
||||
format("%02d:%02d:%02d", hours, minutes, remaining_seconds)
|
||||
end
|
||||
|
||||
def self.duration_simple(scope = all)
|
||||
# 3 hours 10 min => "3 hrs"
|
||||
# 1 hour 10 min => "1 hr"
|
||||
# 10 min => "10 min"
|
||||
seconds = duration_seconds(scope)
|
||||
hours = seconds / 3600
|
||||
minutes = (seconds % 3600) / 60
|
||||
|
||||
if hours > 1
|
||||
"#{hours} hrs"
|
||||
elsif hours == 1
|
||||
"1 hr"
|
||||
elsif minutes > 0
|
||||
"#{minutes} min"
|
||||
else
|
||||
"0 min"
|
||||
end
|
||||
end
|
||||
|
||||
def self.daily_durations(start_date: 365.days.ago, end_date: Time.current)
|
||||
select(Arel.sql("DATE_TRUNC('day', time) as day_group"))
|
||||
.where(time: start_date..end_date)
|
||||
.group("day_group")
|
||||
.duration_seconds
|
||||
.map { |date, duration| [ date.to_date, duration ] }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
class Heartbeat < ApplicationRecord
|
||||
before_save :set_fields_hash!
|
||||
|
||||
include Heartbeatable
|
||||
|
||||
heartbeat_timeout_duration 2.minutes
|
||||
|
||||
scope :today, -> { where(time: Time.current.beginning_of_day..Time.current.end_of_day) }
|
||||
|
||||
enum :source_type, {
|
||||
direct_entry: 0,
|
||||
wakapi_import: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ class SailorsLogLeaderboard < ApplicationRecord
|
||||
.pluck(:slack_uid)
|
||||
|
||||
# Get all durations for users in channel
|
||||
user_durations = Hackatime::Heartbeat.where(user_id: users_in_channel)
|
||||
user_durations = Hackatime.where(user_id: users_in_channel)
|
||||
.today
|
||||
.group(:user_id)
|
||||
.duration_seconds
|
||||
@@ -47,7 +47,7 @@ class SailorsLogLeaderboard < ApplicationRecord
|
||||
|
||||
# Now get detailed project info only for top 10 users
|
||||
top_user_ids.map do |user_id|
|
||||
user_heartbeats = Hackatime::Heartbeat.where(user_id: user_id).today
|
||||
user_heartbeats = Hackatime.where(user_id: user_id).today
|
||||
|
||||
# Get most common language per project using ActiveRecord
|
||||
most_common_languages = user_heartbeats
|
||||
|
||||
@@ -6,7 +6,9 @@ class User < ApplicationRecord
|
||||
validates :slack_uid, presence: true, uniqueness: true
|
||||
validates :username, presence: true
|
||||
|
||||
has_many :heartbeats,
|
||||
has_many :heartbeats
|
||||
|
||||
has_many :hackatime_heartbeats,
|
||||
foreign_key: :user_id,
|
||||
primary_key: :slack_uid,
|
||||
class_name: "Hackatime::Heartbeat"
|
||||
@@ -67,8 +69,8 @@ class User < ApplicationRecord
|
||||
|
||||
current_project = heartbeats.order(time: :desc).first&.project
|
||||
current_project_heartbeats = heartbeats.today.where(project: current_project)
|
||||
current_project_duration = Hackatime::Heartbeat.duration_seconds(current_project_heartbeats)
|
||||
current_project_duration_formatted = Hackatime::Heartbeat.duration_simple(current_project_heartbeats)
|
||||
current_project_duration = Heartbeat.duration_seconds(current_project_heartbeats)
|
||||
current_project_duration_formatted = Heartbeat.duration_simple(current_project_heartbeats)
|
||||
|
||||
# for 0 duration, don't set a status – this will let status expire when the user has not been cooking today
|
||||
return if current_project_duration.zero?
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
<p>
|
||||
Build <%= link_to Rails.application.config.git_version, Rails.application.config.commit_link %>
|
||||
from <%= time_ago_in_words(Rails.application.config.server_start_time) %> ago.
|
||||
<%= pluralize(Hackatime::Heartbeat.cached_recent_count, 'heartbeat') %> in the last 24 hours.
|
||||
<%= pluralize(Heartbeat.where("created_at > ?", 24.hours.ago).count, 'heartbeat') %>
|
||||
(<%= Heartbeat.where("created_at > ?", 24.hours.ago).where.not(source_type: :direct_entry).count %> imported)
|
||||
in the last 24 hours.
|
||||
(DB: <%= pluralize(QueryCount::Counter.counter, "query") %>, <%= QueryCount::Counter.counter_cache %> cached)
|
||||
(CACHE: <%= cache_stats[:hits] %> hits, <%= cache_stats[:misses] %> misses)
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= turbo_frame_tag "activity_graph" do %>
|
||||
<%= cache ["project_durations", current_user.id], expires_in: 5.hours do %>
|
||||
<%= cache ["activity_graph", current_user.id], expires_in: 5.hours do %>
|
||||
<div class="activity-graph">
|
||||
<% (365.days.ago.to_date..Time.current.to_date).to_a.each do |date| %>
|
||||
<% duration = daily_durations[date] || 0 %>
|
||||
|
||||
@@ -22,6 +22,29 @@
|
||||
601
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "SQL Injection",
|
||||
"warning_code": 0,
|
||||
"fingerprint": "db41fbf90d0feb7b7c1c11545d498447162cf5e54e091d2ddcfcaba28b2513f6",
|
||||
"check_name": "SQL",
|
||||
"message": "Possible SQL injection",
|
||||
"file": "app/jobs/one_time/generate_unique_heartbeat_hashes_job.rb",
|
||||
"line": 32,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||
"code": "Heartbeat.where(:id => chunk.map do\n index = 1\nputs(\"Processing heartbeat #{heartbeat.id} (#{1} of #{batch.size})\")\nfield_hash = Heartbeat.generate_fields_hash(heartbeat.attributes)\nputs(\"Field hash: #{Heartbeat.generate_fields_hash(heartbeat.attributes)}\")\n[heartbeat.id, Heartbeat.generate_fields_hash(heartbeat.attributes)]\n end.map(&:first)).update_all(\"fields_hash = CASE #{chunk.map do\n index = 1\nputs(\"Processing heartbeat #{heartbeat.id} (#{1} of #{batch.size})\")\nfield_hash = Heartbeat.generate_fields_hash(heartbeat.attributes)\nputs(\"Field hash: #{Heartbeat.generate_fields_hash(heartbeat.attributes)}\")\n[heartbeat.id, Heartbeat.generate_fields_hash(heartbeat.attributes)]\n end.map do\n \"WHEN id = #{id} THEN '#{hash}'\"\n end.join(\" \")} END\")",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "OneTime::GenerateUniqueHeartbeatHashesJob",
|
||||
"method": "perform"
|
||||
},
|
||||
"user_input": "Heartbeat.generate_fields_hash(heartbeat.attributes)",
|
||||
"confidence": "High",
|
||||
"cwe_id": [
|
||||
89
|
||||
],
|
||||
"note": ""
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.0.0"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class ChangeHeartbeatsTimeToFloat8 < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
change_column :heartbeats, :time, :float8, null: false
|
||||
end
|
||||
end
|
||||
50
db/schema.rb
generated
50
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_06_033109) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@@ -139,7 +139,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "fields_hash"
|
||||
t.integer "source_type", default: 0, null: false
|
||||
t.integer "source_type", null: false
|
||||
t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash", unique: true
|
||||
t.index ["user_id"], name: "index_heartbeats_on_user_id"
|
||||
end
|
||||
@@ -163,19 +163,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
|
||||
t.datetime "deleted_at"
|
||||
end
|
||||
|
||||
create_table "project_checks", force: :cascade do |t|
|
||||
t.integer "check_type"
|
||||
t.integer "status"
|
||||
t.datetime "started_at"
|
||||
t.datetime "ended_at"
|
||||
t.text "output_message"
|
||||
t.jsonb "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["check_type", "created_at"], name: "index_project_checks_on_check_type_and_created_at"
|
||||
t.index ["status"], name: "index_project_checks_on_status"
|
||||
end
|
||||
|
||||
create_table "sailors_log_leaderboards", force: :cascade do |t|
|
||||
t.string "slack_channel_id"
|
||||
t.string "slack_uid"
|
||||
@@ -211,36 +198,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "ship_chains", force: :cascade do |t|
|
||||
t.text "code_url"
|
||||
t.text "demo_url"
|
||||
t.text "readme_url"
|
||||
t.text "description"
|
||||
t.integer "ysws_type"
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["code_url"], name: "index_ship_chains_on_code_url"
|
||||
t.index ["demo_url"], name: "index_ship_chains_on_demo_url"
|
||||
t.index ["user_id"], name: "index_ship_chains_on_user_id"
|
||||
end
|
||||
|
||||
create_table "ship_update_descriptions", force: :cascade do |t|
|
||||
t.string "what_changed"
|
||||
t.bigint "ship_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["ship_id"], name: "index_ship_update_descriptions_on_ship_id"
|
||||
end
|
||||
|
||||
create_table "ships", force: :cascade do |t|
|
||||
t.bigint "ship_chain_id", null: false
|
||||
t.integer "duration"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["ship_chain_id"], name: "index_ships_on_ship_chain_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "slack_uid", null: false
|
||||
t.string "email", null: false
|
||||
@@ -270,7 +227,4 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_195904) do
|
||||
add_foreign_key "api_keys", "users"
|
||||
add_foreign_key "heartbeats", "users"
|
||||
add_foreign_key "leaderboard_entries", "leaderboards"
|
||||
add_foreign_key "ship_chains", "users"
|
||||
add_foreign_key "ship_update_descriptions", "ships"
|
||||
add_foreign_key "ships", "ship_chains"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user