Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 55 additions & 77 deletions align_browser/build.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import shutil
import json
import http.server
import socket
from pathlib import Path
import argparse
Expand All @@ -20,39 +19,17 @@

def copy_static_assets(output_dir):
"""Copy static assets from package static/ directory to output directory."""
try:
# Use importlib.resources for robust package data access
static_files = files("align_browser.static")

for filename in ["index.html", "app.js", "state.js", "style.css"]:
try:
# Read the file content from the package
file_content = (static_files / filename).read_bytes()

# Write to destination
dst_file = output_dir / filename
dst_file.write_bytes(file_content)

except FileNotFoundError:
pass

except Exception as e:
# Fallback to filesystem approach for development
print(f"Package resource access failed, trying filesystem fallback: {e}")
script_dir = Path(__file__).parent
static_dir = script_dir / "static"
# Use importlib.resources for robust package data access
static_files = files("align_browser.static")

if not static_dir.exists():
raise FileNotFoundError(f"Static assets directory not found: {static_dir}")

static_files = ["index.html", "app.js", "state.js", "style.css"]

for filename in static_files:
src_file = static_dir / filename
dst_file = output_dir / filename

if src_file.exists():
shutil.copy2(src_file, dst_file)
# Copy all files from the static directory, excluding Python files
for file_ref in static_files.iterdir():
if file_ref.is_file() and not file_ref.name.endswith(".py"):
# Read the file content from the package
file_content = file_ref.read_bytes()
# Write to destination
dst_file = output_dir / file_ref.name
dst_file.write_bytes(file_content)


