nightingale 0.1.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 +7 -0
- data/ARCHITECTURE.md +56 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +98 -0
- data/Rakefile +12 -0
- data/examples/demo/app.rb +28 -0
- data/frontend/.gitignore +24 -0
- data/frontend/README.md +16 -0
- data/frontend/components.json +17 -0
- data/frontend/eslint.config.js +29 -0
- data/frontend/index.html +13 -0
- data/frontend/package-lock.json +5467 -0
- data/frontend/package.json +47 -0
- data/frontend/postcss.config.ts +6 -0
- data/frontend/public/vite.svg +1 -0
- data/frontend/src/App.css +42 -0
- data/frontend/src/App.tsx +192 -0
- data/frontend/src/assets/react.svg +1 -0
- data/frontend/src/components/ui/button.tsx +56 -0
- data/frontend/src/components/ui/card.tsx +79 -0
- data/frontend/src/components/ui/input.tsx +22 -0
- data/frontend/src/components/ui/label.tsx +24 -0
- data/frontend/src/components/ui/separator.tsx +31 -0
- data/frontend/src/components/ui/slider.tsx +26 -0
- data/frontend/src/components/ui/table.tsx +117 -0
- data/frontend/src/index.css +76 -0
- data/frontend/src/lib/utils.ts +6 -0
- data/frontend/src/main.tsx +10 -0
- data/frontend/tailwind.config.ts +77 -0
- data/frontend/tsconfig.json +30 -0
- data/frontend/tsconfig.node.json +10 -0
- data/frontend/vite.config.ts +13 -0
- data/lib/nightingale/cli.rb +126 -0
- data/lib/nightingale/dsl.rb +74 -0
- data/lib/nightingale/runner.rb +79 -0
- data/lib/nightingale/server.rb +104 -0
- data/lib/nightingale/version.rb +5 -0
- data/lib/nightingale.rb +11 -0
- data/public/nightingale.png +0 -0
- metadata +205 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
|
|
10
|
+
--card: 0 0% 100%;
|
|
11
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
12
|
+
|
|
13
|
+
--popover: 0 0% 100%;
|
|
14
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
15
|
+
|
|
16
|
+
--primary: 222.2 47.4% 11.2%;
|
|
17
|
+
--primary-foreground: 210 40% 98%;
|
|
18
|
+
|
|
19
|
+
--secondary: 210 40% 96.1%;
|
|
20
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
21
|
+
|
|
22
|
+
--muted: 210 40% 96.1%;
|
|
23
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
24
|
+
|
|
25
|
+
--accent: 210 40% 96.1%;
|
|
26
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
27
|
+
|
|
28
|
+
--destructive: 0 84.2% 60.2%;
|
|
29
|
+
--destructive-foreground: 210 40% 98%;
|
|
30
|
+
|
|
31
|
+
--border: 214.3 31.8% 91.4%;
|
|
32
|
+
--input: 214.3 31.8% 91.4%;
|
|
33
|
+
--ring: 222.2 84% 4.9%;
|
|
34
|
+
|
|
35
|
+
--radius: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.dark {
|
|
39
|
+
--background: 222.2 84% 4.9%;
|
|
40
|
+
--foreground: 210 40% 98%;
|
|
41
|
+
|
|
42
|
+
--card: 222.2 84% 4.9%;
|
|
43
|
+
--card-foreground: 210 40% 98%;
|
|
44
|
+
|
|
45
|
+
--popover: 222.2 84% 4.9%;
|
|
46
|
+
--popover-foreground: 210 40% 98%;
|
|
47
|
+
|
|
48
|
+
--primary: 210 40% 98%;
|
|
49
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
50
|
+
|
|
51
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
52
|
+
--secondary-foreground: 210 40% 98%;
|
|
53
|
+
|
|
54
|
+
--muted: 217.2 32.6% 17.5%;
|
|
55
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
56
|
+
|
|
57
|
+
--accent: 217.2 32.6% 17.5%;
|
|
58
|
+
--accent-foreground: 210 40% 98%;
|
|
59
|
+
|
|
60
|
+
--destructive: 0 62.8% 30.6%;
|
|
61
|
+
--destructive-foreground: 210 40% 98%;
|
|
62
|
+
|
|
63
|
+
--border: 217.2 32.6% 17.5%;
|
|
64
|
+
--input: 217.2 32.6% 17.5%;
|
|
65
|
+
--ring: 212.7 26.8% 83.9%;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@layer base {
|
|
70
|
+
* {
|
|
71
|
+
@apply border-border;
|
|
72
|
+
}
|
|
73
|
+
body {
|
|
74
|
+
@apply bg-background text-foreground;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
darkMode: ["class"],
|
|
4
|
+
content: [
|
|
5
|
+
'./pages/**/*.{ts,tsx}',
|
|
6
|
+
'./components/**/*.{ts,tsx}',
|
|
7
|
+
'./app/**/*.{ts,tsx}',
|
|
8
|
+
'./src/**/*.{ts,tsx}',
|
|
9
|
+
],
|
|
10
|
+
prefix: "",
|
|
11
|
+
theme: {
|
|
12
|
+
container: {
|
|
13
|
+
center: true,
|
|
14
|
+
padding: "2rem",
|
|
15
|
+
screens: {
|
|
16
|
+
"2xl": "1400px",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
extend: {
|
|
20
|
+
colors: {
|
|
21
|
+
border: "hsl(var(--border))",
|
|
22
|
+
input: "hsl(var(--input))",
|
|
23
|
+
ring: "hsl(var(--ring))",
|
|
24
|
+
background: "hsl(var(--background))",
|
|
25
|
+
foreground: "hsl(var(--foreground))",
|
|
26
|
+
primary: {
|
|
27
|
+
DEFAULT: "hsl(var(--primary))",
|
|
28
|
+
foreground: "hsl(var(--primary-foreground))",
|
|
29
|
+
},
|
|
30
|
+
secondary: {
|
|
31
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
32
|
+
foreground: "hsl(var(--secondary-foreground))",
|
|
33
|
+
},
|
|
34
|
+
destructive: {
|
|
35
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
36
|
+
foreground: "hsl(var(--destructive-foreground))",
|
|
37
|
+
},
|
|
38
|
+
muted: {
|
|
39
|
+
DEFAULT: "hsl(var(--muted))",
|
|
40
|
+
foreground: "hsl(var(--muted-foreground))",
|
|
41
|
+
},
|
|
42
|
+
accent: {
|
|
43
|
+
DEFAULT: "hsl(var(--accent))",
|
|
44
|
+
foreground: "hsl(var(--accent-foreground))",
|
|
45
|
+
},
|
|
46
|
+
popover: {
|
|
47
|
+
DEFAULT: "hsl(var(--popover))",
|
|
48
|
+
foreground: "hsl(var(--popover-foreground))",
|
|
49
|
+
},
|
|
50
|
+
card: {
|
|
51
|
+
DEFAULT: "hsl(var(--card))",
|
|
52
|
+
foreground: "hsl(var(--card-foreground))",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
borderRadius: {
|
|
56
|
+
lg: "var(--radius)",
|
|
57
|
+
md: "calc(var(--radius) - 2px)",
|
|
58
|
+
sm: "calc(var(--radius) - 4px)",
|
|
59
|
+
},
|
|
60
|
+
keyframes: {
|
|
61
|
+
"accordion-down": {
|
|
62
|
+
from: { height: "0" },
|
|
63
|
+
to: { height: "var(--radix-accordion-content-height)" },
|
|
64
|
+
},
|
|
65
|
+
"accordion-up": {
|
|
66
|
+
from: { height: "var(--radix-accordion-content-height)" },
|
|
67
|
+
to: { height: "0" },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
animation: {
|
|
71
|
+
"accordion-down": "accordion-down 0.2s ease-out",
|
|
72
|
+
"accordion-up": "accordion-up 0.2s ease-out",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
|
77
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
|
|
23
|
+
"baseUrl": ".",
|
|
24
|
+
"paths": {
|
|
25
|
+
"@/*": ["./src/*"]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"include": ["src"],
|
|
29
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
// https://vitejs.dev/config/
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react()],
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: {
|
|
10
|
+
"@": path.resolve(__dirname, "./src"),
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Nightingale
|
|
7
|
+
class CLI
|
|
8
|
+
def self.new_project(name)
|
|
9
|
+
puts "Creating new Nightingale project: #{name}..."
|
|
10
|
+
FileUtils.mkdir_p(name)
|
|
11
|
+
|
|
12
|
+
# Copy frontend template
|
|
13
|
+
# In a real gem, this would be properly located.
|
|
14
|
+
# For this repo, we assume we are running from the root.
|
|
15
|
+
gem_root = File.expand_path("../..", __dir__)
|
|
16
|
+
frontend_template = File.join(gem_root, "frontend")
|
|
17
|
+
|
|
18
|
+
if File.directory?(frontend_template)
|
|
19
|
+
puts "Copying frontend template..."
|
|
20
|
+
FileUtils.cp_r(frontend_template, File.join(name, "frontend"))
|
|
21
|
+
|
|
22
|
+
# Clean up node_modules and other artifacts from the template copy
|
|
23
|
+
FileUtils.rm_rf(File.join(name, "frontend", "node_modules"))
|
|
24
|
+
FileUtils.rm_rf(File.join(name, "frontend", "dist"))
|
|
25
|
+
FileUtils.rm_rf(File.join(name, "frontend", ".git"))
|
|
26
|
+
|
|
27
|
+
# Create a basic app.rb
|
|
28
|
+
puts "Creating sample app.rb..."
|
|
29
|
+
File.write(File.join(name, "app.rb"), <<~RUBY)
|
|
30
|
+
require 'nightingale'
|
|
31
|
+
|
|
32
|
+
title "My Nightingale App"
|
|
33
|
+
markdown "Welcome to your new app!"
|
|
34
|
+
|
|
35
|
+
if button "Click me"
|
|
36
|
+
markdown "You clicked the button!"
|
|
37
|
+
end
|
|
38
|
+
RUBY
|
|
39
|
+
|
|
40
|
+
# Create Gemfile
|
|
41
|
+
puts "Creating Gemfile..."
|
|
42
|
+
File.write(File.join(name, "Gemfile"), <<~RUBY)
|
|
43
|
+
source 'https://rubygems.org'
|
|
44
|
+
gem 'nightingale', path: '#{gem_root}' # Local path for now
|
|
45
|
+
RUBY
|
|
46
|
+
|
|
47
|
+
puts "Installing frontend dependencies..."
|
|
48
|
+
Dir.chdir(File.join(name, "frontend")) do
|
|
49
|
+
system("npm install")
|
|
50
|
+
|
|
51
|
+
puts "Initializing Shadcn UI components..."
|
|
52
|
+
# We assume components.json and tailwind.config.js are already in the template
|
|
53
|
+
# So we just need to add the components
|
|
54
|
+
# Note: 'shadcn add --all' might require interaction or confirmation, adding --yes or similar if available
|
|
55
|
+
# Actually, shadcn add doesn't have a --yes flag usually, but we can try piping yes?
|
|
56
|
+
# Or just installing specific components we use.
|
|
57
|
+
# For now, let's install the ones we use in the template if any, or just let the user do it?
|
|
58
|
+
# The user request said: "when user initialize the app will install them automatically"
|
|
59
|
+
# Let's try to install button, slider, etc.
|
|
60
|
+
|
|
61
|
+
# But wait, we haven't actually implemented the Shadcn components in our App.tsx yet.
|
|
62
|
+
# We are still using HTML buttons.
|
|
63
|
+
# We should update App.tsx to use Shadcn components first?
|
|
64
|
+
# Or we install them now and the user can use them.
|
|
65
|
+
|
|
66
|
+
# Let's install common components
|
|
67
|
+
components = %w[button slider card input label table separator]
|
|
68
|
+
puts "Installing components: #{components.join(", ")}..."
|
|
69
|
+
system("npx shadcn@latest add #{components.join(" ")} --yes --overwrite")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
puts "Project created successfully!"
|
|
73
|
+
puts "Run: cd #{name} && bundle install && nightingale run app.rb"
|
|
74
|
+
else
|
|
75
|
+
puts "Error: Frontend template not found at #{frontend_template}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.run(script_path)
|
|
80
|
+
script_path = File.expand_path(script_path)
|
|
81
|
+
unless File.exist?(script_path)
|
|
82
|
+
puts "Error: Script not found: #{script_path}"
|
|
83
|
+
exit 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "Starting Nightingale..."
|
|
87
|
+
puts "Script: #{script_path}"
|
|
88
|
+
|
|
89
|
+
# Start Backend (Sinatra)
|
|
90
|
+
server_pid = fork do
|
|
91
|
+
ENV["NIGHTINGALE_SCRIPT"] = script_path
|
|
92
|
+
# Suppress Sinatra startup logs if needed, or keep them
|
|
93
|
+
require "nightingale/server"
|
|
94
|
+
Nightingale::Server.run!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Start Frontend (Vite)
|
|
98
|
+
# Check if we are in a project with a frontend folder
|
|
99
|
+
frontend_dir = File.join(Dir.pwd, "frontend")
|
|
100
|
+
vite_pid = nil
|
|
101
|
+
|
|
102
|
+
if File.directory?(frontend_dir)
|
|
103
|
+
puts "Starting Frontend (Vite)..."
|
|
104
|
+
vite_pid = fork do
|
|
105
|
+
Dir.chdir(frontend_dir)
|
|
106
|
+
exec "npm run dev"
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
# If no local frontend, maybe we should serve the gem's frontend?
|
|
110
|
+
# For now, warn.
|
|
111
|
+
puts "Warning: 'frontend' directory not found. UI might not load."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
trap("INT") do
|
|
115
|
+
puts "\nStopping..."
|
|
116
|
+
Process.kill("TERM", server_pid)
|
|
117
|
+
Process.kill("TERM", vite_pid) if vite_pid
|
|
118
|
+
Process.wait(server_pid)
|
|
119
|
+
Process.wait(vite_pid) if vite_pid
|
|
120
|
+
exit
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Process.wait(server_pid)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nightingale
|
|
4
|
+
module DSL
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def components
|
|
11
|
+
@components ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def reset_components!
|
|
15
|
+
@components = []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def title(text)
|
|
20
|
+
Nightingale::Runner.current.add_component({ type: "title", props: { text: text } })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def markdown(text)
|
|
24
|
+
Nightingale::Runner.current.add_component({ type: "markdown", props: { content: text } })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def button(label, key: nil)
|
|
28
|
+
key ||= "button_#{label}"
|
|
29
|
+
# Check if this button was clicked in the current event
|
|
30
|
+
clicked = Nightingale::Runner.current.event_triggered?(key, "click")
|
|
31
|
+
|
|
32
|
+
Nightingale::Runner.current.add_component({
|
|
33
|
+
type: "button",
|
|
34
|
+
id: key,
|
|
35
|
+
props: { label: label, value: clicked }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
clicked
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def slider(label, min:, max:, value: nil, step: 1, key: nil)
|
|
42
|
+
key ||= "slider_#{label}"
|
|
43
|
+
current_value = Nightingale::Runner.current.get_widget_value(key) || value || min
|
|
44
|
+
|
|
45
|
+
Nightingale::Runner.current.add_component({
|
|
46
|
+
type: "slider",
|
|
47
|
+
id: key,
|
|
48
|
+
props: { label: label, min: min, max: max, value: current_value,
|
|
49
|
+
step: step }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
current_value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dataframe(data, key: nil)
|
|
56
|
+
key ||= "dataframe_#{data.object_id}"
|
|
57
|
+
# data should be an array of hashes or similar
|
|
58
|
+
Nightingale::Runner.current.add_component({
|
|
59
|
+
type: "dataframe",
|
|
60
|
+
id: key,
|
|
61
|
+
props: { data: data }
|
|
62
|
+
})
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def session_state
|
|
66
|
+
Nightingale::Runner.current.session_state
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Layout helpers could go here (sidebar, etc.)
|
|
70
|
+
def sidebar(&block)
|
|
71
|
+
Nightingale::Runner.current.with_container("sidebar", &block)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Nightingale
|
|
6
|
+
class Runner
|
|
7
|
+
attr_reader :components, :session_state
|
|
8
|
+
|
|
9
|
+
def self.current
|
|
10
|
+
Thread.current[:nightingale_runner]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(script_path)
|
|
14
|
+
@script_path = script_path
|
|
15
|
+
@components = []
|
|
16
|
+
@session_state = {}
|
|
17
|
+
@widget_values = {}
|
|
18
|
+
@current_event = nil
|
|
19
|
+
@container_stack = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(event = nil, widget_values = {})
|
|
23
|
+
@components = []
|
|
24
|
+
@current_event = event
|
|
25
|
+
@widget_values.merge!(widget_values) if widget_values
|
|
26
|
+
|
|
27
|
+
Thread.current[:nightingale_runner] = self
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
# We load the script content and eval it
|
|
31
|
+
# This is a simple way to execute it.
|
|
32
|
+
# In a real app, we might want to use a separate process or more isolation.
|
|
33
|
+
content = File.read(@script_path)
|
|
34
|
+
|
|
35
|
+
# Create a context to evaluate in
|
|
36
|
+
context = Object.new
|
|
37
|
+
context.extend(Nightingale::DSL)
|
|
38
|
+
|
|
39
|
+
context.instance_eval(content, @script_path)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
puts "Error running script: #{e.message}"
|
|
42
|
+
puts e.backtrace
|
|
43
|
+
add_component({ type: "error", props: { message: e.message, backtrace: e.backtrace } })
|
|
44
|
+
ensure
|
|
45
|
+
Thread.current[:nightingale_runner] = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@components
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_component(component)
|
|
52
|
+
if @container_stack.any?
|
|
53
|
+
parent = @container_stack.last
|
|
54
|
+
parent[:children] ||= []
|
|
55
|
+
parent[:children] << component
|
|
56
|
+
else
|
|
57
|
+
@components << component
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def with_container(type)
|
|
62
|
+
container = { type: type, props: {}, children: [] }
|
|
63
|
+
add_component(container)
|
|
64
|
+
@container_stack.push(container)
|
|
65
|
+
yield
|
|
66
|
+
@container_stack.pop
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_widget_value(key)
|
|
70
|
+
@widget_values[key]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def event_triggered?(key, event_type)
|
|
74
|
+
return false unless @current_event
|
|
75
|
+
|
|
76
|
+
@current_event["id"] == key && @current_event["event"] == event_type
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "faye/websocket"
|
|
5
|
+
require "json"
|
|
6
|
+
require "listen"
|
|
7
|
+
|
|
8
|
+
module Nightingale
|
|
9
|
+
class Server < Sinatra::Base
|
|
10
|
+
set :server, "puma"
|
|
11
|
+
set :bind, "0.0.0.0"
|
|
12
|
+
set :port, 4567
|
|
13
|
+
|
|
14
|
+
# Store runners per session (connection)
|
|
15
|
+
# In a real app, we'd use a proper session store
|
|
16
|
+
@@runners = {}
|
|
17
|
+
@@connections = []
|
|
18
|
+
|
|
19
|
+
def self.run!
|
|
20
|
+
# Start file watcher
|
|
21
|
+
script_path = ENV["NIGHTINGALE_SCRIPT"]
|
|
22
|
+
if script_path && File.exist?(script_path)
|
|
23
|
+
puts "Watching #{script_path} for changes..."
|
|
24
|
+
listener = Listen.to(File.dirname(script_path),
|
|
25
|
+
only: /#{File.basename(script_path)}$/) do |modified, added, removed|
|
|
26
|
+
puts "File changed: #{modified}"
|
|
27
|
+
# Trigger rerun for all connections
|
|
28
|
+
@@connections.each do |ws|
|
|
29
|
+
runner = @@runners[ws.object_id]
|
|
30
|
+
next unless runner
|
|
31
|
+
|
|
32
|
+
puts "Rerunning for connection #{ws.object_id}"
|
|
33
|
+
tree = runner.run
|
|
34
|
+
ws.send({ type: "render", components: tree }.to_json)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
listener.start
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
get "/" do
|
|
44
|
+
"Nightingale Server Running. Connect via WebSocket at /ws"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
get "/ws" do
|
|
48
|
+
if Faye::WebSocket.websocket?(env)
|
|
49
|
+
ws = Faye::WebSocket.new(env)
|
|
50
|
+
|
|
51
|
+
ws.on :open do |event|
|
|
52
|
+
puts "WebSocket connection open"
|
|
53
|
+
@@connections << ws
|
|
54
|
+
|
|
55
|
+
# Initialize runner for this connection
|
|
56
|
+
script_path = ENV["NIGHTINGALE_SCRIPT"]
|
|
57
|
+
runner = Nightingale::Runner.new(script_path)
|
|
58
|
+
@@runners[ws.object_id] = runner
|
|
59
|
+
|
|
60
|
+
# Initial run
|
|
61
|
+
tree = runner.run
|
|
62
|
+
ws.send({ type: "render", components: tree }.to_json)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
ws.on :message do |event|
|
|
66
|
+
data = JSON.parse(event.data)
|
|
67
|
+
puts "Received message: #{data}"
|
|
68
|
+
|
|
69
|
+
if data["type"] == "event"
|
|
70
|
+
runner = @@runners[ws.object_id]
|
|
71
|
+
if runner
|
|
72
|
+
# Rerun with event
|
|
73
|
+
# We pass the widget value from the event if applicable
|
|
74
|
+
# But usually the frontend sends all widget values or we track them?
|
|
75
|
+
# For MVP, let's assume the event contains the value of the widget that changed.
|
|
76
|
+
# And maybe we need to sync other widget values?
|
|
77
|
+
# Streamlit sends all widget states.
|
|
78
|
+
# For MVP, let's assume we just update the specific widget value in the runner's state?
|
|
79
|
+
# Or we pass it to run.
|
|
80
|
+
|
|
81
|
+
widget_values = data["values"] || {}
|
|
82
|
+
widget_values[data["id"]] = data["value"] if data["id"] && data.key?("value")
|
|
83
|
+
|
|
84
|
+
tree = runner.run(data, widget_values)
|
|
85
|
+
ws.send({ type: "render", components: tree }.to_json)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
ws.on :close do |event|
|
|
91
|
+
puts "WebSocket connection closed"
|
|
92
|
+
@@connections.delete(ws)
|
|
93
|
+
@@runners.delete(ws.object_id)
|
|
94
|
+
ws = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return ws.rack_response
|
|
98
|
+
else
|
|
99
|
+
# Fallback for non-WS requests
|
|
100
|
+
"WebSocket connection required"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/nightingale.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nightingale/version"
|
|
4
|
+
require_relative "nightingale/cli"
|
|
5
|
+
require_relative "nightingale/server"
|
|
6
|
+
require_relative "nightingale/runner"
|
|
7
|
+
require_relative "nightingale/dsl"
|
|
8
|
+
|
|
9
|
+
module Nightingale
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
end
|
|
Binary file
|