Init
This commit is contained in:
parent
9146896837
commit
8c6ed3d642
23
.env
Normal file
23
.env
Normal file
@ -0,0 +1,23 @@
|
||||
# JupyterHub Notebook Viewer Configuration
|
||||
|
||||
# Core Settings
|
||||
JUPYTERHUB_SHARED_DIR=/shared
|
||||
APP_TITLE=JupyterHub Notebook Viewer
|
||||
|
||||
# Flask Settings
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
FLASK_DEBUG=True
|
||||
FLASK_SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# File Handling
|
||||
MAX_FILE_SIZE=16777216
|
||||
NOTEBOOKS_PER_PAGE=50
|
||||
ALLOWED_EXTENSIONS=.ipynb,.py,.md
|
||||
|
||||
# Feature Toggles
|
||||
ENABLE_DOWNLOAD=True
|
||||
ENABLE_API=True
|
||||
|
||||
# UI Settings
|
||||
THEME=dark
|
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
||||
# JupyterHub Notebook Viewer Configuration
|
||||
# Copy this file to .env and modify as needed
|
||||
|
||||
# Core Settings
|
||||
JUPYTERHUB_SHARED_DIR=/shared
|
||||
APP_TITLE=JupyterHub Notebook Viewer
|
||||
|
||||
# Flask Settings
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
FLASK_DEBUG=True
|
||||
FLASK_SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# File Handling
|
||||
MAX_FILE_SIZE=16777216 # 16MB in bytes
|
||||
NOTEBOOKS_PER_PAGE=50
|
||||
ALLOWED_EXTENSIONS=.ipynb,.py,.md
|
||||
|
||||
# Feature Toggles
|
||||
ENABLE_DOWNLOAD=True
|
||||
ENABLE_API=True
|
||||
|
||||
# UI Settings
|
||||
THEME=dark # dark or light
|
0
Dockerfile
Normal file
0
Dockerfile
Normal file
163
Justfile
Normal file
163
Justfile
Normal file
@ -0,0 +1,163 @@
|
||||
# JupyterHub Notebook Viewer - Development Commands
|
||||
|
||||
# Default recipe
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Set up development environment
|
||||
setup:
|
||||
@echo "Setting up development environment..."
|
||||
mkdir -p shared notebooks templates
|
||||
[ ! -f .env ] && cp .env.example .env || true
|
||||
@echo "✓ Development environment ready"
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
python app.py
|
||||
|
||||
# Start development server with auto-reload (using nix shell)
|
||||
dev-nix:
|
||||
nix develop --command dev-server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
python -m pytest tests/ -v || python -c "import app; print('Basic import test passed')"
|
||||
|
||||
# Run tests in nix environment
|
||||
test-nix:
|
||||
nix develop --command run-tests
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
docker build -t jupyterhub-notebook-viewer .
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-up:
|
||||
docker-compose up
|
||||
|
||||
# Start with Docker Compose in background
|
||||
docker-up-daemon:
|
||||
docker-compose up -d
|
||||
|
||||
# Start with Docker Compose and rebuild
|
||||
docker-rebuild:
|
||||
docker-compose up --build
|
||||
|
||||
# Start with nginx proxy
|
||||
docker-proxy:
|
||||
docker-compose --profile with-proxy up
|
||||
|
||||
# Stop Docker services
|
||||
docker-down:
|
||||
docker-compose down
|
||||
|
||||
# View Docker logs
|
||||
docker-logs:
|
||||
docker-compose logs -f
|
||||
|
||||
# Clean Docker resources
|
||||
docker-clean:
|
||||
docker-compose down -v
|
||||
docker system prune -f
|
||||
|
||||
# Install Python dependencies
|
||||
install:
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Format code
|
||||
format:
|
||||
black app.py
|
||||
isort app.py
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
flake8 app.py
|
||||
pylint app.py
|
||||
|
||||
# Generate requirements.txt
|
||||
freeze:
|
||||
pip freeze > requirements.txt
|
||||
|
||||
# Create sample notebook
|
||||
sample-notebook:
|
||||
@mkdir -p shared
|
||||
@cat > shared/sample.ipynb << 'EOF'
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Sample Notebook\n\nThis is a sample Jupyter notebook."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print('Hello World!')\nimport numpy as np\nprint(f'NumPy version: {np.__version__}')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
EOF
|
||||
@echo "✓ Sample notebook created in shared/sample.ipynb"
|
||||
|
||||
# Check environment configuration
|
||||
check-env:
|
||||
@echo "Environment Configuration:"
|
||||
@echo "========================="
|
||||
@grep -E '^[A-Z_]' .env 2>/dev/null || echo "No .env file found"
|
||||
@echo ""
|
||||
@echo "Current Python: $(which python)"
|
||||
@echo "Flask available: $(python -c 'import flask; print(flask.__version__)' 2>/dev/null || echo 'Not installed')"
|
||||
@echo "Shared directory: ${JUPYTERHUB_SHARED_DIR:-./shared}"
|
||||
|
||||
# Show application logs
|
||||
logs:
|
||||
tail -f app.log 2>/dev/null || echo "No log file found"
|
||||
|
||||
# Clean temporary files
|
||||
clean:
|
||||
rm -rf __pycache__/
|
||||
rm -rf .pytest_cache/
|
||||
rm -rf *.pyc
|
||||
rm -rf .coverage
|
||||
rm -rf htmlcov/
|
||||
rm -rf dist/
|
||||
rm -rf build/
|
||||
rm -rf *.egg-info/
|
||||
|
||||
# Security scan
|
||||
security:
|
||||
safety check -r requirements.txt || echo "Safety not installed, skipping security scan"
|
||||
bandit -r . -f json || echo "Bandit not installed, skipping security scan"
|
||||
|
||||
# Performance test
|
||||
perf:
|
||||
@echo "Running performance test..."
|
||||
ab -n 100 -c 10 http://localhost:5000/ || echo "Apache Bench not available"
|
||||
|
||||
# Health check
|
||||
health:
|
||||
curl -f http://localhost:5000/ || echo "Application not responding"
|
||||
|
||||
# Generate documentation
|
||||
docs:
|
||||
@echo "Generating documentation..."
|
||||
mkdir -p docs
|
||||
@echo "# JupyterHub Notebook Viewer\n\nGenerated documentation\n" > docs/README.md
|
||||
@echo "✓ Documentation generated in docs/"
|
236
README.md
236
README.md
@ -0,0 +1,236 @@
|
||||
JupyterHub Notebook Viewer
|
||||
|
||||
A Flask-based web application for viewing and browsing Jupyter notebooks from a JupyterHub shared directory. Features a responsive web interface with directory navigation, notebook preview, and download capabilities.
|
||||
Features
|
||||
|
||||
📁 Directory Navigation - Browse through nested directories with breadcrumb navigation
|
||||
📄 Notebook Viewing - Convert and display Jupyter notebooks as HTML in the browser
|
||||
⬇️ Download Support - Direct download of notebook files
|
||||
🎨 Responsive Design - Bootstrap-based UI that works on desktop and mobile
|
||||
🔒 Security - Path traversal protection and configurable access controls
|
||||
🛠️ Configurable - All settings configurable via environment variables
|
||||
🐳 Docker Support - Ready-to-use Docker containers and compose files
|
||||
❄️ Nix Development - Complete Nix flake for reproducible development
|
||||
|
||||
Quick Start
|
||||
Using Nix (Recommended)
|
||||
|
||||
bash
|
||||
|
||||
# Enter development environment
|
||||
nix develop
|
||||
|
||||
# Set up the environment (first time only)
|
||||
setup-dev
|
||||
|
||||
# Start development server
|
||||
dev-server
|
||||
|
||||
Using Docker
|
||||
|
||||
bash
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose up
|
||||
|
||||
Manual Installation
|
||||
|
||||
bash
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Start the application
|
||||
python app.py
|
||||
|
||||
Configuration
|
||||
|
||||
All configuration is done via environment variables, typically in a .env file:
|
||||
Core Settings
|
||||
|
||||
JUPYTERHUB_SHARED_DIR - Path to the shared notebook directory (default: /shared)
|
||||
APP_TITLE - Application title shown in the interface
|
||||
|
||||
Flask Settings
|
||||
|
||||
FLASK_HOST - Host to bind to (default: 0.0.0.0)
|
||||
FLASK_PORT - Port to run on (default: 5000)
|
||||
FLASK_DEBUG - Enable debug mode (default: True)
|
||||
FLASK_SECRET_KEY - Secret key for sessions (change in production!)
|
||||
|
||||
File Handling
|
||||
|
||||
MAX_FILE_SIZE - Maximum file size in bytes (default: 16777216 = 16MB)
|
||||
NOTEBOOKS_PER_PAGE - Maximum notebooks to show per directory (default: 50)
|
||||
ALLOWED_EXTENSIONS - Comma-separated list of allowed file extensions (default: .ipynb,.py,.md)
|
||||
|
||||
Feature Toggles
|
||||
|
||||
ENABLE_DOWNLOAD - Enable file downloads (default: True)
|
||||
ENABLE_API - Enable JSON API endpoints (default: True)
|
||||
|
||||
UI Settings
|
||||
|
||||
THEME - UI theme, dark or light (default: dark)
|
||||
|
||||
Development
|
||||
With Nix
|
||||
|
||||
The project includes a complete Nix flake for reproducible development:
|
||||
|
||||
bash
|
||||
|
||||
# Enter development shell
|
||||
nix develop
|
||||
|
||||
# Available commands in the shell:
|
||||
setup-dev # Set up development environment
|
||||
dev-server # Start development server with auto-reload
|
||||
run-tests # Run basic tests
|
||||
|
||||
With Just
|
||||
|
||||
If you have just installed:
|
||||
|
||||
bash
|
||||
|
||||
# See all available commands
|
||||
just
|
||||
|
||||
# Common commands
|
||||
just setup # Set up development environment
|
||||
just dev # Start development server
|
||||
just docker-up # Start with Docker
|
||||
just test # Run tests
|
||||
just clean # Clean temporary files
|
||||
|
||||
Manual Development
|
||||
|
||||
bash
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
|
||||
# Create sample content
|
||||
mkdir -p shared
|
||||
# Add some .ipynb files to shared/
|
||||
|
||||
# Start development server
|
||||
python app.py
|
||||
|
||||
Docker Deployment
|
||||
Basic Deployment
|
||||
|
||||
bash
|
||||
|
||||
# Build and start
|
||||
docker-compose up --build
|
||||
|
||||
# Run in background
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
With Reverse Proxy
|
||||
|
||||
The compose file includes an optional nginx reverse proxy:
|
||||
|
||||
bash
|
||||
|
||||
# Start with proxy
|
||||
docker-compose --profile with-proxy up
|
||||
|
||||
Production Considerations
|
||||
|
||||
For production deployment:
|
||||
|
||||
Change the secret key: Set FLASK_SECRET_KEY to a secure random value
|
||||
Disable debug mode: Set FLASK_DEBUG=False
|
||||
Configure volumes: Mount your actual shared directory
|
||||
Set up SSL: Configure HTTPS in your reverse proxy
|
||||
Resource limits: Set appropriate CPU and memory limits in Docker
|
||||
|
||||
API Endpoints
|
||||
|
||||
When ENABLE_API=True, the following JSON API endpoints are available:
|
||||
|
||||
GET /api/notebooks?dir=<path> - List notebooks and directories
|
||||
GET / - Main web interface
|
||||
GET /view/<path> - View notebook as HTML
|
||||
GET /download/<path> - Download notebook file (if enabled)
|
||||
|
||||
Security Features
|
||||
|
||||
Path Traversal Protection - Prevents access outside the configured directory
|
||||
File Type Validation - Only allows configured file extensions
|
||||
Configurable Downloads - Downloads can be disabled entirely
|
||||
Non-root Docker User - Container runs as non-privileged user
|
||||
|
||||
Troubleshooting
|
||||
Common Issues
|
||||
|
||||
"No notebooks found"
|
||||
Check that JUPYTERHUB_SHARED_DIR points to the correct directory
|
||||
Ensure the directory contains .ipynb files
|
||||
Check file permissions
|
||||
Notebook won't display
|
||||
Verify the notebook file is valid JSON
|
||||
Check that nbconvert dependencies are installed
|
||||
Look for errors in the application logs
|
||||
Permission denied
|
||||
Ensure the application has read access to the shared directory
|
||||
Check Docker volume mounts
|
||||
|
||||
Logs
|
||||
|
||||
Application logs are printed to stdout. In Docker:
|
||||
|
||||
bash
|
||||
|
||||
docker-compose logs -f notebook-viewer
|
||||
|
||||
Contributing
|
||||
|
||||
Fork the repository
|
||||
Enter the development environment: nix develop
|
||||
Make your changes
|
||||
Run tests: run-tests
|
||||
Submit a pull request
|
||||
|
||||
License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
Architecture
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ HTTP
|
||||
┌─────────────────────▼───────────────────────────────────────┐
|
||||
│ Flask App │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Routes │ │ Templates │ │ Static Assets │ │
|
||||
│ └─────────────┘ └──────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ File System
|
||||
┌─────────────────────▼───────────────────────────────────────┐
|
||||
│ JupyterHub Shared Directory │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Notebooks │ │ Directories │ │ Other Files │ │
|
||||
│ │ (.ipynb) │ │ │ │ (.py, .md) │ │
|
||||
│ └─────────────┘ └──────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
440
app.py
Normal file
440
app.py
Normal file
@ -0,0 +1,440 @@
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template, request, jsonify, send_file, abort
|
||||
import nbformat
|
||||
from nbconvert import HTMLExporter
|
||||
import mimetypes
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configuration from environment variables
|
||||
app.config.update({
|
||||
'SHARED_DIRECTORY': os.environ.get('JUPYTERHUB_SHARED_DIR', '/shared'),
|
||||
'HOST': os.environ.get('FLASK_HOST', '0.0.0.0'),
|
||||
'PORT': int(os.environ.get('FLASK_PORT', 5000)),
|
||||
'DEBUG': os.environ.get('FLASK_DEBUG', 'True').lower() == 'true',
|
||||
'SECRET_KEY': os.environ.get('FLASK_SECRET_KEY', 'dev-key-change-in-production'),
|
||||
'MAX_CONTENT_LENGTH': int(os.environ.get('MAX_FILE_SIZE', 16 * 1024 * 1024)), # 16MB default
|
||||
'NOTEBOOKS_PER_PAGE': int(os.environ.get('NOTEBOOKS_PER_PAGE', 50)),
|
||||
'ALLOWED_EXTENSIONS': os.environ.get('ALLOWED_EXTENSIONS', '.ipynb').split(','),
|
||||
'ENABLE_DOWNLOAD': os.environ.get('ENABLE_DOWNLOAD', 'True').lower() == 'true',
|
||||
'ENABLE_API': os.environ.get('ENABLE_API', 'True').lower() == 'true',
|
||||
'APP_TITLE': os.environ.get('APP_TITLE', 'JupyterHub Notebook Viewer'),
|
||||
'THEME': os.environ.get('THEME', 'dark'), # dark or light
|
||||
})
|
||||
|
||||
def get_notebook_files(directory):
|
||||
"""Recursively get all notebook files from directory"""
|
||||
notebooks = []
|
||||
allowed_extensions = app.config['ALLOWED_EXTENSIONS']
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if any(file.endswith(ext) for ext in allowed_extensions):
|
||||
full_path = os.path.join(root, file)
|
||||
relative_path = os.path.relpath(full_path, directory)
|
||||
notebooks.append({
|
||||
'name': file,
|
||||
'path': relative_path,
|
||||
'full_path': full_path,
|
||||
'size': os.path.getsize(full_path),
|
||||
'modified': os.path.getmtime(full_path)
|
||||
})
|
||||
except (OSError, PermissionError) as e:
|
||||
app.logger.error(f"Error accessing directory {directory}: {e}")
|
||||
|
||||
# Limit results based on configuration
|
||||
notebooks = sorted(notebooks, key=lambda x: x['modified'], reverse=True)
|
||||
return notebooks[:app.config['NOTEBOOKS_PER_PAGE']]
|
||||
|
||||
def get_directory_structure(directory):
|
||||
"""Get directory structure for navigation"""
|
||||
structure = []
|
||||
try:
|
||||
for item in os.listdir(directory):
|
||||
item_path = os.path.join(directory, item)
|
||||
if os.path.isdir(item_path):
|
||||
structure.append({
|
||||
'name': item,
|
||||
'type': 'directory',
|
||||
'path': os.path.relpath(item_path, app.config['SHARED_DIRECTORY'])
|
||||
})
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
return sorted(structure, key=lambda x: x['name'])
|
||||
|
||||
def convert_notebook_to_html(notebook_path):
|
||||
"""Convert Jupyter notebook to HTML"""
|
||||
try:
|
||||
with open(notebook_path, 'r', encoding='utf-8') as f:
|
||||
notebook = nbformat.read(f, as_version=4)
|
||||
|
||||
html_exporter = HTMLExporter()
|
||||
html_exporter.template_name = 'classic'
|
||||
|
||||
(body, resources) = html_exporter.from_notebook_node(notebook)
|
||||
return body
|
||||
except Exception as e:
|
||||
return f"<div class='alert alert-danger'>Error converting notebook: {str(e)}</div>"
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main page showing all notebooks"""
|
||||
current_dir = request.args.get('dir', '')
|
||||
full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir)
|
||||
|
||||
if not os.path.exists(full_current_dir):
|
||||
abort(404)
|
||||
|
||||
notebooks = get_notebook_files(full_current_dir)
|
||||
directories = get_directory_structure(full_current_dir)
|
||||
|
||||
# Breadcrumb navigation
|
||||
breadcrumbs = []
|
||||
if current_dir:
|
||||
parts = current_dir.split(os.sep)
|
||||
for i, part in enumerate(parts):
|
||||
breadcrumbs.append({
|
||||
'name': part,
|
||||
'path': os.sep.join(parts[:i+1])
|
||||
})
|
||||
|
||||
return render_template('index.html',
|
||||
notebooks=notebooks,
|
||||
directories=directories,
|
||||
current_dir=current_dir,
|
||||
breadcrumbs=breadcrumbs,
|
||||
config=app.config)
|
||||
|
||||
@app.route('/view/<path:notebook_path>')
|
||||
def view_notebook(notebook_path):
|
||||
"""View a specific notebook"""
|
||||
full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
abort(404)
|
||||
|
||||
# Check if file has allowed extension
|
||||
if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']):
|
||||
abort(403)
|
||||
|
||||
# Security check - ensure path is within shared directory
|
||||
if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']:
|
||||
abort(403)
|
||||
|
||||
html_content = convert_notebook_to_html(full_path)
|
||||
|
||||
return render_template('notebook.html',
|
||||
html_content=html_content,
|
||||
notebook_name=os.path.basename(notebook_path),
|
||||
notebook_path=notebook_path,
|
||||
config=app.config)
|
||||
|
||||
@app.route('/download/<path:notebook_path>')
|
||||
def download_notebook(notebook_path):
|
||||
"""Download a notebook file"""
|
||||
if not app.config['ENABLE_DOWNLOAD']:
|
||||
abort(403, "Downloads are disabled")
|
||||
|
||||
full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
abort(404)
|
||||
|
||||
# Check if file has allowed extension
|
||||
if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']):
|
||||
abort(403)
|
||||
|
||||
# Security check
|
||||
if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']:
|
||||
abort(403)
|
||||
|
||||
return send_file(full_path, as_attachment=True)
|
||||
|
||||
@app.route('/api/notebooks')
|
||||
def api_notebooks():
|
||||
"""API endpoint to get notebooks as JSON"""
|
||||
if not app.config['ENABLE_API']:
|
||||
abort(403, "API is disabled")
|
||||
|
||||
current_dir = request.args.get('dir', '')
|
||||
full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir)
|
||||
|
||||
notebooks = get_notebook_files(full_current_dir)
|
||||
directories = get_directory_structure(full_current_dir)
|
||||
|
||||
return jsonify({
|
||||
'notebooks': notebooks,
|
||||
'directories': directories,
|
||||
'current_dir': current_dir,
|
||||
'config': {
|
||||
'app_title': app.config['APP_TITLE'],
|
||||
'enable_download': app.config['ENABLE_DOWNLOAD'],
|
||||
'notebooks_per_page': app.config['NOTEBOOKS_PER_PAGE']
|
||||
}
|
||||
})
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template('error.html',
|
||||
error_code=404,
|
||||
error_message="Notebook or directory not found"), 404
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="Access forbidden"), 403
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(error):
|
||||
return render_template('error.html',
|
||||
error_code=500,
|
||||
error_message="Internal server error"), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Create templates directory if it doesn't exist
|
||||
os.makedirs('templates', exist_ok=True)
|
||||
|
||||
# Determine theme classes
|
||||
navbar_class = "navbar-dark bg-dark" if app.config['THEME'] == 'dark' else "navbar-light bg-light"
|
||||
|
||||
# Basic templates
|
||||
index_template = f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{{{ config.APP_TITLE }}}}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar {navbar_class}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
|
||||
<i class="fas fa-book"></i> {{{{ config.APP_TITLE }}}}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
{{% if breadcrumbs %}}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{{{ url_for('index') }}}}">Home</a></li>
|
||||
{{% for crumb in breadcrumbs %}}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{{{ url_for('index', dir=crumb.path) }}}}">{{{{ crumb.name }}}}</a>
|
||||
</li>
|
||||
{{% endfor %}}
|
||||
</ol>
|
||||
</nav>
|
||||
{{% endif %}}
|
||||
|
||||
<h1 class="mb-4">
|
||||
{{% if current_dir %}}
|
||||
Notebooks in {{{{ current_dir }}}}
|
||||
{{% else %}}
|
||||
All Notebooks
|
||||
{{% endif %}}
|
||||
</h1>
|
||||
|
||||
<!-- Directories -->
|
||||
{{% if directories %}}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h3><i class="fas fa-folder"></i> Directories</h3>
|
||||
{{% for dir in directories %}}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{{{{ url_for('index', dir=dir.path) }}}}" class="text-decoration-none">
|
||||
<i class="fas fa-folder text-warning"></i> {{{{ dir.name }}}}
|
||||
</a>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
{{% endfor %}}
|
||||
</div>
|
||||
</div>
|
||||
{{% endif %}}
|
||||
|
||||
<!-- Notebooks -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3><i class="fas fa-file-code"></i> Notebooks</h3>
|
||||
{{% if notebooks %}}
|
||||
{{% for notebook in notebooks %}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-file-code text-primary"></i> {{{{ notebook.name }}}}
|
||||
</h5>
|
||||
<p class="card-text text-muted">
|
||||
<small>
|
||||
Size: {{{{ "%.1f"|format(notebook.size/1024) }}}} KB |
|
||||
Modified: {{{{ notebook.modified|int|datetime }}}}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{{{ url_for('view_notebook', notebook_path=notebook.path) }}}}"
|
||||
class="btn btn-primary btn-sm me-2">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
{{% if config.ENABLE_DOWNLOAD %}}
|
||||
<a href="{{{{ url_for('download_notebook', notebook_path=notebook.path) }}}}"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
{{% endif %}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{% endfor %}}
|
||||
{{% if notebooks|length == config.NOTEBOOKS_PER_PAGE %}}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Showing first {{{{ config.NOTEBOOKS_PER_PAGE }}}} notebooks. Configure NOTEBOOKS_PER_PAGE to show more.
|
||||
</div>
|
||||
{{% endif %}}
|
||||
{{% else %}}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> No notebooks found in this directory.
|
||||
</div>
|
||||
{{% endif %}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
notebook_template = f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{{{ notebook_name }}}} - {{{{ config.APP_TITLE }}}}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.notebook-content {{
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}}
|
||||
.notebook-content img {{
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar {navbar_class}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
|
||||
<i class="fas fa-book"></i> {{{{ config.APP_TITLE }}}}
|
||||
</a>
|
||||
<div>
|
||||
{{% if config.ENABLE_DOWNLOAD %}}
|
||||
<a href="{{{{ url_for('download_notebook', notebook_path=notebook_path) }}}}"
|
||||
class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
{{% endif %}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">
|
||||
<i class="fas fa-file-code text-primary"></i> {{{{ notebook_name }}}}
|
||||
</h2>
|
||||
<div class="notebook-content">
|
||||
{{{{ html_content|safe }}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
error_template = f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error {{{{ error_code }}}} - {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar {navbar_class}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
|
||||
<i class="fas fa-book"></i> {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<div class="alert alert-danger">
|
||||
<h1><i class="fas fa-exclamation-triangle"></i></h1>
|
||||
<h2>Error {{{{ error_code }}}}</h2>
|
||||
<p>{{{{ error_message }}}}</p>
|
||||
<a href="{{{{ url_for('index') }}}}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
# Write templates
|
||||
with open('templates/index.html', 'w') as f:
|
||||
f.write(index_template)
|
||||
|
||||
with open('templates/notebook.html', 'w') as f:
|
||||
f.write(notebook_template)
|
||||
|
||||
with open('templates/error.html', 'w') as f:
|
||||
f.write(error_template)
|
||||
|
||||
# Add datetime filter
|
||||
@app.template_filter('datetime')
|
||||
def datetime_filter(timestamp):
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
print(f"Starting {app.config['APP_TITLE']}")
|
||||
print(f"Shared directory: {app.config['SHARED_DIRECTORY']}")
|
||||
print(f"Server running on http://{app.config['HOST']}:{app.config['PORT']}")
|
||||
print(f"Debug mode: {app.config['DEBUG']}")
|
||||
print(f"Downloads enabled: {app.config['ENABLE_DOWNLOAD']}")
|
||||
print(f"API enabled: {app.config['ENABLE_API']}")
|
||||
|
||||
app.run(
|
||||
debug=app.config['DEBUG'],
|
||||
host=app.config['HOST'],
|
||||
port=app.config['PORT']
|
||||
)
|
36
compose.yml
Normal file
36
compose.yml
Normal file
@ -0,0 +1,36 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
notebook-viewer:
|
||||
build: .
|
||||
ports:
|
||||
- "${FLASK_PORT:-5000}:5000"
|
||||
volumes:
|
||||
- "${JUPYTERHUB_SHARED_DIR:-./shared}:/shared:ro"
|
||||
- "./notebooks:/notebooks:ro" # Additional notebooks directory
|
||||
environment:
|
||||
- JUPYTERHUB_SHARED_DIR=/shared
|
||||
- FLASK_HOST=0.0.0.0
|
||||
- FLASK_PORT=5000
|
||||
- FLASK_DEBUG=${FLASK_DEBUG:-False}
|
||||
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-change-me-in-production}
|
||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-16777216}
|
||||
- NOTEBOOKS_PER_PAGE=${NOTEBOOKS_PER_PAGE:-50}
|
||||
- ALLOWED_EXTENSIONS=${ALLOWED_EXTENSIONS:-.ipynb,.py,.md}
|
||||
- ENABLE_DOWNLOAD=${ENABLE_DOWNLOAD:-True}
|
||||
- ENABLE_API=${ENABLE_API:-True}
|
||||
- APP_TITLE=${APP_TITLE:-JupyterHub Notebook Viewer}
|
||||
- THEME=${THEME:-dark}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
shared:
|
||||
driver: local
|
313
flake.nix
Normal file
313
flake.nix
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
description = "JupyterHub Notebook Viewer Development Environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Python with required packages
|
||||
python = pkgs.python311.withPackages (ps: with ps; [
|
||||
flask
|
||||
python-dotenv
|
||||
nbformat
|
||||
nbconvert
|
||||
werkzeug
|
||||
jinja2
|
||||
markupsafe
|
||||
itsdangerous
|
||||
click
|
||||
blinker
|
||||
jupyter-core
|
||||
traitlets
|
||||
jupyter-client
|
||||
ipython
|
||||
bleach
|
||||
defusedxml
|
||||
mistune
|
||||
pandocfilters
|
||||
beautifulsoup4
|
||||
webencodings
|
||||
fastjsonschema
|
||||
jsonschema
|
||||
pyrsistent
|
||||
pyzmq
|
||||
tornado
|
||||
nest-asyncio
|
||||
psutil
|
||||
packaging
|
||||
platformdirs
|
||||
debugpy
|
||||
matplotlib-inline
|
||||
pygments
|
||||
cffi
|
||||
argon2-cffi
|
||||
jupyterlab-pygments
|
||||
nbclient
|
||||
tinycss2
|
||||
]);
|
||||
|
||||
# Development tools
|
||||
devTools = with pkgs; [
|
||||
# Core development
|
||||
python
|
||||
nodejs_20
|
||||
|
||||
# System tools
|
||||
curl
|
||||
jq
|
||||
tree
|
||||
htop
|
||||
|
||||
# Container tools
|
||||
docker
|
||||
docker-compose
|
||||
|
||||
# Documentation tools
|
||||
pandoc
|
||||
|
||||
# LaTeX for nbconvert
|
||||
texlive.combined.scheme-medium
|
||||
|
||||
# Version control
|
||||
git
|
||||
|
||||
# Text editors
|
||||
vim
|
||||
nano
|
||||
|
||||
# Network tools
|
||||
netcat
|
||||
|
||||
# Process management
|
||||
supervisor
|
||||
];
|
||||
|
||||
# Shell script for easy setup
|
||||
setupScript = pkgs.writeShellScriptBin "setup-dev" ''
|
||||
echo "Setting up JupyterHub Notebook Viewer development environment..."
|
||||
|
||||
# Create directories
|
||||
mkdir -p shared notebooks templates
|
||||
|
||||
# Copy example .env if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env 2>/dev/null || echo "No .env.example found, using defaults"
|
||||
fi
|
||||
|
||||
# Set up git hooks if in git repo
|
||||
if [ -d .git ]; then
|
||||
echo "Setting up git hooks..."
|
||||
cp .githooks/* .git/hooks/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create sample notebook if shared directory is empty
|
||||
if [ ! "$(ls -A shared)" ]; then
|
||||
echo "Creating sample notebook..."
|
||||
cat > shared/sample.ipynb << 'EOF'
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Sample Notebook\n",
|
||||
"\n",
|
||||
"This is a sample Jupyter notebook for testing the viewer."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print('Hello from JupyterHub Notebook Viewer!')\n",
|
||||
"import numpy as np\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"x = np.linspace(0, 10, 100)\n",
|
||||
"y = np.sin(x)\n",
|
||||
"\n",
|
||||
"plt.figure(figsize=(10, 6))\n",
|
||||
"plt.plot(x, y)\n",
|
||||
"plt.title('Sample Plot')\n",
|
||||
"plt.xlabel('X')\n",
|
||||
"plt.ylabel('sin(x)')\n",
|
||||
"plt.grid(True)\n",
|
||||
"plt.show()"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Development environment setup complete!"
|
||||
echo "Run 'python app.py' to start the development server"
|
||||
'';
|
||||
|
||||
# Script for running tests
|
||||
testScript = pkgs.writeShellScriptBin "run-tests" ''
|
||||
echo "Running tests for JupyterHub Notebook Viewer..."
|
||||
|
||||
# Basic import test
|
||||
python -c "
|
||||
import app
|
||||
print('✓ App imports successfully')
|
||||
|
||||
# Test configuration loading
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
print('✓ Environment variables loaded')
|
||||
|
||||
# Test nbconvert
|
||||
import nbformat
|
||||
import nbconvert
|
||||
print('✓ Jupyter dependencies working')
|
||||
"
|
||||
|
||||
# Test Flask app creation
|
||||
python -c "
|
||||
import app
|
||||
test_app = app.app.test_client()
|
||||
response = test_app.get('/')
|
||||
if response.status_code in [200, 404]: # 404 is OK if no shared dir
|
||||
print('✓ Flask app responds correctly')
|
||||
else:
|
||||
print('✗ Flask app error:', response.status_code)
|
||||
exit(1)
|
||||
"
|
||||
|
||||
echo "All tests passed!"
|
||||
'';
|
||||
|
||||
# Development server with auto-reload
|
||||
devServerScript = pkgs.writeShellScriptBin "dev-server" ''
|
||||
echo "Starting development server with auto-reload..."
|
||||
export FLASK_DEBUG=True
|
||||
export FLASK_ENV=development
|
||||
|
||||
# Load .env file
|
||||
if [ -f .env ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
# Start the server
|
||||
python app.py
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
# Development shell
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = devTools ++ [
|
||||
setupScript
|
||||
testScript
|
||||
devServerScript
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 JupyterHub Notebook Viewer Development Environment"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo " setup-dev - Set up development environment"
|
||||
echo " dev-server - Start development server with auto-reload"
|
||||
echo " run-tests - Run basic tests"
|
||||
echo " python app.py - Start the application"
|
||||
echo ""
|
||||
echo "Docker commands:"
|
||||
echo " docker-compose up - Start with Docker"
|
||||
echo " docker-compose up --build - Rebuild and start"
|
||||
echo " docker-compose up -d - Start in background"
|
||||
echo " docker-compose --profile with-proxy up - Start with nginx proxy"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Edit .env file to configure the application"
|
||||
echo " JUPYTERHUB_SHARED_DIR: $(echo ''${JUPYTERHUB_SHARED_DIR:-/shared})"
|
||||
echo ""
|
||||
|
||||
# Auto-setup on first run
|
||||
if [ ! -f .env ] && [ ! -d shared ]; then
|
||||
echo "First run detected, running setup..."
|
||||
setup-dev
|
||||
fi
|
||||
'';
|
||||
|
||||
# Environment variables for development
|
||||
FLASK_DEBUG = "True";
|
||||
FLASK_ENV = "development";
|
||||
PYTHONPATH = ".";
|
||||
};
|
||||
|
||||
# Package for the application
|
||||
packages.default = pkgs.stdenv.mkDerivation {
|
||||
pname = "jupyterhub-notebook-viewer";
|
||||
version = "1.0.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
buildInputs = [ python ];
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/share/jupyterhub-notebook-viewer
|
||||
|
||||
# Copy application files
|
||||
cp app.py $out/share/jupyterhub-notebook-viewer/
|
||||
cp -r templates $out/share/jupyterhub-notebook-viewer/
|
||||
|
||||
# Create wrapper script
|
||||
cat > $out/bin/jupyterhub-notebook-viewer << EOF
|
||||
#!${pkgs.bash}/bin/bash
|
||||
cd $out/share/jupyterhub-notebook-viewer
|
||||
exec ${python}/bin/python app.py "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/jupyterhub-notebook-viewer
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Web viewer for JupyterHub shared notebooks";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
# Docker image build
|
||||
packages.docker = pkgs.dockerTools.buildImage {
|
||||
name = "jupyterhub-notebook-viewer";
|
||||
tag = "latest";
|
||||
|
||||
contents = [ python pkgs.pandoc ];
|
||||
|
||||
config = {
|
||||
Cmd = [ "${python}/bin/python" "/app/app.py" ];
|
||||
WorkingDir = "/app";
|
||||
ExposedPorts = {
|
||||
"5000/tcp" = {};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Formatter for nix files
|
||||
formatter = pkgs.nixpkgs-fmt;
|
||||
});
|
||||
}
|
0
requirements.txt
Normal file
0
requirements.txt
Normal file
Loading…
Reference in New Issue
Block a user