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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +179 -0
- data/bin/showtime +47 -0
- data/lib/showtime/app.rb +399 -0
- data/lib/showtime/charts.rb +229 -0
- data/lib/showtime/component_registry.rb +38 -0
- data/lib/showtime/components/Components.md +309 -0
- data/lib/showtime/components/alerts.rb +83 -0
- data/lib/showtime/components/base.rb +63 -0
- data/lib/showtime/components/charts.rb +119 -0
- data/lib/showtime/components/data.rb +328 -0
- data/lib/showtime/components/inputs.rb +390 -0
- data/lib/showtime/components/layout.rb +135 -0
- data/lib/showtime/components/media.rb +73 -0
- data/lib/showtime/components/sidebar.rb +130 -0
- data/lib/showtime/components/text.rb +156 -0
- data/lib/showtime/components.rb +18 -0
- data/lib/showtime/compute_tracker.rb +21 -0
- data/lib/showtime/helpers.rb +53 -0
- data/lib/showtime/logger.rb +143 -0
- data/lib/showtime/public/.vite/manifest.json +34 -0
- data/lib/showtime/public/assets/antd-3aDVoXqG.js +447 -0
- data/lib/showtime/public/assets/charts-iowb_sWQ.js +3858 -0
- data/lib/showtime/public/assets/index-B2b3lWS5.js +43 -0
- data/lib/showtime/public/assets/index-M6NVamDM.css +1 -0
- data/lib/showtime/public/assets/react-BE6xecJX.js +32 -0
- data/lib/showtime/public/index.html +19 -0
- data/lib/showtime/public/letter.png +0 -0
- data/lib/showtime/public/logo.png +0 -0
- data/lib/showtime/release.rb +108 -0
- data/lib/showtime/session.rb +131 -0
- data/lib/showtime/version.rb +3 -0
- data/lib/showtime/views/index.erb +32 -0
- data/lib/showtime.rb +157 -0
- 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
|
+

|
|
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])
|
data/lib/showtime/app.rb
ADDED
|
@@ -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
|