Skip to content

Commit 7fc56e7

Browse files
authored
Merge pull request ArgLab#279 from ArgLab/berickson/20260312-small-fixes
Merging in the dashboard fixes to bump overall system and roll in Portfolio Dashboard.
2 parents 956719b + 1ae4755 commit 7fc56e7

23 files changed

Lines changed: 465 additions & 73 deletions

File tree

‎.gitignore‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,3 @@ learning_observer/learning_observer/static_data/admins.yaml
4040
.ipynb_checkpoints/
4141
.eggs/
4242
.next/
43-
modules/wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/*

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ install: install-pre-commit-hook
4646
# recent version. We would like to directly fetch `lodrc-current`,
4747
# however, the fetch only returns the name of the file it's
4848
# linked to. We do an additional fetch for the linked file.
49-
@LODRC_CURRENT=$$(curl -s https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/lo_dash_react_components-current.tar.gz); \
50-
pip install https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/$${LODRC_CURRENT}
49+
@LODRC_CURRENT=$$(curl -s https://raw.githubusercontent.com/ArgLab/lo_assets/main/lo_dash_react_components/lo_dash_react_components-current.tar.gz); \
50+
pip install https://raw.githubusercontent.com/ArgLab/lo_assets/main/lo_dash_react_components/$${LODRC_CURRENT}
5151

5252
install-dev:
5353
# TODO create a dev requirements file

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2026.03.13T15.40.37.391Z.377c68ef.berickson.20260313.local.loremipsum
1+
0.1.0+2026.03.16T14.11.00.133Z.ed3b6ac0.berickson.20260312.small.fixes

‎learning_observer/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2026.03.13T15.40.37.391Z.377c68ef.berickson.20260313.local.loremipsum
1+
0.1.0+2026.03.16T14.11.00.133Z.ed3b6ac0.berickson.20260312.small.fixes
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
learning_observer.remote_assets
3+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4+
5+
Utilities for downloading and installing prebuilt frontend asset bundles
6+
(e.g. exported Next.js apps) that are hosted in a GitHub repository.
7+
8+
Intended use: LO module ``__init__.py`` files call :func:`fetch_module_assets`
9+
from their startup checks to ensure their bundled frontend is present.
10+
11+
Typical repository layout assumed by the pointer-file convention::
12+
13+
lo_assets/
14+
my_module/
15+
my_module-current.tar.gz ← plain-text file containing the tarball name
16+
my_module-v1.2.3.tar.gz ← the actual tarball
17+
"""
18+
19+
import os
20+
import shutil
21+
import sys
22+
import tarfile
23+
import tempfile
24+
import urllib.request
25+
26+
# ---------------------------------------------------------------------------
27+
# Default repository coordinates
28+
# ---------------------------------------------------------------------------
29+
30+
_LO_ASSETS_REPO = 'ArgLab/lo_assets'
31+
_LO_ASSETS_REF = 'main'
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# Interactive / non-interactive prompts
36+
# ---------------------------------------------------------------------------
37+
38+
def confirm(prompt, default_noninteractive):
39+
"""
40+
Ask a yes/no question and return a boolean.
41+
42+
Resolution order:
43+
1. *default_noninteractive* when stdin is not a TTY.
44+
2. An interactive prompt otherwise.
45+
46+
Example usage in a module startup check::
47+
48+
should_fetch = confirm(
49+
prompt='Download missing assets? (y/n) ',
50+
default_noninteractive=False,
51+
)
52+
"""
53+
if not sys.stdin.isatty():
54+
return default_noninteractive
55+
56+
return input(prompt).strip().lower() in {'y', 'yes'}
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Low-level network helpers
61+
# ---------------------------------------------------------------------------
62+
63+
def read_url(url):
64+
"""
65+
Fetch *url* and return its contents as a stripped string.
66+
"""
67+
with urllib.request.urlopen(url) as response:
68+
return response.read().decode('utf-8').strip()
69+
70+
71+
def download_file(url, destination):
72+
"""
73+
Stream *url* to *destination*, avoiding loading the full response into
74+
memory.
75+
"""
76+
with urllib.request.urlopen(url) as response, open(destination, 'wb') as output_file:
77+
shutil.copyfileobj(response, output_file)
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# Archive helpers
82+
# ---------------------------------------------------------------------------
83+
84+
def find_nextjs_root(extracted_dir):
85+
"""
86+
Walk *extracted_dir* and return the shallowest path that looks like an
87+
exported Next.js app (contains both ``index.html`` and a ``_next/``
88+
directory).
89+
90+
Raises ``ValueError`` if no such directory is found.
91+
"""
92+
candidates = []
93+
for root, _, files in os.walk(extracted_dir):
94+
if 'index.html' in files and os.path.isdir(os.path.join(root, '_next')):
95+
candidates.append(root)
96+
97+
if not candidates:
98+
raise ValueError(
99+
"Could not find an exported Next.js app inside the archive "
100+
"(expected index.html and _next/ in the same directory)."
101+
)
102+
103+
return min(candidates, key=len)
104+
105+
106+
def extract_assets_tarball(tar_path, target_dir):
107+
"""
108+
Extract the Next.js app bundle at *tar_path* into *target_dir*.
109+
110+
The archive may contain arbitrary wrapper directories; only the shallowest
111+
directory that looks like an exported Next.js app is copied to
112+
*target_dir*. Any pre-existing *target_dir* is removed first.
113+
114+
Raises ``ValueError`` (from :func:`find_nextjs_root`) if the archive does
115+
not contain a recognisable Next.js export.
116+
"""
117+
with tempfile.TemporaryDirectory() as temp_extract_dir:
118+
with tarfile.open(tar_path, mode='r:gz') as archive:
119+
archive.extractall(temp_extract_dir)
120+
121+
source_root = find_nextjs_root(temp_extract_dir)
122+
123+
if os.path.isdir(target_dir):
124+
shutil.rmtree(target_dir)
125+
shutil.copytree(source_root, target_dir)
126+
127+
128+
# ---------------------------------------------------------------------------
129+
# High-level fetch interface
130+
# ---------------------------------------------------------------------------
131+
132+
def fetch_module_assets(
133+
target_dir,
134+
pointer_file,
135+
assets_url=None,
136+
assets_repo=_LO_ASSETS_REPO,
137+
assets_ref=_LO_ASSETS_REF,
138+
):
139+
"""
140+
Download and install a prebuilt Next.js asset bundle for an LO module.
141+
142+
Parameters
143+
----------
144+
target_dir:
145+
Local directory where the extracted app should be placed.
146+
pointer_file:
147+
Path within *assets_repo* to a plain-text file whose content is the
148+
filename of the current tarball, e.g.
149+
``'wo_portfolio_diff/wo_portfolio_diff-current.tar.gz'``.
150+
The tarball is assumed to live in the same directory as this file.
151+
assets_url:
152+
If supplied, skip pointer-file resolution and download this URL
153+
directly. Useful for pinning a specific release or local testing.
154+
assets_repo:
155+
``owner/repo`` slug on GitHub. Defaults to the canonical LO assets
156+
repository.
157+
assets_ref:
158+
Branch, tag, or commit SHA to resolve *pointer_file* against.
159+
Defaults to ``'main'``.
160+
161+
Raises
162+
------
163+
urllib.error.URLError
164+
Network error while resolving the pointer file or downloading.
165+
ValueError
166+
The downloaded archive does not contain a recognisable Next.js export.
167+
OSError
168+
File-system error while writing or extracting the archive.
169+
tarfile.TarError
170+
The downloaded file is not a valid gzipped tar archive.
171+
"""
172+
if assets_url is None:
173+
pointer_url = (
174+
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
175+
f'/{pointer_file}'
176+
)
177+
resolved_name = read_url(pointer_url)
178+
pointer_dir = pointer_file.rsplit('/', 1)[0]
179+
assets_url = (
180+
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
181+
f'/{pointer_dir}/{resolved_name}'
182+
)
183+
184+
with tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False) as tmp:
185+
archive_path = tmp.name
186+
187+
try:
188+
download_file(assets_url, archive_path)
189+
extract_assets_tarball(archive_path, target_dir)
190+
finally:
191+
os.unlink(archive_path)

‎learning_observer/learning_observer/routes.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,22 @@ async def _nextjs_handler(request):
480480
sub_url = request.match_info.get('tail')
481481
if sub_url is None:
482482
return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
483-
# TODO will this handle multi-layered sub-urls? /foo/bar
484-
return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html'))
483+
# Normalize the subpath and ensure it stays in the exported Next.js folder.
484+
# This allows exported assets like `runtime-config.js` to be served directly.
485+
relative_path = sub_url.lstrip('/')
486+
file_path = os.path.normpath(os.path.join(path, relative_path))
487+
if os.path.commonpath([os.path.abspath(file_path), os.path.abspath(path)]) != os.path.abspath(path):
488+
raise aiohttp.web.HTTPNotFound()
489+
490+
if os.path.isfile(file_path):
491+
return aiohttp.web.FileResponse(file_path)
492+
493+
# Handle extension-less routes exported as html files.
494+
html_path = os.path.normpath(os.path.join(path, f'{relative_path}.html'))
495+
if os.path.commonpath([os.path.abspath(html_path), os.path.abspath(path)]) != os.path.abspath(path):
496+
raise aiohttp.web.HTTPNotFound()
497+
498+
return aiohttp.web.FileResponse(html_path)
485499
return _nextjs_handler
486500

487501

@@ -501,6 +515,11 @@ def register_nextjs_routes(app):
501515
tail_path = page_path + '{tail:.*}'
502516
app.router.add_get(tail_path, create_nextjs_handler(full_path))
503517

518+
# Some exported apps resolve local assets under an `/_next<page_path>` prefix.
519+
# Mirror those requests back to the same exported directory.
520+
next_prefixed_tail_path = f'/_next{page_path}' + '{tail:.*}'
521+
app.router.add_get(next_prefixed_tail_path, create_nextjs_handler(full_path))
522+
504523

505524
def register_wsgi_routes(app):
506525
'''

‎modules/lo_dash_react_components/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ In Learning Observer, we create React components and generate a Python package f
66

77
### Pre-built installation
88

9-
These components take a bit of extra infrastructure to build (mainly `node`). If you just with to use these components, without developing new ones or changing current ones, we suggest installing the pre-built package available in the [LO Assets github repository](https://github.com/ETS-Next-Gen/lo_assets/tree/main/lo_dash_react_components).
9+
These components take a bit of extra infrastructure to build (mainly `node`). If you just with to use these components, without developing new ones or changing current ones, we suggest installing the pre-built package available in the [LO Assets github repository](https://github.com/ArgLab/lo_assets/tree/main/lo_dash_react_components).
1010

1111
### Requirements
1212

‎modules/portfolio_diff/README.md‎

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,46 @@
1-
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
1+
# portfolio_diff frontend
22

3-
## Getting Started
3+
This directory contains the Next.js frontend for portfolio diff visualizations. The app is configured with `output: "export"`, so a production build generates static files in `out/` that can be copied into Python modules or archived for an external assets repository.
44

5-
First, run the development server:
5+
## Prerequisites
6+
7+
- Node.js + npm available locally.
8+
- Install dependencies from this directory:
9+
10+
```bash
11+
npm install
12+
```
13+
14+
## Build the static site
15+
16+
From `modules/portfolio_diff`:
617

718
```bash
8-
npm run dev
9-
# or
10-
yarn dev
11-
# or
12-
pnpm dev
13-
# or
14-
bun dev
19+
rm -rf out/
20+
npm run build
1521
```
1622

17-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
23+
After build completion, the static export is written to `out/`.
1824

19-
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
25+
## Bundle build output into a `.tar.gz`
2026

21-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
27+
If you need an artifact for an assets repository, archive the exported files from `out/`:
2228

23-
## Learn More
29+
```bash
30+
VERSION_TAG=$(date +%Y%m%d-%H%M%S)
31+
tar -C out -czf "portfolio_diff_${VERSION_TAG}.tar.gz" .
32+
```
2433

25-
To learn more about Next.js, take a look at the following resources:
34+
That creates a tarball in `modules/portfolio_diff` (for example `portfolio_diff-20260710-153010.tar.gz`) containing the static site root.
2635

27-
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
36+
You should include copy this to the [`lo_assets` repository](github.com/ArgLab/lo_assets) and update the `portfolio_diff-current.tar.gz` link to point to the new version.
2937

30-
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
38+
## Existing helper script
3139

32-
## Deploy on Vercel
40+
This repo also includes `build_and_add_to_module.sh`, which builds and copies `out/` into `../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/` for local module packaging:
3341

34-
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
42+
```bash
43+
./build_and_add_to_module.sh
44+
```
3545

36-
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
46+
Use that helper when updating the in-repo Python package. Use the tarball workflow above when publishing to a separate assets repository.

‎modules/portfolio_diff/next.config.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/** @type {import('next').NextConfig} */
2+
const basePath = "/_next/wo_portfolio_diff/portfolio_diff";
3+
24
const nextConfig = {
3-
basePath: "/_next/wo_portfolio_diff/portfolio_diff",
5+
basePath,
6+
env: {
7+
NEXT_PUBLIC_BASE_PATH: basePath,
8+
},
49
eslint: {
510
ignoreDuringBuilds: true,
611
},

‎modules/portfolio_diff/src/app/layout.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Navbar from "./components/Navbar";
22
import { CourseIdProvider } from "@/app/providers/CourseIdProvider";
3+
import Script from "next/script";
34
import "./globals.css";
45

56
export const metadata = {
@@ -11,6 +12,12 @@ export default function RootLayout({ children }) {
1112
return (
1213
<CourseIdProvider>
1314
<html lang="en">
15+
<head>
16+
<Script
17+
src={`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/runtime-config.js`}
18+
strategy="beforeInteractive"
19+
/>
20+
</head>
1421
<body className="antialiased">
1522
<Navbar />
1623
{children}

0 commit comments

Comments
 (0)