diff --git a/Gemfile b/Gemfile
index bfc255271..d762a257c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -56,6 +56,7 @@ gem "sprockets-rails"
gem "strong_migrations"
gem "pghero"
gem "pg_query", ">= 2"
+gem "user_agent_parser"
group :development, :test do
gem "byebug", platform: :mri
diff --git a/Gemfile.lock b/Gemfile.lock
index 72cd6a331..3912c2419 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -541,6 +541,7 @@ GEM
concurrent-ruby (~> 1.0)
uber (0.1.0)
uri (1.0.2)
+ user_agent_parser (2.18.0)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
@@ -626,6 +627,7 @@ DEPENDENCIES
syntax_tree (~> 6.2)
syntax_tree-haml (~> 4.0)
syntax_tree-rbs (~> 1.0.0)
+ user_agent_parser
web-console
webmock
diff --git a/app/controllers/admins/graphs_controller.rb b/app/controllers/admins/graphs_controller.rb
index 8cd830740..a7176cd05 100644
--- a/app/controllers/admins/graphs_controller.rb
+++ b/app/controllers/admins/graphs_controller.rb
@@ -16,6 +16,8 @@ def show
bot_signups
when "spam"
spam
+ when "user-agents"
+ user_agents
end
render json: data
end
@@ -67,6 +69,27 @@ def spam
end
end
+ def user_agents
+ base_relation = UserAgent.where("day > ?", 2.months.ago)
+ base_relation
+ .select(:name)
+ .distinct
+ .pluck(:name)
+ .reject { |name| name.blank? }
+ .map do |name|
+ {
+ name: name,
+ data:
+ base_relation
+ .where(name: name)
+ .group(:day)
+ .order(day: :asc)
+ .pluck(Arel.sql("day, count(*)"))
+ .map { |d| [d.first.to_datetime.to_i * 1000, d.last] }
+ }
+ end
+ end
+
def collected_inks
build CollectedInk
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 4345782d5..aaad38d8e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,6 +1,7 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
around_action :set_time_zone
+ before_action :save_user_agent
if !Rails.env.development?
rescue_from ActionView::MissingTemplate do |exception|
@@ -17,4 +18,13 @@ def set_time_zone
yield
end
end
+
+ def save_user_agent
+ user_agent = request.user_agent
+ UserAgent.create(
+ name: UserAgentParser.parse(user_agent).family,
+ raw_name: user_agent,
+ day: Date.current
+ )
+ end
end
diff --git a/app/javascript/src/admin/graphs/UserAgents.jsx b/app/javascript/src/admin/graphs/UserAgents.jsx
new file mode 100644
index 000000000..fcee575c9
--- /dev/null
+++ b/app/javascript/src/admin/graphs/UserAgents.jsx
@@ -0,0 +1,38 @@
+import React, { useEffect, useState } from "react";
+import Highcharts from "highcharts";
+import HighchartsReact from "highcharts-react-official";
+
+import { Spinner } from "../components/Spinner";
+import { getRequest } from "../../fetch";
+
+export const UserAgents = () => {
+ const [data, setData] = useState(null);
+ useEffect(() => {
+ getRequest("/admins/graphs/user-agents.json")
+ .then((res) => res.json())
+ .then((json) => setData(json));
+ }, []);
+ if (data) {
+ const options = {
+ chart: { type: "spline" },
+ legend: { enabled: true },
+ series: data,
+ title: { text: "User Agents" },
+ xAxis: {
+ type: "datetime"
+ },
+ yAxis: { title: { text: "" } }
+ };
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+
+
+ );
+ }
+};
diff --git a/app/javascript/src/admin/graphs/index.jsx b/app/javascript/src/admin/graphs/index.jsx
index f943dcfdd..667f8fecb 100644
--- a/app/javascript/src/admin/graphs/index.jsx
+++ b/app/javascript/src/admin/graphs/index.jsx
@@ -8,6 +8,7 @@ import { CurrentlyInked } from "./CurrentlyInked";
import { UsageRecords } from "./UsageRecords";
import { BotSignUps } from "./BotSignUps";
import { Spam } from "./Spam";
+import { UserAgents } from "./UserAgents";
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("signups-graph");
@@ -17,6 +18,14 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
+document.addEventListener("DOMContentLoaded", () => {
+ const el = document.getElementById("user-agents-graph");
+ if (el) {
+ const root = createRoot(el);
+ root.render();
+ }
+});
+
document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById("bot-signups-graph");
if (el) {
diff --git a/app/javascript/stylesheets/admin/graphs.scss b/app/javascript/stylesheets/admin/graphs.scss
index cab02a356..2d35fa6a4 100644
--- a/app/javascript/stylesheets/admin/graphs.scss
+++ b/app/javascript/stylesheets/admin/graphs.scss
@@ -1,6 +1,6 @@
#graphs {
- .row {
- box-shadow: none;
+ .row div {
+ margin-bottom: 5px;
}
.loader {
diff --git a/app/models/user_agent.rb b/app/models/user_agent.rb
new file mode 100644
index 000000000..6f4f9ab7d
--- /dev/null
+++ b/app/models/user_agent.rb
@@ -0,0 +1,2 @@
+class UserAgent < ApplicationRecord
+end
diff --git a/app/views/admins/dashboards/show.html.slim b/app/views/admins/dashboards/show.html.slim
index dfcd58ea5..f9df7725a 100644
--- a/app/views/admins/dashboards/show.html.slim
+++ b/app/views/admins/dashboards/show.html.slim
@@ -122,9 +122,11 @@ div#graphs
div#currently-inked-graph
div.col-sm-12.col-lg-6
div#usage-records-graph
- div.col-sm-12.col-md-6.col-lg-4
+ div.col-sm-12.col-lg-6
div#signups-graph
- div.col-sm-12.col-md-6.col-lg-4
+ div.col-sm-12.col-lg-6
div#bot-signups-graph
- div.col-sm-12.col-md-6.col-lg-4
+ div.col-sm-12.col-lg-6
div#spam-graph
+ div.col-sm-12.col-lg-6
+ div#user-agents-graph
diff --git a/db/migrate/20250107083051_create_user_agents.rb b/db/migrate/20250107083051_create_user_agents.rb
new file mode 100644
index 000000000..d0126b8a8
--- /dev/null
+++ b/db/migrate/20250107083051_create_user_agents.rb
@@ -0,0 +1,12 @@
+class CreateUserAgents < ActiveRecord::Migration[8.0]
+ def change
+ create_table :user_agents do |t|
+ t.string :name
+ t.string :raw_name
+ t.date :day
+
+ t.index %i[name day]
+ t.timestamps
+ end
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 656b661b0..1d92016f1 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -822,6 +822,39 @@ CREATE SEQUENCE public.usage_records_id_seq
ALTER SEQUENCE public.usage_records_id_seq OWNED BY public.usage_records.id;
+--
+-- Name: user_agents; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.user_agents (
+ id bigint NOT NULL,
+ name character varying,
+ raw_name character varying,
+ day date,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: user_agents_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.user_agents_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: user_agents_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.user_agents_id_seq OWNED BY public.user_agents.id;
+
+
--
-- Name: users; Type: TABLE; Schema: public; Owner: -
--
@@ -1092,6 +1125,13 @@ ALTER TABLE ONLY public.reading_statuses ALTER COLUMN id SET DEFAULT nextval('pu
ALTER TABLE ONLY public.usage_records ALTER COLUMN id SET DEFAULT nextval('public.usage_records_id_seq'::regclass);
+--
+-- Name: user_agents id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_agents ALTER COLUMN id SET DEFAULT nextval('public.user_agents_id_seq'::regclass);
+
+
--
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -1297,6 +1337,14 @@ ALTER TABLE ONLY public.usage_records
ADD CONSTRAINT usage_records_pkey PRIMARY KEY (id);
+--
+-- Name: user_agents user_agents_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_agents
+ ADD CONSTRAINT user_agents_pkey PRIMARY KEY (id);
+
+
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -1657,6 +1705,13 @@ CREATE INDEX index_reading_statuses_on_user_id ON public.reading_statuses USING
CREATE UNIQUE INDEX index_usage_records_on_currently_inked_id_and_used_on ON public.usage_records USING btree (currently_inked_id, used_on);
+--
+-- Name: index_user_agents_on_name_and_day; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_agents_on_name_and_day ON public.user_agents USING btree (name, day);
+
+
--
-- Name: index_users_on_confirmation_token; Type: INDEX; Schema: public; Owner: -
--
@@ -1926,6 +1981,7 @@ ALTER TABLE ONLY public.collected_inks
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
+('20250107083051'),
('20241228204210'),
('20241228202733'),
('20241211115823'),
diff --git a/spec/factories/user_agents.rb b/spec/factories/user_agents.rb
new file mode 100644
index 000000000..569aa0e38
--- /dev/null
+++ b/spec/factories/user_agents.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :user_agent do
+ name { "MyString" }
+ raw_name { "MyString" }
+ day { "2025-01-07" }
+ end
+end
diff --git a/spec/models/user_agent_spec.rb b/spec/models/user_agent_spec.rb
new file mode 100644
index 000000000..f58696ffa
--- /dev/null
+++ b/spec/models/user_agent_spec.rb
@@ -0,0 +1,5 @@
+require "rails_helper"
+
+RSpec.describe UserAgent, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end