11 Commits

Author SHA1 Message Date
bionickatana f62ce57279 Mostly working state 2026-07-28 22:05:33 -06:00
bionickatana f497329169 Partly working 2026-07-28 14:21:21 -06:00
bionickatana f55348f0b9 updated interface 2026-07-28 11:34:00 -06:00
bionickatana 7ebf6dbf41 working basic state 2026-07-27 21:55:41 -06:00
bionickatana 0b4532282d Runner stuff 2026-07-27 20:57:54 -06:00
bionickatana d6c8fdb7ae Runner stuff
Build and Package JRuby Rails App / build (push) Failing after 2m5s
2026-07-27 20:35:09 -06:00
bionickatana 03bb708e1f Runner stuff 2026-07-27 20:34:44 -06:00
bionickatana 0c392e5c71 runner fun
Build and Package JRuby Rails App / build (push) Failing after 1m58s
2026-07-27 20:23:54 -06:00
bionickatana 0fbd307749 runner fun 2026-07-27 20:22:40 -06:00
bionickatana 76977b9e90 Trying to fix runners 2026-07-27 20:19:18 -06:00
bionickatana 01ada2bf65 Added runner to build Linux application 2026-07-27 20:15:33 -06:00
9 changed files with 410 additions and 108 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Build and Package JRuby Rails App
#on:
# push:
# branches:
# - main
# - second_try
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Java and JRuby
run: |
apt-get update && apt-get install -y wget curl libgmp-dev
# Install Java (needed for JRuby and jpackage)
apt-get install -y openjdk-17-jdk
# Download and install JRuby directly
JRUBY_VERSION="9.4.6.0"
curl -O https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz
tar -xzf jruby-dist-${JRUBY_VERSION}-bin.tar.gz -C /opt/
ln -s /opt/jruby-${JRUBY_VERSION}/bin/jruby /usr/local/bin/jruby
ln -s /opt/jruby-${JRUBY_VERSION}/bin/bundle /usr/local/bin/bundle
- name: Install Dependencies
run: |
bundle install
- name: Build JAR with Jarbler
run: |
gem install jarbler
jarble
- name: Generate Unique App Name with Git Hash
id: vars
run: |
SHORT_HASH=$(git rev-parse --short HEAD)
echo "app_name=custom-app-${SHORT_HASH}" >> $GITHUB_OUTPUT
- name: Package with jpackage
run: |
# Find the generated jar file name dynamically
JAR_FILE=$(ls build/*.jar | head -n 1)
# Run jpackage to bundle the application and runtime into an app-image directory
jpackage \
--type app-image \
--name "${{ steps.vars.outputs.app_name }}" \
--input "$(dirname "$JAR_FILE")" \
--main-jar "$(basename "$JAR_FILE")" \
--dest dist
- name: Compress Executable Package
run: |
cd dist
tar -czvf "${{ steps.vars.outputs.app_name }}.tar.gz" "${{ steps.vars.outputs.app_name }}"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.vars.outputs.app_name }}
path: dist/*.tar.gz
+3 -3
View File
@@ -3,7 +3,7 @@ name: CI
on: on:
pull_request: pull_request:
push: push:
branches: [ main ] branches: [ main, second_try ]
jobs: jobs:
scan_ruby: scan_ruby:
@@ -76,7 +76,7 @@ jobs:
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips run: apt-get update && apt-get install --no-install-recommends -y libvips
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -104,7 +104,7 @@ jobs:
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips run: apt-get update && apt-get install --no-install-recommends -y libvips
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
+68 -21
View File
@@ -1,31 +1,78 @@
require 'yaml'
## Debugging:
# If the response should be filtered and it is not being filtered **and** you
# are geting all the values for that column, check to make sure that you are
# selecting from the allowed tables and columns.
ALLOWED_TABLES = {
"JointInfo" => ["Joint", "Osteokinematics"],
"Joint" => ["Common Name", "Joint"]
}
class DashboardController < ApplicationController class DashboardController < ApplicationController
before_action :load_manifest
def index def index
joint_column = 'joint' # Renders the main page
# Grab distinct values for dropdown filters
@available_joints = Joint.distinct.pluck(joint_column)
# Fetch initial records
@records = filter_records
respond_to do |format| respond_to do |format|
format.html # Renders app/views/dashboards/index.html.erb format.html
format.json { render json: @records } # Returns filtered data as JSON for JS format.json { render json: @manifest }
end end
end end
# GET /dashboard/options?table=Joint&filter_col=joint_id&value=1
def options
# 1. Define what is allowed to be accessed
table_name = params[:table]
col_name = params[:pluck_column]
# 2. Validate that the table and column exist in your allowlist
if ALLOWED_TABLES.key?(table_name) && ALLOWED_TABLES[table_name].include?(col_name)
table = table_name.constantize
quoted_col = ActiveRecord::Base.connection.quote_column_name(col_name)
if params[:filter_column].present? && ALLOWED_TABLES[table_name].include?(params[:filter_column])
puts "Filtering with 'filter_column'"
options = table.select(Arel.sql(quoted_col)).where(params[:filter_column] => params[:value]).pluck(Arel.sql(quoted_col))
else
puts "***NO FILTERING***"
options = table.distinct.pluck(Arel.sql(quoted_col))
end
render json: options
else
render json: { error: "Invalid table or column requested" }, status: :bad_request
end
end
# GET /dashboard/data?joint=Acromioclavicular&sub_joint=X
def data
results = {}
# Use the data_sources from YAML to aggregate data
@manifest['data_sources'].each do |source|
debugger
table = source['table'].constantize
# Find the filter value from the params that matches the filter_column
# We assume the filter_column name in DB matches the filter ID in YAML
filter_val = params[source['filter_column']]
record = table.find_by(source['filter_column'] => filter_val)
results[source['key']] = record ? record.attributes : {}
end
#debugger
render json: results
end
private private
def filter_records def load_manifest
records = Joint.all @manifest = YAML.load_file(Rails.root.join('config', 'dashboard_manifest.yml'))
records = records.where(joint: params[:joint]) if params[:joint].present?
records
end end
# old broken: def index
# old broken: @joint = Joint.distinct.pluck(:Joint)
# old broken:
# old broken: @records = Joint.all
# old broken:
# old broken: @records = @records.where(Joint: params[joint]) if params[:joint].present?
# old broken: end
end end
+5
View File
@@ -0,0 +1,5 @@
class JointInfo < ApplicationRecord
def readonly?
true
end
end
-5
View File
@@ -1,5 +0,0 @@
class JointsInfo < ApplicationRecord
def readonly?
true
end
end
+195 -64
View File
@@ -1,88 +1,219 @@
<h1>Excel Replacement Dashboard</h1> <h1 style="font-family: Arial, sans-serif;">Dynamic Dashboard</h1>
<!-- Dropdown Filters (No submit button needed, JS handles it) --> <div id="filter-area" style="margin-bottom: 20px; display: flex; gap: 15px; font-family: Arial, sans-serif;">
<%= form_with url: dashboard_path, method: :get, id: "filter-form" do |f| %> <!-- Dropdowns injected here by JS -->
<div>
<%= f.label :joint, "Select Joint:" %>
<%= f.select :joint, options_for_select(@available_joints, params[:joint]), include_blank: "All joints", id: "joint" %>
</div> </div>
<% end %>
<hr> <div id="results-area" style="display: flex; gap: 20px;">
<!-- Grids injected here by JS -->
<h2>Dashboard Results</h2>
<div style="overflow-x: auto;">
<table border="1" id="results-table">
<thead>
<tr id="table-headers">
<!-- Dynamically populated headers -->
</tr>
</thead>
<tbody id="table-body">
<!-- Dynamically populated rows (supports 4 to 20+ values) -->
</tbody>
</table>
</div> </div>
<style>
.dyn-grid { border-collapse: collapse; border: 2px solid #000; font-family: Arial; font-size: 12px; }
.dyn-grid td { border: 1px solid #000; padding: 4px 8px; text-align: center; }
.bg-dark-blue { background-color: #002060; color: white; font-weight: bold; }
.bg-light-blue { background-color: #D9E1F2; font-weight: bold; }
.bg-pale-yellow { background-color: #FFFFCC; }
.bg-pale-green { background-color: #E2EFDA; }
.bg-pale-orange { background-color: #FCE4D6; }
</style>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const jointSelect = document.getElementById("joint"); let manifest = {};
const tableHeaders = document.getElementById("table-headers"); let currentSelections = {};
const tableBody = document.getElementById("table-body");
function fetchFilteredData() { async function init() {
const joint = jointSelect.value; const resp = await fetch(
'/dashboard',
// Build query parameters {
const params = new URLSearchParams(); headers: {
if (joint) params.append("joint", joint); 'Accept': 'application/json'
}
// Fetch data from Rails with JSON format });
fetch(`/dashboard?${params.toString()}`, { manifest = await resp.json();
headers: { "Accept": "application/json" } await renderRootFilters();
})
.then(response => response.json())
.then(data => {
renderTable(data);
})
.catch(error => console.error("Error fetching dashboard data:", error));
} }
function renderTable(records) { async function renderRootFilters() {
tableHeaders.innerHTML = ""; const rootFilters = manifest.filters.filter(f => f.depends_on === null);
tableBody.innerHTML = ""; for (const f of rootFilters) {
await createDropdown(f);
if (records.length === 0) { }
tableBody.innerHTML = "<tr><td colspan='100'>No records found</td></tr>"; //rootFilters.forEach(f => await createDropdown(f));
return;
} }
// Extract column keys dynamically from the first record (handles variable lengths) async function createDropdown(config) {
const columns = Object.keys(records[0]); const container = document.createElement("div");
container.id = `container_${config.id}`;
// 1. Build dynamic headers const label = document.createElement("label");
columns.forEach(col => { label.textContent = config.label + ": ";
const th = document.createElement("th"); label.style.fontWeight = "bold";
th.textContent = col.replace(/_/g, " ").toUpperCase();
tableHeaders.appendChild(th); const select = document.createElement("select");
select.id = config.id;
select.innerHTML = `<option value="">Select...</option>`;
// Marked callback as async
select.addEventListener("change", async (e) => {
const val = e.target.value;
currentSelections[config.id] = val;
// 1. Clear dependent filters and data
clearDependents(config.id);
// 2. Load options for children (Fixed with for...of)
const children = manifest.filters.filter(f => f.depends_on === config.id);
for (const child of children) {
const options = await fetchOptions(child, val);
populateDropdown(child.id, options);
}
// 3. Update data grid
updateGrid();
}); });
// 2. Build dynamic rows (whether 4 or 20 attributes long) container.appendChild(label);
records.forEach(record => { container.appendChild(select);
document.getElementById("filter-area").appendChild(container);
// Special handling for parent items:
if (config.depends_on === null) {
var options = await fetchOptions(config);
populateDropdown(select.id, options)
}
}
async function fetchOptions(config, value) {
var params
if (config.filter_column == undefined) {
console.log(config + "Undefined filter col");
params = new URLSearchParams({
table: config.table,
pluck_column: config.pluck_column,
});
} else {
params = new URLSearchParams({
table: config.table,
pluck_column: config.pluck_column,
filter_column: config.filter_column,
value: value
});
}
const resp = await fetch(`/dashboard/options?${params.toString()}`);
return await resp.json();
}
function populateDropdown(id, options) {
const select = document.getElementById(id) || createMissingDropdown(id);
select.innerHTML = `<option value="">Select...</option>`;
options.forEach(opt => {
select.innerHTML += `<option value="${opt}">${opt}</option>`;
});
// If this is the last dropdown, it should be visible now
const container = document.getElementById(`container_${id}`);
if(container) container.style.display = "block";
}
function createMissingDropdown(id) {
const config = manifest.filters.find(f => f.id === id);
const container = document.createElement("div");
container.id = `container_${id}`;
container.style.display = "none"; // Hidden until parent is selected
const label = document.createElement("label");
label.textContent = config.label + ": ";
const select = document.createElement("select");
select.id = id;
// Marked callback as async
select.addEventListener("change", async (e) => {
currentSelections[id] = e.target.value;
clearDependents(id);
const children = manifest.filters.filter(f => f.depends_on === id);
// Fixed with for...of loop
for (const child of children) {
const opts = await fetchOptions(child, e.target.value);
populateDropdown(child.id, opts);
}
updateGrid();
});
container.appendChild(label);
container.appendChild(select);
document.getElementById("filter-area").appendChild(container);
return select;
}
function clearDependents(id) {
// Recursively find all filters that depend on this ID and reset them
manifest.filters.forEach(f => {
if (f.depends_on === id) {
const select = document.getElementById(f.id);
if (select) select.innerHTML = `<option value="">Select...</option>`;
const container = document.getElementById(`container_${f.id}`);
if (container) container.style.display = "none";
delete currentSelections[f.id];
clearDependents(f.id);
}
});
}
async function updateGrid() {
const params = new URLSearchParams(currentSelections);
const resp = await fetch(`/dashboard/data?${params.toString()}`);
const data = await resp.json();
renderLayout(data);
}
function renderLayout(data) {
const area = document.getElementById("results-area");
area.innerHTML = "";
// Group layout by grid name (defaults to 'main')
const grids = {};
manifest.layout.forEach(item => {
const gridId = item.grid || 'main';
if (!grids[gridId]) grids[gridId] = [];
grids[gridId].push(item);
});
Object.keys(grids).forEach(gridId => {
const table = document.createElement("table");
table.className = "dyn-grid";
const layoutItems = grids[gridId];
const maxRow = Math.max(...layoutItems.map(i => i.row));
for (let r = 0; r <= maxRow; r++) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
columns.forEach(col => { const rowItems = layoutItems.filter(i => i.row === r);
rowItems.forEach(item => {
const td = document.createElement("td"); const td = document.createElement("td");
td.textContent = record[col] !== null ? record[col] : ""; td.className = item.class;
td.colSpan = item.colspan || 1;
td.rowSpan = item.rowspan || 1;
if (item.text) {
td.textContent = item.text;
} else if (item.value) {
// Resolve nested JSON path (e.g., "main_info.joint")
const parts = item.value.split('.');
const val = parts.reduce((obj, key) => (obj && obj[key] !== undefined) ? obj[key] : '', data);
td.textContent = val;
}
tr.appendChild(td); tr.appendChild(td);
}); });
tableBody.appendChild(tr); table.appendChild(tr);
}
area.appendChild(table);
}); });
} }
// Trigger fetch when either dropdown changes init();
jointSelect.addEventListener("change", fetchFilteredData);
// Load initial data on page load
fetchFilteredData();
}); });
</script> </script>
+48
View File
@@ -0,0 +1,48 @@
# Filter Hierarchy: Defines the dropdowns and their dependencies
filters:
- id: "joint"
label: "Select Joint"
table: "Joint"
pluck_column: "Joint"
depends_on: null # Root level
- id: "joint_info"
label: "Select kinematics"
table: "JointInfo" # A separate table in SQLite
pluck_column: "Osteokinematics"
depends_on: "joint" # Only appears after 'joint' is selected
filter_column: "Joint" # Filter SubJoint where joint_id == selected joint value
# Data Mapping: Defines which tables to hit for the final grid
data_sources:
- table: "Joint"
key: "main_info" # Nested JSON key
filter_column: "joint"
- table: "JointInfo" # MUST MATCH DATABASE TABLE
key: "joint_details"
filter_column: "Osteokinematics"
# Layout: Defines the visual grid
# colors: dark_blue, light_blue, pale_yellow, pale_green, pale_orange
layout:
- { row: 0, col: 0, colspan: 3, rowspan: 1, text: "Joint Information", class: "bg-dark-blue" }
- { row: 1, col: 0, colspan: 2, rowspan: 1, text: "Anatomical Name", class: "bg-light-blue" }
- { row: 2, col: 0, colspan: 2, rowspan: 1, value: "main_info.Joint", class: "bg-pale-yellow" }
- { row: 1, col: 2, colspan: 1, rowspan: 1, text: "Common Name", class: "bg-light-blue" }
- { row: 2, col: 2, colspan: 1, rowspan: 1, value: "main_info.Common Name", class: "bg-pale-yellow" }
- { row: 3, col: 0, colspan: 1, rowspan: 1, text: "Diarthrodial Joint Type", class: "bg-light-blue" }
- { row: 4, col: 0, colspan: 1, rowspan: 1, value: "main_info.Diarthrodial joint", class: "bg-pale-yellow" }
- { row: 3, col: 1, colspan: 1, rowspan: 1, text: "Alternate Name", class: "bg-light-blue" }
- { row: 4, col: 1, colspan: 1, rowspan: 1, value: "main_info.Alternate Name", class: "bg-pale-yellow" }
- { row: 3, col: 2, colspan: 1, rowspan: 1, text: "Planes of Freedom", class: "bg-light-blue" }
- { row: 4, col: 2, colspan: 1, rowspan: 1, value: "main_info.# of Plains", class: "bg-pale-yellow" }
- { row: 5, col: 0, colspan: 1, rowspan: 1, text: "Arthrokinematic Rule", class: "bg-light-blue"}
- { row: 6, col: 0, colspan: 1, rowspan: 1, value: "joint_details.Arthrokinematic Rule", class: "bg-pale-yellow"}
#- { row: 5, col: 0, colspan: 4, rowspan: 1, value: "main_info.notes", class: "bg-pale-green" }
# Ligaments Table (separate grid)
#- { grid: "ligaments", row: 0, col: 0, colspan: 1, rowspan: 1, text: "Ligaments", class: "bg-dark-blue" }
#- { grid: "ligaments", row: 0, col: 1, colspan: 1, rowspan: 1, value: ".list", class: "bg-pale-orange" }
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=undefined&value=Glenohumeral
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=Common%20Joint%20Name&value=Shoulder
+2
View File
@@ -13,4 +13,6 @@ Rails.application.routes.draw do
# root "posts#index" # root "posts#index"
root "dashboard#index" root "dashboard#index"
get "dashboard", to: "dashboard#index" get "dashboard", to: "dashboard#index"
get "dashboard/options", to: "dashboard#options"
get "dashboard/data", to: "dashboard#data"
end end
+10 -3
View File
@@ -1,7 +1,14 @@
require "test_helper" require "test_helper"
class DashboardControllerTest < ActionDispatch::IntegrationTest class DashboardControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do test "options returns JSON" do
# assert true get "/dashboard/options",
# end params: {
table: 'Joint',
pluck_column: 'Joint'
}
assert_response :success
assert_equal "application/json", response.media_type
end
end end