s3arch 0.0.4 → 0.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b5d2b31636cdd8dd3d02bfd8140a82263f462207bf80525ef55167e1267a057
4
- data.tar.gz: 8cfa541ae296debeb4ca01737673733805b81268a415280241a57eaba1f22821
3
+ metadata.gz: 6e0ba51260081a3de075c6e14332909892693b64dce6eb1a3e52f7b1f145b537
4
+ data.tar.gz: 7553e90741ef8a1cc6c72b55bb9239c0e238da2a09916d34e8edf18780942a24
5
5
  SHA512:
6
- metadata.gz: fd0c3fabee28274d50d46951fde563bc9196263d59174ee87047bdb8a06d7ae1ee7964ae82d431c6c22ee875e40512918a01c6110292d0a0d06c89e54201e25e
7
- data.tar.gz: '008755884d519604931a7ccfebff4492c0308900230d5e9cd535aa452afb9e7a48d9274c04b15867b2bfd3bbe031aeb2ae08d1c2b6ffdf8759a7a90518ce7b60'
6
+ metadata.gz: 26e0cd3ff75ea9aff46dfd28a5978db557b26cef4780d966345b14fdb0b023bc9a6d22809c30c1422ac967aea3cbcc7bf18a75e94ea07d56af13d0f96d2d12e2
7
+ data.tar.gz: bf5c5b19de20e235f95427218b8beb0ee5ab03b4270bf31cbe38ca2f6f420c5665eb5b2a44e65461997a12412d4256099edaf3bc5be4ddb9f53f73105aaa3e79
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.5] - 2025-06-12
4
+
5
+ ### Added
6
+
7
+ - `S3arch::Dashboard` — mountable Rack application for viewing index stats (records, versions, owner counts)
8
+ - `S3arch::Dashboard::Application` — ERB-rendered HTML dashboard with mobile-friendly layout
9
+ - `S3arch::Holster` — Belt holster integration for convention-over-configuration mounting
10
+ - `S3arch::Routes` — Dispatcher mount DSL compatibility (`#routes` method)
11
+ - `S3arch::Web` — lightweight Rack entry point for Lambda integration
12
+ - `S3archController` — Belt controller for dashboard with Cognito auth gate
13
+ - `Configuration#dashboard_auth` — configurable authentication for dashboard access
14
+ - localStorage-based auth for CloudFront same-origin serving
15
+
16
+ ### Changed
17
+
18
+ - Dashboard renders HTML via ERB instead of JSON
19
+ - Scientific notation fix for large record/version numbers in dashboard
20
+
3
21
  ## [0.0.4] - 2025-06-09
4
22
 
5
23
  ### Fixed
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ TerraDispatch.routes.draw do
4
+ namespace :s3arch, auth: :cognito, tables: [:search_indexes] do
5
+ get '/', action: 'index'
6
+ post '/rebuild', action: 'rebuild'
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ TerraDispatch.schema.define do
4
+ # S3arch does not define request/response models yet.
5
+ # The search_indexes table is provisioned by s3arch's own Terraform module.
6
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'aws-sdk-dynamodb'
5
+
6
+ class S3archController < BeltController::Base
7
+ VIEWS_PATH = File.expand_path('../../lib/s3arch/dashboard/views', __dir__)
8
+
9
+ def index
10
+ @owners = fetch_owners
11
+ html = render_erb('index')
12
+ html_response(html)
13
+ rescue StandardError => e
14
+ @error = e.message
15
+ @owners = []
16
+ html = render_erb('index')
17
+ html_response(html, 500)
18
+ end
19
+
20
+ def rebuild
21
+ owner_id = params['owner_id']&.strip
22
+ return error_response('owner_id is required', 400) if owner_id.nil? || owner_id.empty?
23
+
24
+ handler = S3arch.configuration.rebuild_handler
25
+ if handler
26
+ handler.call(owner_id)
27
+ else
28
+ S3arch::Indexer.new.rebuild(owner_id)
29
+ end
30
+ success_response(status: 'ok', owner_id: owner_id)
31
+ rescue StandardError => e
32
+ error_response("Failed to rebuild index: #{e.message}", 500)
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_owners
38
+ dynamodb = Aws::DynamoDB::Client.new
39
+ config = S3arch.configuration
40
+ items = []
41
+ scan_params = { table_name: config.version_table }
42
+
43
+ loop do
44
+ result = dynamodb.scan(scan_params)
45
+ items.concat(result.items)
46
+ break unless result.last_evaluated_key
47
+
48
+ scan_params[:exclusive_start_key] = result.last_evaluated_key
49
+ end
50
+
51
+ owners = items.map do |item|
52
+ {
53
+ owner_id: item[config.owner_key] || item.values.first,
54
+ version: item['version'],
55
+ record_count: item['record_count'],
56
+ updated_at: item['updated_at']
57
+ }
58
+ end
59
+ owners.sort_by { |o| o[:updated_at].to_s }.reverse
60
+ end
61
+
62
+ def render_erb(template)
63
+ path = File.join(VIEWS_PATH, "#{template}.html.erb")
64
+ ERB.new(File.read(path), trim_mode: '-').result(binding)
65
+ end
66
+ end
@@ -42,6 +42,11 @@ module S3arch
42
42
  # Searcher settings
