From ab33913b41f35adb57ad0a774fbc0014a9504d5f Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 15:50:06 +0800 Subject: [PATCH 01/14] feat(hub): add home landing page with platform content Replace the stock JupyterHub home page with a branded landing page that shows platform info, quick-start guide, available courses, documentation links, and news. The spawner is accessible via a prominent "Start My Server" button in a floating launch bar. Also redirect auto-login users to /hub/home instead of /hub/spawn. Made-with: Cursor --- runtime/hub/core/authenticators/auto_login.py | 4 +- runtime/hub/frontend/templates/home.html | 714 ++++++++++++++++-- 2 files changed, 636 insertions(+), 82 deletions(-) diff --git a/runtime/hub/core/authenticators/auto_login.py b/runtime/hub/core/authenticators/auto_login.py index 9df688c..b7d8096 100644 --- a/runtime/hub/core/authenticators/auto_login.py +++ b/runtime/hub/core/authenticators/auto_login.py @@ -45,7 +45,7 @@ def get_handlers(self, app): """Override to bypass login page and auto-authenticate.""" class AutoLoginHandler(BaseHandler): - """Handler that automatically authenticates and redirects to spawn.""" + """Handler that automatically authenticates and redirects to home.""" async def get(self): """Auto-authenticate user on GET request.""" @@ -59,7 +59,7 @@ async def get(self): next_url = self.get_argument("next", "") if not next_url: - next_url = getattr(user, "url", None) or url_path_join(self.hub.base_url, "spawn") + next_url = url_path_join(self.hub.base_url, "home") self.log.info(f"Auto-login: user '{username}' authenticated, redirecting to {next_url}") self.redirect(next_url) diff --git a/runtime/hub/frontend/templates/home.html b/runtime/hub/frontend/templates/home.html index 8a0943b..dd88d94 100644 --- a/runtime/hub/frontend/templates/home.html +++ b/runtime/hub/frontend/templates/home.html @@ -2,93 +2,647 @@ {% if announcement_home is string %} {% set announcement = announcement_home %} {% endif %} + +{%- block title -%}Home - AUP Learning Cloud{%- endblock title -%} + +{% block stylesheet %} +{{ super() }} + + + + +{% endblock stylesheet %} + {% block main %} +
+ + +
+
+

Welcome to AUP Learning Cloud

+

Experience next-generation AI acceleration with AMD ROCm. Launch GPU-powered Jupyter notebooks for deep learning, computer vision, LLMs, and more.

+
+
+ +
-

JupyterHub home page

-
-
- {% if default_server.active %}Stop My Server{% endif %} - - {% if not default_server.active %}Start{% endif %} +
+
+ +
+
+
My Server - + {% if default_server.active %} + Running + {% else %} + Stopped + {% endif %} +
+
+ {% if default_server.active %} + Your server is running — click “My Server” to open JupyterLab + {% else %} + Choose a course and launch your Jupyter environment + {% endif %} +
+
+
+ {% if default_server.active %} + + Stop My Server + + + My Server + + {% else %} + + Start My Server + + {% endif %}
- {% if allow_named_servers %} -

Named Servers

-

- In addition to your default server, - you may have additional - {% if named_server_limit_per_user > 0 %}{{ named_server_limit_per_user }}{% endif %} - server(s) with names. - This allows you to have more than one server running at the same time. -

- {% set named_spawners = user.all_spawners(include_default=False)|list %} - - - - - - - - - - - - - - {% for spawner in named_spawners %} - - {# name #} - - {# url #} - - {# activity #} - - {# actions #} - - - {% endfor %} - -
Server nameURLLast activityActions
-
- - +
+ + +
+
+

Quick Start

+
+
+
1
+

Choose a Course

+

Select from pre-configured environments for CV, Deep Learning, LLMs, or Physics Simulation.

+
+
+
2
+

Launch Your Server

+

Click “Start My Server” to spin up a Jupyter environment with GPU/NPU acceleration.

+
+
+
3
+

Start Learning

+

Open notebooks, run experiments, and explore AMD ROCm-powered AI workflows.

+
+
+
+
+ + + + + +
{{ spawner.name }} - {{ user.server_url(spawner.name) }} - - {% if spawner.last_activity %} - {{ spawner.last_activity.isoformat() + 'Z' }} - {% else %} - Never - {% endif %} - - stop - start - -
- {% endif %} + + +
+
+

Platform Getting Started

+

First-time user guide for ROCm setup

+
+
+ +
+
+

Custom Git Repository

+

Clone & launch your own repos

+
+
+
+
+
+

News & Updates

+
+
+
Platform
+

Welcome to AUP Learning Cloud

+

Get started with GPU-accelerated Jupyter notebooks powered by AMD ROCm technology.

+
+
+
+
+ +
+ + + + + {% endblock main %} + {% block script %} - {{ super() }} - +{{ super() }} + + {% endblock script %} From f3ccb24547dc8f94dc389ae3c7b83e7f38ec9b06 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 18:28:35 +0800 Subject: [PATCH 02/14] fix(hub): fix launch bar not updating after stop and wrong start URL - Convert server_active from a static module variable to React state so the launch bar (icon, badge, description, buttons) re-renders after stopping the server via DELETE API call - Point "Start My Server" href to /hub/spawn instead of /hub/user/ so users go directly to the spawner rather than the "server not running" interstitial page - Remove legacy require(["home"]) from template to prevent the JupyterHub home.js from conflicting with React's stop handler - Remove debug logging panel Made-with: Cursor --- dockerfiles/Hub/Dockerfile | 14 +- .../hub/frontend/apps/home/eslint.config.js | 35 + runtime/hub/frontend/apps/home/index.html | 12 + runtime/hub/frontend/apps/home/package.json | 31 + runtime/hub/frontend/apps/home/src/App.tsx | 357 ++++++++++ runtime/hub/frontend/apps/home/src/main.tsx | 17 + runtime/hub/frontend/apps/home/src/styles.css | 467 +++++++++++++ .../hub/frontend/apps/home/tsconfig.app.json | 26 + runtime/hub/frontend/apps/home/tsconfig.json | 7 + .../hub/frontend/apps/home/tsconfig.node.json | 23 + runtime/hub/frontend/apps/home/vite.config.ts | 17 + runtime/hub/frontend/package.json | 2 + runtime/hub/frontend/pnpm-lock.yaml | 49 ++ runtime/hub/frontend/templates/home.html | 651 +----------------- 14 files changed, 1070 insertions(+), 638 deletions(-) create mode 100644 runtime/hub/frontend/apps/home/eslint.config.js create mode 100644 runtime/hub/frontend/apps/home/index.html create mode 100644 runtime/hub/frontend/apps/home/package.json create mode 100644 runtime/hub/frontend/apps/home/src/App.tsx create mode 100644 runtime/hub/frontend/apps/home/src/main.tsx create mode 100644 runtime/hub/frontend/apps/home/src/styles.css create mode 100644 runtime/hub/frontend/apps/home/tsconfig.app.json create mode 100644 runtime/hub/frontend/apps/home/tsconfig.json create mode 100644 runtime/hub/frontend/apps/home/tsconfig.node.json create mode 100644 runtime/hub/frontend/apps/home/vite.config.ts diff --git a/dockerfiles/Hub/Dockerfile b/dockerfiles/Hub/Dockerfile index 487b857..0f1b8cb 100644 --- a/dockerfiles/Hub/Dockerfile +++ b/dockerfiles/Hub/Dockerfile @@ -29,7 +29,8 @@ ARG NPM_REGISTRY WORKDIR /app -RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +RUN if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi +RUN npm install -g pnpm@10.27.0 RUN if [ -n "${NPM_REGISTRY}" ]; then pnpm config set registry "${NPM_REGISTRY}"; fi # Copy monorepo configuration and lockfile @@ -39,6 +40,7 @@ COPY runtime/hub/frontend/pnpm-workspace.yaml runtime/hub/frontend/pnpm-lock.yam COPY runtime/hub/frontend/packages/shared/package.json ./packages/shared/ COPY runtime/hub/frontend/apps/admin/package.json ./apps/admin/ COPY runtime/hub/frontend/apps/spawn/package.json ./apps/spawn/ +COPY runtime/hub/frontend/apps/home/package.json ./apps/home/ # Install dependencies with frozen lockfile RUN pnpm install --frozen-lockfile @@ -59,13 +61,16 @@ USER root # Frontend: templates and static files RUN mkdir -p /tmp/custom_templates/static/css /tmp/custom_templates/static/js \ /usr/local/share/jupyterhub/static/admin-ui \ - /usr/local/share/jupyterhub/static/spawn-ui + /usr/local/share/jupyterhub/static/spawn-ui \ + /usr/local/share/jupyterhub/static/home-ui COPY --chmod=644 runtime/hub/frontend/templates/ /tmp/custom_templates/ COPY --from=frontend-builder /app/apps/admin/dist/ /usr/local/share/jupyterhub/static/admin-ui/ COPY --from=frontend-builder /app/apps/spawn/dist/ /usr/local/share/jupyterhub/static/spawn-ui/ +COPY --from=frontend-builder /app/apps/home/dist/ /usr/local/share/jupyterhub/static/home-ui/ RUN chown -R 1000:1000 /usr/local/share/jupyterhub/static/admin-ui/ \ - /usr/local/share/jupyterhub/static/spawn-ui/ + /usr/local/share/jupyterhub/static/spawn-ui/ \ + /usr/local/share/jupyterhub/static/home-ui/ # Backend: Python dependencies and core logic RUN pip install --no-cache-dir uv @@ -90,4 +95,5 @@ ENV JUPYTERHUB_TEMPLATE_PATH=/tmp/custom_templates RUN ls -la /tmp/custom_templates && \ ls -la /usr/local/share/jupyterhub/static/admin-ui/ && \ - ls -la /usr/local/share/jupyterhub/static/spawn-ui/ + ls -la /usr/local/share/jupyterhub/static/spawn-ui/ && \ + ls -la /usr/local/share/jupyterhub/static/home-ui/ diff --git a/runtime/hub/frontend/apps/home/eslint.config.js b/runtime/hub/frontend/apps/home/eslint.config.js new file mode 100644 index 0000000..54f8816 --- /dev/null +++ b/runtime/hub/frontend/apps/home/eslint.config.js @@ -0,0 +1,35 @@ +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default tseslint.config( + { ignores: ['dist'] }, + { + files: ['**/*.{ts,tsx}'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + tsconfigRootDir: __dirname, + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/runtime/hub/frontend/apps/home/index.html b/runtime/hub/frontend/apps/home/index.html new file mode 100644 index 0000000..a868d51 --- /dev/null +++ b/runtime/hub/frontend/apps/home/index.html @@ -0,0 +1,12 @@ + + + + + + AUP Learning Cloud - Home + + +
+ + + diff --git a/runtime/hub/frontend/apps/home/package.json b/runtime/hub/frontend/apps/home/package.json new file mode 100644 index 0000000..76f7f07 --- /dev/null +++ b/runtime/hub/frontend/apps/home/package.json @@ -0,0 +1,31 @@ +{ + "name": "@auplc/home", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@auplc/shared": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@eslint/js": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "globals": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "vite": "catalog:" + } +} diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx new file mode 100644 index 0000000..6a5b6cf --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -0,0 +1,357 @@ +import { useState, useEffect, useCallback } from "react"; + +interface JHData { + base_url: string; + xsrf_token: string; + user: string; +} + +interface HomeData { + server_active: boolean; + server_url: string; +} + +declare global { + interface Window { + jhdata?: JHData; + HOME_DATA?: HomeData; + } +} + +const jhdata: JHData = window.jhdata ?? { + base_url: "/hub/", + xsrf_token: "", + user: "student", +}; + +const homeData: HomeData = window.HOME_DATA ?? { + server_active: false, + server_url: `${jhdata.base_url}spawn`, +}; + +const baseUrl = jhdata.base_url; + +function App() { + const [serverActive, setServerActive] = useState(homeData.server_active); + const [stopping, setStopping] = useState(false); + const [announcement, setAnnouncement] = useState(null); + + useEffect(() => { + fetch(`${baseUrl}static/announcement.txt`) + .then((resp) => { + if (!resp.ok) throw new Error("Not found"); + return resp.text(); + }) + .then((data) => { + if (data?.trim()) setAnnouncement(data.trim()); + }) + .catch(() => {}); + }, []); + + const handleStop = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.nativeEvent.stopImmediatePropagation(); + if (stopping) return; + setStopping(true); + try { + const resp = await fetch( + `${baseUrl}api/users/${jhdata.user}/server`, + { + method: "DELETE", + headers: { "X-XSRFToken": jhdata.xsrf_token }, + }, + ); + if (resp.ok || resp.status === 204) { + setServerActive(false); + } else if (resp.status === 202) { + setServerActive(false); + } + } catch (err) { + console.error("Failed to stop server:", err); + } finally { + setStopping(false); + } + }, + [stopping], + ); + + return ( +
+ {/* Hero */} +
+
+