def build_frontend(
Expand All @@ -73,11 +50,8 @@ def build_frontend(
print(f"Processing experiments directory: {experiments_root}")

# Determine output directory based on mode
if dev_mode:
print("Development mode: using provided directory")
else:
# Production mode: copy static assets
print(f"Production mode: creating site in {output_dir}")
if not dev_mode:
print(f"creating site in {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
copy_static_assets(output_dir)

Expand All @@ -100,8 +74,6 @@ def build_frontend(
with open(data_output_dir / "manifest.json", "w") as f:
json.dump(manifest.model_dump(), f, indent=2)

print(f"Data generated in {data_output_dir}")

return output_dir


Expand Down Expand Up @@ -179,45 +151,51 @@ def main():

def serve_directory(directory, host="localhost", port=8000):
"""Start HTTP server to serve the specified directory."""
import os
from waitress import serve
import mimetypes
from pathlib import Path

# Find an available port starting from the requested port
actual_port = find_available_port(port, host)

def static_app(environ, start_response):
"""Simple WSGI app for serving static files."""
path_info = environ["PATH_INFO"]
if path_info == "/":
path_info = "/index.html"

file_path = Path(directory) / path_info.lstrip("/")

if not file_path.exists() or not file_path.is_file():
start_response("404 Not Found", [("Content-Type", "text/plain")])
return [b"404 Not Found"]

content_type, _ = mimetypes.guess_type(str(file_path))
if content_type is None:
content_type = "application/octet-stream"

start_response("200 OK", [("Content-Type", content_type)])
with open(file_path, "rb") as f:
return [f.read()]

if actual_port != port:
print(f"Port {port} was busy, using port {actual_port} instead")

# Display appropriate URL based on host
if host == "0.0.0.0":
url = f"http://localhost:{actual_port}"
print(f"Serving {directory} on all network interfaces at port {actual_port}")
print(f"Local access: {url}")
print(f"Network access: http://<your-ip>:{actual_port}")
else:
url = f"http://{host}:{actual_port}"
print(f"Serving {directory} at {url}")

# Change to the output directory
original_dir = os.getcwd()
print("Press Ctrl+C to stop the server")
try:
os.chdir(directory)

# Find an available port starting from the requested port
actual_port = find_available_port(port, host)

# Create HTTP server
handler = http.server.SimpleHTTPRequestHandler
with http.server.HTTPServer((host, actual_port), handler) as httpd:
# Enable socket reuse to prevent "Address already in use" errors
httpd.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Display appropriate URL based on host
if host == "0.0.0.0":
url = f"http://localhost:{actual_port}"
print(
f"Serving {directory} on all network interfaces at port {actual_port}"
)
print(f"Local access: {url}")
print(f"Network access: http://<your-ip>:{actual_port}")
else:
url = f"http://{host}:{actual_port}"
print(f"Serving {directory} at {url}")

if actual_port != port:
print(f"Port {port} was busy, using port {actual_port} instead")

print("Press Ctrl+C to stop the server")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")

finally:
# Restore original directory
os.chdir(original_dir)
serve(static_app, host=host, port=actual_port)
except KeyboardInterrupt:
print("\nServer stopped")


def find_available_port(start_port=8000, host="localhost"):
Expand Down
26 changes: 17 additions & 9 deletions align_browser/experiment_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ def parse_alignment_target_id(alignment_target_id: str) -> List[KDMAValue]:

Supports both single and multi-KDMA formats:
- Single: "ADEPT-June2025-merit-0.0" -> [KDMAValue(kdma="merit", value=0.0)]
- Single with underscore: "ADEPT-June2025-personal_safety-0.0" -> [KDMAValue(kdma="personal_safety", value=0.0)]
- Short format: "personal_safety-0.0" -> [KDMAValue(kdma="personal_safety", value=0.0)]
- Multi: "ADEPT-June2025-affiliation_merit-0.0_0.0" ->
[KDMAValue(kdma="affiliation", value=0.0), KDMAValue(kdma="merit", value=0.0)]
- Unaligned: "unaligned" -> [] (no KDMAs)

Args:
alignment_target_id: String like "ADEPT-June2025-merit-0.0",
"ADEPT-June2025-personal_safety-0.0", "personal_safety-0.0",
"ADEPT-June2025-affiliation_merit-0.0_0.0", or "unaligned"

Returns:
Expand All @@ -59,28 +62,33 @@ def parse_alignment_target_id(alignment_target_id: str) -> List[KDMAValue]:
if not alignment_target_id or alignment_target_id == "unaligned":
return []

# Split by hyphens: [prefix, scenario, kdma_part, value_part]
# Split by hyphens
parts = alignment_target_id.split("-")
if len(parts) < 4:
if len(parts) < 2:
return []

# Extract KDMA names and values from the last two parts
kdma_part = parts[-2] # e.g., "affiliation_merit" or "merit"
kdma_part = parts[-2] # e.g., "affiliation_merit", "merit", or "personal_safety"
value_part = parts[-1] # e.g., "0.0_0.0" or "0.0"

# Split KDMA names by underscore
kdma_names = kdma_part.split("_")

# Split values by underscore and convert to float
try:
value_strings = value_part.split("_")
values = [float(v) for v in value_strings]
except ValueError:
return []

# Ensure we have the same number of KDMAs and values
if len(kdma_names) != len(values):
return []
# Determine how to split KDMA names based on number of values
if len(values) == 1:
# Single value: treat entire kdma_part as one KDMA name (handles personal_safety)
kdma_names = [kdma_part]
else:
# Multiple values: split KDMA names by underscore
kdma_names = kdma_part.split("_")

# Ensure we have the same number of KDMAs and values
if len(kdma_names) != len(values):
return []

# Create KDMAValue objects
kdma_values = []
Expand Down
1 change: 0 additions & 1 deletion align_browser/experiment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ def build_manifest_from_experiments(
input_output_files.add(experiment.experiment_path / "input_output.json")

# Calculate checksums for all files
print(f"Calculating checksums for {len(input_output_files)} files...")
source_file_checksums = calculate_file_checksums(list(input_output_files))

# Process experiments with conflict detection similar to original
Expand Down
97 changes: 97 additions & 0 deletions align_browser/static/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Notification system for temporary user messages
// Provides toast-style notifications that auto-dismiss

let notificationContainer = null;
let notificationQueue = [];
let isProcessingQueue = false;

// Initialize notification container
function initNotificationContainer() {
if (!notificationContainer) {
notificationContainer = document.createElement('div');
notificationContainer.id = 'notification-container';
notificationContainer.className = 'notification-container';
document.body.appendChild(notificationContainer);
}
}

// Create notification element
function createNotificationElement(message, type = 'warning') {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
return notification;
}

// Show notification with animation
function displayNotification(notification, duration = 4000) {
initNotificationContainer();

// Add to container
notificationContainer.appendChild(notification);

// Trigger slide-in animation
requestAnimationFrame(() => {
notification.classList.add('notification-show');
});

// Auto-dismiss after duration
setTimeout(() => {
dismissNotification(notification);
}, duration);
}

// Dismiss notification with animation
function dismissNotification(notification) {
if (notification && notification.parentNode) {
notification.classList.remove('notification-show');
notification.classList.add('notification-hide');

// Remove from DOM after animation
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
processNotificationQueue();
}, 300);
}
}

// Process queued notifications
function processNotificationQueue() {
if (notificationQueue.length > 0 && !isProcessingQueue) {
isProcessingQueue = true;
const { message, type, duration } = notificationQueue.shift();
const notification = createNotificationElement(message, type);
displayNotification(notification, duration);

// Reset processing flag after notification is shown
setTimeout(() => {
isProcessingQueue = false;
}, 500);
}
}

// Main function to show notifications
export function showNotification(message, type = 'warning', duration = 4000) {
// Add to queue to prevent overlapping
notificationQueue.push({ message, type, duration });

// Process immediately if not already processing
if (!isProcessingQueue) {
processNotificationQueue();
}
}

// Convenience functions for different notification types
export function showWarning(message, duration = 4000) {
showNotification(message, 'warning', duration);
}

export function showInfo(message, duration = 3000) {
showNotification(message, 'info', duration);
}

export function showError(message, duration = 5000) {
showNotification(message, 'error', duration);
}
4 changes: 3 additions & 1 deletion align_browser/static/state.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Functional State Management Module
// Pure functions for managing application state without mutations

import { showWarning } from './notifications.js';

// Constants for KDMA processing
const KDMA_CONSTANTS = {
DECIMAL_PRECISION: 10, // For 1 decimal place normalization
Expand Down Expand Up @@ -200,7 +202,7 @@ export function decodeStateFromURL() {
const currentManifest = GlobalState.getManifest();
if (currentManifest && decodedState.manifestCreatedAt &&
decodedState.manifestCreatedAt !== currentManifest.generated_at) {
console.warn('URL parameters are from a different manifest version, ignoring URL state');
showWarning('URL parameters are from an older version and have been reset');
return null;
}

Expand Down
Loading
Loading