spinnaker 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10e502553f469597a2bcf82321040b8d467323acd0406e79df00e1e53b451535
4
- data.tar.gz: cf251bff4735e9468139d96af86d0b8bfb4fab3184910ea0816bdfb1d6836934
3
+ metadata.gz: 29b7a91a929c2b0a3fce2b6b97059043b4c0522698d29da535e8b76189ccd4b5
4
+ data.tar.gz: 3d66013517a6a029006b5ca6ffc9cb6bac2ed685fd77a64b7b577eec97ddb1a3
5
5
  SHA512:
6
- metadata.gz: 7e07368552e7944d464268d8667592b9ccb599177cd40c7e8e2b4f58b426ee36335bc29d4ea0b6680d175b8c23c6ec3b94076d99363c65cb88180260d7075294
7
- data.tar.gz: e61f70fbbcf6ec1cee84f1d8ed17ec6ff2cd6b5dc06e23fa2b6b1d8815441f4f7903fe78490cc70fe7e69f5c32d1a8d8670bbdb18e25ee726e2e7c2c97a0a760
6
+ metadata.gz: 217c7cfcb4403d8e055d1a5802566f0a14e3e427d24a740ffa383b93450366dddb8b62605cac851b1178662827bf8f8c9515311a3fa007baa2c84de90e119fbe
7
+ data.tar.gz: 5161e790d2e11ebf9dac1e60b33346da5f95f3bca185e613fe7b2a7313394cea249efba02f30f209af97a80b554b0152073f5bff6c4fa823827e5df888ab2beb
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml CHANGED
@@ -1,6 +1,21 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 2.6
3
3
 
4
+ Metrics/AbcSize:
5
+ Enabled: true
6
+ Exclude:
7
+ - lib/spinnaker/api.rb
8
+
9
+ Metrics/MethodLength:
10
+ Enabled: true
11
+ Exclude:
12
+ - lib/spinnaker/api.rb
13
+
14
+ Style/ClassVars:
15
+ Enabled: true
16
+ Exclude:
17
+ - lib/spinnaker.rb
18
+
4
19
  Style/StringLiterals:
5
20
  Enabled: true
6
21
  EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ ## [0.1.0] - 2023-10-23
2
+
3
+ This is the initial release.
4
+
5
+ ### Added
6
+
7
+ - Ability to track page visits
8
+ - Tracks the following info:
9
+ - Path
10
+ - Title
11
+ - Meta info (is it the index, a feed, etc.)
12
+ - Exists (whether or not the path is valid according to the site)
13
+ - Visits (the number of visits, as well as the time of each visit)
14
+ - Serves page data over an API
15
+ - Query the API over a specified time period:
16
+ - Default is to get page info for the last 24 hours
17
+ - /today: page data for the current day
18
+ - /week: page data for the current week
19
+ - /month: page data for the current month
data/README.md CHANGED
@@ -1,34 +1,70 @@
1
1
  # Spinnaker
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/spinnaker`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Spinnaker is a drop-in tool for collecting and serving website page metrics. It's designed for use with [Jekyll](https://github.com/jekyll/jekyll) (and [Lanyon](https://github.com/stomar/lanyon)) in mind, but since it's just middleware for [Rack](https://github.com/rack/rack), it will work for any compatible frameworks. Spinnaker is quite private out of the box, as it only tracks what pages are being requested, and at what times those requests occur.
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
7
+ To add spinnaker to your project, run:
8
+ ```sh
9
+ bundle add spinnaker
10
+ ```
11
+
12
+ You could also install it manuall with `gem install spinnaker`.
13
+
14
+ Then `use` it in your config.ru file.
15
+ ```ruby
16
+ # config.ru
17
+
18
+ require "lanyon"
19
+ require "spinnaker"
10
20
 
11
- Install the gem and add to the application's Gemfile by executing:
21
+ use Spinnaker
22
+ run Lanyon.application
23
+ ```
12
24
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
25
+ If needed you can configure some settings before `use`ing it.
26
+ ```ruby
27
+ # config.ru
14
28
 
