omnitrack-rb 0.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.example +6 -0
- data/AI_GEM_SETUP.md +33 -0
- data/CHANGELOG.md +8 -0
- data/README.md +44 -1
- data/app/controllers/omnitrack/application_controller.rb +24 -0
- data/app/controllers/omnitrack/path/application_controller.rb +15 -0
- data/app/controllers/omnitrack/path/sessions_controller.rb +39 -0
- data/app/controllers/omnitrack/path/visit_events_controller.rb +21 -0
- data/app/controllers/omnitrack/sessions_controller.rb +37 -0
- data/app/controllers/omnitrack/visit_events_controller.rb +19 -0
- data/app/models/omnitrack/application_record.rb +7 -0
- data/app/models/omnitrack/delivery_status.rb +12 -0
- data/app/models/omnitrack/visit_event.rb +15 -0
- data/app/views/layouts/omnitrack/admin.html.erb +55 -0
- data/app/views/omnitrack/path/sessions/new.html.erb +16 -0
- data/app/views/omnitrack/path/visit_events/index.html.erb +50 -0
- data/app/views/omnitrack/path/visit_events/show.html.erb +51 -0
- data/app/views/omnitrack/sessions/new.html.erb +16 -0
- data/app/views/omnitrack/visit_events/index.html.erb +50 -0
- data/app/views/omnitrack/visit_events/show.html.erb +51 -0
- data/config/locales/ar.yml +33 -0
- data/config/locales/en.yml +33 -0
- data/config/routes.rb +10 -0
- data/lib/generators/omnitrack/install/install_generator.rb +18 -2
- data/lib/generators/omnitrack/install/templates/create_omnitrack_dashboard_tables.rb +41 -0
- data/lib/generators/omnitrack/install/templates/env.example +6 -0
- data/lib/generators/omnitrack/install/templates/initializer.rb +9 -0
- data/lib/omnitrack/audit/recorder.rb +134 -0
- data/lib/omnitrack/compat/hash_compact_backport.rb +12 -0
- data/lib/omnitrack/configuration.rb +22 -0
- data/lib/omnitrack/context.rb +11 -3
- data/lib/omnitrack/engine.rb +11 -0
- data/lib/omnitrack/middleware/request_tracker.rb +7 -0
- data/lib/omnitrack/railtie.rb +8 -0
- data/lib/omnitrack/registry.rb +2 -2
- data/lib/omnitrack/version.rb +1 -1
- data/lib/omnitrack.rb +12 -2
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff7c5f93aedbb1f18f5b24a083661516212f52c8973b2b464ddef8667701de31
|
|
4
|
+
data.tar.gz: dfb4173d07da82b896221c87f3c107b1061eea2d1183d5da16787c5420924235
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40b53c4312d9986943c0dd6b174b061070731f9a083e3cfd12c171692a6bc11630a68e89a7f013fd8c411dc47e31c3c047f88c8710bf959b5f68870b5c63ff9f
|
|
7
|
+
data.tar.gz: fbb0e26662fd43e79a9d87d7d6e18b9716681ae232c564deec84b4e0adf64f2a4718c5274a85ac30b956329cf850178b090364bfe62d92eb28d47e6b2553e451
|
data/.env.example
CHANGED
|
@@ -41,3 +41,9 @@ TIKTOK_ACCESS_TOKEN=
|
|
|
41
41
|
SNAPCHAT_ENABLED=false
|
|
42
42
|
SNAPCHAT_PIXEL_ID=
|
|
43
43
|
SNAPCHAT_ACCESS_TOKEN=
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# OmniTrack Admin Dashboard
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
OMNITRACK_ADMIN_USERNAME=admin
|
|
49
|
+
OMNITRACK_ADMIN_PASSWORD=change-me
|
data/AI_GEM_SETUP.md
CHANGED
|
@@ -4,6 +4,30 @@ Use this document when **adding `omnitrack-rb` to an existing Rails 6+ app** so
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## 0. Ruby version (including 2.7.8)
|
|
8
|
+
|
|
9
|
+
The gem declares **`required_ruby_version >= 2.7.0`** and is written to run on **Ruby 2.7.8** without Ruby 3-only syntax. Your **host app** should use a supported Ruby and lock it so CI and production match.
|
|
10
|
+
|
|
11
|
+
**Example in the host `Gemfile` (optional but recommended):**
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
ruby "2.7.8" # or "3.0.6", "3.2.2", etc. — must be >= 2.7.0
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Check locally:**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ruby -v # should be 2.7.8 or newer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Rails:** use **Rails 6.x or 7.x** (or newer) with a Ruby version that Rails supports for that release. If the app is stuck on **Ruby 2.7.8**, keep **Rails 6.1.x** (or the last 6.x line you use) unless you have verified a newer stack.
|
|
24
|
+
|
|
25
|
+
**Bundler:** Bundler 2.x is typical; run `bundle install` with the same Ruby the app uses in production.
|
|
26
|
+
|
|
27
|
+
Nothing else is required for 2.7.8 specifically — no extra `require` in `application.rb`; the Railtie loads OmniTrack like any other Rails engine/gem.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
7
31
|
## 1. Gemfile (required)
|
|
8
32
|
|
|
9
33
|
In the **host** application `Gemfile`:
|
|
@@ -33,6 +57,13 @@ rails generate omnitrack:install
|
|
|
33
57
|
This creates:
|
|
34
58
|
|
|
35
59
|
- `config/initializers/omnitrack.rb`
|
|
60
|
+
- `db/migrate/*_create_omnitrack_dashboard_tables.rb`
|
|
61
|
+
|
|
62
|
+
Then run:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
rails db:migrate
|
|
66
|
+
```
|
|
36
67
|
|
|
37
68
|
**Boot check (must succeed):**
|
|
38
69
|
|
|
@@ -128,6 +159,7 @@ Until `Omnitrack::Jobs::TrackingJob` and ActiveJob are loaded, the gem runs sync
|
|
|
128
159
|
|
|
129
160
|
| Symptom | What to do |
|
|
130
161
|
|--------|------------|
|
|
162
|
+
| `ruby` version error from Bundler | Host `Gemfile` `ruby "x.y.z"` must match your runtime; use `ruby -v` and align RVM/rbenv/asdf. |
|
|
131
163
|
| `uninitialized constant Omnitrack` | Run `bundle install`; ensure `config/application.rb` loads Bundler default group; boot with `bin/rails c`. |
|
|
132
164
|
| `ENV[...] is nil` in logs | Adapters with `enabled: true` but missing tokens may log or skip; set `*_ENABLED=false` until credentials exist. |
|
|
133
165
|
| Job errors when `async: true` | Set `config.async = false` until ActiveJob and queue are configured, or add queue adapter. |
|
|
@@ -142,6 +174,7 @@ Until `Omnitrack::Jobs::TrackingJob` and ActiveJob are loaded, the gem runs sync
|
|
|
142
174
|
|------|--------|
|
|
143
175
|
| `Gemfile` | `gem "omnitrack-rb"` (and `dotenv-rails` if using `.env`) |
|
|
144
176
|
| `config/initializers/omnitrack.rb` | `Omnitrack.configure` |
|
|
177
|
+
| `db/migrate/*_create_omnitrack_dashboard_tables.rb` | Dashboard history tables migration |
|
|
145
178
|
| `.env.example` | Document variable names (no secrets) — optional if committed |
|
|
146
179
|
| `.env` | Local secrets — **gitignored** |
|
|
147
180
|
| `app/views/layouts/...` | `<%= omnitrack_tags %>` for full-stack |
|
data/CHANGELOG.md
CHANGED
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.0.0] - 2026-04-26
|
|
6
|
+
|
|
5
7
|
### Added
|
|
6
8
|
- `AI_GEM_SETUP.md` — step-by-step integration for host apps (Bundler, initializer, ENV, ActiveJob, pitfalls)
|
|
7
9
|
- `.env.example` — variable names matching the install template; install generator copies `env.example` → `.env.example` when missing
|
|
8
10
|
- `USAGE.md` — concrete ERB/Ruby examples for layout, HTML/API controllers, and a service object
|
|
11
|
+
- `lib/omnitrack/compat/hash_compact_backport` — `Hash#compact` on Rubies before 3.1 (e.g. 2.7.x, 3.0)
|
|
9
12
|
|
|
10
13
|
### Fixed
|
|
14
|
+
- **Ruby 2.7.8** and **Ruby 3.0** compatibility: no endless `def` or `filter_map` (require Ruby 3+ in core for those); compatible `Enumerable` + `Hash#compact` usage
|
|
11
15
|
- Conventional `lib/omnitrack` gem layout, loadable `require "omnitrack"`
|
|
12
16
|
- `rails generate omnitrack:install` generator at `lib/generators/omnitrack/install/`
|
|
13
17
|
- `Omnitrack::Controller` as an alias of `Omnitrack::Concerns::Controller`
|
|
14
18
|
- Default `adapter_name` for adapters uses `underscore` (snake_case) so config keys (e.g. `google_ads`) match the registry; `Omnitrack.reset!` re-registers first-party adapters in tests
|
|
15
19
|
- `activesupport` as a direct runtime dependency; explicit requires for `ActiveSupport` extensions used at load time
|
|
16
20
|
|
|
21
|
+
### Upgrade from 0.1.0
|
|
22
|
+
- In the host `Gemfile`, pin `gem "omnitrack-rb", "~> 2.0"` and run `bundle update omnitrack-rb`
|
|
23
|
+
- If you had relied on a mis-registered adapter key (`:googleads` from an old build), use **`google_ads`** in `config.adapters` (and env-driven flags) to match the registry
|
|
24
|
+
|
|
17
25
|
## [0.1.0] - 2024-01-15
|
|
18
26
|
|
|
19
27
|
### Added
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# OmniTrack-rb 🎯
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/omnitrack-rb)
|
|
4
|
-
[](https://www.ruby-lang.org)
|
|
5
5
|
[](https://rubyonrails.org)
|
|
6
6
|
[](LICENSE.txt)
|
|
7
7
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
OmniTrack dispatches events to multiple ad/analytics platforms through a clean adapter pattern — works identically in full-stack and API-only Rails apps, never crashes your main application, and emits structured JSON logs to a dedicated file.
|
|
11
11
|
|
|
12
|
+
Runtime: **Ruby ≥ 2.7** (including **2.7.8**) and **Rails ≥ 6**. No Ruby 3-only syntax in the gem runtime.
|
|
13
|
+
|
|
12
14
|
**Practical usage (file placement + method examples):** [USAGE.md](USAGE.md)
|
|
13
15
|
**Add the gem to any Rails app without boot errors (ENV, jobs, checklists):** [AI_GEM_SETUP.md](AI_GEM_SETUP.md)
|
|
14
16
|
**ENV variable names (copy into host `.env.example`):** [.env.example](.env.example)
|
|
@@ -321,6 +323,47 @@ config.active_job.queue_adapter = :sidekiq
|
|
|
321
323
|
|
|
322
324
|
---
|
|
323
325
|
|
|
326
|
+
## Admin Dashboard (Full Visitor History)
|
|
327
|
+
|
|
328
|
+
OmniTrack now includes a dashboard UI at:
|
|
329
|
+
|
|
330
|
+
`/omnitrack/login` (default mount path)
|
|
331
|
+
|
|
332
|
+
Features:
|
|
333
|
+
|
|
334
|
+
- Admin login (username/password)
|
|
335
|
+
- Full visitor lifecycle history (from first request through dispatch)
|
|
336
|
+
- Per-platform delivery status (`success`, `failure`, `queued`, etc.)
|
|
337
|
+
- Event detail page with payload + context snapshots
|
|
338
|
+
- Pagination
|
|
339
|
+
- Arabic/English toggle
|
|
340
|
+
|
|
341
|
+
Setup:
|
|
342
|
+
|
|
343
|
+
1. Run installer and migration:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
rails generate omnitrack:install
|
|
347
|
+
rails db:migrate
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
2. Set dashboard credentials in ENV:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
OMNITRACK_ADMIN_USERNAME=admin
|
|
354
|
+
OMNITRACK_ADMIN_PASSWORD=change-me
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
3. Optional initializer settings:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
config.dashboard_enabled = true
|
|
361
|
+
config.dashboard_per_page = 25
|
|
362
|
+
config.dashboard_mount_path = "/omnitrack" # change per host app if needed
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
324
367
|
## Middleware
|
|
325
368
|
|
|
326
369
|
`Omnitrack::Middleware::RequestTracker` is automatically inserted into your Rack stack. It:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
layout "omnitrack/admin"
|
|
6
|
+
|
|
7
|
+
before_action :set_locale
|
|
8
|
+
before_action :require_admin_authentication
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def require_admin_authentication
|
|
13
|
+
return if session[:omnitrack_admin_authenticated]
|
|
14
|
+
|
|
15
|
+
redirect_to login_path(locale: I18n.locale), alert: t("omnitrack.auth.login_required")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_locale
|
|
19
|
+
requested_locale = params[:locale].presence
|
|
20
|
+
locale = requested_locale&.to_sym
|
|
21
|
+
I18n.locale = I18n.available_locales.include?(locale) ? locale : I18n.default_locale
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Path
|
|
5
|
+
class ApplicationController < ::Omnitrack::ApplicationController
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def require_admin_authentication
|
|
9
|
+
return if session[:omnitrack_admin_authenticated]
|
|
10
|
+
|
|
11
|
+
redirect_to login_path(locale: I18n.locale), alert: t("omnitrack.auth.login_required")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
module Path
|
|
7
|
+
class SessionsController < ApplicationController
|
|
8
|
+
skip_before_action :require_admin_authentication, only: %i[new create]
|
|
9
|
+
|
|
10
|
+
def new; end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
if valid_credentials?
|
|
14
|
+
session[:omnitrack_admin_authenticated] = true
|
|
15
|
+
redirect_to visit_events_path(locale: I18n.locale), notice: t("omnitrack.auth.login_success")
|
|
16
|
+
else
|
|
17
|
+
flash.now[:alert] = t("omnitrack.auth.login_failed")
|
|
18
|
+
render :new, status: :unprocessable_entity
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def destroy
|
|
23
|
+
session.delete(:omnitrack_admin_authenticated)
|
|
24
|
+
redirect_to login_path(locale: I18n.locale), notice: t("omnitrack.auth.logout_success")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def valid_credentials?
|
|
30
|
+
secure_compare(params[:username].to_s, Omnitrack.config.admin_username.to_s) &&
|
|
31
|
+
secure_compare(params[:password].to_s, Omnitrack.config.admin_password.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def secure_compare(value, expected)
|
|
35
|
+
ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(value), Digest::SHA256.hexdigest(expected))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Path
|
|
5
|
+
class VisitEventsController < ApplicationController
|
|
6
|
+
def index
|
|
7
|
+
@page = [params.fetch(:page, 1).to_i, 1].max
|
|
8
|
+
@per_page = Omnitrack.config.dashboard_per_page.to_i
|
|
9
|
+
|
|
10
|
+
scoped = Omnitrack::VisitEvent.recent_first
|
|
11
|
+
@total_count = scoped.count
|
|
12
|
+
@total_pages = (@total_count.to_f / @per_page).ceil
|
|
13
|
+
@events = scoped.includes(:delivery_statuses).offset((@page - 1) * @per_page).limit(@per_page)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
@event = Omnitrack::VisitEvent.includes(:delivery_statuses).find(params[:id])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
class SessionsController < ApplicationController
|
|
7
|
+
skip_before_action :require_admin_authentication, only: %i[new create]
|
|
8
|
+
|
|
9
|
+
def new; end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
if valid_credentials?
|
|
13
|
+
session[:omnitrack_admin_authenticated] = true
|
|
14
|
+
redirect_to visit_events_path(locale: I18n.locale), notice: t("omnitrack.auth.login_success")
|
|
15
|
+
else
|
|
16
|
+
flash.now[:alert] = t("omnitrack.auth.login_failed")
|
|
17
|
+
render :new, status: :unprocessable_entity
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def destroy
|
|
22
|
+
session.delete(:omnitrack_admin_authenticated)
|
|
23
|
+
redirect_to login_path(locale: I18n.locale), notice: t("omnitrack.auth.logout_success")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def valid_credentials?
|
|
29
|
+
secure_compare(params[:username].to_s, Omnitrack.config.admin_username.to_s) &&
|
|
30
|
+
secure_compare(params[:password].to_s, Omnitrack.config.admin_password.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def secure_compare(value, expected)
|
|
34
|
+
ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(value), Digest::SHA256.hexdigest(expected))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
class VisitEventsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@page = [params.fetch(:page, 1).to_i, 1].max
|
|
7
|
+
@per_page = Omnitrack.config.dashboard_per_page.to_i
|
|
8
|
+
|
|
9
|
+
scoped = Omnitrack::VisitEvent.recent_first
|
|
10
|
+
@total_count = scoped.count
|
|
11
|
+
@total_pages = (@total_count.to_f / @per_page).ceil
|
|
12
|
+
@events = scoped.includes(:delivery_statuses).offset((@page - 1) * @per_page).limit(@per_page)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
@event = Omnitrack::VisitEvent.includes(:delivery_statuses).find(params[:id])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
class DeliveryStatus < ApplicationRecord
|
|
5
|
+
self.table_name = "omnitrack_delivery_statuses"
|
|
6
|
+
|
|
7
|
+
belongs_to :visit_event,
|
|
8
|
+
class_name: "Omnitrack::VisitEvent",
|
|
9
|
+
foreign_key: :visit_event_id,
|
|
10
|
+
inverse_of: :delivery_statuses
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
class VisitEvent < ApplicationRecord
|
|
5
|
+
self.table_name = "omnitrack_visit_events"
|
|
6
|
+
|
|
7
|
+
has_many :delivery_statuses,
|
|
8
|
+
class_name: "Omnitrack::DeliveryStatus",
|
|
9
|
+
foreign_key: :visit_event_id,
|
|
10
|
+
inverse_of: :visit_event,
|
|
11
|
+
dependent: :delete_all
|
|
12
|
+
|
|
13
|
+
scope :recent_first, -> { order(started_at: :desc) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="<%= I18n.locale %>" dir="<%= I18n.locale.to_s == 'ar' ? 'rtl' : 'ltr' %>">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title><%= t("omnitrack.ui.title") %></title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#0f172a; --card:#111827; --text:#f3f4f6; --muted:#9ca3af; --ok:#22c55e; --bad:#ef4444; --warn:#f59e0b; --line:#374151; }
|
|
9
|
+
body { margin:0; background:var(--bg); color:var(--text); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
10
|
+
.shell { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
11
|
+
.top { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:20px; }
|
|
12
|
+
.brand { font-size: 20px; font-weight: 700; }
|
|
13
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; margin-bottom:16px; }
|
|
14
|
+
.grid { display:grid; gap:12px; }
|
|
15
|
+
table { width:100%; border-collapse: collapse; }
|
|
16
|
+
th, td { padding:10px; border-bottom:1px solid var(--line); text-align:start; vertical-align:top; }
|
|
17
|
+
th { color:var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing:.04em; }
|
|
18
|
+
a { color:#93c5fd; text-decoration:none; }
|
|
19
|
+
.badge { border-radius:999px; padding:4px 10px; font-size:12px; font-weight:600; display:inline-block; }
|
|
20
|
+
.success { background: rgba(34,197,94,.15); color: var(--ok); }
|
|
21
|
+
.failure { background: rgba(239,68,68,.15); color: var(--bad); }
|
|
22
|
+
.partial_failure, .processing, .queued, .skipped { background: rgba(245,158,11,.15); color: var(--warn); }
|
|
23
|
+
.muted { color:var(--muted); }
|
|
24
|
+
input, button, select { font: inherit; padding:10px 12px; border-radius:10px; border:1px solid var(--line); background:#0b1220; color:var(--text); }
|
|
25
|
+
button { cursor:pointer; }
|
|
26
|
+
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
|
27
|
+
.alert { padding:10px 12px; border-radius:10px; margin-bottom:14px; }
|
|
28
|
+
.alert-ok { background: rgba(34,197,94,.15); color: var(--ok); }
|
|
29
|
+
.alert-bad { background: rgba(239,68,68,.15); color: var(--bad); }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div class="shell">
|
|
34
|
+
<div class="top">
|
|
35
|
+
<div class="brand"><%= t("omnitrack.ui.title") %></div>
|
|
36
|
+
<div class="row">
|
|
37
|
+
<%= link_to "EN", params.to_unsafe_h.merge(locale: :en) %>
|
|
38
|
+
<%= link_to "AR", params.to_unsafe_h.merge(locale: :ar) %>
|
|
39
|
+
<% if session[:omnitrack_admin_authenticated] %>
|
|
40
|
+
<%= button_to t("omnitrack.auth.logout"), logout_path(locale: I18n.locale), method: :delete, form_class: "row" %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% if flash[:notice] %>
|
|
46
|
+
<div class="alert alert-ok"><%= flash[:notice] %></div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% if flash[:alert] %>
|
|
49
|
+
<div class="alert alert-bad"><%= flash[:alert] %></div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<%= yield %>
|
|
53
|
+
</div>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div class="card" style="max-width: 440px; margin: 50px auto;">
|
|
2
|
+
<h2><%= t("omnitrack.auth.login_title") %></h2>
|
|
3
|
+
<p class="muted"><%= t("omnitrack.auth.login_hint") %></p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: login_submit_path(locale: I18n.locale), method: :post, local: true, class: "grid" do %>
|
|
6
|
+
<label>
|
|
7
|
+
<span><%= t("omnitrack.auth.username") %></span><br>
|
|
8
|
+
<%= text_field_tag :username, nil, required: true, autocomplete: "username" %>
|
|
9
|
+
</label>
|
|
10
|
+
<label>
|
|
11
|
+
<span><%= t("omnitrack.auth.password") %></span><br>
|
|
12
|
+
<%= password_field_tag :password, nil, required: true, autocomplete: "current-password" %>
|
|
13
|
+
</label>
|
|
14
|
+
<button type="submit"><%= t("omnitrack.auth.login_button") %></button>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<div class="row" style="justify-content: space-between;">
|
|
3
|
+
<h2 style="margin:0;"><%= t("omnitrack.events.title") %></h2>
|
|
4
|
+
<div class="muted">
|
|
5
|
+
<%= t("omnitrack.events.total", count: @total_count) %> |
|
|
6
|
+
<%= t("omnitrack.events.page", current: @page, total: [@total_pages, 1].max) %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="card">
|
|
12
|
+
<table>
|
|
13
|
+
<thead>
|
|
14
|
+
<tr>
|
|
15
|
+
<th><%= t("omnitrack.events.columns.id") %></th>
|
|
16
|
+
<th><%= t("omnitrack.events.columns.event") %></th>
|
|
17
|
+
<th><%= t("omnitrack.events.columns.ip") %></th>
|
|
18
|
+
<th><%= t("omnitrack.events.columns.started_at") %></th>
|
|
19
|
+
<th><%= t("omnitrack.events.columns.status") %></th>
|
|
20
|
+
<th><%= t("omnitrack.events.columns.actions") %></th>
|
|
21
|
+
</tr>
|
|
22
|
+
</thead>
|
|
23
|
+
<tbody>
|
|
24
|
+
<% @events.each do |event| %>
|
|
25
|
+
<tr>
|
|
26
|
+
<td><%= event.id %></td>
|
|
27
|
+
<td><%= event.event_name %></td>
|
|
28
|
+
<td><%= event.ip.presence || "-" %></td>
|
|
29
|
+
<td><%= event.started_at %></td>
|
|
30
|
+
<td><span class="badge <%= event.overall_status %>"><%= event.overall_status %></span></td>
|
|
31
|
+
<td><%= link_to t("omnitrack.events.view"), visit_event_path(event, locale: I18n.locale) %></td>
|
|
32
|
+
</tr>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if @events.empty? %>
|
|
35
|
+
<tr>
|
|
36
|
+
<td colspan="6" class="muted"><%= t("omnitrack.events.empty") %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="row">
|
|
44
|
+
<% if @page > 1 %>
|
|
45
|
+
<%= link_to t("omnitrack.events.prev"), visit_events_path(page: @page - 1, locale: I18n.locale) %>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% if @page < @total_pages %>
|
|
48
|
+
<%= link_to t("omnitrack.events.next"), visit_events_path(page: @page + 1, locale: I18n.locale) %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<div class="row" style="justify-content: space-between;">
|
|
3
|
+
<h2 style="margin:0;"><%= t("omnitrack.events.details_title", id: @event.id) %></h2>
|
|
4
|
+
<%= link_to t("omnitrack.events.back"), visit_events_path(locale: I18n.locale) %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="grid" style="margin-top:12px;">
|
|
8
|
+
<div><strong><%= t("omnitrack.events.columns.event") %>:</strong> <%= @event.event_name %></div>
|
|
9
|
+
<div><strong><%= t("omnitrack.events.columns.status") %>:</strong> <span class="badge <%= @event.overall_status %>"><%= @event.overall_status %></span></div>
|
|
10
|
+
<div><strong><%= t("omnitrack.events.columns.ip") %>:</strong> <%= @event.ip.presence || "-" %></div>
|
|
11
|
+
<div><strong>User Agent:</strong> <%= @event.user_agent.presence || "-" %></div>
|
|
12
|
+
<div><strong>Path:</strong> <%= @event.request_method %> <%= @event.request_path %></div>
|
|
13
|
+
<div><strong>Visitor Token:</strong> <%= @event.visitor_token %></div>
|
|
14
|
+
<div><strong>Started:</strong> <%= @event.started_at %></div>
|
|
15
|
+
<div><strong>Completed:</strong> <%= @event.completed_at %></div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="card">
|
|
20
|
+
<h3><%= t("omnitrack.events.delivery_history") %></h3>
|
|
21
|
+
<table>
|
|
22
|
+
<thead>
|
|
23
|
+
<tr>
|
|
24
|
+
<th>Adapter</th>
|
|
25
|
+
<th><%= t("omnitrack.events.columns.status") %></th>
|
|
26
|
+
<th>Sent At</th>
|
|
27
|
+
<th>Error</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<% @event.delivery_statuses.order(sent_at: :asc).each do |delivery| %>
|
|
32
|
+
<tr>
|
|
33
|
+
<td><%= delivery.adapter_name %></td>
|
|
34
|
+
<td><span class="badge <%= delivery.status %>"><%= delivery.status %></span></td>
|
|
35
|
+
<td><%= delivery.sent_at %></td>
|
|
36
|
+
<td><%= delivery.error_message.presence || "-" %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="card">
|
|
44
|
+
<h3>Payload</h3>
|
|
45
|
+
<pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.payload_json || {}) %></pre>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="card">
|
|
49
|
+
<h3>Context</h3>
|
|
50
|
+
<pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.context_json || {}) %></pre>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div class="card" style="max-width: 440px; margin: 50px auto;">
|
|
2
|
+
<h2><%= t("omnitrack.auth.login_title") %></h2>
|
|
3
|
+
<p class="muted"><%= t("omnitrack.auth.login_hint") %></p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: admin_login_submit_path(locale: I18n.locale), method: :post, local: true, class: "grid" do %>
|
|
6
|
+
<label>
|
|
7
|
+
<span><%= t("omnitrack.auth.username") %></span><br>
|
|
8
|
+
<%= text_field_tag :username, nil, required: true, autocomplete: "username" %>
|
|
9
|
+
</label>
|
|
10
|
+
<label>
|
|
11
|
+
<span><%= t("omnitrack.auth.password") %></span><br>
|
|
12
|
+
<%= password_field_tag :password, nil, required: true, autocomplete: "current-password" %>
|
|
13
|
+
</label>
|
|
14
|
+
<button type="submit"><%= t("omnitrack.auth.login_button") %></button>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<div class="row" style="justify-content: space-between;">
|
|
3
|
+
<h2 style="margin:0;"><%= t("omnitrack.events.title") %></h2>
|
|
4
|
+
<div class="muted">
|
|
5
|
+
<%= t("omnitrack.events.total", count: @total_count) %> |
|
|
6
|
+
<%= t("omnitrack.events.page", current: @page, total: [@total_pages, 1].max) %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="card">
|
|
12
|
+
<table>
|
|
13
|
+
<thead>
|
|
14
|
+
<tr>
|
|
15
|
+
<th><%= t("omnitrack.events.columns.id") %></th>
|
|
16
|
+
<th><%= t("omnitrack.events.columns.event") %></th>
|
|
17
|
+
<th><%= t("omnitrack.events.columns.ip") %></th>
|
|
18
|
+
<th><%= t("omnitrack.events.columns.started_at") %></th>
|
|
19
|
+
<th><%= t("omnitrack.events.columns.status") %></th>
|
|
20
|
+
<th><%= t("omnitrack.events.columns.actions") %></th>
|
|
21
|
+
</tr>
|
|
22
|
+
</thead>
|
|
23
|
+
<tbody>
|
|
24
|
+
<% @events.each do |event| %>
|
|
25
|
+
<tr>
|
|
26
|
+
<td><%= event.id %></td>
|
|
27
|
+
<td><%= event.event_name %></td>
|
|
28
|
+
<td><%= event.ip.presence || "-" %></td>
|
|
29
|
+
<td><%= event.started_at %></td>
|
|
30
|
+
<td><span class="badge <%= event.overall_status %>"><%= event.overall_status %></span></td>
|
|
31
|
+
<td><%= link_to t("omnitrack.events.view"), visit_event_path(event, locale: I18n.locale) %></td>
|
|
32
|
+
</tr>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if @events.empty? %>
|
|
35
|
+
<tr>
|
|
36
|
+
<td colspan="6" class="muted"><%= t("omnitrack.events.empty") %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="row">
|
|
44
|
+
<% if @page > 1 %>
|
|
45
|
+
<%= link_to t("omnitrack.events.prev"), visit_events_path(page: @page - 1, locale: I18n.locale) %>
|
|
46
|
+
<% end %>
|
|
47
|
+
<% if @page < @total_pages %>
|
|
48
|
+
<%= link_to t("omnitrack.events.next"), visit_events_path(page: @page + 1, locale: I18n.locale) %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<div class="row" style="justify-content: space-between;">
|
|
3
|
+
<h2 style="margin:0;"><%= t("omnitrack.events.details_title", id: @event.id) %></h2>
|
|
4
|
+
<%= link_to t("omnitrack.events.back"), visit_events_path(locale: I18n.locale) %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="grid" style="margin-top:12px;">
|
|
8
|
+
<div><strong><%= t("omnitrack.events.columns.event") %>:</strong> <%= @event.event_name %></div>
|
|
9
|
+
<div><strong><%= t("omnitrack.events.columns.status") %>:</strong> <span class="badge <%= @event.overall_status %>"><%= @event.overall_status %></span></div>
|
|
10
|
+
<div><strong><%= t("omnitrack.events.columns.ip") %>:</strong> <%= @event.ip.presence || "-" %></div>
|
|
11
|
+
<div><strong>User Agent:</strong> <%= @event.user_agent.presence || "-" %></div>
|
|
12
|
+
<div><strong>Path:</strong> <%= @event.request_method %> <%= @event.request_path %></div>
|
|
13
|
+
<div><strong>Visitor Token:</strong> <%= @event.visitor_token %></div>
|
|
14
|
+
<div><strong>Started:</strong> <%= @event.started_at %></div>
|
|
15
|
+
<div><strong>Completed:</strong> <%= @event.completed_at %></div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="card">
|
|
20
|
+
<h3><%= t("omnitrack.events.delivery_history") %></h3>
|
|
21
|
+
<table>
|
|
22
|
+
<thead>
|
|
23
|
+
<tr>
|
|
24
|
+
<th>Adapter</th>
|
|
25
|
+
<th><%= t("omnitrack.events.columns.status") %></th>
|
|
26
|
+
<th>Sent At</th>
|
|
27
|
+
<th>Error</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<% @event.delivery_statuses.order(sent_at: :asc).each do |delivery| %>
|
|
32
|
+
<tr>
|
|
33
|
+
<td><%= delivery.adapter_name %></td>
|
|
34
|
+
<td><span class="badge <%= delivery.status %>"><%= delivery.status %></span></td>
|
|
35
|
+
<td><%= delivery.sent_at %></td>
|
|
36
|
+
<td><%= delivery.error_message.presence || "-" %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="card">
|
|
44
|
+
<h3>Payload</h3>
|
|
45
|
+
<pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.payload_json || {}) %></pre>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="card">
|
|
49
|
+
<h3>Context</h3>
|
|
50
|
+
<pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.context_json || {}) %></pre>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
ar:
|
|
2
|
+
omnitrack:
|
|
3
|
+
ui:
|
|
4
|
+
title: "لوحة أومنِتراك"
|
|
5
|
+
auth:
|
|
6
|
+
login_required: "يرجى تسجيل الدخول كمسؤول أولاً."
|
|
7
|
+
login_success: "تم تسجيل الدخول بنجاح."
|
|
8
|
+
login_failed: "اسم المستخدم أو كلمة المرور غير صحيحة."
|
|
9
|
+
logout_success: "تم تسجيل الخروج بنجاح."
|
|
10
|
+
logout: "تسجيل الخروج"
|
|
11
|
+
login_title: "تسجيل دخول المسؤول"
|
|
12
|
+
login_hint: "استخدم بيانات دخول مسؤول OmniTrack."
|
|
13
|
+
username: "اسم المستخدم"
|
|
14
|
+
password: "كلمة المرور"
|
|
15
|
+
login_button: "دخول"
|
|
16
|
+
events:
|
|
17
|
+
title: "سجل الزائر الكامل"
|
|
18
|
+
details_title: "الحدث رقم %{id}"
|
|
19
|
+
total: "الإجمالي: %{count}"
|
|
20
|
+
page: "الصفحة %{current} / %{total}"
|
|
21
|
+
view: "عرض"
|
|
22
|
+
back: "العودة إلى الأحداث"
|
|
23
|
+
prev: "السابق"
|
|
24
|
+
next: "التالي"
|
|
25
|
+
empty: "لا توجد أحداث بعد."
|
|
26
|
+
delivery_history: "سجل الإرسال للمنصات"
|
|
27
|
+
columns:
|
|
28
|
+
id: "المعرف"
|
|
29
|
+
event: "الحدث"
|
|
30
|
+
ip: "عنوان IP"
|
|
31
|
+
started_at: "وقت البدء"
|
|
32
|
+
status: "الحالة"
|
|
33
|
+
actions: "إجراءات"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
en:
|
|
2
|
+
omnitrack:
|
|
3
|
+
ui:
|
|
4
|
+
title: "OmniTrack Admin"
|
|
5
|
+
auth:
|
|
6
|
+
login_required: "Please login as admin first."
|
|
7
|
+
login_success: "Logged in successfully."
|
|
8
|
+
login_failed: "Invalid username or password."
|
|
9
|
+
logout_success: "Logged out successfully."
|
|
10
|
+
logout: "Logout"
|
|
11
|
+
login_title: "Admin Login"
|
|
12
|
+
login_hint: "Use your OmniTrack admin credentials."
|
|
13
|
+
username: "Username"
|
|
14
|
+
password: "Password"
|
|
15
|
+
login_button: "Login"
|
|
16
|
+
events:
|
|
17
|
+
title: "Visitor Event History"
|
|
18
|
+
details_title: "Event #%{id}"
|
|
19
|
+
total: "Total: %{count}"
|
|
20
|
+
page: "Page %{current} / %{total}"
|
|
21
|
+
view: "View"
|
|
22
|
+
back: "Back to events"
|
|
23
|
+
prev: "Previous"
|
|
24
|
+
next: "Next"
|
|
25
|
+
empty: "No events found yet."
|
|
26
|
+
delivery_history: "Delivery History"
|
|
27
|
+
columns:
|
|
28
|
+
id: "ID"
|
|
29
|
+
event: "Event"
|
|
30
|
+
ip: "IP"
|
|
31
|
+
started_at: "Started At"
|
|
32
|
+
status: "Status"
|
|
33
|
+
actions: "Actions"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Omnitrack::Engine.routes.draw do
|
|
2
|
+
scope module: :path do
|
|
3
|
+
get "login", to: "sessions#new", as: :login
|
|
4
|
+
post "login", to: "sessions#create", as: :login_submit
|
|
5
|
+
delete "logout", to: "sessions#destroy", as: :logout
|
|
6
|
+
resources :visit_events, path: "events", only: %i[index show]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
root to: "path/sessions#new"
|
|
10
|
+
end
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
4
5
|
|
|
5
6
|
module Omnitrack
|
|
6
7
|
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include Rails::Generators::Migration
|
|
9
|
+
|
|
7
10
|
source_root File.expand_path("templates", __dir__)
|
|
8
11
|
|
|
9
12
|
desc "Creates an Omnitrack initializer and adds it to your application"
|
|
@@ -18,12 +21,23 @@ module Omnitrack
|
|
|
18
21
|
copy_file "env.example", ".env.example"
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
def create_dashboard_migration
|
|
25
|
+
migration_template "create_omnitrack_dashboard_tables.rb",
|
|
26
|
+
"db/migrate/create_omnitrack_dashboard_tables.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
21
29
|
def show_readme
|
|
22
30
|
readme "README" if behavior == :invoke
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
private
|
|
26
34
|
|
|
35
|
+
def self.next_migration_number(dirname)
|
|
36
|
+
@previous_migration_number ||= current_migration_number(dirname)
|
|
37
|
+
@previous_migration_number = @previous_migration_number.succ
|
|
38
|
+
@previous_migration_number.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
27
41
|
def readme(path)
|
|
28
42
|
say <<~MSG
|
|
29
43
|
|
|
@@ -35,8 +49,10 @@ module Omnitrack
|
|
|
35
49
|
1. Copy .env.example to .env (gitignore .env) and set *_ENABLED + secrets
|
|
36
50
|
2. Optional: add gem "dotenv-rails" in development to load .env
|
|
37
51
|
3. Edit config/initializers/omnitrack.rb
|
|
38
|
-
4.
|
|
39
|
-
5.
|
|
52
|
+
4. Run rails db:migrate to create OmniTrack dashboard tables
|
|
53
|
+
5. Visit /omnitrack/login for the dashboard (or custom dashboard_mount_path)
|
|
54
|
+
6. Place <%= omnitrack_tags %> in your layout <head> (full-stack)
|
|
55
|
+
7. Call Omnitrack.track(...) from controllers/services (see USAGE.md)
|
|
40
56
|
|
|
41
57
|
Full docs: README.md — integration checklist: AI_GEM_SETUP.md
|
|
42
58
|
============================================================
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateOmnitrackDashboardTables < ActiveRecord::Migration[6.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :omnitrack_visit_events do |t|
|
|
6
|
+
t.string :operation
|
|
7
|
+
t.string :event_name
|
|
8
|
+
t.string :request_path
|
|
9
|
+
t.string :request_method
|
|
10
|
+
t.string :ip
|
|
11
|
+
t.string :user_agent
|
|
12
|
+
t.string :session_id
|
|
13
|
+
t.string :visitor_token
|
|
14
|
+
t.string :locale
|
|
15
|
+
t.json :payload_json
|
|
16
|
+
t.json :context_json
|
|
17
|
+
t.datetime :started_at
|
|
18
|
+
t.datetime :completed_at
|
|
19
|
+
t.string :overall_status
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :omnitrack_visit_events, :started_at
|
|
24
|
+
add_index :omnitrack_visit_events, :visitor_token
|
|
25
|
+
add_index :omnitrack_visit_events, :overall_status
|
|
26
|
+
|
|
27
|
+
create_table :omnitrack_delivery_statuses do |t|
|
|
28
|
+
t.references :visit_event, null: false, foreign_key: { to_table: :omnitrack_visit_events }
|
|
29
|
+
t.string :adapter_name
|
|
30
|
+
t.string :status
|
|
31
|
+
t.string :error_message
|
|
32
|
+
t.json :response_json
|
|
33
|
+
t.json :metadata_json
|
|
34
|
+
t.datetime :sent_at
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :omnitrack_delivery_statuses, :status
|
|
39
|
+
add_index :omnitrack_delivery_statuses, :sent_at
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -41,3 +41,9 @@ TIKTOK_ACCESS_TOKEN=
|
|
|
41
41
|
SNAPCHAT_ENABLED=false
|
|
42
42
|
SNAPCHAT_PIXEL_ID=
|
|
43
43
|
SNAPCHAT_ACCESS_TOKEN=
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# OmniTrack Dashboard
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
OMNITRACK_ADMIN_USERNAME=admin
|
|
49
|
+
OMNITRACK_ADMIN_PASSWORD=change-me
|
|
@@ -109,4 +109,13 @@ Omnitrack.configure do |config|
|
|
|
109
109
|
# config.on_error = ->(error, adapter) {
|
|
110
110
|
# Sentry.capture_exception(error, tags: { adapter: adapter.name })
|
|
111
111
|
# }
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Admin Dashboard (full visitor history + platform delivery statuses)
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
config.dashboard_enabled = true
|
|
117
|
+
config.dashboard_per_page = 25
|
|
118
|
+
config.dashboard_mount_path = "/omnitrack"
|
|
119
|
+
config.admin_username = ENV.fetch("OMNITRACK_ADMIN_USERNAME", "admin")
|
|
120
|
+
config.admin_password = ENV.fetch("OMNITRACK_ADMIN_PASSWORD", "change-me")
|
|
112
121
|
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
module Audit
|
|
7
|
+
class Recorder
|
|
8
|
+
def self.start(operation, args)
|
|
9
|
+
return NullRecorder.new unless available?
|
|
10
|
+
|
|
11
|
+
new(operation, args)
|
|
12
|
+
rescue StandardError
|
|
13
|
+
NullRecorder.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.available?
|
|
17
|
+
return false unless defined?(ActiveRecord::Base)
|
|
18
|
+
return false unless Omnitrack.config.dashboard_enabled
|
|
19
|
+
|
|
20
|
+
Omnitrack::VisitEvent.table_exists? && Omnitrack::DeliveryStatus.table_exists?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(operation, args)
|
|
24
|
+
@operation = operation.to_s
|
|
25
|
+
@args = args
|
|
26
|
+
@event = create_event!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finish(result)
|
|
30
|
+
Array(result&.results).each do |adapter_result|
|
|
31
|
+
Omnitrack::DeliveryStatus.create!(
|
|
32
|
+
visit_event_id: @event.id,
|
|
33
|
+
adapter_name: adapter_result.adapter.to_s.presence || "queue",
|
|
34
|
+
status: adapter_result.status.to_s,
|
|
35
|
+
error_message: adapter_result.error&.message,
|
|
36
|
+
response_json: safe_json(adapter_result.data),
|
|
37
|
+
metadata_json: safe_json(adapter_result.metadata),
|
|
38
|
+
sent_at: Time.current
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@event.update!(
|
|
43
|
+
overall_status: overall_status_from(result),
|
|
44
|
+
completed_at: Time.current
|
|
45
|
+
)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
# Never interrupt app flow because of dashboard persistence.
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fail!(error)
|
|
51
|
+
@event&.update!(
|
|
52
|
+
overall_status: "failure",
|
|
53
|
+
completed_at: Time.current,
|
|
54
|
+
context_json: safe_json(base_context.merge(dispatch_error: error.message))
|
|
55
|
+
)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
# no-op
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def create_event!
|
|
63
|
+
Omnitrack::VisitEvent.create!(
|
|
64
|
+
operation: @operation,
|
|
65
|
+
event_name: extract_event_name,
|
|
66
|
+
request_path: request_meta[:path],
|
|
67
|
+
request_method: request_meta[:method],
|
|
68
|
+
ip: context_hash["ip"],
|
|
69
|
+
user_agent: context_hash["user_agent"],
|
|
70
|
+
session_id: request_meta[:session_id],
|
|
71
|
+
visitor_token: request_meta[:visitor_token] || SecureRandom.uuid,
|
|
72
|
+
locale: request_meta[:locale],
|
|
73
|
+
payload_json: safe_json(extract_payload),
|
|
74
|
+
context_json: safe_json(base_context),
|
|
75
|
+
started_at: Time.current,
|
|
76
|
+
overall_status: "processing"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def request_meta
|
|
81
|
+
@request_meta ||= (context_hash["custom_data"] || {}).transform_keys(&:to_sym)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def base_context
|
|
85
|
+
{
|
|
86
|
+
click_ids: context_hash["click_ids"],
|
|
87
|
+
utm_params: context_hash["utm_params"],
|
|
88
|
+
headers: context_hash["headers"],
|
|
89
|
+
custom_data: context_hash["custom_data"]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def context_hash
|
|
94
|
+
@context_hash ||= begin
|
|
95
|
+
raw = Omnitrack::Context.current&.to_h || {}
|
|
96
|
+
raw.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def extract_event_name
|
|
101
|
+
return @args.first.to_s if @operation == "track_event"
|
|
102
|
+
return "conversion" if @operation == "track_conversion"
|
|
103
|
+
return "identify" if @operation == "identify_user"
|
|
104
|
+
|
|
105
|
+
@operation
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def extract_payload
|
|
109
|
+
return @args.second || {} if @operation == "track_event"
|
|
110
|
+
|
|
111
|
+
@args.first || {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def overall_status_from(result)
|
|
115
|
+
return "success" if result.respond_to?(:success?) && result.success?
|
|
116
|
+
return "partial_failure" if result.respond_to?(:any_failure?) && result.any_failure?
|
|
117
|
+
|
|
118
|
+
"queued"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def safe_json(value)
|
|
122
|
+
value.to_h
|
|
123
|
+
rescue StandardError
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class NullRecorder
|
|
129
|
+
def finish(_result); end
|
|
130
|
+
|
|
131
|
+
def fail!(_error); end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hash#compact was added in Ruby 3.1. This gem supports Ruby 2.7+ and runs on
|
|
4
|
+
# Ruby 3.0 with no core Hash#compact, so we backport only when missing.
|
|
5
|
+
|
|
6
|
+
unless {}.respond_to?(:compact)
|
|
7
|
+
class Hash
|
|
8
|
+
def compact
|
|
9
|
+
select { |_, v| !v.nil? }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -45,6 +45,12 @@ module Omnitrack
|
|
|
45
45
|
# Hook: called with (event_name, adapter_name, error) on adapter failure
|
|
46
46
|
attr_accessor :on_error
|
|
47
47
|
|
|
48
|
+
# Admin dashboard authentication (used by Omnitrack::Engine)
|
|
49
|
+
attr_accessor :admin_username, :admin_password
|
|
50
|
+
|
|
51
|
+
# Admin dashboard behavior
|
|
52
|
+
attr_accessor :dashboard_enabled, :dashboard_per_page, :dashboard_mount_path
|
|
53
|
+
|
|
48
54
|
def initialize
|
|
49
55
|
@mode = :auto
|
|
50
56
|
@adapters = {}
|
|
@@ -57,6 +63,11 @@ module Omnitrack
|
|
|
57
63
|
@max_retries = 3
|
|
58
64
|
@retry_delay = 1
|
|
59
65
|
@on_error = nil
|
|
66
|
+
@admin_username = ENV.fetch("OMNITRACK_ADMIN_USERNAME", "admin")
|
|
67
|
+
@admin_password = ENV.fetch("OMNITRACK_ADMIN_PASSWORD", "change-me")
|
|
68
|
+
@dashboard_enabled = true
|
|
69
|
+
@dashboard_per_page = 25
|
|
70
|
+
@dashboard_mount_path = "/omnitrack"
|
|
60
71
|
end
|
|
61
72
|
|
|
62
73
|
def validate!
|
|
@@ -70,6 +81,17 @@ module Omnitrack
|
|
|
70
81
|
"Invalid log_level: #{@log_level.inspect}. Must be one of #{VALID_LOG_LVLS}"
|
|
71
82
|
end
|
|
72
83
|
|
|
84
|
+
unless @dashboard_per_page.to_i.positive?
|
|
85
|
+
raise Omnitrack::ConfigurationError,
|
|
86
|
+
"dashboard_per_page must be a positive integer"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
mount_path = @dashboard_mount_path.to_s
|
|
90
|
+
unless mount_path.start_with?("/") && mount_path.length > 1
|
|
91
|
+
raise Omnitrack::ConfigurationError,
|
|
92
|
+
"dashboard_mount_path must start with '/' (example: /omnitrack)"
|
|
93
|
+
end
|
|
94
|
+
|
|
73
95
|
true
|
|
74
96
|
end
|
|
75
97
|
|
data/lib/omnitrack/context.rb
CHANGED
|
@@ -54,9 +54,17 @@ module Omnitrack
|
|
|
54
54
|
self
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
def gclid
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
def gclid
|
|
58
|
+
click_ids[:gclid]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fbclid
|
|
62
|
+
click_ids[:fbclid]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ttclid
|
|
66
|
+
click_ids[:ttclid]
|
|
67
|
+
end
|
|
60
68
|
|
|
61
69
|
# Full context as a hash — passed into adapter payloads
|
|
62
70
|
def to_h
|
|
@@ -22,6 +22,13 @@ module Omnitrack
|
|
|
22
22
|
|
|
23
23
|
if Omnitrack.config.auto_capture
|
|
24
24
|
ctx = Omnitrack::Context.from_request(request)
|
|
25
|
+
ctx.merge!(
|
|
26
|
+
path: request.path,
|
|
27
|
+
method: request.request_method,
|
|
28
|
+
session_id: request.session&.id.to_s,
|
|
29
|
+
visitor_token: request.cookies["omnitrack_visitor_token"] || request.session&.id.to_s,
|
|
30
|
+
locale: request.params["locale"]
|
|
31
|
+
)
|
|
25
32
|
Omnitrack::Context.current = ctx
|
|
26
33
|
|
|
27
34
|
Omnitrack.logger.debug("middleware.request",
|
data/lib/omnitrack/railtie.rb
CHANGED
|
@@ -45,6 +45,14 @@ module Omnitrack
|
|
|
45
45
|
Omnitrack.send(:reset_logger!)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
initializer "omnitrack.mount_dashboard_routes" do |app|
|
|
49
|
+
next unless Omnitrack.config.dashboard_enabled
|
|
50
|
+
|
|
51
|
+
app.routes.prepend do
|
|
52
|
+
mount Omnitrack::Engine => Omnitrack.config.dashboard_mount_path, as: :omnitrack
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
48
56
|
# Expose rake tasks
|
|
49
57
|
rake_tasks do
|
|
50
58
|
load File.join(__dir__, "tasks/omnitrack.rake")
|
data/lib/omnitrack/registry.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Omnitrack
|
|
|
38
38
|
# Instantiate all *enabled* adapters based on current configuration.
|
|
39
39
|
# @return [Array<Omnitrack::Adapters::Base>]
|
|
40
40
|
def enabled_adapters
|
|
41
|
-
MUTEX.synchronize { @adapters.dup }.
|
|
41
|
+
MUTEX.synchronize { @adapters.dup }.map do |adapter_name, klass|
|
|
42
42
|
cfg = Omnitrack.config.adapter_config(adapter_name)
|
|
43
43
|
next unless cfg.fetch(:enabled, false)
|
|
44
44
|
|
|
@@ -47,7 +47,7 @@ module Omnitrack
|
|
|
47
47
|
Omnitrack.logger.error("registry.init_error",
|
|
48
48
|
adapter: adapter_name, message: e.message)
|
|
49
49
|
nil
|
|
50
|
-
end
|
|
50
|
+
end.compact
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Clear all registrations (useful in tests)
|
data/lib/omnitrack/version.rb
CHANGED
data/lib/omnitrack.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "omnitrack/compat/hash_compact_backport"
|
|
3
4
|
require "active_support/concern"
|
|
4
5
|
require "active_support/core_ext/object/blank"
|
|
5
6
|
require "active_support/core_ext/string/inflections"
|
|
@@ -14,6 +15,7 @@ require_relative "omnitrack/logger"
|
|
|
14
15
|
require_relative "omnitrack/context"
|
|
15
16
|
require_relative "omnitrack/registry"
|
|
16
17
|
require_relative "omnitrack/pipeline/dispatcher"
|
|
18
|
+
require_relative "omnitrack/audit/recorder"
|
|
17
19
|
require_relative "omnitrack/adapters/base"
|
|
18
20
|
require_relative "omnitrack/adapters/google_ads"
|
|
19
21
|
require_relative "omnitrack/adapters/google_analytics"
|
|
@@ -26,6 +28,7 @@ require_relative "omnitrack/concerns/controller"
|
|
|
26
28
|
|
|
27
29
|
# Lazy-load Rails integrations only when Rails is present
|
|
28
30
|
if defined?(Rails)
|
|
31
|
+
require_relative "omnitrack/engine"
|
|
29
32
|
require_relative "omnitrack/railtie"
|
|
30
33
|
require_relative "generators/omnitrack/install/install_generator"
|
|
31
34
|
end
|
|
@@ -135,18 +138,25 @@ module Omnitrack
|
|
|
135
138
|
private
|
|
136
139
|
|
|
137
140
|
def dispatch(operation, *args)
|
|
141
|
+
recorder = Omnitrack::Audit::Recorder.start(operation, args)
|
|
142
|
+
|
|
138
143
|
if config.async && defined?(Omnitrack::Jobs::TrackingJob)
|
|
139
144
|
Omnitrack::Jobs::TrackingJob.perform_later(operation.to_s, *args)
|
|
140
|
-
|
|
145
|
+
result = Omnitrack::MultiResult.new([
|
|
141
146
|
Omnitrack::Result.success(metadata: { queued: true, operation: operation })
|
|
142
147
|
])
|
|
148
|
+
recorder.finish(result)
|
|
149
|
+
return result
|
|
143
150
|
end
|
|
144
151
|
|
|
145
|
-
Omnitrack::Pipeline::Dispatcher.dispatch(operation, *args)
|
|
152
|
+
result = Omnitrack::Pipeline::Dispatcher.dispatch(operation, *args)
|
|
153
|
+
recorder.finish(result)
|
|
154
|
+
result
|
|
146
155
|
rescue StandardError => e
|
|
147
156
|
# Top-level safety net — never raise into the host application
|
|
148
157
|
logger.error("omnitrack.dispatch_error",
|
|
149
158
|
operation: operation, error: e.class.name, message: e.message)
|
|
159
|
+
recorder&.fail!(e)
|
|
150
160
|
Omnitrack::MultiResult.new([
|
|
151
161
|
Omnitrack::Result.failure(error: e)
|
|
152
162
|
])
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omnitrack-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Your Name
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -153,8 +153,28 @@ files:
|
|
|
153
153
|
- LICENSE.txt
|
|
154
154
|
- README.md
|
|
155
155
|
- USAGE.md
|
|
156
|
+
- app/controllers/omnitrack/application_controller.rb
|
|
157
|
+
- app/controllers/omnitrack/path/application_controller.rb
|
|
158
|
+
- app/controllers/omnitrack/path/sessions_controller.rb
|
|
159
|
+
- app/controllers/omnitrack/path/visit_events_controller.rb
|
|
160
|
+
- app/controllers/omnitrack/sessions_controller.rb
|
|
161
|
+
- app/controllers/omnitrack/visit_events_controller.rb
|
|
162
|
+
- app/models/omnitrack/application_record.rb
|
|
163
|
+
- app/models/omnitrack/delivery_status.rb
|
|
164
|
+
- app/models/omnitrack/visit_event.rb
|
|
165
|
+
- app/views/layouts/omnitrack/admin.html.erb
|
|
166
|
+
- app/views/omnitrack/path/sessions/new.html.erb
|
|
167
|
+
- app/views/omnitrack/path/visit_events/index.html.erb
|
|
168
|
+
- app/views/omnitrack/path/visit_events/show.html.erb
|
|
169
|
+
- app/views/omnitrack/sessions/new.html.erb
|
|
170
|
+
- app/views/omnitrack/visit_events/index.html.erb
|
|
171
|
+
- app/views/omnitrack/visit_events/show.html.erb
|
|
172
|
+
- config/locales/ar.yml
|
|
173
|
+
- config/locales/en.yml
|
|
174
|
+
- config/routes.rb
|
|
156
175
|
- lib/generators/omnitrack/install/install_generator.rb
|
|
157
176
|
- lib/generators/omnitrack/install/templates/README
|
|
177
|
+
- lib/generators/omnitrack/install/templates/create_omnitrack_dashboard_tables.rb
|
|
158
178
|
- lib/generators/omnitrack/install/templates/env.example
|
|
159
179
|
- lib/generators/omnitrack/install/templates/initializer.rb
|
|
160
180
|
- lib/omnitrack.rb
|
|
@@ -164,9 +184,12 @@ files:
|
|
|
164
184
|
- lib/omnitrack/adapters/meta.rb
|
|
165
185
|
- lib/omnitrack/adapters/snapchat.rb
|
|
166
186
|
- lib/omnitrack/adapters/tiktok.rb
|
|
187
|
+
- lib/omnitrack/audit/recorder.rb
|
|
188
|
+
- lib/omnitrack/compat/hash_compact_backport.rb
|
|
167
189
|
- lib/omnitrack/concerns/controller.rb
|
|
168
190
|
- lib/omnitrack/configuration.rb
|
|
169
191
|
- lib/omnitrack/context.rb
|
|
192
|
+
- lib/omnitrack/engine.rb
|
|
170
193
|
- lib/omnitrack/errors.rb
|
|
171
194
|
- lib/omnitrack/helpers/view_helpers.rb
|
|
172
195
|
- lib/omnitrack/jobs/tracking_job.rb
|