+ Welcome to AUP Learning Cloud +

+

+ Experience next-generation AI acceleration with AMD ROCm. Launch + GPU-powered Jupyter notebooks for deep learning, computer vision, + LLMs, and more. +

+
+
+ + {/* Launch Bar */} +
+
+
+ +
+
+
+ My Server + {serverActive ? ( + + Running + + ) : ( + + Stopped + + )} +
+
+ {stopping + ? "Stopping your server…" + : serverActive + ? 'Your server is running \u2014 click "My Server" to open JupyterLab' + : "Choose a course and launch your Jupyter environment"} +
+
+ +
+
+ + {/* Quick Start */} +
+
+
+

Quick Start

+
+
+
+
1
+

Choose a Course

+

+ Select from pre-configured environments for CV, Deep Learning, + LLMs, or Physics Simulation. +

+
+
+
2
+

Launch Your Server

+

+ Click “Start My Server” to spin up a Jupyter + environment with GPU/NPU acceleration. +

+
+
+
3
+

Start Learning

+

+ Open notebooks, run experiments, and explore AMD ROCm-powered AI + workflows. +

+
+
+
+
+ + {/* Available Courses */} +
+
+
+

Available Courses

+ + View all in Spawner{" "} + + +
+ +
+
+ + {/* Docs + News */} +
+
+
+ +
+
+

News & Updates

+
+
+ {announcement && ( +
+
Announcement
+

Platform Announcement

+

{announcement}

+
+ )} +
+
Platform
+

Welcome to AUP Learning Cloud

+

+ Get started with GPU-accelerated Jupyter notebooks powered + by AMD ROCm technology. +

+
+
+
+
+
+
+ + {/* Footer */} +
+
+ © 2025 Advanced Micro Devices, Inc. All rights reserved. · + AUP Learning Cloud +
+
+
+ ); +} + +export default App; diff --git a/runtime/hub/frontend/apps/home/src/main.tsx b/runtime/hub/frontend/apps/home/src/main.tsx new file mode 100644 index 0000000..628b690 --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "./styles.css"; + +const rootElement = + document.getElementById("home-root") ?? document.getElementById("root"); + +if (rootElement) { + createRoot(rootElement).render( + + + , + ); +} else { + console.error("Could not find root element to mount React app"); +} diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css new file mode 100644 index 0000000..0fc9642 --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -0,0 +1,467 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&display=swap'); + +/* ================================================================ + AUP Learning Cloud – Home Page + Slate palette: matches spawner, clean and understated + ================================================================ */ +:root { + --home-primary: #2c3e50; + --home-primary-hover: #1a252f; + --home-green: #34c759; + --home-red: #c0392b; + --home-surface-0: #ffffff; + --home-surface-1: #f5f5f7; + --home-surface-2: #e8e8ed; + --home-text: #1d1d1f; + --home-text-secondary: #86868b; + --home-text-muted: #aeaeb2; + --home-border: rgba(0,0,0,0.08); + --home-radius: 16px; + --home-radius-sm: 12px; + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.08); +} +[data-bs-theme="dark"] { + --home-primary: #86a8c7; + --home-primary-hover: #9dbdd8; + --home-green: #30d158; + --home-red: #ff453a; + --home-surface-0: #1c1c1e; + --home-surface-1: #2c2c2e; + --home-surface-2: #3a3a3c; + --home-text: #f5f5f7; + --home-text-secondary: #98989d; + --home-text-muted: #636366; + --home-border: rgba(255,255,255,0.08); + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.25); +} + +.home-page { + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ---- Hero ---- */ +.home-hero { + background: #000000; + color: #fff; + padding: 3.5rem 0 4rem; + position: relative; + overflow: hidden; + text-align: center; +} +[data-bs-theme="dark"] .home-hero { background: #000000; } +.home-hero::before { + content: ''; + position: absolute; + top: -50%; left: 50%; + transform: translateX(-50%); + width: 800px; height: 800px; + background: radial-gradient(circle, rgba(44,62,80,0.08) 0%, transparent 60%); + pointer-events: none; +} +.home-hero h1 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 2.5rem; + font-weight: 700; + line-height: 1.15; + margin-bottom: 0.75rem; + letter-spacing: -0.025em; + position: relative; + z-index: 1; +} +.home-hero h1 .accent { + background: linear-gradient(135deg, #86a8c7 0%, #b0cfe0 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.home-hero .hero-desc { + font-size: 1.05rem; + color: rgba(255,255,255,0.5); + max-width: 520px; + margin: 0 auto; + position: relative; + z-index: 1; + font-weight: 400; + line-height: 1.5; +} + +/* ---- Launch bar ---- */ +.launch-bar { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.25rem 1.5rem; + display: flex; + align-items: center; + gap: 1.25rem; + margin-top: -2rem; + position: relative; + z-index: 10; + box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06); +} +[data-bs-theme="dark"] .launch-bar { + box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 8px 24px rgba(0,0,0,0.2); +} +.lb-icon { + width: 44px; height: 44px; + border-radius: var(--home-radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.15rem; + flex-shrink: 0; + color: #fff; +} +.lb-icon.stopped { background: var(--home-primary); } +.lb-icon.running { background: var(--home-green); } +.lb-info { flex: 1; } +.lb-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--home-text); + display: flex; + align-items: center; + gap: 0.5rem; +} +.lb-desc { + font-size: 0.8rem; + color: var(--home-text-secondary); + margin-top: 1px; +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.7rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 100px; +} +.status-badge.stopped { + background: var(--home-surface-1); + color: var(--home-text-secondary); +} +.status-badge.running { + background: rgba(52,199,89,0.12); + color: #248a3d; +} +[data-bs-theme="dark"] .status-badge.running { + background: rgba(48,209,88,0.15); + color: #30d158; +} +.status-dot { + width: 6px; height: 6px; + border-radius: 50%; + display: inline-block; +} +.status-dot.stopped { background: var(--home-text-muted); } +.status-dot.running { + background: var(--home-green); +} +.lb-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* ---- Buttons ---- */ +.btn-launch { + display: inline-flex; + align-items: center; + gap: 0.45rem; + background: var(--home-primary); + color: #fff; + border: none; + padding: 0.6rem 1.5rem; + border-radius: 980px; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.15s; + text-decoration: none; + font-family: inherit; + white-space: nowrap; +} +.btn-launch:hover { + background: var(--home-primary-hover); + transform: scale(1.02); + color: #fff; + text-decoration: none; +} +.btn-launch:active { + transform: scale(0.98); +} +.btn-home-sm { + padding: 0.4rem 0.9rem; + border-radius: 980px; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid var(--home-border); + background: var(--home-surface-0); + cursor: pointer; + transition: all 0.15s; + color: var(--home-text); + font-family: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.btn-home-sm:hover { + background: var(--home-surface-1); + text-decoration: none; + color: var(--home-text); +} +.btn-home-sm.danger { + background: transparent; + color: var(--home-red); + border-color: var(--home-red); +} +.btn-home-sm.danger:hover { + background: var(--home-red); + color: #fff; +} + +/* ---- Sections ---- */ +.home-section { + padding: 2.25rem 0; +} +.home-section-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 1.25rem; +} +.home-section-header h2 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.3rem; + font-weight: 700; + color: var(--home-text); + letter-spacing: -0.01em; +} +.home-section-header a { + font-size: 0.82rem; + color: var(--home-primary); + text-decoration: none; + font-weight: 500; +} +.home-section-header a:hover { text-decoration: underline; } + +/* ---- Quick Start ---- */ +.qs-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} +.qs-card { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.5rem; + transition: box-shadow 0.25s ease, transform 0.2s ease; +} +.qs-card:hover { + box-shadow: var(--home-hover-shadow); + transform: translateY(-2px); +} +.qs-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; height: 30px; + background: var(--home-primary); + color: #fff; + border-radius: 50%; + font-size: 0.8rem; + font-weight: 700; + margin-bottom: 0.75rem; +} +[data-bs-theme="dark"] .qs-num { + background: var(--home-primary); + color: #fff; +} +.qs-card h3 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.35rem; + color: var(--home-text); +} +.qs-card p { + font-size: 0.82rem; + color: var(--home-text-secondary); + line-height: 1.55; + margin: 0; +} + +/* ---- Courses ---- */ +.courses-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} +.course-card { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.25rem; + transition: box-shadow 0.25s ease, transform 0.2s ease; + cursor: pointer; + text-decoration: none; + color: var(--home-text); + display: block; +} +.course-card:hover { + box-shadow: var(--home-hover-shadow); + transform: translateY(-2px); + text-decoration: none; + color: var(--home-text); +} +.course-icon { + width: 40px; height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.82rem; + font-weight: 700; + margin-bottom: 0.75rem; +} +.course-card h3 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.25rem; +} +.course-card p { + font-size: 0.78rem; + color: var(--home-text-secondary); + line-height: 1.5; + margin: 0; +} +.course-tag { + display: inline-block; + font-size: 0.62rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 6px; + margin-top: 0.55rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.icon-cv { background: #e8f0fe; color: #1a73e8; } +.icon-dl { background: #fce8e6; color: #d93025; } +.icon-llm { background: #f3e8fd; color: #9334e6; } +.icon-phy { background: #e6f4ea; color: #1e8e3e; } +.tag-gpu { background: #f3e8fd; color: #9334e6; } +.tag-cpu { background: #e8f0fe; color: #1a73e8; } + +/* ---- Two-column layout ---- */ +.home-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +/* ---- Doc list ---- */ +.doc-list { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + overflow: hidden; +} +.doc-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 1.15rem; + border-bottom: 1px solid var(--home-border); + text-decoration: none; + color: var(--home-text); + transition: background 0.15s; +} +.doc-item:last-child { border-bottom: none; } +.doc-item:hover { + background: var(--home-surface-1); + text-decoration: none; + color: var(--home-text); +} +.doc-icon { + width: 36px; height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + flex-shrink: 0; +} +.doc-text h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.85rem; + font-weight: 600; + margin: 0; + color: var(--home-text); +} +.doc-text p { + font-size: 0.75rem; + color: var(--home-text-secondary); + margin: 0; +} + +/* ---- News ---- */ +.news-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.news-card { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.1rem 1.25rem; + transition: box-shadow 0.25s ease; +} +.news-card:hover { box-shadow: var(--home-hover-shadow); } +.news-meta { + font-size: 0.68rem; + color: var(--home-text-muted); + margin-bottom: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} +.news-card h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.2rem; + color: var(--home-text); +} +.news-card p { + font-size: 0.78rem; + color: var(--home-text-secondary); + line-height: 1.55; + margin: 0; +} + +/* ---- Footer ---- */ +.home-footer { + border-top: 1px solid var(--home-border); + padding: 1.5rem 0; + text-align: center; + font-size: 0.78rem; + color: var(--home-text-muted); + margin-top: 1rem; +} + +/* ---- Responsive ---- */ +@media (max-width: 768px) { + .home-hero h1 { font-size: 1.8rem; } + .qs-grid { grid-template-columns: 1fr; } + .courses-grid { grid-template-columns: repeat(2, 1fr); } + .home-two-col { grid-template-columns: 1fr; } + .launch-bar { flex-direction: column; text-align: center; } + .lb-actions { justify-content: center; } +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.app.json b/runtime/hub/frontend/apps/home/tsconfig.app.json new file mode 100644 index 0000000..c328724 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.json b/runtime/hub/frontend/apps/home/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.node.json b/runtime/hub/frontend/apps/home/tsconfig.node.json new file mode 100644 index 0000000..ddae9c9 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/runtime/hub/frontend/apps/home/vite.config.ts b/runtime/hub/frontend/apps/home/vite.config.ts new file mode 100644 index 0000000..0c0b4ea --- /dev/null +++ b/runtime/hub/frontend/apps/home/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/hub/static/home-ui/', + build: { + outDir: 'dist', + rollupOptions: { + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + }, +}) diff --git a/runtime/hub/frontend/package.json b/runtime/hub/frontend/package.json index fd809f4..a6ce303 100644 --- a/runtime/hub/frontend/package.json +++ b/runtime/hub/frontend/package.json @@ -7,9 +7,11 @@ "dev": "pnpm -r --parallel run dev", "dev:admin": "pnpm --filter @auplc/admin run dev", "dev:spawn": "pnpm --filter @auplc/spawn run dev", + "dev:home": "pnpm --filter @auplc/home run dev", "build": "pnpm -r run build", "build:admin": "pnpm --filter @auplc/admin run build", "build:spawn": "pnpm --filter @auplc/spawn run build", + "build:home": "pnpm --filter @auplc/home run build", "lint": "pnpm -r run lint", "clean": "pnpm -r exec rm -rf dist node_modules" }, diff --git a/runtime/hub/frontend/pnpm-lock.yaml b/runtime/hub/frontend/pnpm-lock.yaml index c4e9aab..ce3c710 100644 --- a/runtime/hub/frontend/pnpm-lock.yaml +++ b/runtime/hub/frontend/pnpm-lock.yaml @@ -132,6 +132,55 @@ importers: specifier: 'catalog:' version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + apps/home: + dependencies: + '@auplc/shared': + specifier: workspace:* + version: link:../../packages/shared + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + devDependencies: + '@eslint/js': + specifier: 'catalog:' + version: 9.39.2 + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.1.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)) + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: 'catalog:' + version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) + globals: + specifier: 'catalog:' + version: 16.5.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: 'catalog:' + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + apps/spawn: dependencies: '@auplc/shared': diff --git a/runtime/hub/frontend/templates/home.html b/runtime/hub/frontend/templates/home.html index dd88d94..e93f4a1 100644 --- a/runtime/hub/frontend/templates/home.html +++ b/runtime/hub/frontend/templates/home.html @@ -7,642 +7,25 @@ {% block stylesheet %} {{ super() }} - - - - -{% endblock stylesheet %} + +{% endblock %} {% block main %} -
- - -
-
-