15
- If bundler is not being used to manage dependencies, install the gem by executing:
29
+ require "lanyon"
30
+ require "spinnaker"
16
31
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
32
+ Spinnaker.new.tap do |s|
33
+ # Set the endpoint where spinnaker will serve metrics.
34
+ # The default value is '/spinnaker'.
35
+ s.endoint = "stats"
36
+
37
+ # Set the address (or address range) to listen on the metrics endpoint.
38
+ # Accepts CIDR range syntax (i.e. 192.168.1.0/24).
39
+ # Accepts an array of addresses, or simply a single string.
40
+ # The default value is ['127.0.0.1'].
41
+ s.listen = ["127.0.0.1", "192.168.1.0/24"]
42
+ end
43
+
44
+ use Spinnaker
45
+ run Lanyon.application
46
+ ```
18
47
 
19
48
  ## Usage
20
49
 
21
- TODO: Write usage instructions here
50
+ Right now, you can query data over a specified time period on the API. Asumming the endpoint is '/spinnaker':
51
+ | Request Type | Path | Data |
52
+ | - | - | - |
53
+ | GET | /spinnaker{/latest} | Get page data for the last 24 hours |
54
+ | GET | /spinnaker/today | Get page data for the current day |
55
+ | GET | /spinnaker/week | Get page data for the current week |
56
+ | GET | /spinnaker/month | Get page data for the current month |
57
+
22
58
 
23
59
  ## Development
24
60
 
25
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
61
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
26
62
 
27
63
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
28
64
 
29
65
  ## Contributing
30
66
 
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/spinnaker.
67
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pinecat/spinnaker.
32
68
 
33
69
  ## License
34
70
 
data/Rakefile CHANGED
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "rake/testtask"
4
+ require "rspec/core/rake_task"
5
5
 
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/test_*.rb"]
10
- end
6
+ RSpec::Core::RakeTask.new(:spec)
11
7
 
12
8
  require "rubocop/rake_task"
13
9
 
14
10
  RuboCop::RakeTask.new
15
11
 
16
- task default: %i[test rubocop]
12
+ task default: %i[spec rubocop]
data/config.ru ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/spinnaker"
4
+
5
+ use Spinnaker
6
+ run do |_env|
7
+ [200, {}, ["<head><title>The Expanse</title></head><body><p>Welcome to my blog!</p></body>"]]
8
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Spinnaker
4
+ # API endpoints for querying the page data.
5
+ class API
6
+ # Initializer to setup the the endpoints hash and define routes.
7
+ def initialize
8
+ @endpoints = {}
9
+ routes
10
+ end
11
+
12
+ # Called with a paht to retrieve data from one of the routes.
13
+ def handle(endpoint)
14
+ blk = @endpoints[endpoint]
15
+ return [404, {}, ["Not found"]] if blk.nil?
16
+
17
+ [200, {}, [blk.call]]
18
+ end
19
+
20
+ private
21
+
22
+ # Simple DSL to define an API route.
23
+ def route(*eps, &block)
24
+ eps.each do |ep|
25
+ @endpoints[ep] = block
26
+ end
27
+ end
28
+
29
+ # Define all API routes here (TODO: Need a better solution for this)
30
+ # TODO: These endpoints could be auto-generated with a list of timeframes/periods
31
+ def routes
32
+ # /latest, /
33
+ # Default API endpoint, serves metrics for the last 24 hours.
34
+ route "", "latest" do
35
+ period = :the_last_24_hours
36
+ {
37
+ visits: Visit.where("timestamp > ?", Helper.period(period)).count,
38
+ pages: Helper.latest(period)
39
+ }.to_json
40
+ end
41
+
42
+ # /today
43
+ # Serves metrics for the current day.
44
+ route "today" do
45
+ period = :today
46
+ {
47
+ visits: Visit.where("timestamp > ?", Helper.period(period)).count,
48
+ pages: Helper.latest(period).as_json(period)
49
+ }.to_json
50
+ end
51
+
52
+ # /week
53
+ # Serves metrics for the current day.
54
+ route "week" do
55
+ period = :this_week
56
+ {
57
+ visits: Visit.where("timestamp > ?", Helper.period(period)).count,
58
+ pages: Helper.latest(period).as_json(period)
59
+ }.to_json
60
+ end
61
+
62
+ # /month
63
+ # Serves metrics for the current day.
64
+ route "week" do
65
+ period = :this_month
66
+ {
67
+ visits: Visit.where("timestamp > ?", Helper.period(period)).count,
68
+ pages: Helper.latest(period).as_json(period)
69
+ }.to_json
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Spinnaker # :nodoc:
4
+ # return [String] The name of the pages table.
5
+ PAGES_TABLE = :spinnaker_pages
6
+
7
+ # return [String] The name of the visits table.
8
+ VISITS_TABLE = :spinnaker_visits
9
+
10
+ # Migrations for the Spinnaker table, which keeps track of page hits.
11
+ class SpinnakerMigrations < ActiveRecord::Migration[7.1]
12
+ def create_pages
13
+ create_table PAGES_TABLE do |t|
14
+ t.string :path, default: "", null: false
15
+ t.string :title, default: nil
16
+ t.string :meta, default: nil
17
+ t.boolean :exists, default: false
18
+ end
19
+
20
+ add_index PAGES_TABLE, :path
21
+ end
22
+
23
+ def create_visits
24
+ create_table VISITS_TABLE do |t|
25
+ t.datetime :timestamp, null: false
26
+ end
27
+
28
+ add_reference VISITS_TABLE, :page, null: false, foreign_key: { to_table: PAGES_TABLE }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ class Spinnaker # :nodoc:
2
+ # Helper methods for the models
3
+ module Helper
4
+ def self.latest(timeframe)
5
+ vs = Visit.where("timestamp > ?", period(timeframe))
6
+ pages = []
7
+ vs.each do |v|
8
+ pages << Page.find(v.page_id)
9
+ end
10
+
11
+ pages.uniq
12
+ end
13
+
14
+ def self.period(timeframe)
15
+ case timeframe
16
+ when :today then Date.today.beginning_of_day
17
+ when :this_week then Date.today.beginning_of_week - 1.day
18
+ when :this_month then Date.today.beginning_of_month
19
+ else (Time.now - 1.day).beginning_of_hour
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Spinnaker # :nodoc:
4
+ # Model to access data from the 'spinnaker_pages' table.
5
+ class Page < ActiveRecord::Base
6
+ #
7
+ # Database info]
8
+ #
9
+ self.table_name = PAGES_TABLE
10
+ self.primary_key = "id"
11
+
12
+ #
13
+ # Relationships
14
+ #
15
+
16
+ # Visits track the specific time the page was requested.
17
+ has_many :visits
18
+
19
+ #
20
+ # Aliases
21
+ #
22
+
23
+ # Needed for the relation with visits to work properly.
24
+ alias_attribute :page_id, :id
25
+
26
+ #
27
+ # Functions
28
+ #
29
+
30
+ # Get when the last visit to the page was.
31
+ def last_visit
32
+ visits.last.timestamp.localtime.to_s
33
+ end
34
+
35
+ # Get visits for a specified timeframe:
36
+ # :the_last_24_hours
37
+ # :today
38
+ # :this_week
39
+ # :this_month
40
+ # :this_year
41
+ def visits_for(timeframe)
42
+ visits.where("timestamp > ?", Helper.period(timeframe)).count
43
+ end
44
+
45
+ # Override the default as_json method.
46
+ def as_json(*options)
47
+ {
48
+ path: path,
49
+ title: title,
50
+ meta: meta,
51
+ exists: exists,
52
+ visits: visits_for(options[0]),
53
+ last: last_visit
54
+ }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Spinnaker # :nodoc:
4
+ # Model to access data from the 'spinnaker_visits' table.
5
+ class Visit < ActiveRecord::Base
6
+ self.table_name = VISITS_TABLE
7
+ self.primary_key = "id"
8
+ belongs_to :page
9
+ end
10
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Spinnaker
4
- VERSION = "0.0.1"
3
+ class Spinnaker
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/spinnaker.rb CHANGED
@@ -1,8 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_record"
4
+ require "json"
5
+ require "nokogiri"
6
+ require "rack"
7
+ require "yaml"
8
+
9
+ require_relative "spinnaker/api"
10
+ require_relative "spinnaker/migrate"
11
+ require_relative "spinnaker/models/helper"
12
+ require_relative "spinnaker/models/page"
13
+ require_relative "spinnaker/models/visit"
3
14
  require_relative "spinnaker/version"
4
15
 
5
- module Spinnaker
16
+ # Spinnaker is a rack middleware used to log page hits to a SQL database.
17
+ class Spinnaker
18
+ # Error template.
6
19
  class Error < StandardError; end
7
- # Your code goes here...
20
+
21
+ # return [String] CIDR range to listen on for the API, default is ['127.0.0.1'].
22
+ @@listen = [IPAddr.new("127.0.0.1")]
23
+
24
+ # return [String] Endpoint for the API, default is 'spinnaker'.
25
+ @@endpoint = "spinnaker"
26
+
27
+ def initialize(app = nil)
28
+ database
29
+ @api = API.new
30
+ @app = app
31
+ end
32
+
33
+ def call(env)
34
+ status, headers, body = @app.call(env)
35
+ req = Rack::Request.new(env)
36
+
37
+ if req.path.start_with? "/#{@@endpoint}"
38
+ return [403, {}, ["Forbidden"]] unless its_from_a_valid_ip(req.ip)
39
+
40
+ return @api.handle(req.path.delete_prefix("/#{@@endpoint}").delete_prefix("/"))
41
+ end
42
+
43
+ record(req.path, status, body)
44
+ [status, headers, body]
45
+ end
46
+
47
+ def listen=(val)
48
+ @@listen.clear
49
+ if val.instance_of?(Array)
50
+ val.each do |v|
51
+ @@listen << IPAddr.new(v)
52
+ end
53
+ else
54
+ @@listen << IPAddr.new(val)
55
+ end
56
+ end
57
+
58
+ def endpoint=(val)
59
+ @@endpoint = val
60
+ end
61
+
62
+ private
63
+
64
+ def database
65
+ conn = ENV.fetch("DATABASE_URL", { adapter: "sqlite3", database: "db/test.db" })
66
+ ActiveRecord::Base.establish_connection(conn)
67
+ SpinnakerMigrations.migrate(:create_pages) unless ActiveRecord::Base.connection.table_exists? PAGES_TABLE
68
+ SpinnakerMigrations.migrate(:create_visits) unless ActiveRecord::Base.connection.table_exists? VISITS_TABLE
69
+ end
70
+
71
+ def its_from_a_valid_ip(ip)
72
+ @@listen.each do |addr|
73
+ return true if addr.include? IPAddr.new(ip)
74
+ end
75
+
76
+ false
77
+ end
78
+
79
+ def record(path, status, body)
80
+ # Check if the page exists
81
+ exists = (status >= 400 && status < 500 ? false : true)
82
+
83
+ # Create an entry in the database
84
+ page = Page.find_or_create_by!(path: path)
85
+ page.update!(title: Nokogiri::HTML(body[0]).xpath("//title")&.text, exists: exists)
86
+ Visit.create(page: page, timestamp: Time.now)
87
+
88
+ # Determine if there is any meta data to add for the page
89
+ metainfo(page, path)
90
+ end
91
+
92
+ def metainfo(page, path)
93
+ meta = nil
94
+ if ["/", ""].include?(path)
95
+ meta = "Index"
96
+ elsif path.include?(".xml") || path.include?(".atom")
97
+ meta = "Feed"
98
+ end
99
+
100
+ page.update!(meta: meta)
101
+ end
8
102
  end
metadata CHANGED
@@ -1,30 +1,37 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spinnaker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rory Dudley
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-07-28 00:00:00.000000000 Z
11
+ date: 2023-10-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: A simple server that can serve Duck apps (https://rubygems.org/gems/duck).
13
+ description: Track page hits for rack apps and store in a database.
14
14
  email:
15
15
  - rory.dudley@gmail.com
16
16
  executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - ".rspec"
20
21
  - ".rubocop.yml"
22
+ - CHANGELOG.md
21
23
  - LICENSE.txt
22
24
  - README.md
23
25
  - Rakefile
26
+ - config.ru
24
27
  - lib/spinnaker.rb
28
+ - lib/spinnaker/api.rb
29
+ - lib/spinnaker/migrate.rb
30
+ - lib/spinnaker/models/helper.rb
31
+ - lib/spinnaker/models/page.rb
32
+ - lib/spinnaker/models/visit.rb
25
33
  - lib/spinnaker/version.rb
26
34
  - sig/spinnaker.rbs
27
- - spinnaker.gemspec
28
35
  homepage: https://github.com/pinecat/spinnaker
29
36
  licenses:
30
37
  - MIT
@@ -33,7 +40,7 @@ metadata:
33
40
  homepage_uri: https://github.com/pinecat/spinnaker
34
41
  source_code_uri: https://github.com/pinecat/spinnaker
35
42
  changelog_uri: https://github.com/pinecat/spinnaker
36
- post_install_message:
43
+ post_install_message:
37
44
  rdoc_options: []
38
45
  require_paths:
39
46
  - lib
@@ -48,8 +55,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
48
55
  - !ruby/object:Gem::Version
49
56
  version: '0'
50
57
  requirements: []
51
- rubygems_version: 3.4.17
52
- signing_key:
58
+ rubygems_version: 3.4.21
59
+ signing_key:
53
60
  specification_version: 4
54
- summary: A server for Ruby/Duck applications.
61
+ summary: Track page hits for rack apps.
55
62
  test_files: []
data/spinnaker.gemspec DELETED
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/spinnaker/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "spinnaker"
7
- spec.version = Spinnaker::VERSION
8
- spec.authors = ["Rory Dudley"]
9
- spec.email = ["rory.dudley@gmail.com"]
10
-
11
- spec.summary = "A server for Ruby/Duck applications."
12
- spec.description = "A simple server that can serve Duck apps (https://rubygems.org/gems/duck)."
13
- spec.homepage = "https://github.com/pinecat/spinnaker"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.6.0"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
-
19
- spec.metadata["homepage_uri"] = spec.homepage
20
- spec.metadata["source_code_uri"] = "https://github.com/pinecat/spinnaker"
21
- spec.metadata["changelog_uri"] = "https://github.com/pinecat/spinnaker"
22
-
23
- # Specify which files should be added to the gem when it is released.
24
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) ||
28
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- # Uncomment to register a new dependency of your gem
36
- # spec.add_dependency "example-gem", "~> 1.0"
37
-
38
- # For more information and examples about making a new gem, check out our
39
- # guide at: https://bundler.io/guides/creating_gem.html
40
- end