its-showtime 0.1.1

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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +179 -0
  4. data/bin/showtime +47 -0
  5. data/lib/showtime/app.rb +399 -0
  6. data/lib/showtime/charts.rb +229 -0
  7. data/lib/showtime/component_registry.rb +38 -0
  8. data/lib/showtime/components/Components.md +309 -0
  9. data/lib/showtime/components/alerts.rb +83 -0
  10. data/lib/showtime/components/base.rb +63 -0
  11. data/lib/showtime/components/charts.rb +119 -0
  12. data/lib/showtime/components/data.rb +328 -0
  13. data/lib/showtime/components/inputs.rb +390 -0
  14. data/lib/showtime/components/layout.rb +135 -0
  15. data/lib/showtime/components/media.rb +73 -0
  16. data/lib/showtime/components/sidebar.rb +130 -0
  17. data/lib/showtime/components/text.rb +156 -0
  18. data/lib/showtime/components.rb +18 -0
  19. data/lib/showtime/compute_tracker.rb +21 -0
  20. data/lib/showtime/helpers.rb +53 -0
  21. data/lib/showtime/logger.rb +143 -0
  22. data/lib/showtime/public/.vite/manifest.json +34 -0
  23. data/lib/showtime/public/assets/antd-3aDVoXqG.js +447 -0
  24. data/lib/showtime/public/assets/charts-iowb_sWQ.js +3858 -0
  25. data/lib/showtime/public/assets/index-B2b3lWS5.js +43 -0
  26. data/lib/showtime/public/assets/index-M6NVamDM.css +1 -0
  27. data/lib/showtime/public/assets/react-BE6xecJX.js +32 -0
  28. data/lib/showtime/public/index.html +19 -0
  29. data/lib/showtime/public/letter.png +0 -0
  30. data/lib/showtime/public/logo.png +0 -0
  31. data/lib/showtime/release.rb +108 -0
  32. data/lib/showtime/session.rb +131 -0
  33. data/lib/showtime/version.rb +3 -0
  34. data/lib/showtime/views/index.erb +32 -0
  35. data/lib/showtime.rb +157 -0
  36. metadata +300 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 17624f4f4049dda21e1a632de6eb5d26b0be8eff514a245e3376c80e3b99780a