Welcome to AUP Learning Cloud

-

Experience next-generation AI acceleration with AMD ROCm. Launch GPU-powered Jupyter notebooks for deep learning, computer vision, LLMs, and more.

-
-
- - -
-
-
- -
-
-
- My Server - {% if default_server.active %} - Running - {% else %} - Stopped - {% endif %} -
-
- {% if default_server.active %} - Your server is running — click “My Server” to open JupyterLab - {% else %} - Choose a course and launch your Jupyter environment - {% endif %} -
-
-
- {% if default_server.active %} - - Stop My Server - - - My Server - - {% else %} - - Start My Server - - {% endif %} -
-
-
- - -
-
-

Quick Start

-
-
-
1
-

Choose a Course

-

Select from pre-configured environments for CV, Deep Learning, LLMs, or Physics Simulation.

-
-
-
2
-

Launch Your Server

-

Click “Start My Server” to spin up a Jupyter environment with GPU/NPU acceleration.

-
-
-
3
-

Start Learning

-

Open notebooks, run experiments, and explore AMD ROCm-powered AI workflows.

-
-
-
-
- - - - - -
-
- -
-
- - - - -
-{% endblock main %} +
+ + +{% endblock %} {% block script %} {{ super() }} - - -{% endblock script %} +{% endblock %} From 33077de005aa8b9336908cdf3774bb90e6cda168 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 18:36:22 +0800 Subject: [PATCH 03/14] Update announcement value --- runtime/values.yaml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/runtime/values.yaml b/runtime/values.yaml index 8cb99fc..6f7b35e 100644 --- a/runtime/values.yaml +++ b/runtime/values.yaml @@ -343,12 +343,7 @@ hub: # If you want to edit the notice on login.html, change here. mountPath: /usr/local/share/jupyterhub/static/announcement.txt stringData: | -
-

Welcome to AUP Learning Cloud!

-

This is a dynamic announcement.

-

My location is on runtime/values.yaml

-

You can edit this via ConfigMap without rebuilding the image.