43
43
  attr_accessor :version_ttl, :max_results, :max_cached_dbs, :ephemeral_storage_mb
44
44
 
45
+ # Custom rebuild handler — proc/lambda that receives owner_id.
46
+ # When set, the controller uses this instead of calling Indexer.new.rebuild directly.
47
+ # Useful for triggering rebuilds via Lambda invocation instead of in-process.
48
+ attr_accessor :rebuild_handler
49
+
45
50
  def initialize
46
51
  @owner_key = 'user_id'
47
52
  @searchable_fields = %w[name description]
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+ require 'aws-sdk-dynamodb'
6
+
7
+ module S3arch
8
+ module Dashboard
9
+ class Application
10
+ VIEWS_PATH = File.expand_path('views', __dir__)
11
+
12
+ # Dispatcher mount DSL contract — keep in sync with #call dispatch below.
13
+ def routes
14
+ [
15
+ { method: :get, path: '/' },
16
+ { method: :post, path: '/rebuild' }
17
+ ]
18
+ end
19
+
20
+ def call(env)
21
+ req = Rack::Request.new(env)
22
+ path = req.path_info.sub(%r{^/}, '')
23
+
24
+ case [req.request_method, path]
25
+ when ['GET', ''], %w[GET index]
26
+ index(req)
27
+ when %w[POST rebuild]
28
+ rebuild(req)
29
+ else
30
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def index(req)
37
+ @env = req.env
38
+ @owners = fetch_owners
39
+ html = render('index')
40
+ [200, { 'content-type' => 'text/html' }, [html]]
41
+ end
42
+
43
+ def rebuild(req)
44
+ owner_id = req.params['owner_id']&.strip
45
+ if owner_id && !owner_id.empty?
46
+ indexer = S3arch::Indexer.new
47
+ indexer.rebuild(owner_id)
48
+ end
49
+ [303, { 'location' => "#{req.script_name}/" }, []]
50
+ end
51
+
52
+ def fetch_owners
53
+ config = S3arch.configuration
54
+ dynamodb = Aws::DynamoDB::Client.new
55
+ items = []
56
+ params = { table_name: config.version_table }
57
+
58
+ loop do
59
+ result = dynamodb.scan(params)
60
+ items.concat(result.items)
61
+ break unless result.last_evaluated_key
62
+
63
+ params[:exclusive_start_key] = result.last_evaluated_key
64
+ end
65
+
66
+ owners = items.map do |item|
67
+ {
68
+ owner_id: item[config.owner_key] || item.values.first,
69
+ version: item['version'],
70
+ record_count: item['record_count'],
71
+ updated_at: item['updated_at']
72
+ }
73
+ end
74
+ owners.sort_by { |o| o[:updated_at].to_s }.reverse
75
+ rescue StandardError => e
76
+ @error = e.message
77
+ []
78
+ end
79
+
80
+ def render(template)
81
+ path = File.join(VIEWS_PATH, "#{template}.html.erb")
82
+ ERB.new(File.read(path), trim_mode: '-').result(binding)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,150 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>S3arch Dashboard</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <script>
7
+ (function() {
8
+ function getIdToken() {
9
+ try {
10
+ for (var i = 0; i < localStorage.length; i++) {
11
+ var key = localStorage.key(i);
12
+ if (key && key.indexOf('.idToken') !== -1) return localStorage.getItem(key);
13
+ }
14
+ } catch(e) {}
15
+ return null;
16
+ }
17
+ function isTokenValid(token) {
18
+ try {
19
+ var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
20
+ return payload.exp && payload.exp > Date.now() / 1000;
21
+ } catch(e) { return false; }
22
+ }
23
+ var token = getIdToken();
24
+ if (!token || !isTokenValid(token)) {
25
+ window.location.replace('/login');
26
+ }
27
+ })();
28
+ </script>
29
+ <style>
30
+ * { box-sizing: border-box; margin: 0; padding: 0; }
31
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 1.5rem; }
32
+ h1 { margin-bottom: 1rem; font-size: 1.5rem; }
33
+ .error { background: #fee; border: 1px solid #fcc; padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; color: #c00; }
34
+ .success { background: #efe; border: 1px solid #cfc; padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; color: #060; display: none; }
35
+ .meta { font-size: 0.85rem; color: #666; margin-bottom: 1rem; }
36
+ .empty { padding: 2rem; text-align: center; color: #666; }
37
+
38
+ /* Desktop: table */
39
+ table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
40
+ th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
41
+ th { background: #fafafa; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: #555; }
42
+ tr:last-child td { border-bottom: none; }
43
+ tr:hover td { background: #f9fafb; }
44
+ .btn { border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: background 0.15s; }
45
+ .btn-rebuild { background: #2563eb; color: #fff; }
46
+ .btn-rebuild:hover { background: #1d4ed8; }
47
+ .btn-rebuild:disabled { background: #93c5fd; cursor: not-allowed; }
48
+ .owner-id { font-family: "SF Mono", SFMono-Regular, Consolas, monospace; font-size: 0.8rem; }
49
+
50
+ /* Mobile: card layout */
51
+ @media (max-width: 640px) {
52
+ body { padding: 1rem; }
53
+ table, thead, tbody, th, tr, td { display: block; }
54
+ thead { display: none; }
55
+ tr { background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 0.75rem; padding: 0.75rem; }
56
+ tr:hover td { background: transparent; }
57
+ td { padding: 0.25rem 0; border: none; display: flex; justify-content: space-between; align-items: center; }
58
+ td::before { content: attr(data-label); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; color: #555; margin-right: 0.5rem; }
59
+ td:last-child { margin-top: 0.5rem; justify-content: flex-start; }
60
+ td:last-child::before { display: none; }
61
+ .btn { width: 100%; text-align: center; padding: 0.6rem; }
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <h1>S3arch Dashboard</h1>
67
+ <p class="meta"><%= @owners.size %> indexed owner<%= @owners.size == 1 ? '' : 's' %></p>
68
+
69
+ <div id="flash" class="success"></div>
70
+
71
+ <%- if @error -%>
72
+ <div class="error"><strong>Error:</strong> <%= @error %></div>
73
+ <%- end -%>
74
+
75
+ <%- if @owners.any? -%>
76
+ <table>
77
+ <thead>
78
+ <tr>
79
+ <th>Owner</th>
80
+ <th>Version</th>
81
+ <th>Records</th>
82
+ <th>Last Updated</th>
83
+ <th>Actions</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ <%- @owners.each do |owner| -%>
88
+ <tr>
89
+ <td class="owner-id" data-label="Owner"><%= owner[:owner_id] %></td>
90
+ <td data-label="Version"><%= owner[:version].to_i %></td>
91
+ <td data-label="Records"><%= owner[:record_count] ? owner[:record_count].to_i.to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,') : '—' %></td>
92
+ <td data-label="Updated"><%= owner[:updated_at] || '—' %></td>
93
+ <td>
94
+ <button class="btn btn-rebuild" onclick="rebuild('<%= owner[:owner_id] %>', this)">Recreate SQLite</button>
95
+ </td>
96
+ </tr>
97
+ <%- end -%>
98
+ </tbody>
99
+ </table>
100
+ <%- else -%>
101
+ <div class="empty">No indexed owners found.</div>
102
+ <%- end -%>
103
+
104
+ <script>
105
+ function getIdToken() {
106
+ try {
107
+ for (var i = 0; i < localStorage.length; i++) {
108
+ var key = localStorage.key(i);
109
+ if (key && key.indexOf('.idToken') !== -1) return localStorage.getItem(key);
110
+ }
111
+ } catch(e) {}
112
+ return null;
113
+ }
114
+
115
+ function rebuild(ownerId, btn) {
116
+ btn.disabled = true;
117
+ btn.textContent = 'Rebuilding\u2026';
118
+ var headers = { 'Content-Type': 'application/json' };
119
+ var token = getIdToken();
120
+ if (token) headers['Authorization'] = 'Bearer ' + token;
121
+
122
+ fetch(window.location.pathname.replace(/\/$/, '') + '/rebuild', {
123
+ method: 'POST',
124
+ headers: headers,
125
+ body: JSON.stringify({ owner_id: ownerId })
126
+ }).then(function(r) { return r.json(); }).then(function(data) {
127
+ btn.textContent = 'Recreate SQLite';
128
+ btn.disabled = false;
129
+ var flash = document.getElementById('flash');
130
+ if (data.status === 'ok') {
131
+ flash.className = 'success';
132
+ flash.textContent = 'Rebuild triggered for ' + ownerId;
133
+ flash.style.display = 'block';
134
+ } else {
135
+ flash.className = 'error';
136
+ flash.textContent = data.error || 'Rebuild failed';
137
+ flash.style.display = 'block';
138
+ }
139
+ }).catch(function(e) {
140
+ btn.textContent = 'Recreate SQLite';
141
+ btn.disabled = false;
142
+ var flash = document.getElementById('flash');
143
+ flash.className = 'error';
144
+ flash.textContent = 'Request failed: ' + e.message;
145
+ flash.style.display = 'block';
146
+ });
147
+ }
148
+ </script>
149
+ </body>
150
+ </html>
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'erb'
5
+ require_relative 'dashboard/application'
6
+
7
+ module S3arch
8
+ module Dashboard
9
+ def self.app
10
+ Application.new
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3arch
4
+ class Holster < Belt::Holster
5
+ self.gem_root = File.expand_path('../..', __dir__)
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3arch
4
+ # Lightweight route manifest for Dispatcher mount DSL.
5
+ # No heavy dependencies — safe to load in parse-only contexts.
6
+ module Routes
7
+ def self.routes
8
+ [
9
+ { method: :get, path: '/' },
10
+ { method: :post, path: '/rebuild' }
11
+ ]
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module S3arch
4
- VERSION = '0.0.4'
4
+ VERSION = '0.0.5'
5
5
  end
data/lib/s3arch/web.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lightweight S3arch load for web/API contexts (no SQLite dependency).
4
+ # Provides configuration, holster registration, and routes only.
5
+ require_relative 'version'
6
+ require_relative 'configuration'
7
+ require_relative 'routes'
8
+ require_relative 'holster'
9
+
10
+ module S3arch
11
+ class Error < StandardError; end
12
+
13
+ class << self
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def reset_configuration!
23
+ @configuration = Configuration.new
24
+ end
25
+ end
26
+ end
data/lib/s3arch.rb CHANGED
@@ -6,6 +6,11 @@ require_relative 's3arch/tokenizer'
6
6
  require_relative 's3arch/indexer'
7
7
  require_relative 's3arch/searcher'
8
8
  require_relative 's3arch/handler'
9
+ require_relative 's3arch/routes'
10
+ require_relative 's3arch/dashboard'
11
+
12
+ # Register as a Belt holster when Belt is loaded
13
+ require_relative 's3arch/holster' if defined?(Belt::Holster)
9
14
 
10
15
  module S3arch
11
16
  class Error < StandardError; end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: s3arch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Dalton
@@ -64,6 +64,20 @@ dependencies:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
66
  version: '1.0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: rack
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '2.0'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '2.0'
67
81
  - !ruby/object:Gem::Dependency
68
82
  name: sqlite3
69
83
  requirement: !ruby/object:Gem::Requirement
@@ -78,8 +92,8 @@ dependencies:
78
92
  - - "~>"
79
93
  - !ruby/object:Gem::Version
80
94
  version: '2.0'
81
- description: Per-owner SQLite FTS5 indexes stored on S3, queried from Lambda /tmp.
82
- DynamoDB source records are indexed via streams, with version tracking and LRU caching.
95
+ description: Per-owner SQLite FTS5 indexes stored on S3, queried from Lambda /tmp
96
+ with version tracking.
83
97
  email:
84
98
  - adam@stowzilla.com
85
99
  executables: []
@@ -90,13 +104,22 @@ files:
90
104
  - LICENSE.txt
91
105
  - README.md
92
106
  - certs/stowzilla.pem
107
+ - infrastructure/routes.tf.rb
108
+ - infrastructure/schema.tf.rb
109
+ - lambda/controllers/s3arch_controller.rb
93
110
  - lib/s3arch.rb
94
111
  - lib/s3arch/configuration.rb
112
+ - lib/s3arch/dashboard.rb
113
+ - lib/s3arch/dashboard/application.rb
114
+ - lib/s3arch/dashboard/views/index.html.erb
95
115
  - lib/s3arch/handler.rb
116
+ - lib/s3arch/holster.rb
96
117
  - lib/s3arch/indexer.rb
118
+ - lib/s3arch/routes.rb
97
119
  - lib/s3arch/searcher.rb
98
120
  - lib/s3arch/tokenizer.rb
99
121
  - lib/s3arch/version.rb
122
+ - lib/s3arch/web.rb
100
123
  homepage: https://github.com/stowzilla/s3arch
101
124
  licenses:
102
125
  - MIT
metadata.gz.sig CHANGED
Binary file