4
+ data.tar.gz: f7d5e81323a64db1c34847f50786eb3fb757ee9ff05161a62ffa44503877aeb0
5
+ SHA512:
6
+ metadata.gz: 8c094fd17bcd86de2c44955ae3c0965bf3c9d5d7847207d26bf20f87a8989ef408685fc018c79e2e5badc1b60bb3d8fbbe53d2aa2b058d68c71b60d76faab8a5
7
+ data.tar.gz: d26a3220bfdf20264b4ae1385faa8cfc2800a5dfe9a23f206150225dd4298f0174fa0052f0affad508c0476f015bfb8a032ea5f887abc00b9fd5030050dde0a2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Showtime Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ ![showtime logo](/showtime.png)
2
+
3
+ # Showtime
4
+
5
+ Showtime is a Ruby framework for building interactive data visualization UIs, inspired by Python's Streamlit. It allows you to easily create web applications with just a few lines of Ruby code, without needing to know HTML, CSS, or JavaScript.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'its-showtime'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ $ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ $ gem install its-showtime
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - Simple, intuitive API similar to Streamlit
30
+ - Interactive components like buttons, sliders, and input fields
31
+ - Support for displaying data frames via ruby-polars
32
+ - Plotly-first charts with a Rails-friendly contract
33
+ - Auto-reloading when code changes
34
+ - Sidebar for organizing controls
35
+ - No frontend experience required
36
+
37
+ ## Quick Start
38
+
39
+ Create a file named `app.rb` with the following content:
40
+
41
+ ```ruby
42
+ require 'showtime'
43
+
44
+ # Display a title
45
+ St.title("My First Showtime App")
46
+
47
+ # Add a text input box
48
+ name = St.text_input("What's your name?")
49
+
50
+ # Use the input to personalize a greeting
51
+ if name.empty?
52
+ St.write("Please enter your name")
53
+ else
54
+ St.write("Hello, #{name}!")
55
+ end
56
+
57
+ # Create a button
58
+ if St.button("Click me")
59
+ St.success("Button clicked!")
60
+ end
61
+
62
+ # Display a dataframe (if polars is installed)
63
+ begin
64
+ require 'polars-df'
65
+
66
+ df = Polars::DataFrame.new({
67
+ 'Name' => ['John', 'Alice', 'Bob'],
68
+ 'Age' => [28, 24, 32],
69
+ 'City' => ['New York', 'Paris', 'London']
70
+ })
71
+
72
+ St.dataframe(df)
73
+ rescue LoadError
74
+ St.error("Polars not installed. Run 'gem install polars-df' to use dataframes.")
75
+ end
76
+
77
+ # Plotly-first charts (data frames, hashes, arrays, grouped counts all work)
78
+ sales = [
79
+ { region: "North", quarter: "Q1", revenue: 10 },
80
+ { region: "South", quarter: "Q1", revenue: 5 },
81
+ { region: "North", quarter: "Q2", revenue: 12 },
82
+ ]
83
+
84
+ St.bar_chart(
85
+ sales,
86
+ encoding: { x: :quarter, y: :revenue, series: :region, stack: true },
87
+ colorway: ["#2563eb", "#10b981"],
88
+ layout: { title: "Revenue by Quarter" },
89
+ )
90
+
91
+ # You can override axis/layout if needed (defaults are sensible)
92
+ spec = St.bar_chart(
93
+ [{ ticker: "AAPL", volume: 100_000 }, { ticker: "AMZN", volume: 6_000 }],
94
+ { x: :volume, y: :ticker, layout: { xaxis: { type: "linear" }, yaxis: { type: "category" } } }
95
+ )
96
+ ```
97
+
98
+ Then run your app with:
99
+
100
+ ```bash
101
+ $ showtime app.rb
102
+ ```
103
+
104
+ Visit `http://localhost:8501` in your web browser to see your app in action.
105
+
106
+ ## Contributing
107
+
108
+ Bug reports and pull requests are welcome on GitHub at https://github.com/glmaljkovich/showtime.
109
+
110
+ ### Development Requirements
111
+
112
+ For development, you'll need:
113
+ - Ruby (see .ruby-version for specific version)
114
+ - Node.js and pnpm (for frontend development)
115
+ - Git
116
+
117
+ ### Development Setup
118
+
119
+ 1. Clone the repository
120
+ ```bash
121
+ git clone https://github.com/glmaljkovich/showtime.git
122
+ cd showtime
123
+ ```
124
+
125
+ 2. Install Ruby dependencies
126
+ ```bash
127
+ bundle install
128
+ ```
129
+
130
+ 3. Install frontend dependencies
131
+ ```bash
132
+ cd frontend
133
+ pnpm install
134
+ cd ..
135
+ ```
136
+
137
+ ### Frontend Development
138
+
139
+ The frontend is built with React and Vite. The build process is automated, and compiled assets are generated during gem packaging.
140
+
141
+ To modify the frontend:
142
+ 1. Make changes in the `/frontend` directory
143
+ 2. Test your changes with `rake build_frontend` (assets will be built to `lib/showtime/public/assets/`)
144
+ 3. Only commit your source changes - compiled assets are gitignored and will be built during gem packaging
145
+
146
+ For development workflow:
147
+ ```bash
148
+ # Start with clean assets
149
+ rake clean_frontend
150
+
151
+ # Install frontend dependencies (only needed once or when dependencies change)
152
+ rake install_frontend
153
+
154
+ # Build frontend assets for testing
155
+ rake build_frontend
156
+
157
+ # Or use build_dev to install dependencies and build in one step
158
+ rake build_dev
159
+ ```
160
+
161
+ The compiled assets in `lib/showtime/public/assets/` are automatically included in the gem package when building, so end users don't need Node.js installed.
162
+
163
+ ### Testing Changes
164
+
165
+ To test your changes locally:
166
+
167
+ ```bash
168
+ # Only needed for the first time or when react dependencies change
169
+ rake install_frontend
170
+
171
+ # Compile frontend assets and install gem and dependencies globally for testing
172
+ rake build_cli
173
+ ```
174
+
175
+ Note: `rake build_cli` installs dependencies in the global namespace, so don't use `bundle exec`.
176
+
177
+ ## License
178
+
179
+ The gem is available as open source under the terms of the MIT License.
data/bin/showtime ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "showtime"
5
+ require "optparse"
6
+
7
+ options = {
8
+ port: 8501,
9
+ host: "localhost"
10
+ }
11
+
12
+ # Parse command line arguments
13
+ parser = OptionParser.new do |opts|
14
+ opts.banner = "Usage: showtime [options] <script_file>"
15
+
16
+ opts.on("-p", "--port PORT", Integer, "Port to run the server on (default: 8501)") do |p|
17
+ options[:port] = p
18
+ end
19
+
20
+ opts.on("-h", "--host HOST", "Host to bind the server to (default: localhost)") do |h|
21
+ options[:host] = h
22
+ end
23
+
24
+ opts.on("--help", "Prints this help") do
25
+ puts opts
26
+ exit
27
+ end
28
+ end
29
+
30
+ parser.parse!
31
+
32
+ # Get the script file from command line arguments
33
+ script_file = ARGV[0]
34
+
35
+ if script_file.nil?
36
+ puts "Error: Script file is required"
37
+ puts parser
38
+ exit 1
39
+ end
40
+
41
+ unless File.exist?(script_file)
42
+ puts "Error: Script file '#{script_file}' does not exist"
43
+ exit 1
44
+ end
45
+
46
+ # Run the showtime application
47
+ Showtime.run(script_file, port: options[:port], host: options[:host])
@@ -0,0 +1,399 @@
1
+ require 'rack/handler/puma'
2
+ require 'puma'
3
+ require 'rack'
4
+ require 'sinatra/base'
5
+ require 'sinatra/json'
6
+ require 'sinatra/reloader'
7
+ require 'securerandom'
8
+ require 'json'
9
+ require 'logger'
10
+ require 'faye/websocket'
11
+ require 'listen'
12
+ require 'tmpdir'
13
+ require 'pastel'
14
+ require_relative 'helpers'
15
+ require_relative 'session' # Assuming session.rb contains Showtime::Session and Showtime::BaseSession
16
+ require_relative 'logger' # Assuming logger.rb defines Showtime::Logger
17
+
18
+ # Forward declare ScriptContext if it's not in a separate file yet or defined globally
19
+ module Showtime
20
+ class ScriptContext
21
+ end
22
+ end
23
+
24
+ module Showtime
25
+ class App < Sinatra::Base
26
+ helpers Showtime::Helpers
27
+ # Assuming vite_ruby is available in the Rails context,
28
+ # otherwise, Vite::Helpers might need to be conditionally included or handled differently.
29
+ # For now, let's assume it's handled by the host Rails app.
30
+ helpers Vite::Helpers
31
+
32
+ attr_reader :script_path, :options, :color, :websocket_connections, :client_sessions, :client_last_seen, :main_session, :file_watcher, :cleanup_thread, :cleanup_mutex
33
+
34
+ # Session timeout in seconds (5 minutes)
35
+ SESSION_TIMEOUT = 300
36
+ # Cleanup interval in seconds (1 minute)
37
+ CLEANUP_INTERVAL = 60
38
+
39
+ # Configure Sinatra settings at the class level
40
+ set :public_folder, File.expand_path(File.join(File.dirname(__FILE__), 'public'))
41
+ set :views, File.expand_path(File.join(File.dirname(__FILE__), 'views'))
42
+ set :sessions, key: 'showtime_app_session', secret: ENV.fetch('SESSION_SECRET_SHOWTIME_APP') { SecureRandom.hex(64) }
43
+
44
+ # Development-specific settings
45
+ if ENV['RACK_ENV'] == 'development'
46
+ register Sinatra::Reloader if defined?(Sinatra::Reloader)
47
+ # Reloader might need specific configuration for script_path
48
+ # settings.also_reload @script_path # @script_path is an instance variable, cannot be used here directly
49
+ end
50
+
51
+ def initialize(app = nil, script_path:, **options)
52
+ super(app) # Call Sinatra::Base's initialize
53
+ @script_path = File.expand_path(script_path)
54
+ @options = options
55
+ @color = Pastel.new
56
+
57
+ Showtime::Logger.info("Initializing Showtime::App for script: #{@color.cyan(@script_path)}")
58
+
59
+ # Instance-specific state
60
+ @websocket_connections = {}
61
+ @client_sessions = {}
62
+ @client_last_seen = {}
63
+ @main_session = nil
64
+ @file_watcher = nil
65
+ @cleanup_thread = nil
66
+ @cleanup_mutex = Mutex.new
67
+
68
+ # Initialize the main session for this script instance
69
+ @main_session = Showtime::BaseSession.new
70
+ @main_session.main_script_path = @script_path
71
+
72
+ # Load the script, start file watcher, and cleanup thread for this instance
73
+ load_script(@main_session)
74
+ @main_session.component_registry.clear_dirty
75
+
76
+ start_file_watcher(@script_path)
77
+ start_cleanup_thread
78
+ end
79
+
80
+ # --- Sinatra Routes (adapted from Showtime::Server) ---
81
+ get '/' do
82
+ # `request.script_name` will hold the base path where this app is mounted.
83
+ # This is crucial for `index.erb` to set `window.SHOWTIME_BASE_PATH`.
84
+ erb :index
85
+ end
86
+
87
+ get '/ws' do
88
+ if Faye::WebSocket.websocket?(request.env)
89
+ query_string = request.env['QUERY_STRING']
90
+ query_params = Rack::Utils.parse_query(query_string)
91
+ client_id = query_params['client_id']
92
+
93
+ connection_session_id = SecureRandom.uuid # Unique ID for this specific WS connection
94
+
95
+ ws = Faye::WebSocket.new(request.env, nil, {
96
+ ping: 30,
97
+ max_length: 10 * 1024 * 1024
98
+ })
99
+
100
+ connection_session_object = nil
101
+
102
+ @client_last_seen[client_id] = Time.now if client_id
103
+
104
+ if client_id && @client_sessions.key?(client_id)
105
+ Showtime::Logger.debug("Reusing existing session for client [#{client_id}] in app for #{@script_path}")
106
+ connection_session_object = @client_sessions[client_id]
107
+ else
108
+ client_id_to_use = client_id || SecureRandom.uuid
109
+ Showtime::Logger.debug("Creating new session for client [#{client_id_to_use}] in app for #{@script_path}")
110
+ connection_session_object = create_session_for_connection
111
+ @client_sessions[client_id_to_use] = connection_session_object
112
+ @client_last_seen[client_id_to_use] = Time.now
113
+ client_id = client_id_to_use # Ensure client_id is set if it was nil
114
+ end
115
+
116
+ @websocket_connections[connection_session_id] = {
117
+ ws: ws,
118
+ session: connection_session_object,
119
+ client_id: client_id
120
+ }
121
+
122
+ ws.on :open do |event|
123
+ Showtime::Logger.debug("WebSocket connection opened [#{connection_session_id}] for client [#{client_id}] in app for #{@script_path}")
124
+ response = Showtime.with_session(connection_session_object) do
125
+ JSON.parse(connection_session_object.to_json)
126
+ end
127
+ response['client_id'] = client_id
128
+ ws.send(response.to_json)
129
+ end
130
+
131
+ ws.on :ping do |event|
132
+ Showtime::Logger.debug("Received ping from client [#{client_id}] in app for #{@script_path}")
133
+ @client_last_seen[client_id] = Time.now if client_id # Update for this specific client_id
134
+ end
135
+
136
+ ws.on :message do |event|
137
+ begin
138
+ message_start_time = Time.now
139
+ data = JSON.parse(event.data)
140
+ key = data['key']
141
+ value = data['value']
142
+
143
+ Showtime::Logger.info("Received WebSocket message: key=#{@color.bright_yellow(key)}, value=#{@color.bright_yellow(value)} for script #{@script_path}")
144
+
145
+ current_client_id = @websocket_connections[connection_session_id][:client_id]
146
+ @client_last_seen[current_client_id] = Time.now if current_client_id
147
+
148
+ session_for_message = @websocket_connections[connection_session_id][:session]
149
+ handle_component_update(session_for_message, key, value)
150
+
151
+ Showtime.with_session(session_for_message) do
152
+ ws.send(session_for_message.to_json)
153
+ end
154
+
155
+ message_time = ((Time.now - message_start_time) * 1000).round(2)
156
+ Showtime::Logger.debug("Message processed in #{message_time}ms for script #{@script_path}")
157
+ rescue => e
158
+ Showtime::Logger.error("Error handling WebSocket message for script #{@script_path}: #{e.message}")
159
+ Showtime::Logger.error(e.backtrace.join("\n"))
160
+ end
161
+ end
162
+
163
+ ws.on :close do |event|
164
+ Showtime::Logger.debug("WebSocket connection closed [#{connection_session_id}] for client [#{client_id}] in app for #{@script_path}")
165
+ @websocket_connections.delete(connection_session_id)
166
+ end
167
+
168
+ ws.rack_response
169
+ else
170
+ # Handle non-WebSocket requests to /ws if necessary, or let them 404
171
+ status 400
172
+ body "This endpoint is for WebSocket connections only."
173
+ end
174
+ end
175
+
176
+ post '/api/upload' do
177
+ # Ensure this uses instance-specific logic if needed, though file uploads might be generic
178
+ # `request.script_name` can be used to build URLs if files are served relative to the mount path.
179
+ begin
180
+ upload_dir = File.join(Dir.tmpdir, "showtime-uploads-#{script_path.gsub(/[^0-9a-zA-Z]/, '')}") # Instance specific tmp dir
181
+ FileUtils.mkdir_p(upload_dir)
182
+
183
+ file = params[:file][:tempfile]
184
+ filename = params[:file][:filename]
185
+
186
+ unique_filename = "#{SecureRandom.uuid}-#{filename}"
187
+ file_path = File.join(upload_dir, unique_filename)
188
+
189
+ FileUtils.cp(file.path, file_path)
190
+
191
+ content_type :json
192
+ {
193
+ status: 'success',
194
+ file: {
195
+ name: filename,
196
+ path: file_path, # This path is server-local. Client might need a relative URL.
197
+ size: File.size(file_path),
198
+ type: params[:file][:type] || 'application/octet-stream'
199
+ }
200
+ }.to_json
201
+ rescue => e
202
+ status 500
203
+ content_type :json
204
+ { status: 'error', message: e.message }.to_json
205
+ end
206
+ end
207
+
208
+ get '/api/ping' do
209
+ content_type :json
210
+ { status: 'ok', timestamp: Time.now.to_i, script_path: script_path }.to_json
211
+ end
212
+
213
+ not_found do
214
+ content_type :json
215
+ { error: 'Not found in Showtime::App', script_path: script_path }.to_json
216
+ end
217
+
218
+ error do
219
+ content_type :json
220
+ { error: env['sinatra.error'].message, script_path: script_path }.to_json
221
+ end
222
+
223
+ # --- Instance Methods (adapted from Showtime::Server static methods) ---
224
+ def load_script(session, preserve_values = false)
225
+ session.set_script_error(nil)
226
+ start_time = Time.now
227
+ context = ScriptContext.new # Or Showtime::ScriptContext.new
228
+
229
+ begin
230
+ # Ensure @script_path is used
231
+ script_content = File.read(@script_path)
232
+ if preserve_values
233
+ session.clear_elements
234
+ Showtime.with_session(session) do
235
+ session.reset_counters
236
+ context.instance_eval(script_content, @script_path) # Pass @script_path for error reporting
237
+ end
238
+ else
239
+ session.clear
240
+ Showtime.with_session(session) do
241
+ context.instance_eval(script_content, @script_path)
242
+ end
243
+ end
244
+ rescue => e
245
+ Showtime::Logger.error("Error executing script #{@script_path}: #{e.message}")
246
+ Showtime::Logger.error(e.backtrace.join("\n"))
247
+ session.set_script_error({ message: e.message, backtrace: e.backtrace, script_path: @script_path })
248
+ end
249
+
250
+ execution_time = ((Time.now - start_time) * 1000).round(2)
251
+ Showtime::Logger.info("⏱️ Script #{@script_path} execution completed in #{@color.magenta(execution_time)}ms")
252
+ end
253
+
254
+ def handle_component_update(session, key, value)
255
+ session.update_value(key, value)
256
+
257
+ if key.to_s.start_with?('button_')
258
+ load_script(session, true)
259
+ session.component_registry.clear_dirty
260
+ session.update_value(key, false)
261
+ else
262
+ load_script(session, true)
263
+ session.component_registry.clear_dirty
264
+ end
265
+ end
266
+
267
+ def create_session_for_connection
268
+ session = Showtime::BaseSession.new # Use BaseSession for new connections
269
+ session.main_script_path = @script_path
270
+ load_script(session)
271
+ session.component_registry.clear_dirty # Ensure clean state after initial load
272
+ return session
273
+ end
274
+
275
+ def start_file_watcher(script_path_to_watch)
276
+ return if @file_watcher # Already started for this instance
277
+
278
+ script_dir = File.dirname(script_path_to_watch)
279
+ script_filename = File.basename(script_path_to_watch)
280
+
281
+ Showtime::Logger.info("Starting file watcher for #{@color.cyan(script_path_to_watch)} (instance)")
282
+
283
+ @file_watcher = Listen.to(script_dir, force_polling: true, latency: 0.5) do |modified, added, removed|
284
+ if modified.any? { |f| File.basename(f) == script_filename && File.expand_path(f) == script_path_to_watch }
285
+ Showtime::Logger.info("🔄 Script file changed, reloading: #{@color.yellow(script_path_to_watch)} (instance)")
286
+
287
+ @cleanup_mutex.synchronize do
288
+ @client_sessions.each do |client_id, session|
289
+ load_script(session, true)
290
+ session.component_registry.clear_dirty
291
+ end
292
+
293
+ load_script(@main_session, true) if @main_session
294
+ @main_session.component_registry.clear_dirty if @main_session
295
+
296
+ @websocket_connections.each do |conn_id, connection|
297
+ begin
298
+ Showtime::Logger.debug("Sending updated state to client [#{connection[:client_id]}]")
299
+ Showtime.with_session(connection[:session]) do
300
+ connection[:ws].send(connection[:session].to_json)
301
+ end
302
+ rescue => e
303
+ Showtime::Logger.error("Error sending update to client for #{@script_path}: #{e.message}")
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+ @file_watcher.start
310
+ Showtime::Logger.info("🔍 File watcher started successfully for #{@script_path}")
311
+ end
312
+
313
+ def start_cleanup_thread
314
+ return if @cleanup_thread && @cleanup_thread.alive?
315
+
316
+ @cleanup_thread = Thread.new do
317
+ begin
318
+ loop do
319
+ sleep CLEANUP_INTERVAL
320
+ @cleanup_mutex.synchronize do
321
+ cleanup_stale_sessions
322
+ end
323
+ end
324
+ rescue => e
325
+ Showtime::Logger.error("Error in cleanup thread for #{@script_path}: #{e.message}")
326
+ Showtime::Logger.error(e.backtrace.join("\n"))
327
+ # Consider whether to retry or let the thread die for instance-specific cleanup
328
+ end
329
+ end
330
+ end
331
+
332
+ def cleanup_stale_sessions
333
+ current_time = Time.now
334
+ stale_count = 0
335
+
336
+ stale_clients = @client_last_seen.select do |client_id, last_seen|
337
+ (current_time - last_seen) > SESSION_TIMEOUT
338
+ end
339
+
340
+ stale_clients.each do |client_id, _|
341
+ has_active_connection = @websocket_connections.values.any? { |conn| conn[:client_id] == client_id }
342
+
343
+ unless has_active_connection
344
+ Showtime::Logger.debug("Cleaning up abandoned session for client [#{client_id}] in app for #{@script_path}")
345
+ @client_sessions.delete(client_id)
346
+ @client_last_seen.delete(client_id)
347
+ stale_count += 1
348
+ end
349
+ end
350
+
351
+ active_conn_count = @websocket_connections.size
352
+ session_count = @client_sessions.size
353
+ Showtime::Logger.debug("Session stats for #{@script_path}: #{@color.magenta(active_conn_count)} active, #{@color.magenta(session_count)} sessions, #{@color.magenta(stale_count)} cleaned")
354
+ end
355
+
356
+ # This method might be called when the Rails server shuts down,
357
+ # or when an instance of Showtime::App is explicitly stopped.
358
+ def shutdown
359
+ Showtime::Logger.info("Shutting down Showtime::App for script: #{@color.cyan(@script_path)}")
360
+ @file_watcher&.stop
361
+ @cleanup_thread&.kill # Or a more graceful shutdown signal
362
+ @websocket_connections.each_value do |conn|
363
+ conn[:ws].close if conn[:ws] && conn[:ws].respond_to?(:close)
364
+ end
365
+ Showtime::Logger.info("Showtime::App for script #{@color.cyan(@script_path)} shut down.")
366
+ end
367
+
368
+ def self.start(script_path, port: 8501, host: 'localhost')
369
+ app_instance = new(script_path: script_path)
370
+
371
+ print_banner(host, port)
372
+
373
+ Rack::Handler::Puma.run(
374
+ app_instance,
375
+ Port: port,
376
+ Host: host,
377
+ Silent: true
378
+ )
379
+ end
380
+
381
+ def self.print_banner(host, port)
382
+ logo = <<~LOGO
383
+ \n
384
+ ______ _ _
385
+ / _____) | _ (_)
386
+ ( (____ | |__ ___ _ _ _ _| |_ _ ____ _____
387
+ \\____ \\| _ \\ / _ \\| | | (_ _) | \\| ___ |
388
+ _____) ) | | | |_| | | | | | |_| | | | | ____|
389
+ (______/|_| |_|\\___/ \\___/ \\__)_|_|_|_|_____)
390
+ LOGO
391
+
392
+ Showtime::Logger.info(Pastel.new.red(logo))
393
+ Showtime::Logger.info("#{Pastel.new.bold('Showtime')} is running at #{Pastel.new.bright_blue("http://#{host}:#{port}")}\n")
394
+ end
395
+
396
+
397
+
398
+ end
399
+ end