-
+ Hello, World! config: # ---- Hub Core ---- From f827dd43c2b2461871baa8ad6473f84ce443ee8f Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:02:49 +0800 Subject: [PATCH 04/14] refactor(hub): redesign Home and Spawn UI with unified design system - Home: dynamic resource cards from API, launch bar, quick-start strip, docs/news sections with shared --home-* design tokens - Spawn: two-column layout with resource picker and sticky config sidebar - values.yaml: set redirect_to_server=false so users land on Home first Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 219 ++-- runtime/hub/frontend/apps/home/src/styles.css | 199 +++- runtime/hub/frontend/apps/spawn/src/App.tsx | 448 ++++--- .../hub/frontend/apps/spawn/src/styles.css | 1035 ++++++----------- runtime/values.yaml | 12 +- 5 files changed, 856 insertions(+), 1057 deletions(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 6a5b6cf..e90a1c5 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -1,10 +1,6 @@ import { useState, useEffect, useCallback } from "react"; - -interface JHData { - base_url: string; - xsrf_token: string; - user: string; -} +import type { Resource, ResourceGroup } from "@auplc/shared"; +import { getResources } from "@auplc/shared"; interface HomeData { server_active: boolean; @@ -13,12 +9,11 @@ interface HomeData { declare global { interface Window { - jhdata?: JHData; HOME_DATA?: HomeData; } } -const jhdata: JHData = window.jhdata ?? { +const jhdata = window.jhdata ?? { base_url: "/hub/", xsrf_token: "", user: "student", @@ -31,10 +26,27 @@ const homeData: HomeData = window.HOME_DATA ?? { const baseUrl = jhdata.base_url; +function formatResourceSpecs(r: Resource): string { + const req = r.requirements; + const mem = req.memory.replace("Gi", "GB"); + let spec = `${req.cpu} CPU, ${mem}`; + if (req["amd.com/gpu"]) spec += `, ${req["amd.com/gpu"]} GPU`; + if (req["amd.com/npu"]) spec += `, ${req["amd.com/npu"]} NPU`; + return spec; +} + +function getAcceleratorType(r: Resource): "gpu" | "npu" | "cpu" { + if (r.requirements["amd.com/gpu"]) return "gpu"; + if (r.requirements["amd.com/npu"]) return "npu"; + return "cpu"; +} + function App() { const [serverActive, setServerActive] = useState(homeData.server_active); const [stopping, setStopping] = useState(false); const [announcement, setAnnouncement] = useState(null); + const [groups, setGroups] = useState([]); + const [resourcesLoading, setResourcesLoading] = useState(true); useEffect(() => { fetch(`${baseUrl}static/announcement.txt`) @@ -48,6 +60,15 @@ function App() { .catch(() => {}); }, []); + useEffect(() => { + getResources() + .then((data) => { + setGroups(data.groups.filter((g) => g.resources.length > 0)); + }) + .catch(() => {}) + .finally(() => setResourcesLoading(false)); + }, []); + const handleStop = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); @@ -59,12 +80,10 @@ function App() { `${baseUrl}api/users/${jhdata.user}/server`, { method: "DELETE", - headers: { "X-XSRFToken": jhdata.xsrf_token }, + headers: { "X-XSRFToken": jhdata.xsrf_token ?? "" }, }, ); - if (resp.ok || resp.status === 204) { - setServerActive(false); - } else if (resp.status === 202) { + if (resp.ok || resp.status === 204 || resp.status === 202) { setServerActive(false); } } catch (err) { @@ -76,6 +95,11 @@ function App() { [stopping], ); + const totalResources = groups.reduce( + (sum, g) => sum + g.resources.length, + 0, + ); + return (
{/* Hero */} @@ -115,10 +139,10 @@ function App() {
{stopping - ? "Stopping your server…" + ? "Stopping your server\u2026" : serverActive ? 'Your server is running \u2014 click "My Server" to open JupyterLab' - : "Choose a course and launch your Jupyter environment"} + : "Choose a resource below and launch your Jupyter environment"}
@@ -130,8 +154,10 @@ function App() { className="btn-home-sm danger" onClick={handleStop} > - {" "} - {stopping ? "Stopping…" : "Stop My Server"} + {" "} + {stopping ? "Stopping\u2026" : "Stop My Server"}
- {/* Quick Start */} + {/* Quick Start (compact strip) */}
-
-
-

Quick Start

-
-
-
+
+
+
1
-

Choose a Course

-

- Select from pre-configured environments for CV, Deep Learning, - LLMs, or Physics Simulation. -

+
+

Choose a Resource

+

Pick a pre-configured environment below

+
-
+
2
-

Launch Your Server

-

- Click “Start My Server” to spin up a Jupyter - environment with GPU/NPU acceleration. -

+
+

Configure & Launch

+

Select GPU, set runtime, then launch

+
-
+
3
-

Start Learning

-

- Open notebooks, run experiments, and explore AMD ROCm-powered AI - workflows. -

+
+

Start Learning

+

Open notebooks and run experiments

+
- {/* Available Courses */} + {/* Available Resources (dynamic from API) */}
-
+
- + ) : ( + groups.map((group) => ( +
+
+

{group.displayName}

+ + {group.resources.length}{" "} + {group.resources.length === 1 ? "resource" : "resources"} + +
+ +
+ )) + )}
@@ -295,7 +354,7 @@ function App() {

Platform Getting Started

-

First-time user guide fo AUP Learning Cloud setup

+

First-time user guide for AUP Learning Cloud setup

AUP Learning Cloud GitHub Repository

-

Clone & launch your own AUP Learning Cloud

+

Clone & launch your own AUP Learning Cloud

@@ -326,7 +385,7 @@ function App() {
Announcement

Platform Announcement

-

{announcement}

+

)}
diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css index 0fc9642..1fa3c9b 100644 --- a/runtime/hub/frontend/apps/home/src/styles.css +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -249,114 +249,202 @@ } .home-section-header a:hover { text-decoration: underline; } -/* ---- Quick Start ---- */ -.qs-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; +/* ---- Quick Start (compact strip) ---- */ +.qs-strip { + display: flex; + gap: 1.25rem; + align-items: stretch; } -.qs-card { +.qs-step { + flex: 1; + display: flex; + align-items: flex-start; + gap: 0.6rem; background: var(--home-surface-0); border: 1px solid var(--home-border); - border-radius: var(--home-radius); - padding: 1.5rem; - transition: box-shadow 0.25s ease, transform 0.2s ease; -} -.qs-card:hover { - box-shadow: var(--home-hover-shadow); - transform: translateY(-2px); + border-radius: var(--home-radius-sm); + padding: 0.85rem 1rem; } .qs-num { display: inline-flex; align-items: center; justify-content: center; - width: 30px; height: 30px; + width: 26px; height: 26px; + min-width: 26px; background: var(--home-primary); color: #fff; border-radius: 50%; - font-size: 0.8rem; + font-size: 0.72rem; font-weight: 700; - margin-bottom: 0.75rem; } [data-bs-theme="dark"] .qs-num { background: var(--home-primary); color: #fff; } -.qs-card h3 { +.qs-step-text h4 { font-family: 'Plus Jakarta Sans', sans-serif; - font-size: 0.95rem; + font-size: 0.82rem; font-weight: 600; - margin-bottom: 0.35rem; + margin-bottom: 0.1rem; color: var(--home-text); } -.qs-card p { - font-size: 0.82rem; +.qs-step-text p { + font-size: 0.72rem; color: var(--home-text-secondary); - line-height: 1.55; + line-height: 1.4; + margin: 0; +} + +/* ---- Resource Groups ---- */ +.resource-group { + margin-bottom: 1.5rem; +} +.resource-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.resource-group-header h3 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.95rem; + font-weight: 600; + color: var(--home-text); margin: 0; } +.group-count { + font-size: 0.72rem; + font-weight: 500; + color: var(--home-text-muted); + background: var(--home-surface-2); + padding: 2px 8px; + border-radius: 100px; +} -/* ---- Courses ---- */ -.courses-grid { +.resources-grid { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; + grid-template-columns: repeat(3, 1fr); + gap: 0.85rem; +} + +.resources-loading, +.resources-empty { + text-align: center; + padding: 2rem; + color: var(--home-text-secondary); + font-size: 0.88rem; +} +.resources-loading i { + margin-right: 0.4rem; +} +.resources-empty a { + color: var(--home-primary); + text-decoration: none; + font-weight: 500; } -.course-card { +.resources-empty a:hover { text-decoration: underline; } + +/* ---- Resource Card ---- */ +.resource-card { background: var(--home-surface-0); border: 1px solid var(--home-border); border-radius: var(--home-radius); - padding: 1.25rem; - transition: box-shadow 0.25s ease, transform 0.2s ease; + padding: 1.15rem 1.25rem; + transition: box-shadow 0.25s ease, transform 0.2s ease, border-color 0.2s ease; cursor: pointer; text-decoration: none; color: var(--home-text); - display: block; + display: flex; + flex-direction: column; + gap: 0.5rem; } -.course-card:hover { +.resource-card:hover { box-shadow: var(--home-hover-shadow); transform: translateY(-2px); text-decoration: none; color: var(--home-text); + border-color: var(--home-primary); } -.course-icon { - width: 40px; height: 40px; - border-radius: 10px; +.resource-card-top { display: flex; - align-items: center; - justify-content: center; - font-size: 0.82rem; - font-weight: 700; - margin-bottom: 0.75rem; + align-items: flex-start; + gap: 0.75rem; +} +.resource-card-info { + flex: 1; + min-width: 0; } -.course-card h3 { +.resource-card-info h4 { font-family: 'Plus Jakarta Sans', sans-serif; - font-size: 0.9rem; + font-size: 0.88rem; font-weight: 600; - margin-bottom: 0.25rem; + margin-bottom: 0.15rem; + color: var(--home-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.course-card p { - font-size: 0.78rem; +.resource-card-info p { + font-size: 0.75rem; color: var(--home-text-secondary); - line-height: 1.5; + line-height: 1.45; margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.course-tag { - display: inline-block; +.resource-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} +.resource-card-arrow { + font-size: 0.75rem; + color: var(--home-text-muted); + margin-left: auto; + transition: transform 0.2s, color 0.2s; + flex-shrink: 0; +} +.resource-card:hover .resource-card-arrow { + transform: translateX(3px); + color: var(--home-primary); +} + + +/* ---- Resource tags ---- */ +.resource-tag { + display: inline-flex; + align-items: center; + gap: 3px; font-size: 0.62rem; font-weight: 600; padding: 0.15rem 0.5rem; border-radius: 6px; - margin-top: 0.55rem; text-transform: uppercase; letter-spacing: 0.04em; } -.icon-cv { background: #e8f0fe; color: #1a73e8; } -.icon-dl { background: #fce8e6; color: #d93025; } -.icon-llm { background: #f3e8fd; color: #9334e6; } -.icon-phy { background: #e6f4ea; color: #1e8e3e; } .tag-gpu { background: #f3e8fd; color: #9334e6; } .tag-cpu { background: #e8f0fe; color: #1a73e8; } +.tag-npu { background: #e6f4ea; color: #1e8e3e; } +.tag-spec { + background: var(--home-surface-1); + color: var(--home-text-secondary); + font-weight: 500; + text-transform: none; + letter-spacing: 0; +} +.tag-git { + background: #eaf6e8; + color: #2d6a27; + border: 1px solid #b7ddb0; +} +[data-bs-theme="dark"] .tag-gpu { background: rgba(147,52,230,0.15); color: #c084fc; } +[data-bs-theme="dark"] .tag-cpu { background: rgba(26,115,232,0.15); color: #60a5fa; } +[data-bs-theme="dark"] .tag-npu { background: rgba(30,142,62,0.15); color: #4ade80; } +[data-bs-theme="dark"] .tag-spec { background: var(--home-surface-2); color: var(--home-text-secondary); } +[data-bs-theme="dark"] .tag-git { background: rgba(45,106,39,0.15); color: #4ade80; border-color: rgba(45,106,39,0.3); } /* ---- Two-column layout ---- */ .home-two-col { @@ -457,11 +545,14 @@ } /* ---- Responsive ---- */ +@media (max-width: 900px) { + .resources-grid { grid-template-columns: repeat(2, 1fr); } +} @media (max-width: 768px) { .home-hero h1 { font-size: 1.8rem; } - .qs-grid { grid-template-columns: 1fr; } - .courses-grid { grid-template-columns: repeat(2, 1fr); } + .resources-grid { grid-template-columns: 1fr; } .home-two-col { grid-template-columns: 1fr; } .launch-bar { flex-direction: column; text-align: center; } .lb-actions { justify-content: center; } + .qs-strip { flex-direction: column; } } diff --git a/runtime/hub/frontend/apps/spawn/src/App.tsx b/runtime/hub/frontend/apps/spawn/src/App.tsx index 9a0df4f..3917a69 100644 --- a/runtime/hub/frontend/apps/spawn/src/App.tsx +++ b/runtime/hub/frontend/apps/spawn/src/App.tsx @@ -25,46 +25,22 @@ import { useResources } from './hooks/useResources'; import { useAccelerators } from './hooks/useAccelerators'; import { useQuota } from './hooks/useQuota'; -/** - * Normalize a repo URL typed by the user: - * - Trims whitespace - * - Prepends https:// if no protocol is present - * - Strips /tree/ (GitHub/GitLab style) and returns branch separately - * - Strips trailing .git suffix - * Returns { url, branch } where url is the clean clone URL. - */ function normalizeRepoUrl(raw: string): { url: string; branch: string } { let s = raw.trim(); if (!s) return { url: '', branch: '' }; - - if (!s.includes('://')) { - s = 'https://' + s; - } - + if (!s.includes('://')) s = 'https://' + s; let branch = ''; try { const parsed = new URL(s); let path = parsed.pathname; - - // Strip /tree/ (GitHub: /owner/repo/tree/main) const treeMatch = path.match(/^(\/[^/]+\/[^/]+)\/tree\/(.+)$/); - if (treeMatch) { - path = treeMatch[1]; - branch = treeMatch[2]; - } - - if (path.endsWith('.git')) { - path = path.slice(0, -4); - } - + if (treeMatch) { path = treeMatch[1]; branch = treeMatch[2]; } + if (path.endsWith('.git')) path = path.slice(0, -4); parsed.pathname = path; - // Remove any query string or hash that may have been pasted parsed.search = ''; parsed.hash = ''; return { url: parsed.toString(), branch }; - } catch { - return { url: s, branch: '' }; - } + } catch { return { url: s, branch: '' }; } } function validateRepoUrl(url: string, allowedProviders: string[]): string { @@ -77,9 +53,7 @@ function validateRepoUrl(url: string, allowedProviders: string[]): string { p => hostname === p || hostname.endsWith('.' + p) ); if (!allowed) return `Host not allowed. Supported: ${allowedProviders.join(', ')}.`; - } catch { - return 'Invalid URL format.'; - } + } catch { return 'Invalid URL format.'; } return ''; } @@ -111,15 +85,12 @@ function App() { const [githubRepos, setGithubRepos] = useState([]); const [githubAppInstalled, setGithubAppInstalled] = useState(false); - // Derive branch and shareable /hub/git/ link from raw input const { branch: repoBranch, url: normalizedRepoUrl } = useMemo( - () => normalizeRepoUrl(repoUrl), - [repoUrl] + () => normalizeRepoUrl(repoUrl), [repoUrl] ); const loading = resourcesLoading || acceleratorsLoading || quotaLoading; - // Validate initial repo_url from query params once providers are loaded useEffect(() => { if (!initialRepoUrl || allowedGitProviders.length === 0) return; const { url } = normalizeRepoUrl(initialRepoUrl); @@ -127,83 +98,55 @@ function App() { if (err) setRepoUrlError(err); }, [allowedGitProviders, initialRepoUrl]); - // Pre-select resource from query param or autostart default, then auto-submit if needed const hasAutoSelected = useRef(false); useEffect(() => { if (resourcesLoading || resources.length === 0 || hasAutoSelected.current) return; - let target: Resource | undefined; if (initialResourceKey) { target = resources.find(r => r.key === initialResourceKey); - if (!target) { - setParamWarning(`Unknown resource '${initialResourceKey}', using default.`); - } - } - if (!target && (autostart || initialRepoUrl)) { - target = resources.find(r => r.metadata?.allowGitClone); - } - // Fallback: pre-selection was attempted but failed → select first resource - if (!target && (initialResourceKey || autostart || initialRepoUrl)) { - target = resources[0]; + if (!target) setParamWarning(`Unknown resource '${initialResourceKey}', using default.`); } + if (!target && (autostart || initialRepoUrl)) target = resources.find(r => r.metadata?.allowGitClone); + if (!target && (initialResourceKey || autostart || initialRepoUrl)) target = resources[0]; if (target) { hasAutoSelected.current = true; setSelectedResource(target); - // Expand the group containing the pre-selected resource const targetGroup = groups.find(g => g.resources.some(r => r.key === target!.key)); if (targetGroup) setExpandedGroup(targetGroup.name); if (initialAcceleratorKey) { const validKeys = target.metadata?.acceleratorKeys ?? []; - if (validKeys.includes(initialAcceleratorKey)) { - setSelectedAcceleratorKey(initialAcceleratorKey); - } else if (initialAcceleratorKey) { - setParamWarning(`Unknown accelerator '${initialAcceleratorKey}' for this resource, using default.`); - } + if (validKeys.includes(initialAcceleratorKey)) setSelectedAcceleratorKey(initialAcceleratorKey); + else if (initialAcceleratorKey) setParamWarning(`Unknown accelerator '${initialAcceleratorKey}' for this resource, using default.`); } } else { - // Default: expand the first group const firstGroup = groups.find(g => g.resources.length > 0); if (firstGroup) setExpandedGroup(firstGroup.name); } }, [resources, groups, resourcesLoading, initialResourceKey, initialAcceleratorKey, autostart, initialRepoUrl]); - // Auto-submit once resource is selected and form is ready useEffect(() => { if (!autostart || autostartFired.current) return; if (!selectedResource || loading) return; autostartFired.current = true; - // Brief delay to let the DOM settle before submitting setTimeout(() => { const form = document.getElementById('spawn_form') as HTMLFormElement | null; form?.submit(); }, 300); }, [autostart, selectedResource, loading]); - // Fetch GitHub repos when githubAppName is configured (GitHub OAuth users only) const isGitHub = isCurrentUserGitHub(); useEffect(() => { if (!githubAppName || !isGitHub) return; fetchGitHubRepos() - .then(data => { - setGithubRepos(data.repos); - setGithubAppInstalled(data.installed); - }) - .catch(() => { - // Silently fail - user just won't see repo picker - }); + .then(data => { setGithubRepos(data.repos); setGithubAppInstalled(data.installed); }) + .catch(() => {}); }, [githubAppName, isGitHub]); - // Compute available accelerators based on selected resource const availableAccelerators = useMemo(() => { - if (!selectedResource?.metadata?.acceleratorKeys) { - return []; - } - return accelerators.filter(acc => - selectedResource.metadata?.acceleratorKeys?.includes(acc.key) - ); + if (!selectedResource?.metadata?.acceleratorKeys) return []; + return accelerators.filter(acc => selectedResource.metadata?.acceleratorKeys?.includes(acc.key)); }, [selectedResource, accelerators]); - // Derive selected accelerator: use user selection if valid, otherwise first available const selectedAccelerator = useMemo(() => { if (availableAccelerators.length === 0) return null; const userSelected = availableAccelerators.find(acc => acc.key === selectedAcceleratorKey); @@ -227,35 +170,26 @@ function App() { return `${spawnBase}?${params.toString()}`; }, [normalizedRepoUrl, repoBranch, repoUrlError, allowGitClone, selectedResource, selectedAccelerator]); - // Memoize quota calculations const { cost, canAfford, insufficientQuota, maxRuntime } = useMemo(() => { const rate = selectedAccelerator?.quotaRate ?? quota?.rates?.cpu ?? 1; const calculatedCost = quota?.enabled ? rate * runtime : 0; const balance = quota?.balance ?? 0; - return { cost: calculatedCost, canAfford: quota?.unlimited || balance >= calculatedCost, insufficientQuota: quota?.enabled && !quota?.unlimited && balance < 10, - maxRuntime: quota?.enabled && !quota?.unlimited - ? Math.min(240, Math.floor(balance / rate)) - : 240, + maxRuntime: quota?.enabled && !quota?.unlimited ? Math.min(240, Math.floor(balance / rate)) : 240, }; }, [quota, selectedAccelerator?.quotaRate, runtime]); const canStart = selectedResource && canAfford && !repoUrlError && !repoValidating; - // Memoize non-empty groups filter - const nonEmptyGroups = useMemo( - () => groups.filter(g => g.resources.length > 0), - [groups] - ); + const nonEmptyGroups = useMemo(() => groups.filter(g => g.resources.length > 0), [groups]); + const totalResources = resources.length; - // Accordion: toggle group, only one open at a time const handleToggleGroup = useCallback((groupName: string) => { setExpandedGroup(prev => prev === groupName ? null : groupName); }, []); - // Memoize callbacks to prevent child re-renders const handleSelectResource = useCallback((resource: Resource) => { setSelectedResource(resource); }, []); @@ -271,26 +205,16 @@ function App() { setRepoUrlError(formatError); setRepoValidating(false); setRepoValid(false); - - // Clear pending validation if (validateTimerRef.current) clearTimeout(validateTimerRef.current); - - // If format is valid and URL is non-empty, debounce remote validation if (!formatError && url) { setRepoValidating(true); validateTimerRef.current = setTimeout(async () => { try { const result = await validateRepo(url, branch || undefined); - if (result.valid) { - setRepoValid(true); - } else { - setRepoUrlError(result.error); - } - } catch { - // API error — don't block the user - } finally { - setRepoValidating(false); - } + if (result.valid) setRepoValid(true); + else setRepoUrlError(result.error); + } catch { /* API error */ } + finally { setRepoValidating(false); } }, 800); } }, [allowedGitProviders]); @@ -298,29 +222,20 @@ function App() { const handleSelectGitHubRepo = useCallback((repo: GitHubRepo) => { const url = repo.html_url; setRepoUrl(url); - const { url: normalizedUrl, branch } = normalizeRepoUrl(url); - const formatError = validateRepoUrl(normalizedUrl, allowedGitProviders); + const { url: nUrl, branch } = normalizeRepoUrl(url); + const formatError = validateRepoUrl(nUrl, allowedGitProviders); setRepoUrlError(formatError); setRepoValid(false); - if (validateTimerRef.current) clearTimeout(validateTimerRef.current); - - if (!formatError && normalizedUrl) { + if (!formatError && nUrl) { setRepoValidating(true); validateTimerRef.current = setTimeout(async () => { try { - const result = await validateRepo(normalizedUrl, branch || undefined); - if (result.valid) { - setRepoValid(true); - setRepoUrlError(''); - } else { - setRepoUrlError(result.error); - } - } catch { - // API error - } finally { - setRepoValidating(false); - } + const result = await validateRepo(nUrl, branch || undefined); + if (result.valid) { setRepoValid(true); setRepoUrlError(''); } + else setRepoUrlError(result.error); + } catch { /* API error */ } + finally { setRepoValidating(false); } }, 300); } }, [allowedGitProviders]); @@ -332,25 +247,16 @@ function App() { const handleRuntimeChange = useCallback((e: React.ChangeEvent) => { setRuntimeInput(e.target.value); const value = parseInt(e.target.value); - if (!isNaN(value) && value > 0) { - setRuntime(value); - } + if (!isNaN(value) && value > 0) setRuntime(value); }, []); const handleRuntimeBlur = useCallback(() => { const value = parseInt(runtimeInput); const min = 10; const max = Math.min(240, maxRuntime); - if (isNaN(value) || value < min) { - setRuntime(min); - setRuntimeInput(String(min)); - } else if (value > max) { - setRuntime(max); - setRuntimeInput(String(max)); - } else { - setRuntime(value); - setRuntimeInput(String(value)); - } + if (isNaN(value) || value < min) { setRuntime(min); setRuntimeInput(String(min)); } + else if (value > max) { setRuntime(max); setRuntimeInput(String(max)); } + else { setRuntime(value); setRuntimeInput(String(value)); } }, [runtimeInput, maxRuntime]); if (loading) { @@ -363,32 +269,34 @@ function App() { } if (resourcesError) { - return ( -
- Error: {resourcesError} -
- ); + return
Error: {resourcesError}
; } + const homeUrl = `${window.jhdata?.base_url ?? '/hub/'}home`; + const acceleratorType = selectedResource?.metadata?.accelerator ?? 'GPU'; + return ( <> - {/* Hidden inputs for form submission */} + {/* Hidden form inputs */} {selectedResource && ( - + )} - {/* Invalid query param warning */} - {paramWarning && ( -
- Warning: {paramWarning} + {allowGitClone && } + + {/* Page header */} +
+
+ Home + / + Launch Server
- )} +

Launch Your Server

+

Select a resource, configure your environment, and launch

+
- {/* Insufficient quota warning */} + {/* Warnings */} + {paramWarning &&
Warning: {paramWarning}
} {insufficientQuota && (
Insufficient Quota
@@ -396,104 +304,180 @@ function App() {
)} - {/* Resource list */} {resources.length === 0 ? (
No resources available
Please contact administrator for access.
) : ( - <> -
- {nonEmptyGroups.map((group) => ( - - ))} -
- - {/* Runtime input */} -
- - - - {/* Quota cost preview */} - {quota?.enabled && !quota?.unlimited && ( -
- Estimated cost: - {cost} - quota (Remaining: - {(quota?.balance ?? 0) - cost} - ) -
- )} +
+ {/* LEFT: Resource picker */} +
+
+

Choose a Resource

+ {totalResources} {totalResources === 1 ? 'resource' : 'resources'} +
+
+ {nonEmptyGroups.map((group) => ( + + ))} +
- {/* Shareable link - available for all resources */} - {shareableUrl && ( -
- Share link: - {shareableUrl} - + + {quota?.enabled && ( +
+ Quota:{' '} + + {quota?.unlimited ? 'Unlimited' : quota?.balance ?? 0} + +
+ )}
- )} - - {/* Launch section */} -
- - - {quota?.enabled && ( - - Quota: - {quota?.unlimited ? 'Unlimited' : quota?.balance ?? 0} - - + + {/* Share link */} + {shareableUrl && ( +
+ Share link: + {shareableUrl} + +
)} -
- + +
)} ); diff --git a/runtime/hub/frontend/apps/spawn/src/styles.css b/runtime/hub/frontend/apps/spawn/src/styles.css index 06d0a66..258ea53 100644 --- a/runtime/hub/frontend/apps/spawn/src/styles.css +++ b/runtime/hub/frontend/apps/spawn/src/styles.css @@ -1,707 +1,376 @@ /* Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + SPDX-License-Identifier: MIT */ -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -/* ========== Original Spawn UI Styles ========== */ +/* ================================================================ + Spawn UI — 方案三 two-column layout + Left: resource picker | Right: sticky config sidebar + ================================================================ */ :root { - --dark-bg-primary: #212529; - --dark-bg-secondary: #2b3035; - --dark-bg-tertiary: #343a40; - --dark-border: #495057; - --dark-text-primary: #e9ecef; - --dark-text-secondary: #adb5bd; - --dark-text-muted: #6c757d; -} - -/* Main container */ -[data-bs-theme="dark"] #spawn-root { background: var(--dark-bg-secondary) !important; } - -/* Resource container */ -[data-bs-theme="dark"] .resource-container { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-container:hover { background-color: var(--dark-bg-tertiary) !important; } -[data-bs-theme="dark"] .resource-container.selected { background-color: var(--dark-bg-tertiary) !important; border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 1px var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .resource-container strong { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .env-desc { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .dot { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .resource-tag { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .git-clone-badge { background: #1a3a18 !important; border-color: #2d5a28 !important; color: #7ec87a !important; } - -/* Category section */ -[data-bs-theme="dark"] .resource-category { border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-category-header { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-category-header:hover { background: linear-gradient(to right, var(--dark-bg-tertiary), #3d444b) !important; } -[data-bs-theme="dark"] .resource-category-header h5 { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .collapse-icon { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .collapsible-content { background: var(--dark-bg-secondary) !important; } - -/* GPU selection */ -[data-bs-theme="dark"] .gpu-selection { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-selection h6 { color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-option { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-option:hover { background: #3d444b !important; border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .gpu-option.selected { background: #3d444b !important; border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 1px var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .gpu-option-name { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .gpu-option-desc { color: var(--dark-text-secondary) !important; } - -/* Runtime section */ -[data-bs-theme="dark"] .runtime-container { border-top-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .runtime-container label { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .runtime-container input { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-color: var(--dark-border) !important; } - -/* Launch section */ -[data-bs-theme="dark"] .launch-button { background: var(--dark-text-primary) !important; color: var(--dark-bg-primary) !important; } -[data-bs-theme="dark"] .launch-button:hover:not(:disabled) { background: #ffffff !important; } -[data-bs-theme="dark"] .quota-display-simple { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .quota-cost-preview { color: var(--dark-text-secondary) !important; } - -/* Repo URL (inside gpu-selection card) */ -[data-bs-theme="dark"] .repo-url-input { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-url-input:focus { border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 3px rgba(173,181,189,0.15) !important; } -[data-bs-theme="dark"] .repo-url-input.input-error { border-color: #dc3545 !important; } -[data-bs-theme="dark"] .repo-url-input.input-valid { border-color: #2d6a27 !important; } -[data-bs-theme="dark"] .repo-url-hint { background: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-url-tooltip { background: var(--dark-bg-primary) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-url-tooltip::after { border-top-color: var(--dark-bg-primary) !important; } -[data-bs-theme="dark"] .repo-url-error { color: #f8888e !important; } -[data-bs-theme="dark"] .repo-url-validating { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-url-success { color: #7ec87a !important; } -[data-bs-theme="dark"] .shareable-link { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .shareable-link-label { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .shareable-link-url { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .shareable-link-copy { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .shareable-link-copy:hover { background: var(--dark-bg-primary) !important; } - -/* Warning box */ -[data-bs-theme="dark"] .warning-box { background: #4a3f2a !important; border-color: #6b5c3a !important; color: #ffc107 !important; } - -/* Loading spinner */ -[data-bs-theme="dark"] .loading-spinner { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .spinner-icon { border-color: var(--dark-border) !important; border-top-color: var(--dark-text-primary) !important; } - + --home-primary: #2c3e50; + --home-primary-hover: #1a252f; + --home-green: #34c759; + --home-red: #c0392b; + --home-surface-0: #ffffff; + --home-surface-1: #f5f5f7; + --home-surface-2: #e8e8ed; + --home-text: #1d1d1f; + --home-text-secondary: #86868b; + --home-text-muted: #aeaeb2; + --home-border: rgba(0,0,0,0.08); + --home-radius: 16px; + --home-radius-sm: 12px; + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.08); +} +[data-bs-theme="dark"] { + --home-primary: #86a8c7; + --home-primary-hover: #9dbdd8; + --home-green: #30d158; + --home-red: #ff453a; + --home-surface-0: #1c1c1e; + --home-surface-1: #2c2c2e; + --home-surface-2: #3a3a3c; + --home-text: #f5f5f7; + --home-text-secondary: #98989d; + --home-text-muted: #636366; + --home-border: rgba(255,255,255,0.08); + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.25); +} + +/* ---- Root container ---- */ #spawn-root { - max-width: 1000px; - margin: 0 auto; - padding: 25px; - background: #f8f9fa; - border-radius: 16px; - box-shadow: 0 2px 6px rgba(0,0,0,0.05); -} - -.resource-category { - margin-bottom: 20px; - border: 1px solid #e0e0e0; - border-radius: 12px; - overflow: visible; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); -} - -.collapsed .resource-category, -.resource-category.collapsed { - overflow: hidden; -} - -.resource-category-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - background: #f8f9fa; - cursor: pointer; - border-bottom: 1px solid #e0e0e0; - transition: background 0.2s; -} - -.resource-category-header:hover { - background: linear-gradient(to right, #eef1f5, #e8ecf2); -} - -.resource-category-header h5 { - margin: 0; - color: #2c3e50; - font-size: 16px; - font-weight: 600; -} - -.collapse-icon { - font-size: 16px; - transition: transform 0.2s ease; - color: #2c3e50; -} - -.collapsed .collapse-icon { - transform: rotate(-90deg); -} - -.collapsible-content { - max-height: 2000px; - overflow: visible; - transition: max-height 0.3s ease-out, padding 0.3s ease-out; - background: #ffffff; - padding: 20px; -} - -.collapsed .collapsible-content { - max-height: 0; - padding: 0 20px; - overflow: hidden; -} - -.resource-container { - margin-bottom: 14px; - padding: 16px; - border: 1px solid #e0e0e0; - border-radius: 10px; - display: flex; - flex-direction: column; - background: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); - cursor: pointer; - transition: background-color 0.2s ease; -} - -.resource-container:hover { - background-color: #f9fafb; -} - -.resource-container.selected { - background-color: #f5f7fa; - border-color: #2c3e50; - box-shadow: 0 0 0 1px #2c3e50; -} + max-width: 1120px; + margin: 0 auto; + padding: 1.5rem; + background: transparent; + border-radius: 0; + box-shadow: none; + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; + -webkit-font-smoothing: antialiased; + color: var(--home-text); +} +[data-bs-theme="dark"] #spawn-root { background: transparent !important; } + +/* ---- Spawn header ---- */ +.spawn-header { text-align: center; margin-bottom: 1.5rem; } +.spawn-breadcrumb { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 0.78rem; color: var(--home-text-muted); margin-bottom: 0.75rem; +} +.spawn-breadcrumb a { color: var(--home-primary); text-decoration: none; font-weight: 500; } +.spawn-breadcrumb a:hover { text-decoration: underline; } +.spawn-header h1 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; + margin-bottom: 0.3rem; color: var(--home-text); +} +.spawn-header p { font-size: 0.88rem; color: var(--home-text-secondary); } + +/* ---- Two-column layout ---- */ +.spawn-layout { + display: grid; + grid-template-columns: 1fr 360px; + gap: 1.5rem; + align-items: start; +} + +/* ---- LEFT: Resource picker ---- */ +.spawn-picker { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + overflow: hidden; +} +.picker-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--home-border); + display: flex; align-items: center; justify-content: space-between; +} +.picker-header h2 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.05rem; font-weight: 700; color: var(--home-text); +} +.picker-count { + font-size: 0.72rem; color: var(--home-text-muted); + background: var(--home-surface-2); padding: 2px 8px; border-radius: 100px; +} +.picker-body { /* categories render inside */ } + +/* Hide inline GPU/Git panels from CourseCard — sidebar handles them */ +.spawn-picker .gpu-selection { display: none; } + +/* Category accordion — fits inside picker */ +.spawn-picker .resource-category { + margin-bottom: 0; + border: none; + border-bottom: 1px solid var(--home-border); + border-radius: 0; + box-shadow: none; + background: transparent; +} +.spawn-picker .resource-category:last-child { border-bottom: none; } +.spawn-picker .resource-category-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + background: var(--home-surface-0); + border-bottom: 1px solid var(--home-border); + cursor: pointer; + transition: background 0.15s; +} +.spawn-picker .resource-category-header:hover { background: var(--home-surface-1); } +.spawn-picker .resource-category-header h5 { + margin: 0; color: var(--home-text); + font-size: 0.88rem; font-weight: 600; + font-family: 'Plus Jakarta Sans', sans-serif; + flex: 1; min-width: 0; +} +.spawn-picker .collapse-icon { font-size: 0.7rem; color: var(--home-text-muted); } +.spawn-picker .collapsed .collapse-icon { transform: rotate(-90deg); } + +.spawn-picker .collapsible-content { + background: var(--home-surface-1); + padding: 0.75rem 1rem; + max-height: 2000px; + overflow: visible; + transition: max-height 0.3s ease-out, padding 0.3s ease-out; +} +.spawn-picker .collapsed .collapsible-content { + max-height: 0; padding: 0 1rem; overflow: hidden; +} + +/* Resource cards in picker */ +.spawn-picker .resource-container { + margin-bottom: 0.5rem; + padding: 0.75rem 0.85rem; + border: 2px solid var(--home-border); + border-radius: 10px; + display: flex; flex-direction: column; + background: var(--home-surface-0); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.15s; +} +.spawn-picker .resource-container:last-child { margin-bottom: 0; } +.spawn-picker .resource-container:hover { border-color: rgba(44,62,80,0.2); } +.spawn-picker .resource-container.selected { + border-color: var(--home-primary); + box-shadow: 0 0 0 1px var(--home-primary); + background: rgba(44,62,80,0.015); +} +[data-bs-theme="dark"] .spawn-picker .resource-container { background: var(--home-surface-0) !important; border-color: var(--home-border) !important; } +[data-bs-theme="dark"] .spawn-picker .resource-container:hover { border-color: rgba(134,168,199,0.3) !important; } +[data-bs-theme="dark"] .spawn-picker .resource-container.selected { border-color: var(--home-primary) !important; box-shadow: 0 0 0 1px var(--home-primary) !important; } .resource-container input[type="radio"] { - margin-right: 15px; - width: 16px; - height: 16px; - accent-color: #6c7a89; + margin-right: 0.7rem; width: 16px; height: 16px; + accent-color: var(--home-primary); flex-shrink: 0; cursor: pointer; } - .resource-container strong { - display: block; - font-size: 15px; - color: #2c3e50; - margin-bottom: 4px; + display: block; font-size: 0.85rem; font-weight: 600; + color: var(--home-text); margin-bottom: 0.1rem; + font-family: 'Plus Jakarta Sans', sans-serif; } +[data-bs-theme="dark"] .resource-container strong { color: var(--home-text) !important; } +.env-desc { color: var(--home-text-secondary); font-size: 0.72rem; } +[data-bs-theme="dark"] .env-desc { color: var(--home-text-secondary) !important; } +.dot { color: var(--home-text-muted); } +[data-bs-theme="dark"] .dot { color: var(--home-text-muted) !important; } -.env-desc { color: #34495e; } -.dot { color: #95a5a6; } .resource-tag { - color: #2c3e50; - background: #e8e8e8; - padding: 2px 8px; - border-radius: 3px; - border: 1px solid #d0d0d0; - font-size: 13px; + display: inline-flex; align-items: center; gap: 3px; + font-size: 0.6rem; font-weight: 600; padding: 1px 6px; border-radius: 5px; + background: var(--home-surface-1); color: var(--home-text-secondary); + border: none; font-family: 'Plus Jakarta Sans', sans-serif; } +[data-bs-theme="dark"] .resource-tag { background: var(--home-surface-2) !important; color: var(--home-text-secondary) !important; border: none !important; } .git-clone-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 7px; - border-radius: 3px; - border: 1px solid #b7ddb0; - background: #eaf6e8; - color: #2d6a27; - font-size: 12px; - font-weight: 500; - white-space: nowrap; -} - -.gpu-selection { - margin-top: 15px; - margin-left: 40px; - padding: 18px; - background: #ffffff; - border: 1px solid #e5e9f0; - border-radius: 12px; - box-shadow: 0 3px 8px rgba(0,0,0,0.06); -} - -.gpu-selection h6 { - color: #2e3440; - font-size: 1rem; - margin-bottom: 16px; - padding-bottom: 10px; - border-bottom: 2px solid #ebecf0; - font-weight: 600; -} - -.gpu-options-container { - max-height: 180px; - overflow-y: auto; - padding: 0 2px; - box-sizing: border-box; -} - -.gpu-option { - margin: 10px 0; - padding: 14px 16px; - background: #f8f9fc; - border: 1px solid #e5e9f0; - border-radius: 10px; - display: flex; - align-items: center; - cursor: pointer; - transition: all 0.2s; -} - -.gpu-option:hover { - background: #eceff4; - border-color: #2c3e50; -} - -.gpu-option.selected { - background: #e5e9f0; - border-color: #2c3e50; - box-shadow: 0 0 0 1px #2c3e50; -} - -.gpu-option input[type="radio"] { - margin-right: 12px; - width: 18px; - height: 18px; - accent-color: #2c3e50; -} - -.gpu-option-details { flex: 1; } -.gpu-option-name { font-weight: 600; color: #2e3440; margin-bottom: 4px; font-size: 0.95rem; } -.gpu-option-desc { font-size: 0.85rem; color: #4c566a; line-height: 1.4; } - -.runtime-container { - margin-top: 30px; - padding-top: 25px; - border-top: 1px solid #e0e0e0; - display: flex; - align-items: center; - flex-wrap: wrap; -} - -.runtime-container label { - color: #2c3e50; - margin-right: 15px; - font-weight: 500; -} - -.runtime-container input[type="number"] { - width: 120px; - padding: 10px 12px; - border: 1px solid #d0d7de; - border-radius: 8px; - font-size: 14px; - color: #2c3e50; -} - -.quota-cost-preview { - margin-left: 20px; - font-size: 14px; -} - -.launch-section { - margin-top: 25px; - display: flex; - align-items: center; - gap: 20px; -} - -.launch-button { - padding: 10px 20px; - font-weight: 500; - border-radius: 8px; - background: #2c3e50; - color: white; - border: none; - cursor: pointer; -} - -.launch-button:hover:not(:disabled) { - background: #1a252f; -} - -.launch-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.quota-display-simple { font-size: 14px; color: #666; } - + display: inline-flex; align-items: center; gap: 4px; + padding: 1px 6px; border-radius: 5px; + border: 1px solid #b7ddb0; background: #eaf6e8; color: #2d6a27; + font-size: 0.6rem; font-weight: 600; white-space: nowrap; +} +[data-bs-theme="dark"] .git-clone-badge { background: rgba(45,106,39,0.15) !important; border-color: rgba(45,106,39,0.3) !important; color: #4ade80 !important; } + +/* ---- RIGHT: Sidebar ---- */ +.spawn-sidebar { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sidebar-panel { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.25rem; + display: flex; flex-direction: column; gap: 1rem; +} +.sidebar-panel-title { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.95rem; font-weight: 700; color: var(--home-text); + padding-bottom: 0.65rem; border-bottom: 1px solid var(--home-border); +} +.sidebar-empty { + font-size: 0.82rem; color: var(--home-text-muted); + text-align: center; padding: 1rem 0; +} +.sidebar-section { display: flex; flex-direction: column; gap: 0.4rem; } +.sidebar-label { + font-size: 0.78rem; font-weight: 600; color: var(--home-text); + font-family: 'Plus Jakarta Sans', sans-serif; +} +.sidebar-optional { font-weight: 400; font-size: 0.68rem; color: var(--home-text-muted); } + +/* Sidebar GPU cards */ +.sidebar-gpu-list { display: flex; flex-direction: column; gap: 0.4rem; } +.sidebar-gpu-card { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.6rem 0.75rem; + border: 2px solid var(--home-border); border-radius: 10px; + cursor: pointer; background: var(--home-surface-0); + transition: border-color 0.15s, box-shadow 0.15s; +} +.sidebar-gpu-card:hover { border-color: rgba(44,62,80,0.25); } +.sidebar-gpu-card.selected { + border-color: var(--home-primary); + box-shadow: 0 0 0 1px var(--home-primary); +} +[data-bs-theme="dark"] .sidebar-gpu-card { background: var(--home-surface-1) !important; border-color: var(--home-border) !important; } +[data-bs-theme="dark"] .sidebar-gpu-card:hover { border-color: rgba(134,168,199,0.3) !important; } +[data-bs-theme="dark"] .sidebar-gpu-card.selected { border-color: var(--home-primary) !important; box-shadow: 0 0 0 1px var(--home-primary) !important; } +.sidebar-gpu-radio { width: 14px; height: 14px; accent-color: var(--home-primary); cursor: pointer; } +.sidebar-gpu-name { font-size: 0.78rem; font-weight: 600; color: var(--home-text); } +.sidebar-gpu-desc { font-size: 0.65rem; color: var(--home-text-secondary); line-height: 1.3; } + +/* Sidebar Git input */ +.sidebar-git-input { + width: 100%; padding: 0.5rem 0.75rem; + border: 1px solid var(--home-border); border-radius: 10px; + font-size: 0.78rem; color: var(--home-text); + font-family: 'Plus Jakarta Sans', sans-serif; + background: var(--home-surface-0); + transition: border-color 0.2s, box-shadow 0.2s; +} +.sidebar-git-input:focus { outline: none; border-color: var(--home-primary); box-shadow: 0 0 0 3px rgba(44,62,80,0.08); } +.sidebar-git-input.input-error { border-color: var(--home-red); box-shadow: 0 0 0 3px rgba(192,57,43,0.08); } +.sidebar-git-input.input-valid { border-color: var(--home-green); box-shadow: 0 0 0 3px rgba(52,199,89,0.08); } +[data-bs-theme="dark"] .sidebar-git-input { background: var(--home-surface-1) !important; color: var(--home-text) !important; border-color: var(--home-border) !important; } + +.sidebar-git-status { display: block; font-size: 0.68rem; margin-top: 0.25rem; } +.sidebar-git-status.loading { color: var(--home-text-muted); font-style: italic; } +.sidebar-git-status.success { color: #2d6a27; font-weight: 500; } +.sidebar-git-status.error { color: var(--home-red); } +[data-bs-theme="dark"] .sidebar-git-status.success { color: #4ade80; } +[data-bs-theme="dark"] .sidebar-git-status.error { color: #ff6b6b; } + +.sidebar-github-link { + display: inline-flex; align-items: center; gap: 0.3rem; + font-size: 0.72rem; color: var(--home-text-muted); + text-decoration: none; margin-top: 0.15rem; +} +.sidebar-github-link:hover { color: #2d6a27; text-decoration: underline; } + +/* Sidebar Runtime */ +.sidebar-runtime-row { display: flex; align-items: center; gap: 0.5rem; } +.sidebar-runtime-input { + width: 65px; padding: 0.4rem 0.5rem; + border: 1px solid var(--home-border); border-radius: 8px; + font-size: 0.78rem; text-align: center; + font-family: 'Plus Jakarta Sans', sans-serif; + color: var(--home-text); background: var(--home-surface-0); +} +.sidebar-runtime-input:focus { outline: none; border-color: var(--home-primary); box-shadow: 0 0 0 3px rgba(44,62,80,0.08); } +[data-bs-theme="dark"] .sidebar-runtime-input { background: var(--home-surface-1) !important; color: var(--home-text) !important; border-color: var(--home-border) !important; } +.sidebar-runtime-unit { font-size: 0.72rem; color: var(--home-text-secondary); } +.sidebar-quota-preview { font-size: 0.72rem; color: var(--home-text-secondary); margin-top: 0.25rem; } + +/* Sidebar Launch button */ +.sidebar-launch-btn { + display: flex; align-items: center; justify-content: center; gap: 0.5rem; + width: 100%; background: var(--home-primary); color: #fff; border: none; + padding: 0.75rem; border-radius: 980px; + font-size: 0.92rem; font-weight: 600; cursor: pointer; + transition: background 0.2s, transform 0.15s; + font-family: 'Plus Jakarta Sans', sans-serif; +} +.sidebar-launch-btn:hover:not(:disabled) { background: var(--home-primary-hover); transform: scale(1.01); } +.sidebar-launch-btn:active:not(:disabled) { transform: scale(0.98); } +.sidebar-launch-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } +[data-bs-theme="dark"] .sidebar-launch-btn { background: var(--home-primary) !important; color: #fff !important; } + +.sidebar-quota-display { + font-size: 0.78rem; color: var(--home-text-secondary); text-align: center; +} + +/* Sidebar share link */ +.sidebar-share { + display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; + padding: 0.5rem 0.85rem; + background: var(--home-surface-0); + border: 1px solid var(--home-border); border-radius: 10px; +} +.sidebar-share-label { font-size: 0.68rem; color: var(--home-text-muted); white-space: nowrap; font-weight: 500; } +.sidebar-share-url { font-size: 0.62rem; color: var(--home-text-secondary); word-break: break-all; flex: 1; } +.sidebar-share-copy { + padding: 0.2rem 0.5rem; border-radius: 5px; + border: 1px solid var(--home-border); background: var(--home-surface-0); + cursor: pointer; font-size: 0.62rem; font-weight: 500; + font-family: 'Plus Jakarta Sans', sans-serif; + color: var(--home-text-secondary); transition: background 0.15s; +} +.sidebar-share-copy:hover { background: var(--home-surface-2); } + +/* ---- Legacy classes (still used by old runtime/launch/warning) ---- */ .warning-box { - padding: 15px 20px; - background: #fff3cd; - border: 1px solid #ffc107; - border-radius: 8px; - color: #856404; - margin-bottom: 20px; -} - -.loading-spinner { - text-align: center; - padding: 40px; - color: #4c566a; + padding: 0.85rem 1.15rem; + background: #fffbe6; border: 1px solid #ffe58f; + border-radius: var(--home-radius-sm); + color: #7c6a0a; font-size: 0.82rem; margin-bottom: 1rem; } +[data-bs-theme="dark"] .warning-box { background: rgba(255,193,7,0.1) !important; border-color: rgba(255,193,7,0.25) !important; color: #ffc107 !important; } +.loading-spinner { text-align: center; padding: 3rem; color: var(--home-text-secondary); font-size: 0.88rem; } +[data-bs-theme="dark"] .loading-spinner { color: var(--home-text-secondary) !important; } .spinner-icon { - display: inline-block; - width: 24px; - height: 24px; - border: 3px solid #e5e9f0; - border-radius: 50%; - border-top-color: #2c3e50; - animation: spin 1s ease-in-out infinite; - margin-right: 12px; + display: inline-block; width: 24px; height: 24px; + border: 3px solid var(--home-border); border-radius: 50%; + border-top-color: var(--home-primary); + animation: spin 1s ease-in-out infinite; margin-right: 0.75rem; } - @keyframes spin { to { transform: rotate(360deg); } } - .feedback-container { display: none !important; } -.optional-label { - font-weight: 400; - font-size: 13px; - color: #6c757d; -} - -.repo-url-input { - width: 100%; - padding: 10px 12px; - border: 1px solid #d0d7de; - border-radius: 8px; - font-size: 14px; - color: #2c3e50; - box-sizing: border-box; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.repo-url-input:focus { - outline: none; - border-color: #2c3e50; - box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2); -} - -.repo-url-input.input-error { - border-color: #dc3545; - box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.15); -} - -.repo-url-input.input-valid { - border-color: #2d6a27; - box-shadow: 0 0 0 3px rgba(45, 106, 39, 0.15); -} - -.repo-url-error { - display: block; - margin-top: 6px; - font-size: 12px; - color: #dc3545; -} - -.repo-url-validating { - display: block; - margin-top: 6px; - color: #6c757d; - font-size: 12px; - font-style: italic; -} - -.repo-url-success { - display: block; - margin-top: 6px; - color: #2d6a27; - font-size: 12px; - font-weight: 500; -} - -.repo-branch-hint code { - font-size: 12px; - background: #eaf6e8; - padding: 1px 4px; - border-radius: 3px; +/* Legacy classes from CourseCard that are still rendered but hidden in sidebar mode */ +.optional-label { font-weight: 400; font-size: 0.68rem; color: var(--home-text-muted); } +.gpu-option, .gpu-option-details, .gpu-option-name, .gpu-option-desc, +.gpu-options-container, .gpu-selection h6 { /* kept for inline fallback */ } +.repo-url-input, .repo-url-error, .repo-url-validating, .repo-url-success, +.repo-url-hint, .repo-url-tooltip, .repo-branch-hint, +.repo-picker, .repo-picker-trigger, .repo-picker-dropdown, .repo-picker-filter, +.repo-picker-list, .repo-picker-item, .repo-picker-footer, .repo-picker-empty, +.repo-picker-name, .repo-picker-private, .repo-picker-chevron, +.repo-picker-trigger-text, .repo-picker-trigger-placeholder, +.github-app-prompt { /* kept for potential inline usage */ } + +/* Legacy bottom sections are hidden — sidebar replaces them */ +.runtime-container, .launch-section, .shareable-link { display: none; } + +/* ---- Responsive ---- */ +@media (max-width: 900px) { + .spawn-layout { + grid-template-columns: 1fr; + } + .spawn-sidebar { + position: static; + } + /* On mobile, show inline GPU/Git panels instead of sidebar */ + .spawn-picker .gpu-selection { display: block; } +} +@media (max-width: 600px) { + #spawn-root { padding: 1rem; } } - -.shareable-link { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - padding: 6px 10px; - background: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 6px; - flex-wrap: wrap; -} - -.shareable-link-label { - font-size: 12px; - color: #666; - white-space: nowrap; -} - -.shareable-link-url { - font-size: 11px; - color: #2c3e50; - word-break: break-all; - flex: 1; -} - -.shareable-link-copy { - font-size: 11px; - padding: 2px 8px; - border: 1px solid #bbb; - border-radius: 4px; - background: white; - cursor: pointer; - white-space: nowrap; - color: #444; -} - -.shareable-link-copy:hover { - background: #e8e8e8; -} - -.repo-url-hint { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - margin-left: 6px; - border-radius: 50%; - background: #adb5bd; - color: #fff; - font-size: 10px; - font-weight: 700; - font-style: normal; - cursor: default; - vertical-align: middle; - user-select: none; -} - -.repo-url-tooltip { - display: none; - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - width: 260px; - padding: 8px 12px; - background: #2c3e50; - color: #fff; - font-size: 12px; - font-weight: 400; - border-radius: 6px; - line-height: 1.5; - white-space: normal; - z-index: 100; - pointer-events: none; -} - -.repo-url-tooltip::after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 5px solid transparent; - border-top-color: #2c3e50; -} - -.repo-url-hint:hover .repo-url-tooltip { - display: block; -} - -/* ========== Repo Picker (GitHub App) ========== */ - -.repo-picker { - margin-bottom: 14px; - position: relative; -} - -.repo-picker h6 { - color: #2e3440; - font-size: 1rem; - margin-bottom: 10px; - font-weight: 600; -} - -.repo-picker-trigger { - display: flex; - align-items: center; - padding: 9px 12px; - border: 1px solid #e5e9f0; - border-radius: 8px; - cursor: pointer; - background: #f8f9fc; - transition: border-color 0.2s, border-radius 0.15s; -} - -.repo-picker-trigger:hover { border-color: #2c3e50; } - -.repo-picker-trigger.open { - border-color: #2c3e50; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom-color: transparent; -} - -.repo-picker-trigger-text { - flex: 1; - font-size: 13px; - color: #2c3e50; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.repo-picker-trigger-placeholder { - flex: 1; - font-size: 13px; - color: #adb5bd; -} - -.repo-picker-chevron { - font-size: 11px; - color: #6c757d; - margin-left: 8px; - transition: transform 0.2s; -} - -.repo-picker-trigger.open .repo-picker-chevron { transform: rotate(180deg); } - -.repo-picker-dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 10; - border: 1px solid #2c3e50; - border-top: none; - border-radius: 0 0 8px 8px; - max-height: 210px; - overflow: hidden; - display: flex; - flex-direction: column; - background: #f8f9fc; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.repo-picker-filter { - padding: 8px 12px; - border: none; - border-bottom: 1px solid #e5e9f0; - outline: none; - font-size: 13px; - color: #2c3e50; - background: transparent; - box-sizing: border-box; - width: 100%; -} - -.repo-picker-filter::placeholder { color: #adb5bd; } - -.repo-picker-list { - overflow-y: auto; - flex: 1; -} - -.repo-picker-item { - padding: 7px 12px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; - color: #2c3e50; - transition: background 0.15s; -} - -.repo-picker-item:hover { background: #eceff4; } -.repo-picker-item.selected { background: #e0e6ee; } - -.repo-picker-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - -.repo-picker-private { - font-size: 11px; - color: #6c757d; - margin-left: 8px; - flex-shrink: 0; -} - -.repo-picker-empty { - padding: 10px 12px; - color: #6c757d; - font-size: 13px; -} - -.repo-picker-footer { - padding: 7px 12px; - border-top: 1px solid #e5e9f0; - font-size: 12px; - color: #4c566a; - text-decoration: none; - display: block; -} - -.repo-picker-footer:hover { text-decoration: underline; } - -/* ========== GitHub App Install Prompt ========== */ - -.github-app-prompt { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 10px; - font-size: 13px; - color: #6c757d; - text-decoration: none; -} - -.github-app-prompt:hover { - color: #2d6a27; - text-decoration: underline; -} - -.github-app-prompt svg { flex-shrink: 0; } - -/* ========== Dark Mode — Repo Picker ========== */ -[data-bs-theme="dark"] .repo-picker h6 { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-picker-trigger:hover { border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger.open { border-color: var(--dark-text-secondary) !important; border-bottom-color: transparent !important; } -[data-bs-theme="dark"] .repo-picker-trigger-text { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger-placeholder { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-chevron { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-dropdown { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .repo-picker-filter { color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-picker-filter::placeholder { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-item { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-item:hover { background: #3d444b !important; } -[data-bs-theme="dark"] .repo-picker-item.selected { background: #3a4550 !important; } -[data-bs-theme="dark"] .repo-picker-private { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-empty { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-footer { color: var(--dark-text-secondary) !important; border-top-color: var(--dark-border) !important; } - -/* ========== Dark Mode — GitHub App Prompt ========== */ -[data-bs-theme="dark"] .github-app-prompt { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .github-app-prompt:hover { color: #7ec87a !important; } diff --git a/runtime/values.yaml b/runtime/values.yaml index 6f7b35e..d8d6efb 100644 --- a/runtime/values.yaml +++ b/runtime/values.yaml @@ -347,14 +347,10 @@ hub: config: # ---- Hub Core ---- - # JupyterHub: - # Enable JupyterHub REST API - # API Documentation: https://jupyterhub.readthedocs.io/en/stable/reference/rest-api.html - # api_tokens: - # Configure API tokens for external services - # Format: token: username - # It's recommended to use environment variables or secrets to manage tokens - # Example: "your-secure-api-token-here": "admin-service" + JupyterHub: + # Always show Home page first, even when a server is already running. + # Users can access their running server via the "My Server" button on Home. + redirect_to_server: false Authenticator: allow_all: true From e44faa2109c0447850530e9dd25517bdd9d67c1c Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:03:17 +0800 Subject: [PATCH 05/14] fix(home): apply team-based resource filtering on Home page Home page now respects window.AVAILABLE_RESOURCES (injected by Hub based on user team membership), matching the same filtering logic used in the Spawn page's useResources hook. Without this, users could see resources on Home they don't have permission to launch. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index e90a1c5..7370920 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -10,6 +10,7 @@ interface HomeData { declare global { interface Window { HOME_DATA?: HomeData; + AVAILABLE_RESOURCES?: string[]; } } @@ -63,7 +64,20 @@ function App() { useEffect(() => { getResources() .then((data) => { - setGroups(data.groups.filter((g) => g.resources.length > 0)); + const allowedKeys = window.AVAILABLE_RESOURCES ?? []; + const hasFilter = allowedKeys.length > 0; + const allowedSet = new Set(allowedKeys); + + const filtered = hasFilter + ? data.groups + .map((g) => ({ + ...g, + resources: g.resources.filter((r) => allowedSet.has(r.key)), + })) + .filter((g) => g.resources.length > 0) + : data.groups.filter((g) => g.resources.length > 0); + + setGroups(filtered); }) .catch(() => {}) .finally(() => setResourcesLoading(false)); From 7c3613ebf5dd7c508c2cca3e38ed4d6a7d017c98 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:07:43 +0800 Subject: [PATCH 06/14] fix(home): define .container width to avoid relying on external Bootstrap Home page used .container class without defining it, depending entirely on Hub's Bootstrap CSS. This adds an explicit scoped rule under .home-page matching the 1120px max-width used by Spawn's #spawn-root. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/styles.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css index 1fa3c9b..9a0e800 100644 --- a/runtime/hub/frontend/apps/home/src/styles.css +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -40,6 +40,11 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +.home-page .container { + max-width: 1120px; + margin: 0 auto; + padding: 0 1.5rem; +} /* ---- Hero ---- */ .home-hero { From 9674d68b7efac3338b4963f9a6ac0eaa64509c5c Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:08:47 +0800 Subject: [PATCH 07/14] fix(home): show error message when resource loading fails Previously getResources() errors were silently swallowed, leaving users with a confusing "No resources available" message. Now captures the error and displays a visible warning box with a fallback link to the Spawner page. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 16 ++++++++++++++- runtime/hub/frontend/apps/home/src/styles.css | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 7370920..912d328 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -48,6 +48,7 @@ function App() { const [announcement, setAnnouncement] = useState(null); const [groups, setGroups] = useState([]); const [resourcesLoading, setResourcesLoading] = useState(true); + const [resourcesError, setResourcesError] = useState(null); useEffect(() => { fetch(`${baseUrl}static/announcement.txt`) @@ -79,7 +80,11 @@ function App() { setGroups(filtered); }) - .catch(() => {}) + .catch((err) => { + setResourcesError( + err instanceof Error ? err.message : "Failed to load resources", + ); + }) .finally(() => setResourcesLoading(false)); }, []); @@ -243,6 +248,15 @@ function App() {
Loading resources…
+ ) : resourcesError ? ( +
+

+ Error: {resourcesError} +

+

+ Go to Spawner +

+
) : totalResources === 0 ? (

diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css index 9a0e800..056e2d8 100644 --- a/runtime/hub/frontend/apps/home/src/styles.css +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -348,6 +348,26 @@ font-weight: 500; } .resources-empty a:hover { text-decoration: underline; } +.resources-error { + text-align: center; + padding: 1.5rem 2rem; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: var(--home-radius-sm); + color: #7c6a0a; + font-size: 0.85rem; +} +[data-bs-theme="dark"] .resources-error { + background: rgba(255,193,7,0.1); + border-color: rgba(255,193,7,0.25); + color: #ffc107; +} +.resources-error a { + color: var(--home-primary); + text-decoration: none; + font-weight: 500; +} +.resources-error a:hover { text-decoration: underline; } /* ---- Resource Card ---- */ .resource-card { From fe6420b912d45e54b5bed3fe221bcaac83737ce1 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:13:43 +0800 Subject: [PATCH 08/14] fix(home): correct server_url fallback to point to user's Jupyter server The fallback server_url incorrectly pointed to /hub/spawn instead of the user's running server at /hub/user//. When HOME_DATA is not injected by the Hub template, the "My Server" button now navigates to the correct destination. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 912d328..1e5268d 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -22,7 +22,7 @@ const jhdata = window.jhdata ?? { const homeData: HomeData = window.HOME_DATA ?? { server_active: false, - server_url: `${jhdata.base_url}spawn`, + server_url: `${jhdata.base_url.replace(/\/?$/, "/")}user/${jhdata.user}/`, }; const baseUrl = jhdata.base_url; From 9fe681e99395c8a2d92f6f03aaaa3ae40045ab6b Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:14:31 +0800 Subject: [PATCH 09/14] fix(spawn): remove duplicate GPU/Git controls on mobile viewport The mobile CSS re-showed inline .gpu-selection panels while the sidebar remained visible (just non-sticky), causing GPU selection and Git input to appear twice. The sidebar handles all configuration on both desktop and mobile, so the inline fallback is removed. Made-with: Cursor --- runtime/hub/frontend/apps/spawn/src/styles.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/runtime/hub/frontend/apps/spawn/src/styles.css b/runtime/hub/frontend/apps/spawn/src/styles.css index 258ea53..4397adb 100644 --- a/runtime/hub/frontend/apps/spawn/src/styles.css +++ b/runtime/hub/frontend/apps/spawn/src/styles.css @@ -368,8 +368,6 @@ .spawn-sidebar { position: static; } - /* On mobile, show inline GPU/Git panels instead of sidebar */ - .spawn-picker .gpu-selection { display: block; } } @media (max-width: 600px) { #spawn-root { padding: 1rem; } From ace99a8916fd284b0bc74fce0ce6dfd27642ee8d Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:16:34 +0800 Subject: [PATCH 10/14] fix(home): resolve TS error for possibly undefined jhdata.base_url JHData.base_url is typed as optional in the shared package. Add nullish coalescing fallbacks for base_url and user to satisfy strict TypeScript checks, and reorder declarations so baseUrl is available when computing the homeData fallback. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 1e5268d..31ccb78 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -20,13 +20,13 @@ const jhdata = window.jhdata ?? { user: "student", }; +const baseUrl = jhdata.base_url ?? "/hub/"; + const homeData: HomeData = window.HOME_DATA ?? { server_active: false, - server_url: `${jhdata.base_url.replace(/\/?$/, "/")}user/${jhdata.user}/`, + server_url: `${baseUrl.replace(/\/?$/, "/")}user/${jhdata.user ?? "student"}/`, }; -const baseUrl = jhdata.base_url; - function formatResourceSpecs(r: Resource): string { const req = r.requirements; const mem = req.memory.replace("Gi", "GB"); From 86c0a168bbfbe8d351712c1888207f9116b17811 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:24:02 +0800 Subject: [PATCH 11/14] fix(home): render announcement as plain text to prevent XSS Replace dangerouslySetInnerHTML with safe text rendering for the announcement content. HTML announcements are already rendered server-side by the Jinja template; the React News section does not need to support raw HTML injection. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 31ccb78..c6582e3 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -413,7 +413,7 @@ function App() {

Announcement

Platform Announcement

-

+

{announcement}

)}
From 93205f63b728f53a0853d736ab88352844f5a062 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:25:11 +0800 Subject: [PATCH 12/14] fix(home): show user feedback when server stop fails Previously, stop failures (HTTP errors or network errors) were silently logged to console. Now displays a visible red error message in the launch bar so users know the stop action failed. The error clears on the next stop attempt. Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 10 ++++++++-- runtime/hub/frontend/apps/home/src/styles.css | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index c6582e3..274158c 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -49,6 +49,7 @@ function App() { const [groups, setGroups] = useState([]); const [resourcesLoading, setResourcesLoading] = useState(true); const [resourcesError, setResourcesError] = useState(null); + const [stopError, setStopError] = useState(null); useEffect(() => { fetch(`${baseUrl}static/announcement.txt`) @@ -94,6 +95,7 @@ function App() { e.nativeEvent.stopImmediatePropagation(); if (stopping) return; setStopping(true); + setStopError(null); try { const resp = await fetch( `${baseUrl}api/users/${jhdata.user}/server`, @@ -104,9 +106,11 @@ function App() { ); if (resp.ok || resp.status === 204 || resp.status === 202) { setServerActive(false); + } else { + setStopError(`Failed to stop server (HTTP ${resp.status})`); } } catch (err) { - console.error("Failed to stop server:", err); + setStopError("Network error — could not reach the server"); } finally { setStopping(false); } @@ -157,7 +161,9 @@ function App() { )}
- {stopping + {stopError ? ( + {stopError} + ) : stopping ? "Stopping your server\u2026" : serverActive ? 'Your server is running \u2014 click "My Server" to open JupyterLab' diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css index 056e2d8..a22a565 100644 --- a/runtime/hub/frontend/apps/home/src/styles.css +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -135,6 +135,10 @@ color: var(--home-text-secondary); margin-top: 1px; } +.lb-error { + color: var(--home-red); + font-weight: 500; +} .status-badge { display: inline-flex; align-items: center; From 15cf8b0ceb90e39a00ecb3dd5655045145c02704 Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 22:25:36 +0800 Subject: [PATCH 13/14] chore(home): update copyright year to 2025-2026 Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 274158c..489efb7 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -439,7 +439,8 @@ function App() { {/* Footer */}
- © 2025 Advanced Micro Devices, Inc. All rights reserved. · + © 2025–2026 Advanced Micro Devices, Inc. All rights reserved. + · AUP Learning Cloud
From 5df5280c34d4ddbf96eba0100d36ba8cbfe8ec0c Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Thu, 2 Apr 2026 01:29:47 +0800 Subject: [PATCH 14/14] fix(home): remove unused catch binding to pass eslint Made-with: Cursor --- runtime/hub/frontend/apps/home/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index 489efb7..51ac8e0 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -109,7 +109,7 @@ function App() { } else { setStopError(`Failed to stop server (HTTP ${resp.status})`); } - } catch (err) { + } catch { setStopError("Network error — could not reach the server"); } finally { setStopping(false);