From 2a44b8bfa75e4f98df7d9303683746a27e1124b9 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:24:32 +0800 Subject: [PATCH 01/58] feat(installer): support --key=value CLI flags for all configuration options Add CLI flag parsing (--gpu, --docker, --mirror, --mirror-pip, --mirror-npm) as alternatives to environment variables. Flags can appear in any position and override corresponding env vars. --- auplc-installer | 53 ++++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/auplc-installer b/auplc-installer index 8f79bef..7e2b990 100755 --- a/auplc-installer +++ b/auplc-installer @@ -599,37 +599,44 @@ Commands: detect-gpu Show detected GPU configuration -GPU Configuration: - GPU_TYPE Override auto-detected GPU type (phx, strix, strix-halo) - Auto-detection uses rocminfo or KFD topology. +Options (can also be set via environment variables): + --gpu=TYPE Override auto-detected GPU type (phx, strix, strix-halo) + Auto-detection uses rocminfo or KFD topology. + Env: GPU_TYPE - Examples: - GPU_TYPE=strix ./auplc-installer install - GPU_TYPE=phx ./auplc-installer img build base-rocm + --docker=0|1 Use host Docker as K3s container runtime (default: 1). + 1 = Docker mode: images visible to K3s immediately. + 0 = containerd mode: images exported for offline use. + Env: K3S_USE_DOCKER -Runtime Configuration: - K3S_USE_DOCKER Use host Docker as K3s container runtime (default: 1). - 1 = Docker mode: images built with "make hub" are visible to K3s - immediately after "rt upgrade", no export needed. - Requires Docker to be installed on the host. - 0 = containerd mode: images are exported to K3s image dir - (K3S_IMAGES_DIR) for offline/portable deployments. + --mirror=PREFIX Registry mirror (e.g. mirror.example.com) + Env: MIRROR_PREFIX + --mirror-pip=URL PyPI mirror URL. Env: MIRROR_PIP + --mirror-npm=URL npm registry URL. Env: MIRROR_NPM Examples: - ./auplc-installer install # Docker mode (default) - K3S_USE_DOCKER=0 ./auplc-installer install # containerd + export mode - -Mirror Configuration: - MIRROR_PREFIX Registry mirror (e.g. mirror.example.com) - MIRROR_PIP PyPI mirror URL - MIRROR_NPM npm registry URL - - Example: - MIRROR_PREFIX="mirror.example.com" ./auplc-installer install + ./auplc-installer install --gpu=strix-halo + ./auplc-installer install --gpu=phx --docker=0 + ./auplc-installer img build base-rocm --gpu=strix + ./auplc-installer install --mirror=mirror.example.com EOF } +# Parse global options (--key=value flags override environment variables) +args=() +for arg in "$@"; do + case "$arg" in + --gpu=*) GPU_TYPE="${arg#--gpu=}" ;; + --docker=*) K3S_USE_DOCKER="${arg#--docker=}" ;; + --mirror=*) MIRROR_PREFIX="${arg#--mirror=}" ;; + --mirror-pip=*) MIRROR_PIP="${arg#--mirror-pip=}" ;; + --mirror-npm=*) MIRROR_NPM="${arg#--mirror-npm=}" ;; + *) args+=("$arg") ;; + esac +done +set -- "${args[@]}" + if [[ $# -eq 0 ]]; then show_help exit 1 From f809d02592cb8cc78e445c341bab9e56b450dd78 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:46:42 +0800 Subject: [PATCH 02/58] docs: update README to use new --flag syntax for installer options --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3eff5bf..026d7ab 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,21 @@ cd aup-learning-cloud sudo ./auplc-installer install ``` After installation completes, open http://localhost:30890 in your browser. No login credentials are required - you will be automatically logged in. -The installer uses **Docker as the default container runtime** (`K3S_USE_DOCKER=1`), see more at [link](https://amdresearch.github.io/aup-learning-cloud/installation/single-node.html#runtime-and-mirror-configuration) +Common options: +```bash +sudo ./auplc-installer install --gpu=strix-halo # specify GPU type +sudo ./auplc-installer install --docker=0 # use containerd instead of Docker +sudo ./auplc-installer install --mirror=mirror.example.com # use registry mirror +``` + +See more at [link](https://amdresearch.github.io/aup-learning-cloud/installation/single-node.html#runtime-and-mirror-configuration) ### Uninstall ```bash sudo ./auplc-installer uninstall ``` -> **💡 Tip**: For mirror configuration (registries, PyPI, npm), see [Mirror Configuration](deploy/README.md#mirror-configuration). - ## Cluster Installation For multi-node cluster installation or need more control over the deployment process: From 6eedc05afd1565184a48891dbc2a6d18497c3214 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:54:02 +0800 Subject: [PATCH 03/58] feat(installer): add RDNA4 (gfx1201) GPU support Add gfx1201/rdna4/dgpu to resolve_gpu_config mapping (GPU_TARGET=gfx120x). Add gfx120x to CI build-config.json (pytorch_whl: gfx120X-all). ROCm tarball and PyTorch wheels confirmed available at repo.amd.com. --- .github/build-config.json | 3 ++- auplc-installer | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/build-config.json b/.github/build-config.json index 7126728..162b12a 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -1,7 +1,8 @@ { "gpu_targets": [ {"name": "gfx110x", "pytorch_whl": "gfx110X-all"}, - {"name": "gfx1151", "pytorch_whl": "gfx1151"} + {"name": "gfx1151", "pytorch_whl": "gfx1151"}, + {"name": "gfx120x", "pytorch_whl": "gfx120X-all"} ], "default_gpu_target": "gfx1151", "courses": ["CV", "DL", "LLM", "PhySim"] diff --git a/auplc-installer b/auplc-installer index 7e2b990..8a6caf7 100755 --- a/auplc-installer +++ b/auplc-installer @@ -137,9 +137,14 @@ function resolve_gpu_config() { GPU_TARGET="gfx1151" ACCEL_ENV="" ;; + gfx1201|gfx1200|rdna4|dgpu) + ACCEL_KEY="dgpu" + GPU_TARGET="gfx120x" + ACCEL_ENV="" + ;; *) echo "Error: Unsupported GPU type: $input" >&2 - echo "Supported: phx (gfx1100-1103), strix (gfx1150), strix-halo (gfx1151)" >&2 + echo "Supported: phx (gfx1100-1103), strix (gfx1150), strix-halo (gfx1151), rdna4 (gfx1201)" >&2 exit 1 ;; esac @@ -600,7 +605,7 @@ Commands: detect-gpu Show detected GPU configuration Options (can also be set via environment variables): - --gpu=TYPE Override auto-detected GPU type (phx, strix, strix-halo) + --gpu=TYPE Override auto-detected GPU type (phx, strix, strix-halo, rdna4) Auto-detection uses rocminfo or KFD topology. Env: GPU_TYPE From 34e3c81bb870165ee30a52e642f50bc9692bc1a4 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:33:51 +0800 Subject: [PATCH 04/58] feat(installer): add offline pack command and CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'pack' command to create self-contained offline deployment bundles. Support all 4 image source × deploy target combinations: install build locally + deploy (existing) install --pull pull from GHCR + deploy (new) pack pull from GHCR + save to bundle (new) pack --local build locally + save to bundle (new) Offline bundles include K3s binary/images, Helm, K9s, ROCm device plugin manifest, all container images, and Helm chart+values. Auto-detected via manifest.json when running from bundle directory. Add pack-bundle.yml CI workflow for manual bundle creation. --- .github/workflows/pack-bundle.yml | 131 ++++++ .gitignore | 4 + auplc-installer | 746 +++++++++++++++++++++++++----- 3 files changed, 766 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/pack-bundle.yml diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml new file mode 100644 index 0000000..b93a32b --- /dev/null +++ b/.github/workflows/pack-bundle.yml @@ -0,0 +1,131 @@ +# 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: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# 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. + +name: Pack Offline Bundle + +on: + workflow_dispatch: + inputs: + gpu_target: + description: 'GPU target for the bundle' + required: true + default: 'gfx1151' + type: choice + options: + - gfx110x + - gfx1151 + gpu_type: + description: 'GPU type (accelerator name)' + required: true + default: 'strix-halo' + type: choice + options: + - strix-halo + - strix + - phx + create_release: + description: 'Create a GitHub Release with the bundle' + required: false + default: false + type: boolean + +permissions: + contents: write + packages: read + +jobs: + pack: + name: "Pack Bundle (${{ inputs.gpu_target }})" + runs-on: ubuntu-latest + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false + + - name: Check available disk space + run: df -h / + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Run pack command + run: | + GPU_TYPE="${{ inputs.gpu_type }}" ./auplc-installer pack + + - name: Verify bundle + run: | + BUNDLE=$(ls auplc-bundle-*.tar.gz) + echo "Bundle: ${BUNDLE}" + echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" + + # Extract and verify structure + tar tzf "${BUNDLE}" | head -30 + echo "---" + echo "Total files: $(tar tzf "${BUNDLE}" | wc -l)" + + - name: Upload bundle as artifact + uses: actions/upload-artifact@v4 + with: + name: auplc-bundle-${{ inputs.gpu_target }} + path: auplc-bundle-*.tar.gz + retention-days: 7 + compression-level: 0 # already compressed + + - name: Create GitHub Release + if: inputs.create_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BUNDLE=$(ls auplc-bundle-*.tar.gz) + BUNDLE_NAME=$(basename "${BUNDLE}" .tar.gz) + TAG="bundle-${{ inputs.gpu_target }}-$(date +%Y%m%d)" + + gh release create "${TAG}" "${BUNDLE}" \ + --title "Offline Bundle: ${{ inputs.gpu_target }} ($(date +%Y-%m-%d))" \ + --notes "$(cat < m.daocloud.io/quay.io/jupyterhub/k8s-hub:4.1.0 +# Registry/package mirror configuration MIRROR_PREFIX="${MIRROR_PREFIX:-}" - -# Package manager mirrors (set via environment variables) MIRROR_PIP="${MIRROR_PIP:-}" MIRROR_NPM="${MIRROR_NPM:-}" -# Custom images (built locally) +# Custom images (built locally or pulled from GHCR) CUSTOM_IMAGES=( "ghcr.io/amdresearch/auplc-hub:latest" "ghcr.io/amdresearch/auplc-default:latest" @@ -48,29 +51,35 @@ CUSTOM_IMAGES=( "ghcr.io/amdresearch/auplc-llm:latest" ) -# External images required by JupyterHub (for offline deployment) +# GPU-specific custom images (have :latest- tags) +GPU_CUSTOM_NAMES=("auplc-base" "auplc-cv" "auplc-dl" "auplc-llm" "auplc-physim") + +# Non-GPU custom images (only :latest tag) +PLAIN_CUSTOM_NAMES=("auplc-hub" "auplc-default") + +# External images required by JupyterHub at runtime EXTERNAL_IMAGES=( - # JupyterHub core components "quay.io/jupyterhub/k8s-hub:4.1.0" "quay.io/jupyterhub/configurable-http-proxy:4.6.3" "quay.io/jupyterhub/k8s-secret-sync:4.1.0" "quay.io/jupyterhub/k8s-network-tools:4.1.0" "quay.io/jupyterhub/k8s-image-awaiter:4.1.0" "quay.io/jupyterhub/k8s-singleuser-sample:4.1.0" - # Kubernetes components "registry.k8s.io/kube-scheduler:v1.30.8" "registry.k8s.io/pause:3.10" - # Traefik proxy "traefik:v3.3.1" - # Utility images "curlimages/curl:8.5.0" - # Base images for Docker build + "alpine/git:2.47.2" +) + +# Base images only needed for local Docker build, not for runtime or bundle +BUILD_ONLY_IMAGES=( "node:20-alpine" "ubuntu:24.04" "quay.io/jupyter/base-notebook" ) -# Combined list for backward compatibility +# Combined list for backward compatibility (img pull still pulls everything) IMAGES=("${CUSTOM_IMAGES[@]}") # GPU configuration globals (set by detect_and_configure_gpu) @@ -78,8 +87,43 @@ ACCEL_KEY="" GPU_TARGET="" ACCEL_ENV="" +# ============================================================ +# Offline Bundle Detection +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OFFLINE_MODE=0 +BUNDLE_DIR="" + +function detect_offline_bundle() { + if [[ ! -f "${SCRIPT_DIR}/manifest.json" ]]; then + return + fi + + BUNDLE_DIR="${SCRIPT_DIR}" + OFFLINE_MODE=1 + K3S_USE_DOCKER=0 + echo "Offline bundle detected at: ${BUNDLE_DIR}" + + # Parse GPU config from manifest without python + local gpu_target accel_key accel_env + gpu_target=$(sed -n 's/.*"gpu_target"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + accel_key=$(sed -n 's/.*"accel_key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + accel_env=$(sed -n 's/.*"accel_env"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + + if [[ -n "${gpu_target}" ]]; then + GPU_TARGET="${gpu_target}" + ACCEL_KEY="${accel_key}" + ACCEL_ENV="${accel_env}" + echo " GPU config: accelerator=${ACCEL_KEY}, GPU_TARGET=${GPU_TARGET}${ACCEL_ENV:+, HSA_OVERRIDE=${ACCEL_ENV}}" + fi +} + +# ============================================================ +# GPU Detection & Configuration +# ============================================================ + function detect_gpu() { - # Try rocminfo first (most readable output) if command -v rocminfo &>/dev/null; then local gfx gfx=$(rocminfo 2>/dev/null | grep -o 'gfx[0-9]*' | head -1) @@ -171,8 +215,15 @@ function detect_and_configure_gpu() { echo " accelerator=${ACCEL_KEY}, GPU_TARGET=${GPU_TARGET}${ACCEL_ENV:+, HSA_OVERRIDE=${ACCEL_ENV}}" } +# ============================================================ +# Values Overlay +# ============================================================ + function generate_values_overlay() { local overlay_path="runtime/values.local.yaml" + if [[ "${OFFLINE_MODE}" == "1" ]]; then + overlay_path="${BUNDLE_DIR}/config/values.local.yaml" + fi echo "Generating values overlay: ${overlay_path}" local tag="latest-${GPU_TARGET}" @@ -205,6 +256,10 @@ function generate_values_overlay() { } > "${overlay_path}" } +# ============================================================ +# Tool Installation (Helm, K9s) +# ============================================================ + function check_root() { if [[ $EUID -ne 0 ]]; then echo "Error: This script must be run as root." >&2 @@ -215,9 +270,22 @@ function check_root() { function install_tools() { echo "Checking/Installing tools (may require sudo)..." + if [[ "${OFFLINE_MODE}" == "1" ]]; then + if ! command -v helm &> /dev/null; then + echo "Installing Helm from bundle..." + sudo cp "${BUNDLE_DIR}/bin/helm" /usr/local/bin/helm + sudo chmod +x /usr/local/bin/helm + fi + if ! command -v k9s &> /dev/null; then + echo "Installing K9s from bundle..." + sudo dpkg -i "${BUNDLE_DIR}/bin/k9s_linux_amd64.deb" + fi + return + fi + if ! command -v helm &> /dev/null; then echo "Installing Helm..." - wget https://get.helm.sh/helm-v3.17.2-linux-amd64.tar.gz -O /tmp/helm-linux-amd64.tar.gz + wget https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz -O /tmp/helm-linux-amd64.tar.gz tar -zxvf /tmp/helm-linux-amd64.tar.gz -C /tmp sudo mv /tmp/linux-amd64/helm /usr/local/bin/helm rm /tmp/helm-linux-amd64.tar.gz @@ -226,16 +294,17 @@ function install_tools() { if ! command -v k9s &> /dev/null; then echo "Installing K9s..." - wget https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.deb -O /tmp/k9s_linux_amd64.deb + wget "https://github.com/derailed/k9s/releases/download/${K9S_VERSION}/k9s_linux_amd64.deb" -O /tmp/k9s_linux_amd64.deb sudo apt install /tmp/k9s_linux_amd64.deb -y rm /tmp/k9s_linux_amd64.deb fi } -function configure_registry_mirrors() { - # Configure K3s registry mirrors using MIRROR_PREFIX - # This must be done BEFORE k3s starts +# ============================================================ +# K3s Management +# ============================================================ +function configure_registry_mirrors() { if [[ -z "${MIRROR_PREFIX}" ]]; then echo "No registry mirror configured. Using default registries." return 0 @@ -244,7 +313,6 @@ function configure_registry_mirrors() { echo "Configuring registry mirrors with prefix: ${MIRROR_PREFIX}" sudo mkdir -p "$(dirname "${K3S_REGISTRIES_FILE}")" - # Configure mirrors for all registries using the prefix pattern local config="mirrors: docker.io: endpoint: @@ -263,15 +331,9 @@ function configure_registry_mirrors() { echo "Registry mirrors configured at ${K3S_REGISTRIES_FILE}" } -# Dummy interface IP for K3s node binding -# Using a private IP range that won't conflict with typical networks K3S_NODE_IP="10.255.255.1" function setup_dummy_interface() { - # Create a dummy network interface for offline/portable operation - # This provides a stable node IP that doesn't change when WiFi/network changes - # Reference: https://docs.k3s.io/installation/airgap - if ip link show dummy0 &>/dev/null; then echo "Dummy interface already exists, skipping setup" return 0 @@ -281,10 +343,8 @@ function setup_dummy_interface() { sudo ip link add dummy0 type dummy sudo ip link set dummy0 up sudo ip addr add "${K3S_NODE_IP}/32" dev dummy0 - # Add a low-priority default route so K3s can detect a valid route sudo ip route add default via "${K3S_NODE_IP}" dev dummy0 metric 1000 2>/dev/null || true - # Make persistent across reboots cat << EOF | sudo tee /etc/systemd/system/dummy-interface.service > /dev/null [Unit] Description=Setup dummy network interface for K3s portable operation @@ -308,30 +368,41 @@ EOF function install_k3s_single_node() { echo "Starting K3s installation..." - if [[ "${K3S_USE_DOCKER}" == "1" ]]; then - echo "Using Docker as container runtime (K3S_USE_DOCKER=1). Images stay in Docker; no export to agent/images." - if ! command -v docker &> /dev/null; then - echo "Error: K3S_USE_DOCKER is set but Docker is not installed. Install Docker first." >&2 - exit 1 - fi - fi - - # Setup dummy interface for offline operation setup_dummy_interface + local k3s_exec="--node-ip=${K3S_NODE_IP} --flannel-iface=dummy0" - # Configure registry mirrors before starting k3s - configure_registry_mirrors + if [[ "${OFFLINE_MODE}" == "1" ]]; then + echo "Offline mode: installing K3s from bundle (containerd)..." - # Build K3s server exec flags (--docker = use host Docker so image updates are visible in dev) - local k3s_exec="--node-ip=${K3S_NODE_IP} --flannel-iface=dummy0" - if [[ "${K3S_USE_DOCKER}" == "1" ]]; then - k3s_exec="${k3s_exec} --docker" - fi + sudo cp "${BUNDLE_DIR}/bin/k3s" /usr/local/bin/k3s + sudo chmod +x /usr/local/bin/k3s - # Bind K3s to dummy interface IP for portable operation - # With --docker, K3s uses host Docker; image updates (e.g. make hub) are visible without re-export. - curl -sfL https://get.k3s.io | sudo K3S_KUBECONFIG_MODE="644" \ - INSTALL_K3S_EXEC="${k3s_exec}" sh - + sudo mkdir -p "${K3S_IMAGES_DIR}" + for img_file in "${BUNDLE_DIR}"/k3s-images/*; do + [[ -f "${img_file}" ]] || continue + echo " Copying: $(basename "${img_file}")" + sudo cp "${img_file}" "${K3S_IMAGES_DIR}/" + done + + sudo INSTALL_K3S_SKIP_DOWNLOAD=true \ + K3S_KUBECONFIG_MODE="644" \ + INSTALL_K3S_EXEC="${k3s_exec}" \ + bash "${BUNDLE_DIR}/bin/k3s-install.sh" + else + if [[ "${K3S_USE_DOCKER}" == "1" ]]; then + echo "Using Docker as container runtime (K3S_USE_DOCKER=1)." + if ! command -v docker &> /dev/null; then + echo "Error: K3S_USE_DOCKER is set but Docker is not installed." >&2 + exit 1 + fi + k3s_exec="${k3s_exec} --docker" + fi + + configure_registry_mirrors + + curl -sfL https://get.k3s.io | sudo K3S_KUBECONFIG_MODE="644" \ + INSTALL_K3S_EXEC="${k3s_exec}" sh - + fi echo "Configuring kubeconfig for user: $(whoami)" mkdir -p "$HOME/.kube" @@ -367,7 +438,6 @@ function remove_k3s() { echo "Removing K3S local data" sudo rm -rf /var/lib/rancher/k3s - # Remove dummy interface service if [[ -f /etc/systemd/system/dummy-interface.service ]]; then echo "Removing dummy interface service..." sudo systemctl disable dummy-interface.service 2>/dev/null || true @@ -375,13 +445,16 @@ function remove_k3s() { sudo systemctl daemon-reload fi - # Remove dummy interface if ip link show dummy0 &>/dev/null; then echo "Removing dummy interface..." sudo ip link del dummy0 fi } +# ============================================================ +# GPU Device Plugin +# ============================================================ + function deploy_rocm_gpu_device_plugin() { echo "Deploying ROCm GPU device plugin..." @@ -390,7 +463,11 @@ function deploy_rocm_gpu_device_plugin() { return 0 fi - kubectl create -f https://raw.githubusercontent.com/ROCm/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml + if [[ "${OFFLINE_MODE}" == "1" ]]; then + kubectl create -f "${BUNDLE_DIR}/manifests/k8s-ds-amdgpu-dp.yaml" + else + kubectl create -f https://raw.githubusercontent.com/ROCm/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml + fi if ! kubectl wait --for=jsonpath='{.status.numberReady}'=1 --namespace=kube-system ds/amdgpu-device-plugin-daemonset --timeout=300s | grep "condition met"; then exit 1 @@ -399,34 +476,51 @@ function deploy_rocm_gpu_device_plugin() { fi } -function deply_aup_learning_cloud_runtime() { - detect_and_configure_gpu - generate_values_overlay - - echo "Deploying AUP Learning Cloud Runtime..." +# ============================================================ +# Image Helpers +# ============================================================ - helm install jupyterhub runtime/chart --namespace jupyterhub \ - --create-namespace -f runtime/values.yaml -f runtime/values.local.yaml +# Apply MIRROR_PREFIX to an image reference for pulling +function resolve_pull_ref() { + local image="$1" + local full_image="${image}" + local first_segment="${image%%/*}" - echo "Waiting for JupyterHub deployments to be ready..." - kubectl wait --namespace jupyterhub \ - --for=condition=available --timeout=600s \ - deployment/hub deployment/proxy deployment/user-scheduler + if [[ "${image}" == *"/"* ]]; then + [[ "${first_segment}" != *"."* ]] && full_image="docker.io/${image}" + else + full_image="docker.io/library/${image}" + fi - kubectl label "$(kubectl get nodes -o name)" node-type="${ACCEL_KEY}" --overwrite + if [[ -n "${MIRROR_PREFIX}" ]]; then + echo "${MIRROR_PREFIX}/${full_image}" + else + echo "${full_image}" + fi } -function upgrade_aup_learning_cloud_runtime() { - detect_and_configure_gpu - generate_values_overlay +# Pull a single image, apply mirror prefix, tag back to original name. +# Returns 0 on success, 1 on failure. +function pull_and_tag() { + local image="$1" + local pull_ref + pull_ref=$(resolve_pull_ref "${image}") + + echo " Pulling: ${pull_ref}" + if ! docker pull "${pull_ref}"; then + echo " FAILED: ${image}" + return 1 + fi - helm upgrade jupyterhub runtime/chart --namespace jupyterhub \ - --create-namespace -f runtime/values.yaml -f runtime/values.local.yaml + if [[ "${pull_ref}" != "${image}" ]]; then + docker tag "${pull_ref}" "${image}" + fi + return 0 } -function remove_aup_learning_cloud_runtime() { - helm uninstall jupyterhub --namespace jupyterhub -} +# ============================================================ +# Image: Local Build +# ============================================================ # Build local images. Optional: list of Makefile targets (e.g. hub, cv, base-cpu). Default: all. function local_image_build() { @@ -438,7 +532,6 @@ function local_image_build() { local targets=("${@:-all}") echo "Building local images: ${targets[*]}" - # When using Docker runtime, images stay in Docker; no need to export to K3S_IMAGES_DIR if [[ "${K3S_USE_DOCKER}" != "1" ]]; then if [ ! -d "${K3S_IMAGES_DIR}" ]; then sudo mkdir -p "${K3S_IMAGES_DIR}" @@ -448,7 +541,6 @@ function local_image_build() { echo "Build images in Docker (K3S_USE_DOCKER=1; K3s will use them directly)" fi - # Makefile: SAVE_IMAGES=1 and K3S_IMAGES_DIR only when not using Docker backend (containerd + export) local save_images_for_make="" local images_dir_for_make="" if [[ "${K3S_USE_DOCKER}" != "1" ]]; then @@ -471,21 +563,85 @@ function local_image_build() { echo "-------------------------------------------" } -function pull_external_images() { - # Pull external images. When K3S_USE_DOCKER=1, keep in Docker only; else also save to K3S_IMAGES_DIR for offline. +# ============================================================ +# Image: Pull from GHCR (custom images) +# ============================================================ +function pull_custom_images() { if ! command -v docker &> /dev/null; then echo "Please install docker" exit 1 fi + detect_and_configure_gpu + local tag="latest-${GPU_TARGET}" + echo "===========================================" - echo "Pulling external images..." - if [[ "${K3S_USE_DOCKER}" == "1" ]]; then - echo "K3S_USE_DOCKER=1: images stay in Docker (no export to K3s image dir)" + echo "Pulling pre-built custom images from GHCR..." + echo " GPU_TARGET=${GPU_TARGET}, tag=${tag}" + echo "===========================================" + + if [[ "${K3S_USE_DOCKER}" != "1" && ! -d "${K3S_IMAGES_DIR}" ]]; then + sudo mkdir -p "${K3S_IMAGES_DIR}" + fi + + local failed_images=() + + # GPU-specific images: pull :latest-, also tag as :latest + for name in "${GPU_CUSTOM_NAMES[@]}"; do + local image="ghcr.io/amdresearch/${name}:${tag}" + if pull_and_tag "${image}"; then + docker tag "${image}" "ghcr.io/amdresearch/${name}:latest" + + if [[ "${K3S_USE_DOCKER}" != "1" ]]; then + sudo docker save \ + "ghcr.io/amdresearch/${name}:latest" \ + "ghcr.io/amdresearch/${name}:${tag}" \ + -o "${K3S_IMAGES_DIR}/${name}.tar" + fi + else + failed_images+=("${image}") + fi + done + + # Non-GPU images: pull :latest + for name in "${PLAIN_CUSTOM_NAMES[@]}"; do + local image="ghcr.io/amdresearch/${name}:latest" + if pull_and_tag "${image}"; then + if [[ "${K3S_USE_DOCKER}" != "1" ]]; then + sudo docker save "${image}" -o "${K3S_IMAGES_DIR}/${name}.tar" + fi + else + failed_images+=("${image}") + fi + done + + echo "===========================================" + if [[ ${#failed_images[@]} -eq 0 ]]; then + echo "All custom images pulled successfully!" else - echo "Saving to K3s image pool for offline deployment" + echo "Failed images:" + for img in "${failed_images[@]}"; do echo " - ${img}"; done + echo "Warning: Some custom images failed." fi + echo "===========================================" +} + +# ============================================================ +# Image: Pull External Images +# ============================================================ + +function pull_external_images() { + if ! command -v docker &> /dev/null; then + echo "Please install docker" + exit 1 + fi + + # When called during 'install --pull', skip build-only images + local skip_build_only="${1:-0}" + + echo "===========================================" + echo "Pulling external images..." if [[ -n "${MIRROR_PREFIX}" ]]; then echo "Using mirror prefix: ${MIRROR_PREFIX}" fi @@ -495,46 +651,37 @@ function pull_external_images() { sudo mkdir -p "${K3S_IMAGES_DIR}" fi + # Build image list, combining EXTERNAL_IMAGES + optionally BUILD_ONLY_IMAGES + local images_to_pull=("${EXTERNAL_IMAGES[@]}") + if [[ "${skip_build_only}" != "1" ]]; then + images_to_pull+=("${BUILD_ONLY_IMAGES[@]}") + fi + local failed_images=() - for image in "${EXTERNAL_IMAGES[@]}"; do - # Determine the full image path for pulling with mirror - # Images without registry prefix are from docker.io + for image in "${images_to_pull[@]}"; do local full_image="${image}" local first_segment="${image%%/*}" if [[ "${image}" == *"/"* ]]; then - # Has slash - check if first segment looks like a registry (contains a dot) - if [[ "${first_segment}" != *"."* ]]; then - # No dot in first segment, it's docker.io (e.g., curlimages/curl) - full_image="docker.io/${image}" - fi + [[ "${first_segment}" != *"."* ]] && full_image="docker.io/${image}" else - # No slash - it's an official docker image (e.g., traefik:v3.3.1) full_image="docker.io/library/${image}" fi - # Apply mirror prefix if set local pull_image="${full_image}" - if [[ -n "${MIRROR_PREFIX}" ]]; then - pull_image="${MIRROR_PREFIX}/${full_image}" - fi + [[ -n "${MIRROR_PREFIX}" ]] && pull_image="${MIRROR_PREFIX}/${full_image}" echo "-------------------------------------------" echo "Pulling: ${pull_image}" if docker pull "${pull_image}"; then - # Tag to original name so K3s can use it - if [[ "${pull_image}" != "${image}" ]]; then - docker tag "${pull_image}" "${image}" - fi + [[ "${pull_image}" != "${image}" ]] && docker tag "${pull_image}" "${image}" - # Also tag to mirror-prefixed name so Docker build with MIRROR_PREFIX can use local cache if [[ -n "${MIRROR_PREFIX}" && "${pull_image}" != "${MIRROR_PREFIX}/${full_image}" ]]; then docker tag "${pull_image}" "${MIRROR_PREFIX}/${full_image}" fi - # Save to K3S_IMAGES_DIR only when not using Docker backend (so K3s can load at boot) if [[ "${K3S_USE_DOCKER}" != "1" && -n "${K3S_IMAGES_DIR}" ]]; then local filename filename=$(echo "${image}" | sed 's/[\/:]/-/g').tar @@ -560,22 +707,112 @@ function pull_external_images() { echo "All external images pulled and saved successfully!" else echo "Failed images:" - for img in "${failed_images[@]}"; do - echo " - ${img}" - done + for img in "${failed_images[@]}"; do echo " - ${img}"; done echo "Warning: Some images failed. Deployment may require internet access." fi echo "===========================================" } +# ============================================================ +# Image: Load from Offline Bundle +# ============================================================ + +function load_offline_images() { + echo "===========================================" + echo "Loading images from offline bundle..." + echo "===========================================" + + local loaded=0 failed=0 + + for tar_file in "${BUNDLE_DIR}/images/"custom/*.tar "${BUNDLE_DIR}/images/"external/*.tar; do + [[ -f "${tar_file}" ]] || continue + echo " Importing: $(basename "${tar_file}")" + if sudo k3s ctr images import "${tar_file}" 2>/dev/null; then + loaded=$((loaded + 1)) + else + echo " Failed!" + failed=$((failed + 1)) + fi + done + + echo "===========================================" + echo "Loaded ${loaded} images, ${failed} failed" + [[ "${failed}" -gt 0 ]] && echo "Warning: Some images failed to load." + echo "===========================================" +} + +# ============================================================ +# Runtime Management +# ============================================================ + +# Resolve chart/values paths (bundle or local repo) +function get_runtime_paths() { + if [[ "${OFFLINE_MODE}" == "1" ]]; then + CHART_PATH="${BUNDLE_DIR}/chart" + VALUES_PATH="${BUNDLE_DIR}/config/values.yaml" + OVERLAY_PATH="${BUNDLE_DIR}/config/values.local.yaml" + else + CHART_PATH="runtime/chart" + VALUES_PATH="runtime/values.yaml" + OVERLAY_PATH="runtime/values.local.yaml" + fi +} + +function deply_aup_learning_cloud_runtime() { + detect_and_configure_gpu + get_runtime_paths + generate_values_overlay + + echo "Deploying AUP Learning Cloud Runtime..." + + helm install jupyterhub "${CHART_PATH}" --namespace jupyterhub \ + --create-namespace -f "${VALUES_PATH}" -f "${OVERLAY_PATH}" + + echo "Waiting for JupyterHub deployments to be ready..." + kubectl wait --namespace jupyterhub \ + --for=condition=available --timeout=600s \ + deployment/hub deployment/proxy deployment/user-scheduler + + kubectl label "$(kubectl get nodes -o name)" node-type="${ACCEL_KEY}" --overwrite +} + +function upgrade_aup_learning_cloud_runtime() { + detect_and_configure_gpu + get_runtime_paths + generate_values_overlay + + helm upgrade jupyterhub "${CHART_PATH}" --namespace jupyterhub \ + --create-namespace -f "${VALUES_PATH}" -f "${OVERLAY_PATH}" +} + +function remove_aup_learning_cloud_runtime() { + helm uninstall jupyterhub --namespace jupyterhub +} + +# ============================================================ +# Deployment Orchestration +# ============================================================ + function deploy_all_components() { + local flag="${1:-}" + detect_and_configure_gpu + get_runtime_paths generate_values_overlay install_tools install_k3s_single_node deploy_rocm_gpu_device_plugin - pull_external_images - local_image_build + + if [[ "${OFFLINE_MODE}" == "1" ]]; then + load_offline_images + elif [[ "${flag}" == "--pull" ]]; then + pull_custom_images + pull_external_images 1 # skip build-only images + else + pull_external_images + local_image_build + fi + deply_aup_learning_cloud_runtime } @@ -584,22 +821,280 @@ function remove_all_components() { remove_k3s } +# ============================================================ +# Pack: Create Offline Bundle +# ============================================================ + +function pack_download_binaries() { + local staging="$1" + local k3s_url_ver + k3s_url_ver=$(echo "${K3S_VERSION}" | sed 's/+/%2B/g') + + echo "--- Downloading binaries ---" + mkdir -p "${staging}/bin" + + echo " K3s ${K3S_VERSION}..." + wget -q "https://github.com/k3s-io/k3s/releases/download/${k3s_url_ver}/k3s" \ + -O "${staging}/bin/k3s" + chmod +x "${staging}/bin/k3s" + + echo " K3s install script..." + wget -q "https://get.k3s.io" -O "${staging}/bin/k3s-install.sh" + chmod +x "${staging}/bin/k3s-install.sh" + + echo " Helm ${HELM_VERSION}..." + wget -q "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" -O /tmp/helm-pack.tar.gz + tar -zxf /tmp/helm-pack.tar.gz -C /tmp linux-amd64/helm + mv /tmp/linux-amd64/helm "${staging}/bin/helm" + chmod +x "${staging}/bin/helm" + rm -rf /tmp/helm-pack.tar.gz /tmp/linux-amd64 + + echo " K9s ${K9S_VERSION}..." + wget -q "https://github.com/derailed/k9s/releases/download/${K9S_VERSION}/k9s_linux_amd64.deb" \ + -O "${staging}/bin/k9s_linux_amd64.deb" +} + +function pack_download_k3s_images() { + local staging="$1" + local k3s_url_ver + k3s_url_ver=$(echo "${K3S_VERSION}" | sed 's/+/%2B/g') + + echo "--- Downloading K3s airgap images ---" + mkdir -p "${staging}/k3s-images" + + wget -q "https://github.com/k3s-io/k3s/releases/download/${k3s_url_ver}/k3s-airgap-images-amd64.tar.zst" \ + -O "${staging}/k3s-images/k3s-airgap-images-amd64.tar.zst" +} + +function pack_save_manifests() { + local staging="$1" + echo "--- Saving manifests ---" + mkdir -p "${staging}/manifests" + + wget -q "https://raw.githubusercontent.com/ROCm/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml" \ + -O "${staging}/manifests/k8s-ds-amdgpu-dp.yaml" + echo " Saved ROCm device plugin DaemonSet." +} + +function pack_copy_chart() { + local staging="$1" + echo "--- Copying chart and config ---" + + cp -r runtime/chart "${staging}/chart" + mkdir -p "${staging}/config" + cp runtime/values.yaml "${staging}/config/values.yaml" +} + +# Save custom images: pull from GHCR, then docker save +function pack_save_custom_images_pull() { + local staging="$1" + local tag="latest-${GPU_TARGET}" + + echo "--- Pulling and saving custom images from GHCR ---" + mkdir -p "${staging}/images/custom" + + for name in "${GPU_CUSTOM_NAMES[@]}"; do + local image="ghcr.io/amdresearch/${name}:${tag}" + if pull_and_tag "${image}"; then + docker tag "${image}" "ghcr.io/amdresearch/${name}:latest" + docker save \ + "ghcr.io/amdresearch/${name}:latest" \ + "ghcr.io/amdresearch/${name}:${tag}" \ + -o "${staging}/images/custom/${name}.tar" + echo " Saved: ${name} (:latest + :${tag})" + else + echo " ERROR: Failed to pull ${image}" >&2 + fi + done + + for name in "${PLAIN_CUSTOM_NAMES[@]}"; do + local image="ghcr.io/amdresearch/${name}:latest" + if pull_and_tag "${image}"; then + docker save "${image}" -o "${staging}/images/custom/${name}.tar" + echo " Saved: ${name}" + else + echo " ERROR: Failed to pull ${image}" >&2 + fi + done +} + +# Save custom images: build locally via Makefile, then docker save +function pack_save_custom_images_local() { + local staging="$1" + local tag="latest-${GPU_TARGET}" + + echo "--- Building and saving custom images locally ---" + mkdir -p "${staging}/images/custom" + + # Build all images to Docker daemon (no K3s export) + (cd dockerfiles/ && make \ + GPU_TARGET="${GPU_TARGET}" \ + MIRROR_PREFIX="${MIRROR_PREFIX}" \ + MIRROR_PIP="${MIRROR_PIP}" \ + MIRROR_NPM="${MIRROR_NPM}" \ + all) + + echo "--- Saving built images to bundle ---" + + for name in "${GPU_CUSTOM_NAMES[@]}"; do + docker save \ + "ghcr.io/amdresearch/${name}:latest" \ + "ghcr.io/amdresearch/${name}:${tag}" \ + -o "${staging}/images/custom/${name}.tar" + echo " Saved: ${name} (:latest + :${tag})" + done + + for name in "${PLAIN_CUSTOM_NAMES[@]}"; do + docker save "ghcr.io/amdresearch/${name}:latest" \ + -o "${staging}/images/custom/${name}.tar" + echo " Saved: ${name}" + done +} + +# Save external images (always pulled from registries) +function pack_save_external_images() { + local staging="$1" + echo "--- Pulling and saving external images ---" + mkdir -p "${staging}/images/external" + + # Build list: runtime external images (skip build-only) + local pack_images=("${EXTERNAL_IMAGES[@]}") + + # Extract ROCm device plugin image from saved manifest + if [[ -f "${staging}/manifests/k8s-ds-amdgpu-dp.yaml" ]]; then + local dp_image + dp_image=$(sed -n 's/.*image:[[:space:]]*\([^ ]*\).*/\1/p' "${staging}/manifests/k8s-ds-amdgpu-dp.yaml" | head -1) + if [[ -n "${dp_image}" ]]; then + echo " Found device plugin image: ${dp_image}" + pack_images+=("${dp_image}") + fi + fi + + local failed_images=() + for image in "${pack_images[@]}"; do + if pull_and_tag "${image}"; then + local filename + filename=$(echo "${image}" | sed 's/[\/:]/-/g').tar + docker save "${image}" -o "${staging}/images/external/${filename}" + echo " Saved: ${image}" + else + failed_images+=("${image}") + fi + done + + if [[ ${#failed_images[@]} -gt 0 ]]; then + echo " WARNING: Failed to pull ${#failed_images[@]} images:" + for img in "${failed_images[@]}"; do echo " - ${img}"; done + fi +} + +function pack_write_manifest() { + local staging="$1" + cat > "${staging}/manifest.json" << EOF +{ + "format_version": "1", + "build_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "gpu_target": "${GPU_TARGET}", + "accel_key": "${ACCEL_KEY}", + "accel_env": "${ACCEL_ENV}", + "k3s_version": "${K3S_VERSION}", + "helm_version": "${HELM_VERSION}", + "k9s_version": "${K9S_VERSION}" +} +EOF +} + +function pack_bundle() { + local flag="${1:-}" + + echo "===========================================" + echo "AUP Learning Cloud - Pack Offline Bundle" + if [[ "${flag}" == "--local" ]]; then + echo " Image source: local build" + else + echo " Image source: pull from GHCR" + fi + echo "===========================================" + + if ! command -v docker &> /dev/null; then + echo "Error: Docker is required." >&2 + exit 1 + fi + + detect_and_configure_gpu + + local date_stamp + date_stamp=$(date +%Y%m%d) + local bundle_name="auplc-bundle-${GPU_TARGET}-${date_stamp}" + + [[ -d "${bundle_name}" ]] && rm -rf "${bundle_name}" + mkdir -p "${bundle_name}" + + # Copy installer itself + cp "${BASH_SOURCE[0]}" "${bundle_name}/auplc-installer" + chmod +x "${bundle_name}/auplc-installer" + + pack_download_binaries "${bundle_name}" + pack_download_k3s_images "${bundle_name}" + pack_save_manifests "${bundle_name}" + + if [[ "${flag}" == "--local" ]]; then + pack_save_custom_images_local "${bundle_name}" + else + pack_save_custom_images_pull "${bundle_name}" + fi + + pack_save_external_images "${bundle_name}" + pack_copy_chart "${bundle_name}" + pack_write_manifest "${bundle_name}" + + echo "===========================================" + echo "Creating archive: ${bundle_name}.tar.gz ..." + echo "===========================================" + + tar czf "${bundle_name}.tar.gz" "${bundle_name}/" + rm -rf "${bundle_name}" + + local size + size=$(du -sh "${bundle_name}.tar.gz" | cut -f1) + + echo "===========================================" + echo "Bundle created: ${bundle_name}.tar.gz (${size})" + echo "" + echo "Deploy on air-gapped machine:" + echo " tar xzf ${bundle_name}.tar.gz" + echo " cd ${bundle_name}" + echo " sudo ./auplc-installer install" + echo "===========================================" +} + +# ============================================================ +# Help +# ============================================================ + function show_help() { cat << 'EOF' -Usage: ./auplc-installer [subcommand] +Usage: ./auplc-installer [options] Commands: - install Full installation (k3s + images + runtime) - uninstall Remove everything + install [--pull] Full installation (k3s + images + runtime) + Default: build images locally via Makefile + --pull: use pre-built images from GHCR (no local build needed) + + pack [--local] Create offline deployment bundle (requires Docker + internet) + Default: pull pre-built images from GHCR + --local: build images locally then pack (needs build deps) + + uninstall Remove everything (K3s + runtime) install-tools Install helm and k9s rt install Deploy JupyterHub runtime only - rt reinstall Reinstall JupyterHub runtime (For container images changes) - rt upgrade Upgrade JupyterHub runtime (For vaules.yaml changes) + rt reinstall Reinstall JupyterHub runtime (for container image changes) + rt upgrade Upgrade JupyterHub runtime (for values.yaml changes) rt remove Remove JupyterHub runtime - img build Build all custom images - img build [target...] Build custom images (default: all). e.g. img build hub, img build hub cv + img build [target...] Build custom images (default: all) + Targets: all, hub, base-cpu, base-rocm, cv, dl, llm, physim img pull Pull external images for offline use detect-gpu Show detected GPU configuration @@ -625,9 +1120,23 @@ Options (can also be set via environment variables): ./auplc-installer img build base-rocm --gpu=strix ./auplc-installer install --mirror=mirror.example.com +Offline Deployment: + 1. On a machine with internet access, create bundle: + ./auplc-installer pack --gpu=strix-halo # pull from GHCR + ./auplc-installer pack --gpu=strix-halo --local # or build locally + + 2. Transfer bundle to air-gapped machine, then: + tar xzf auplc-bundle-gfx1151-*.tar.gz + cd auplc-bundle-gfx1151-* + sudo ./auplc-installer install + EOF } +# ============================================================ +# Main +# ============================================================ + # Parse global options (--key=value flags override environment variables) args=() for arg in "$@"; do @@ -642,17 +1151,24 @@ for arg in "$@"; do done set -- "${args[@]}" +# Detect offline bundle at startup +detect_offline_bundle + if [[ $# -eq 0 ]]; then show_help exit 1 fi case "$1" in - install) deploy_all_components ;; + install) + deploy_all_components "${2:-}" + ;; + pack) + pack_bundle "${2:-}" + ;; uninstall) remove_all_components ;; install-tools) install_tools ;; detect-gpu) detect_and_configure_gpu ;; - # New short form: rt / img rt) case "${2:-}" in install) deply_aup_learning_cloud_runtime ;; From c31a2f131664a5e4cc32e62897593d33ec01cf55 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:51:17 +0800 Subject: [PATCH 05/58] fix(pack): add IMAGE_REGISTRY support and fail on pull errors - Add IMAGE_REGISTRY env var (default: ghcr.io/amdresearch) for configurable image source in pack and install --pull - Pack now exits with error if any custom or external image fails to pull, preventing incomplete bundles - Add image_registry input to pack-bundle CI workflow - Read IMAGE_REGISTRY from bundle manifest for offline installs --- .github/workflows/pack-bundle.yml | 9 +++- auplc-installer | 81 +++++++++++++++++++------------ 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index b93a32b..2cbd2e6 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -39,6 +39,11 @@ on: - strix-halo - strix - phx + image_registry: + description: 'Registry prefix for custom images' + required: false + default: 'ghcr.io/amdresearch' + type: string create_release: description: 'Create a GitHub Release with the bundle' required: false @@ -80,7 +85,9 @@ jobs: - name: Run pack command run: | - GPU_TYPE="${{ inputs.gpu_type }}" ./auplc-installer pack + GPU_TYPE="${{ inputs.gpu_type }}" \ + IMAGE_REGISTRY="${{ inputs.image_registry }}" \ + ./auplc-installer pack - name: Verify bundle run: | diff --git a/auplc-installer b/auplc-installer index 9deabdb..a955e5f 100755 --- a/auplc-installer +++ b/auplc-installer @@ -42,13 +42,16 @@ MIRROR_PREFIX="${MIRROR_PREFIX:-}" MIRROR_PIP="${MIRROR_PIP:-}" MIRROR_NPM="${MIRROR_NPM:-}" +# Registry prefix for custom images (override for forks or private registries) +IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io/amdresearch}" + # Custom images (built locally or pulled from GHCR) CUSTOM_IMAGES=( - "ghcr.io/amdresearch/auplc-hub:latest" - "ghcr.io/amdresearch/auplc-default:latest" - "ghcr.io/amdresearch/auplc-cv:latest" - "ghcr.io/amdresearch/auplc-dl:latest" - "ghcr.io/amdresearch/auplc-llm:latest" + "${IMAGE_REGISTRY}/auplc-hub:latest" + "${IMAGE_REGISTRY}/auplc-default:latest" + "${IMAGE_REGISTRY}/auplc-cv:latest" + "${IMAGE_REGISTRY}/auplc-dl:latest" + "${IMAGE_REGISTRY}/auplc-llm:latest" ) # GPU-specific custom images (have :latest- tags) @@ -105,16 +108,18 @@ function detect_offline_bundle() { K3S_USE_DOCKER=0 echo "Offline bundle detected at: ${BUNDLE_DIR}" - # Parse GPU config from manifest without python - local gpu_target accel_key accel_env + # Parse config from manifest without python + local gpu_target accel_key accel_env image_registry gpu_target=$(sed -n 's/.*"gpu_target"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") accel_key=$(sed -n 's/.*"accel_key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") accel_env=$(sed -n 's/.*"accel_env"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + image_registry=$(sed -n 's/.*"image_registry"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") if [[ -n "${gpu_target}" ]]; then GPU_TARGET="${gpu_target}" ACCEL_KEY="${accel_key}" ACCEL_ENV="${accel_env}" + [[ -n "${image_registry}" ]] && IMAGE_REGISTRY="${image_registry}" echo " GPU config: accelerator=${ACCEL_KEY}, GPU_TARGET=${GPU_TARGET}${ACCEL_ENV:+, HSA_OVERRIDE=${ACCEL_ENV}}" fi } @@ -242,11 +247,11 @@ function generate_values_overlay() { echo " resources:" echo " images:" - echo " gpu: \"ghcr.io/amdresearch/auplc-base:${tag}\"" - echo " Course-CV: \"ghcr.io/amdresearch/auplc-cv:${tag}\"" - echo " Course-DL: \"ghcr.io/amdresearch/auplc-dl:${tag}\"" - echo " Course-LLM: \"ghcr.io/amdresearch/auplc-llm:${tag}\"" - echo " Course-PhySim: \"ghcr.io/amdresearch/auplc-physim:${tag}\"" + echo " gpu: \"${IMAGE_REGISTRY}/auplc-base:${tag}\"" + echo " Course-CV: \"${IMAGE_REGISTRY}/auplc-cv:${tag}\"" + echo " Course-DL: \"${IMAGE_REGISTRY}/auplc-dl:${tag}\"" + echo " Course-LLM: \"${IMAGE_REGISTRY}/auplc-llm:${tag}\"" + echo " Course-PhySim: \"${IMAGE_REGISTRY}/auplc-physim:${tag}\"" echo " metadata:" for resource in gpu Course-CV Course-DL Course-LLM Course-PhySim; do echo " ${resource}:" @@ -589,14 +594,14 @@ function pull_custom_images() { # GPU-specific images: pull :latest-, also tag as :latest for name in "${GPU_CUSTOM_NAMES[@]}"; do - local image="ghcr.io/amdresearch/${name}:${tag}" + local image="${IMAGE_REGISTRY}/${name}:${tag}" if pull_and_tag "${image}"; then - docker tag "${image}" "ghcr.io/amdresearch/${name}:latest" + docker tag "${image}" "${IMAGE_REGISTRY}/${name}:latest" if [[ "${K3S_USE_DOCKER}" != "1" ]]; then sudo docker save \ - "ghcr.io/amdresearch/${name}:latest" \ - "ghcr.io/amdresearch/${name}:${tag}" \ + "${IMAGE_REGISTRY}/${name}:latest" \ + "${IMAGE_REGISTRY}/${name}:${tag}" \ -o "${K3S_IMAGES_DIR}/${name}.tar" fi else @@ -606,7 +611,7 @@ function pull_custom_images() { # Non-GPU images: pull :latest for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - local image="ghcr.io/amdresearch/${name}:latest" + local image="${IMAGE_REGISTRY}/${name}:latest" if pull_and_tag "${image}"; then if [[ "${K3S_USE_DOCKER}" != "1" ]]; then sudo docker save "${image}" -o "${K3S_IMAGES_DIR}/${name}.tar" @@ -890,32 +895,41 @@ function pack_save_custom_images_pull() { local staging="$1" local tag="latest-${GPU_TARGET}" - echo "--- Pulling and saving custom images from GHCR ---" + echo "--- Pulling and saving custom images (${IMAGE_REGISTRY}) ---" mkdir -p "${staging}/images/custom" + local failed=0 + for name in "${GPU_CUSTOM_NAMES[@]}"; do - local image="ghcr.io/amdresearch/${name}:${tag}" + local image="${IMAGE_REGISTRY}/${name}:${tag}" if pull_and_tag "${image}"; then - docker tag "${image}" "ghcr.io/amdresearch/${name}:latest" + docker tag "${image}" "${IMAGE_REGISTRY}/${name}:latest" docker save \ - "ghcr.io/amdresearch/${name}:latest" \ - "ghcr.io/amdresearch/${name}:${tag}" \ + "${IMAGE_REGISTRY}/${name}:latest" \ + "${IMAGE_REGISTRY}/${name}:${tag}" \ -o "${staging}/images/custom/${name}.tar" echo " Saved: ${name} (:latest + :${tag})" else - echo " ERROR: Failed to pull ${image}" >&2 + failed=$((failed + 1)) fi done for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - local image="ghcr.io/amdresearch/${name}:latest" + local image="${IMAGE_REGISTRY}/${name}:latest" if pull_and_tag "${image}"; then docker save "${image}" -o "${staging}/images/custom/${name}.tar" echo " Saved: ${name}" else - echo " ERROR: Failed to pull ${image}" >&2 + failed=$((failed + 1)) fi done + + if [[ "${failed}" -gt 0 ]]; then + echo "Error: ${failed} custom image(s) failed to pull. Bundle would be incomplete." >&2 + echo " Check that IMAGE_REGISTRY (${IMAGE_REGISTRY}) is correct and you have pull access." >&2 + rm -rf "${staging}" + exit 1 + fi } # Save custom images: build locally via Makefile, then docker save @@ -938,14 +952,14 @@ function pack_save_custom_images_local() { for name in "${GPU_CUSTOM_NAMES[@]}"; do docker save \ - "ghcr.io/amdresearch/${name}:latest" \ - "ghcr.io/amdresearch/${name}:${tag}" \ + "${IMAGE_REGISTRY}/${name}:latest" \ + "${IMAGE_REGISTRY}/${name}:${tag}" \ -o "${staging}/images/custom/${name}.tar" echo " Saved: ${name} (:latest + :${tag})" done for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - docker save "ghcr.io/amdresearch/${name}:latest" \ + docker save "${IMAGE_REGISTRY}/${name}:latest" \ -o "${staging}/images/custom/${name}.tar" echo " Saved: ${name}" done @@ -983,8 +997,10 @@ function pack_save_external_images() { done if [[ ${#failed_images[@]} -gt 0 ]]; then - echo " WARNING: Failed to pull ${#failed_images[@]} images:" - for img in "${failed_images[@]}"; do echo " - ${img}"; done + echo "Error: ${#failed_images[@]} external image(s) failed to pull:" >&2 + for img in "${failed_images[@]}"; do echo " - ${img}" >&2; done + rm -rf "${staging}" + exit 1 fi } @@ -997,6 +1013,7 @@ function pack_write_manifest() { "gpu_target": "${GPU_TARGET}", "accel_key": "${ACCEL_KEY}", "accel_env": "${ACCEL_ENV}", + "image_registry": "${IMAGE_REGISTRY}", "k3s_version": "${K3S_VERSION}", "helm_version": "${HELM_VERSION}", "k9s_version": "${K9S_VERSION}" @@ -1120,6 +1137,10 @@ Options (can also be set via environment variables): ./auplc-installer img build base-rocm --gpu=strix ./auplc-installer install --mirror=mirror.example.com +Image Registry: + IMAGE_REGISTRY Registry prefix for custom images (default: ghcr.io/amdresearch) + Override when pulling from a fork or private registry. + Offline Deployment: 1. On a machine with internet access, create bundle: ./auplc-installer pack --gpu=strix-halo # pull from GHCR From 5dd55b951725e3bf9328832ca13ce5f1610c7976 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:12:16 +0800 Subject: [PATCH 06/58] feat(installer): add IMAGE_TAG env var for configurable image tag prefix Support pulling images with non-default tag prefixes (e.g. develop-gfx1151 instead of latest-gfx1151). The IMAGE_TAG is stored in the bundle manifest and restored on offline install. Default remains "latest". --- .github/workflows/pack-bundle.yml | 6 ++++++ auplc-installer | 22 +++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 2cbd2e6..f4d2c2b 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -44,6 +44,11 @@ on: required: false default: 'ghcr.io/amdresearch' type: string + image_tag: + description: 'Image tag prefix (e.g. latest, develop, v1.0)' + required: false + default: 'latest' + type: string create_release: description: 'Create a GitHub Release with the bundle' required: false @@ -87,6 +92,7 @@ jobs: run: | GPU_TYPE="${{ inputs.gpu_type }}" \ IMAGE_REGISTRY="${{ inputs.image_registry }}" \ + IMAGE_TAG="${{ inputs.image_tag }}" \ ./auplc-installer pack - name: Verify bundle diff --git a/auplc-installer b/auplc-installer index a955e5f..cf9dc0b 100755 --- a/auplc-installer +++ b/auplc-installer @@ -45,6 +45,9 @@ MIRROR_NPM="${MIRROR_NPM:-}" # Registry prefix for custom images (override for forks or private registries) IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io/amdresearch}" +# Image tag prefix (e.g. latest, develop, v1.0). GPU suffix is appended automatically. +IMAGE_TAG="${IMAGE_TAG:-latest}" + # Custom images (built locally or pulled from GHCR) CUSTOM_IMAGES=( "${IMAGE_REGISTRY}/auplc-hub:latest" @@ -109,17 +112,19 @@ function detect_offline_bundle() { echo "Offline bundle detected at: ${BUNDLE_DIR}" # Parse config from manifest without python - local gpu_target accel_key accel_env image_registry + local gpu_target accel_key accel_env image_registry image_tag gpu_target=$(sed -n 's/.*"gpu_target"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") accel_key=$(sed -n 's/.*"accel_key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") accel_env=$(sed -n 's/.*"accel_env"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") image_registry=$(sed -n 's/.*"image_registry"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + image_tag=$(sed -n 's/.*"image_tag"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") if [[ -n "${gpu_target}" ]]; then GPU_TARGET="${gpu_target}" ACCEL_KEY="${accel_key}" ACCEL_ENV="${accel_env}" [[ -n "${image_registry}" ]] && IMAGE_REGISTRY="${image_registry}" + [[ -n "${image_tag}" ]] && IMAGE_TAG="${image_tag}" echo " GPU config: accelerator=${ACCEL_KEY}, GPU_TARGET=${GPU_TARGET}${ACCEL_ENV:+, HSA_OVERRIDE=${ACCEL_ENV}}" fi } @@ -231,7 +236,7 @@ function generate_values_overlay() { fi echo "Generating values overlay: ${overlay_path}" - local tag="latest-${GPU_TARGET}" + local tag="${IMAGE_TAG}-${GPU_TARGET}" { echo "# Auto-generated by auplc-installer (GPU: ${ACCEL_KEY}, target: ${GPU_TARGET})" @@ -579,7 +584,7 @@ function pull_custom_images() { fi detect_and_configure_gpu - local tag="latest-${GPU_TARGET}" + local tag="${IMAGE_TAG}-${GPU_TARGET}" echo "===========================================" echo "Pulling pre-built custom images from GHCR..." @@ -609,9 +614,9 @@ function pull_custom_images() { fi done - # Non-GPU images: pull :latest + # Non-GPU images: pull : for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - local image="${IMAGE_REGISTRY}/${name}:latest" + local image="${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" if pull_and_tag "${image}"; then if [[ "${K3S_USE_DOCKER}" != "1" ]]; then sudo docker save "${image}" -o "${K3S_IMAGES_DIR}/${name}.tar" @@ -893,7 +898,7 @@ function pack_copy_chart() { # Save custom images: pull from GHCR, then docker save function pack_save_custom_images_pull() { local staging="$1" - local tag="latest-${GPU_TARGET}" + local tag="${IMAGE_TAG}-${GPU_TARGET}" echo "--- Pulling and saving custom images (${IMAGE_REGISTRY}) ---" mkdir -p "${staging}/images/custom" @@ -915,7 +920,7 @@ function pack_save_custom_images_pull() { done for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - local image="${IMAGE_REGISTRY}/${name}:latest" + local image="${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" if pull_and_tag "${image}"; then docker save "${image}" -o "${staging}/images/custom/${name}.tar" echo " Saved: ${name}" @@ -1014,6 +1019,7 @@ function pack_write_manifest() { "accel_key": "${ACCEL_KEY}", "accel_env": "${ACCEL_ENV}", "image_registry": "${IMAGE_REGISTRY}", + "image_tag": "${IMAGE_TAG}", "k3s_version": "${K3S_VERSION}", "helm_version": "${HELM_VERSION}", "k9s_version": "${K9S_VERSION}" @@ -1140,6 +1146,8 @@ Options (can also be set via environment variables): Image Registry: IMAGE_REGISTRY Registry prefix for custom images (default: ghcr.io/amdresearch) Override when pulling from a fork or private registry. + IMAGE_TAG Image tag prefix (default: latest). GPU suffix appended automatically. + Use "develop" for images built from the develop branch. Offline Deployment: 1. On a machine with internet access, create bundle: From f3dc90426c42b19d2504ef60ea44e30b6cac9a6f Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:04:10 +0800 Subject: [PATCH 07/58] fix(installer): fix glob quoting in load_offline_images and move registry/tag restore out of gpu_target guard --- auplc-installer | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/auplc-installer b/auplc-installer index cf9dc0b..8cc6d41 100755 --- a/auplc-installer +++ b/auplc-installer @@ -119,12 +119,13 @@ function detect_offline_bundle() { image_registry=$(sed -n 's/.*"image_registry"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") image_tag=$(sed -n 's/.*"image_tag"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${BUNDLE_DIR}/manifest.json") + [[ -n "${image_registry}" ]] && IMAGE_REGISTRY="${image_registry}" + [[ -n "${image_tag}" ]] && IMAGE_TAG="${image_tag}" + if [[ -n "${gpu_target}" ]]; then GPU_TARGET="${gpu_target}" ACCEL_KEY="${accel_key}" ACCEL_ENV="${accel_env}" - [[ -n "${image_registry}" ]] && IMAGE_REGISTRY="${image_registry}" - [[ -n "${image_tag}" ]] && IMAGE_TAG="${image_tag}" echo " GPU config: accelerator=${ACCEL_KEY}, GPU_TARGET=${GPU_TARGET}${ACCEL_ENV:+, HSA_OVERRIDE=${ACCEL_ENV}}" fi } @@ -734,7 +735,7 @@ function load_offline_images() { local loaded=0 failed=0 - for tar_file in "${BUNDLE_DIR}/images/"custom/*.tar "${BUNDLE_DIR}/images/"external/*.tar; do + for tar_file in "${BUNDLE_DIR}/images/custom"/*.tar "${BUNDLE_DIR}/images/external"/*.tar; do [[ -f "${tar_file}" ]] || continue echo " Importing: $(basename "${tar_file}")" if sudo k3s ctr images import "${tar_file}" 2>/dev/null; then From a2c1c0287a1ecfc7942e7cdd7ae187daadc348b6 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:09:22 +0800 Subject: [PATCH 08/58] fix(installer): remove traefik from EXTERNAL_IMAGES, already included in K3s airgap bundle --- auplc-installer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auplc-installer b/auplc-installer index 8cc6d41..bde545f 100755 --- a/auplc-installer +++ b/auplc-installer @@ -73,7 +73,7 @@ EXTERNAL_IMAGES=( "quay.io/jupyterhub/k8s-singleuser-sample:4.1.0" "registry.k8s.io/kube-scheduler:v1.30.8" "registry.k8s.io/pause:3.10" - "traefik:v3.3.1" + # traefik is already included in the K3s airgap images bundle "curlimages/curl:8.5.0" "alpine/git:2.47.2" ) From 3e11295bba70e6193833fa47fca0ec5715763189 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:18:51 +0800 Subject: [PATCH 09/58] fix(installer): load offline images before deploying GPU device plugin --- auplc-installer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auplc-installer b/auplc-installer index bde545f..2918722 100755 --- a/auplc-installer +++ b/auplc-installer @@ -812,7 +812,6 @@ function deploy_all_components() { generate_values_overlay install_tools install_k3s_single_node - deploy_rocm_gpu_device_plugin if [[ "${OFFLINE_MODE}" == "1" ]]; then load_offline_images @@ -824,6 +823,7 @@ function deploy_all_components() { local_image_build fi + deploy_rocm_gpu_device_plugin deply_aup_learning_cloud_runtime } From 7895da60de1326b80febf40f0fcebaf5283e8d88 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:37:53 +0800 Subject: [PATCH 10/58] fix(offline): move hub image override after custom block in values overlay hub.image was incorrectly nested inside custom.resources.images block, causing metadata to be misinterpreted as hub.image property and triggering Helm schema validation failure. --- auplc-installer | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/auplc-installer b/auplc-installer index 2918722..b99f0b9 100755 --- a/auplc-installer +++ b/auplc-installer @@ -264,6 +264,13 @@ function generate_values_overlay() { echo " acceleratorKeys:" echo " - ${ACCEL_KEY}" done + if [[ "${OFFLINE_MODE}" == "1" ]]; then + echo "hub:" + echo " image:" + echo " name: \"${IMAGE_REGISTRY}/auplc-hub\"" + echo " tag: \"${IMAGE_TAG}\"" + echo " pullPolicy: IfNotPresent" + fi } > "${overlay_path}" } @@ -476,6 +483,9 @@ function deploy_rocm_gpu_device_plugin() { if [[ "${OFFLINE_MODE}" == "1" ]]; then kubectl create -f "${BUNDLE_DIR}/manifests/k8s-ds-amdgpu-dp.yaml" + # Patch imagePullPolicy to avoid pulling from registry in air-gapped environments + kubectl patch ds amdgpu-device-plugin-daemonset -n kube-system --type=json \ + -p '[{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"IfNotPresent"}]' else kubectl create -f https://raw.githubusercontent.com/ROCm/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml fi From 96e08e61f723047cd6c9be1581febb20a97b8ab1 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:53:06 +0800 Subject: [PATCH 11/58] fix(pack): save plain images with both :latest and :${IMAGE_TAG} tags for consistency Both pull and local-build modes now save hub/default images with :latest and :${IMAGE_TAG} tags, matching GPU image behavior. This ensures values.local.yaml references always resolve regardless of which IMAGE_TAG was used during pack. --- auplc-installer | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/auplc-installer b/auplc-installer index b99f0b9..8e6ff33 100755 --- a/auplc-installer +++ b/auplc-installer @@ -933,8 +933,12 @@ function pack_save_custom_images_pull() { for name in "${PLAIN_CUSTOM_NAMES[@]}"; do local image="${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" if pull_and_tag "${image}"; then - docker save "${image}" -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name}" + docker tag "${image}" "${IMAGE_REGISTRY}/${name}:latest" + docker save \ + "${IMAGE_REGISTRY}/${name}:latest" \ + "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" \ + -o "${staging}/images/custom/${name}.tar" + echo " Saved: ${name} (:latest + :${IMAGE_TAG})" else failed=$((failed + 1)) fi @@ -975,9 +979,12 @@ function pack_save_custom_images_local() { done for name in "${PLAIN_CUSTOM_NAMES[@]}"; do - docker save "${IMAGE_REGISTRY}/${name}:latest" \ + docker tag "${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" + docker save \ + "${IMAGE_REGISTRY}/${name}:latest" \ + "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" \ -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name}" + echo " Saved: ${name} (:latest + :${IMAGE_TAG})" done } From 0e40738431f3ae047102cf8d0fdda74846cb8016 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:55:40 +0800 Subject: [PATCH 12/58] fix(offline): fail fast when image import fails in load_offline_images Silent warning on import failure could leave the cluster with missing images that cause pod failures at runtime. Now exits immediately so the user sees a clear error instead of a mysteriously broken install. --- auplc-installer | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/auplc-installer b/auplc-installer index 8e6ff33..402e51e 100755 --- a/auplc-installer +++ b/auplc-installer @@ -758,7 +758,10 @@ function load_offline_images() { echo "===========================================" echo "Loaded ${loaded} images, ${failed} failed" - [[ "${failed}" -gt 0 ]] && echo "Warning: Some images failed to load." + if [[ "${failed}" -gt 0 ]]; then + echo "Error: ${failed} image(s) failed to import. Bundle may be corrupted." >&2 + exit 1 + fi echo "===========================================" } From 34584d619b4f4829797be76b88243720e5a2f282 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:00:32 +0800 Subject: [PATCH 13/58] refactor(installer): improve code clarity and consistency - Remove redundant CUSTOM_IMAGES/IMAGES arrays; GPU_CUSTOM_NAMES and PLAIN_CUSTOM_NAMES are the single source of truth for image lists - Fix typo: deply_aup_learning_cloud_runtime -> deploy_aup_learning_cloud_runtime - Remove duplicate generate_values_overlay call in deploy function (orchestration now handled exclusively by callers) - Remove unused check_root function; inline root check at entry points of deploy_all_components and pack_bundle - Add missing section headers for Runtime Management group - rt install/reinstall and legacy install-runtime now correctly call detect/get_paths/generate_overlay before deploy --- auplc-installer | 65 ++++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/auplc-installer b/auplc-installer index 402e51e..6a96111 100755 --- a/auplc-installer +++ b/auplc-installer @@ -48,19 +48,10 @@ IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io/amdresearch}" # Image tag prefix (e.g. latest, develop, v1.0). GPU suffix is appended automatically. IMAGE_TAG="${IMAGE_TAG:-latest}" -# Custom images (built locally or pulled from GHCR) -CUSTOM_IMAGES=( - "${IMAGE_REGISTRY}/auplc-hub:latest" - "${IMAGE_REGISTRY}/auplc-default:latest" - "${IMAGE_REGISTRY}/auplc-cv:latest" - "${IMAGE_REGISTRY}/auplc-dl:latest" - "${IMAGE_REGISTRY}/auplc-llm:latest" -) - -# GPU-specific custom images (have :latest- tags) +# GPU-specific custom images (tagged as :-) GPU_CUSTOM_NAMES=("auplc-base" "auplc-cv" "auplc-dl" "auplc-llm" "auplc-physim") -# Non-GPU custom images (only :latest tag) +# Non-GPU custom images (tagged as :) PLAIN_CUSTOM_NAMES=("auplc-hub" "auplc-default") # External images required by JupyterHub at runtime @@ -85,9 +76,6 @@ BUILD_ONLY_IMAGES=( "quay.io/jupyter/base-notebook" ) -# Combined list for backward compatibility (img pull still pulls everything) -IMAGES=("${CUSTOM_IMAGES[@]}") - # GPU configuration globals (set by detect_and_configure_gpu) ACCEL_KEY="" GPU_TARGET="" @@ -278,13 +266,6 @@ function generate_values_overlay() { # Tool Installation (Helm, K9s) # ============================================================ -function check_root() { - if [[ $EUID -ne 0 ]]; then - echo "Error: This script must be run as root." >&2 - exit 1 - fi -} - function install_tools() { echo "Checking/Installing tools (may require sudo)..." @@ -575,7 +556,6 @@ function local_image_build() { GPU_TARGET="${GPU_TARGET}" \ SAVE_IMAGES="${save_images_for_make}" \ K3S_IMAGES_DIR="${images_dir_for_make}" \ - IMAGES="${IMAGES[*]}" \ MIRROR_PREFIX="${MIRROR_PREFIX}" \ MIRROR_PIP="${MIRROR_PIP}" \ MIRROR_NPM="${MIRROR_NPM}" \ @@ -769,6 +749,10 @@ function load_offline_images() { # Runtime Management # ============================================================ +# ============================================================ +# Runtime Management +# ============================================================ + # Resolve chart/values paths (bundle or local repo) function get_runtime_paths() { if [[ "${OFFLINE_MODE}" == "1" ]]; then @@ -782,11 +766,7 @@ function get_runtime_paths() { fi } -function deply_aup_learning_cloud_runtime() { - detect_and_configure_gpu - get_runtime_paths - generate_values_overlay - +function deploy_aup_learning_cloud_runtime() { echo "Deploying AUP Learning Cloud Runtime..." helm install jupyterhub "${CHART_PATH}" --namespace jupyterhub \ @@ -818,6 +798,11 @@ function remove_aup_learning_cloud_runtime() { # ============================================================ function deploy_all_components() { + if [[ $EUID -ne 0 ]]; then + echo "Error: This script must be run as root." >&2 + exit 1 + fi + local flag="${1:-}" detect_and_configure_gpu @@ -837,7 +822,7 @@ function deploy_all_components() { fi deploy_rocm_gpu_device_plugin - deply_aup_learning_cloud_runtime + deploy_aup_learning_cloud_runtime } function remove_all_components() { @@ -1049,6 +1034,11 @@ EOF } function pack_bundle() { + if [[ $EUID -ne 0 ]]; then + echo "Error: This script must be run as root." >&2 + exit 1 + fi + local flag="${1:-}" echo "===========================================" @@ -1221,13 +1211,21 @@ case "$1" in detect-gpu) detect_and_configure_gpu ;; rt) case "${2:-}" in - install) deply_aup_learning_cloud_runtime ;; + install) + detect_and_configure_gpu + get_runtime_paths + generate_values_overlay + deploy_aup_learning_cloud_runtime + ;; upgrade) upgrade_aup_learning_cloud_runtime ;; remove) remove_aup_learning_cloud_runtime ;; reinstall) remove_aup_learning_cloud_runtime || true sleep 0.5 - deply_aup_learning_cloud_runtime + detect_and_configure_gpu + get_runtime_paths + generate_values_overlay + deploy_aup_learning_cloud_runtime ;; *) echo "Usage: $0 rt {install|upgrade|remove|reinstall}"; exit 1 ;; esac @@ -1243,7 +1241,12 @@ case "$1" in esac ;; # Legacy long form (still supported) - install-runtime) deply_aup_learning_cloud_runtime ;; + install-runtime) + detect_and_configure_gpu + get_runtime_paths + generate_values_overlay + deploy_aup_learning_cloud_runtime + ;; remove-runtime) remove_aup_learning_cloud_runtime ;; upgrade-runtime) upgrade_aup_learning_cloud_runtime ;; build-images) local_image_build ;; From 18ca4ff4fa4fbffd7d5158334cf0ade0953bb221 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:06:23 +0800 Subject: [PATCH 14/58] fix(ci): simplify pack-bundle workflow inputs and fix pack root check - Merge gpu_target + gpu_type into single gpu_type choice; installer derives GPU_TARGET internally via resolve_gpu_config - Add rdna4 option (gfx120x) to match upstream installer support - image_tag now defaults to current branch name (github.ref_name) so develop branch packs use 'develop' tag automatically - Use env: block instead of inline var prefix for cleaner CI syntax - Remove root check from pack_bundle; pack only needs docker/wget, not root access (install still requires root) --- .github/workflows/pack-bundle.yml | 51 +++++++++++++------------------ auplc-installer | 5 --- 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index f4d2c2b..d1afad3 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -22,32 +22,25 @@ name: Pack Offline Bundle on: workflow_dispatch: inputs: - gpu_target: - description: 'GPU target for the bundle' - required: true - default: 'gfx1151' - type: choice - options: - - gfx110x - - gfx1151 gpu_type: - description: 'GPU type (accelerator name)' + description: 'GPU type (determines target architecture and HSA config)' required: true default: 'strix-halo' type: choice options: - - strix-halo - - strix - - phx - image_registry: - description: 'Registry prefix for custom images' + - strix-halo # gfx1151 — Ryzen AI Max+ 395 / Max 390 + - phx # gfx110x — Ryzen AI 300 (Phoenix) + - strix # gfx110x + HSA override — Ryzen AI 300 (Strix Point) + - rdna4 # gfx120x — Radeon RX 9000 series + image_tag: + description: 'Image tag prefix (default: current branch name, e.g. develop, latest, v1.0)' required: false - default: 'ghcr.io/amdresearch' + default: '' type: string - image_tag: - description: 'Image tag prefix (e.g. latest, develop, v1.0)' + image_registry: + description: 'Registry prefix for custom images (override for forks or private registries)' required: false - default: 'latest' + default: 'ghcr.io/amdresearch' type: string create_release: description: 'Create a GitHub Release with the bundle' @@ -61,7 +54,7 @@ permissions: jobs: pack: - name: "Pack Bundle (${{ inputs.gpu_target }})" + name: "Pack Bundle (${{ inputs.gpu_type }})" runs-on: ubuntu-latest steps: - name: Free disk space @@ -89,11 +82,11 @@ jobs: password: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} - name: Run pack command - run: | - GPU_TYPE="${{ inputs.gpu_type }}" \ - IMAGE_REGISTRY="${{ inputs.image_registry }}" \ - IMAGE_TAG="${{ inputs.image_tag }}" \ - ./auplc-installer pack + env: + GPU_TYPE: ${{ inputs.gpu_type }} + IMAGE_REGISTRY: ${{ inputs.image_registry }} + IMAGE_TAG: ${{ inputs.image_tag || github.ref_name }} + run: ./auplc-installer pack - name: Verify bundle run: | @@ -109,7 +102,7 @@ jobs: - name: Upload bundle as artifact uses: actions/upload-artifact@v4 with: - name: auplc-bundle-${{ inputs.gpu_target }} + name: auplc-bundle-${{ inputs.gpu_type }} path: auplc-bundle-*.tar.gz retention-days: 7 compression-level: 0 # already compressed @@ -121,17 +114,17 @@ jobs: run: | BUNDLE=$(ls auplc-bundle-*.tar.gz) BUNDLE_NAME=$(basename "${BUNDLE}" .tar.gz) - TAG="bundle-${{ inputs.gpu_target }}-$(date +%Y%m%d)" + TAG="bundle-${{ inputs.gpu_type }}-$(date +%Y%m%d)" gh release create "${TAG}" "${BUNDLE}" \ - --title "Offline Bundle: ${{ inputs.gpu_target }} ($(date +%Y-%m-%d))" \ + --title "Offline Bundle: ${{ inputs.gpu_type }} ($(date +%Y-%m-%d))" \ --notes "$(cat <&2 - exit 1 - fi - local flag="${1:-}" echo "===========================================" From 993c88dc132793865cde45165a96bbdac4ad7772 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:11:56 +0800 Subject: [PATCH 15/58] fix(ci): sanitize branch name for Docker image tag github.ref_name for feature branches contains '/' (e.g. feature/offline-pack) which is invalid in Docker tags. Replace '/' with '-' when using branch name as default IMAGE_TAG. --- .github/workflows/pack-bundle.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index d1afad3..2a64de4 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -81,11 +81,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + - name: Resolve image tag + id: tag + run: | + # Use explicit input if provided; otherwise sanitize branch name + # (Docker tags cannot contain '/', replace with '-') + if [[ -n "${{ inputs.image_tag }}" ]]; then + echo "value=${{ inputs.image_tag }}" >> "$GITHUB_OUTPUT" + else + echo "value=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> "$GITHUB_OUTPUT" + fi + - name: Run pack command env: GPU_TYPE: ${{ inputs.gpu_type }} IMAGE_REGISTRY: ${{ inputs.image_registry }} - IMAGE_TAG: ${{ inputs.image_tag || github.ref_name }} + IMAGE_TAG: ${{ steps.tag.outputs.value }} run: ./auplc-installer pack - name: Verify bundle @@ -122,7 +133,7 @@ jobs: ## Offline Deployment Bundle - **GPU Type**: ${{ inputs.gpu_type }} - - **Image Tag**: ${{ inputs.image_tag || github.ref_name }} + - **Image Tag**: ${{ steps.tag.outputs.value }} - **Bundle**: ${BUNDLE_NAME} - **Built from**: ${{ github.sha }} (${{ github.ref_name }}) From ad57e6bbcf65d4733b31238bf437c4a254cd8eb3 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:33:32 +0800 Subject: [PATCH 16/58] fix(ci/pack): silently sanitize IMAGE_TAG by replacing '/' with '-' Branch names like 'feature/offline-pack' are invalid Docker tags. Both the workflow and pack_bundle now auto-replace '/' with '-' so no manual sanitization is needed by the caller. --- .github/workflows/pack-bundle.yml | 12 +++++------- auplc-installer | 3 +++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 2a64de4..b63da29 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -84,13 +84,11 @@ jobs: - name: Resolve image tag id: tag run: | - # Use explicit input if provided; otherwise sanitize branch name - # (Docker tags cannot contain '/', replace with '-') - if [[ -n "${{ inputs.image_tag }}" ]]; then - echo "value=${{ inputs.image_tag }}" >> "$GITHUB_OUTPUT" - else - echo "value=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> "$GITHUB_OUTPUT" - fi + # Use explicit input if provided; otherwise derive from branch name. + # Sanitize: Docker tags cannot contain '/' — replace with '-'. + RAW="${{ inputs.image_tag || github.ref_name }}" + echo "value=${RAW//\//-}" >> "$GITHUB_OUTPUT" + echo "Resolved IMAGE_TAG: ${RAW//\//-}" - name: Run pack command env: diff --git a/auplc-installer b/auplc-installer index 30f37f5..f8ff7cd 100755 --- a/auplc-installer +++ b/auplc-installer @@ -1036,6 +1036,9 @@ EOF function pack_bundle() { local flag="${1:-}" + # Sanitize IMAGE_TAG: Docker tags cannot contain '/' (e.g. branch names) + IMAGE_TAG="${IMAGE_TAG//\//-}" + echo "===========================================" echo "AUP Learning Cloud - Pack Offline Bundle" if [[ "${flag}" == "--local" ]]; then From 86d3f862ec8680edac6476c57bc2bb732fedbfd3 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:05:40 +0800 Subject: [PATCH 17/58] ci(pack): auto-pack on release tags, matrix all GPU types - Add workflow_run trigger: fires after 'Build Docker Images' completes, ensuring all images (hub, base, courses) are built before packing starts - pack-release job: matrix over all 4 GPU types, only runs on v* tags pushed to AMDResearch/aup-learning-cloud (main repo guard) - pack-release attaches bundles to the existing GitHub Release - pack-manual job: unchanged workflow_dispatch flow for manual testing - Fix tar SIGPIPE false error in verify step (2>/dev/null) --- .github/workflows/pack-bundle.yml | 112 ++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index b63da29..bb0829e 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -20,6 +20,13 @@ name: Pack Offline Bundle on: + # Automatic: fires after all images are built on a release tag push. + # The job condition below filters to v* tags on the main repo only. + workflow_run: + workflows: ["Build Docker Images"] + types: [completed] + + # Manual: for testing or on-demand bundle creation. workflow_dispatch: inputs: gpu_type: @@ -33,7 +40,7 @@ on: - strix # gfx110x + HSA override — Ryzen AI 300 (Strix Point) - rdna4 # gfx120x — Radeon RX 9000 series image_tag: - description: 'Image tag prefix (default: current branch name, e.g. develop, latest, v1.0)' + description: 'Image tag prefix (default: current branch/tag name)' required: false default: '' type: string @@ -53,9 +60,104 @@ permissions: packages: read jobs: - pack: + # ── Automatic release: one job per GPU target, triggered by workflow_run ── + pack-release: + name: "Pack Bundle (${{ matrix.gpu_type }}) — Release" + if: | + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.repository == 'AMDResearch/aup-learning-cloud' && + startsWith(github.event.workflow_run.head_branch, 'v') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + gpu_type: [strix-halo, phx, strix, rdna4] + + steps: + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false + + - name: Check available disk space + run: df -h / + + - name: Checkout code at the release tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Resolve image tag + id: tag + run: | + # For a release, use the tag name (e.g. v1.0); Docker tags cannot contain '/'. + RAW="${{ github.event.workflow_run.head_branch }}" + SANITIZED="${RAW//\//-}" + echo "value=${SANITIZED}" >> "$GITHUB_OUTPUT" + echo "Resolved IMAGE_TAG: ${SANITIZED}" + + - name: Run pack command + env: + GPU_TYPE: ${{ matrix.gpu_type }} + IMAGE_REGISTRY: ghcr.io/amdresearch + IMAGE_TAG: ${{ steps.tag.outputs.value }} + run: ./auplc-installer pack + + - name: Verify bundle + run: | + BUNDLE=$(ls auplc-bundle-*.tar.gz) + echo "Bundle: ${BUNDLE}" + echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" + tar tzf "${BUNDLE}" 2>/dev/null | head -30 + echo "---" + echo "Total files: $(tar tzf "${BUNDLE}" | wc -l)" + + - name: Upload bundle as artifact + uses: actions/upload-artifact@v4 + with: + name: auplc-bundle-${{ matrix.gpu_type }} + path: auplc-bundle-*.tar.gz + retention-days: 30 + compression-level: 0 # already compressed + + - name: Attach bundle to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BUNDLE=$(ls auplc-bundle-*.tar.gz) + TAG="${{ github.event.workflow_run.head_branch }}" + + # Upload the bundle to the existing release created by the tag push. + # If the release doesn't exist yet, create it. + if gh release view "${TAG}" &>/dev/null; then + gh release upload "${TAG}" "${BUNDLE}" --clobber + else + gh release create "${TAG}" "${BUNDLE}" \ + --title "Release ${TAG}" \ + --notes "Offline deployment bundles for ${TAG}." + fi + echo "Bundle uploaded to release ${TAG}" + + # ── Manual: single GPU target via workflow_dispatch ── + pack-manual: name: "Pack Bundle (${{ inputs.gpu_type }})" + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + steps: - name: Free disk space uses: jlumbroso/free-disk-space@main @@ -84,7 +186,7 @@ jobs: - name: Resolve image tag id: tag run: | - # Use explicit input if provided; otherwise derive from branch name. + # Use explicit input if provided; otherwise derive from branch/tag name. # Sanitize: Docker tags cannot contain '/' — replace with '-'. RAW="${{ inputs.image_tag || github.ref_name }}" echo "value=${RAW//\//-}" >> "$GITHUB_OUTPUT" @@ -102,9 +204,7 @@ jobs: BUNDLE=$(ls auplc-bundle-*.tar.gz) echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" - - # Extract and verify structure - tar tzf "${BUNDLE}" | head -30 + tar tzf "${BUNDLE}" 2>/dev/null | head -30 echo "---" echo "Total files: $(tar tzf "${BUNDLE}" | wc -l)" From fda559b1f9bf40e909397243dc0310c4da98136e Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:07:15 +0800 Subject: [PATCH 18/58] ci(pack): remove slow file count in verify step --- .github/workflows/pack-bundle.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index bb0829e..f249b35 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -123,8 +123,6 @@ jobs: echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" tar tzf "${BUNDLE}" 2>/dev/null | head -30 - echo "---" - echo "Total files: $(tar tzf "${BUNDLE}" | wc -l)" - name: Upload bundle as artifact uses: actions/upload-artifact@v4 @@ -205,8 +203,6 @@ jobs: echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" tar tzf "${BUNDLE}" 2>/dev/null | head -30 - echo "---" - echo "Total files: $(tar tzf "${BUNDLE}" | wc -l)" - name: Upload bundle as artifact uses: actions/upload-artifact@v4 From 7ef70b8c5f6df1e8f8b88b6fd97534d7a2b1203a Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:08:41 +0800 Subject: [PATCH 19/58] ci(pack): simplify verify step to filename and size only --- .github/workflows/pack-bundle.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index f249b35..2072c4c 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -122,8 +122,6 @@ jobs: BUNDLE=$(ls auplc-bundle-*.tar.gz) echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" - tar tzf "${BUNDLE}" 2>/dev/null | head -30 - - name: Upload bundle as artifact uses: actions/upload-artifact@v4 with: @@ -202,8 +200,6 @@ jobs: BUNDLE=$(ls auplc-bundle-*.tar.gz) echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" - tar tzf "${BUNDLE}" 2>/dev/null | head -30 - - name: Upload bundle as artifact uses: actions/upload-artifact@v4 with: From 7ec7d40ba1680f22df07bae91e9dc91dd884a8cc Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:15:24 +0800 Subject: [PATCH 20/58] perf(pack): save all custom images into one tar to deduplicate shared layers Course images (cv/dl/llm/physim) all share auplc-base layers. Saving them separately caused those layers to be written N times. A single docker save call with all image refs deduplicates shared layers automatically, reducing bundle size significantly. --- auplc-installer | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/auplc-installer b/auplc-installer index f8ff7cd..03f69cb 100755 --- a/auplc-installer +++ b/auplc-installer @@ -895,6 +895,7 @@ function pack_copy_chart() { } # Save custom images: pull from GHCR, then docker save +# All images are saved into a single tar to deduplicate shared layers. function pack_save_custom_images_pull() { local staging="$1" local tag="${IMAGE_TAG}-${GPU_TARGET}" @@ -903,16 +904,14 @@ function pack_save_custom_images_pull() { mkdir -p "${staging}/images/custom" local failed=0 + local all_refs=() for name in "${GPU_CUSTOM_NAMES[@]}"; do local image="${IMAGE_REGISTRY}/${name}:${tag}" if pull_and_tag "${image}"; then docker tag "${image}" "${IMAGE_REGISTRY}/${name}:latest" - docker save \ - "${IMAGE_REGISTRY}/${name}:latest" \ - "${IMAGE_REGISTRY}/${name}:${tag}" \ - -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name} (:latest + :${tag})" + all_refs+=("${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${tag}") + echo " Pulled: ${name} (:latest + :${tag})" else failed=$((failed + 1)) fi @@ -922,11 +921,8 @@ function pack_save_custom_images_pull() { local image="${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" if pull_and_tag "${image}"; then docker tag "${image}" "${IMAGE_REGISTRY}/${name}:latest" - docker save \ - "${IMAGE_REGISTRY}/${name}:latest" \ - "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" \ - -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name} (:latest + :${IMAGE_TAG})" + all_refs+=("${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}") + echo " Pulled: ${name} (:latest + :${IMAGE_TAG})" else failed=$((failed + 1)) fi @@ -938,6 +934,10 @@ function pack_save_custom_images_pull() { rm -rf "${staging}" exit 1 fi + + echo " Saving all custom images (shared layers deduplicated)..." + docker save "${all_refs[@]}" -o "${staging}/images/custom/auplc-custom.tar" + echo " Saved: ${staging}/images/custom/auplc-custom.tar" } # Save custom images: build locally via Makefile, then docker save @@ -956,24 +956,23 @@ function pack_save_custom_images_local() { MIRROR_NPM="${MIRROR_NPM}" \ all) - echo "--- Saving built images to bundle ---" + echo "--- Saving built images to bundle (shared layers deduplicated) ---" + + local all_refs=() for name in "${GPU_CUSTOM_NAMES[@]}"; do - docker save \ - "${IMAGE_REGISTRY}/${name}:latest" \ - "${IMAGE_REGISTRY}/${name}:${tag}" \ - -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name} (:latest + :${tag})" + all_refs+=("${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${tag}") + echo " Queued: ${name} (:latest + :${tag})" done for name in "${PLAIN_CUSTOM_NAMES[@]}"; do docker tag "${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" - docker save \ - "${IMAGE_REGISTRY}/${name}:latest" \ - "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}" \ - -o "${staging}/images/custom/${name}.tar" - echo " Saved: ${name} (:latest + :${IMAGE_TAG})" + all_refs+=("${IMAGE_REGISTRY}/${name}:latest" "${IMAGE_REGISTRY}/${name}:${IMAGE_TAG}") + echo " Queued: ${name} (:latest + :${IMAGE_TAG})" done + + docker save "${all_refs[@]}" -o "${staging}/images/custom/auplc-custom.tar" + echo " Saved: ${staging}/images/custom/auplc-custom.tar" } # Save external images (always pulled from registries) From 832e749b587ce1170527c4b93550bb0a39370110 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:39:15 +0800 Subject: [PATCH 21/58] ci(pack): allow fork repo in release trigger condition --- .github/workflows/pack-bundle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 2072c4c..4bb205e 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -66,8 +66,8 @@ jobs: if: | github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && - github.repository == 'AMDResearch/aup-learning-cloud' && - startsWith(github.event.workflow_run.head_branch, 'v') + startsWith(github.event.workflow_run.head_branch, 'v') && + (github.repository == 'AMDResearch/aup-learning-cloud' || github.repository == 'MioYuuIH/aup-learning-cloud') runs-on: ubuntu-latest strategy: fail-fast: false From 48d83c3313079eb3fb3a228375cc5efaeeb45f77 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:57:06 +0800 Subject: [PATCH 22/58] ci: add type=ref,event=tag to all metadata tag lists Ensures any v* tag (semver or not) gets pushed with the exact tag name. Previously non-semver tags (e.g. v0.1-test) would only get sha-based tags, causing course image builds to fail when looking for the base image by tag. Also removes main repo restriction from pack-release trigger condition. --- .github/workflows/docker-build.yml | 4 ++++ .github/workflows/pack-bundle.yml | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 62cdb76..e873c80 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -319,6 +319,7 @@ jobs: type=raw,value=${{ github.event.inputs.version }},enable=${{ github.event.inputs.version != '' }} type=sha,prefix=sha- type=ref,event=branch + type=ref,event=tag type=ref,event=pr - name: Docker metadata (unsuffixed tags — default target only) @@ -335,6 +336,7 @@ jobs: type=raw,value=${{ github.event.inputs.version }},enable=${{ github.event.inputs.version != '' }} type=sha,prefix=sha- type=ref,event=branch + type=ref,event=tag type=ref,event=pr - name: Merge tags @@ -455,6 +457,7 @@ jobs: type=raw,value=${{ github.event.inputs.version }},enable=${{ github.event.inputs.version != '' }} type=sha,prefix=sha- type=ref,event=branch + type=ref,event=tag type=ref,event=pr - name: Docker metadata (unsuffixed tags — default target only) @@ -471,6 +474,7 @@ jobs: type=raw,value=${{ github.event.inputs.version }},enable=${{ github.event.inputs.version != '' }} type=sha,prefix=sha- type=ref,event=branch + type=ref,event=tag type=ref,event=pr - name: Merge tags diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 4bb205e..e5c0411 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -66,8 +66,7 @@ jobs: if: | github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && - startsWith(github.event.workflow_run.head_branch, 'v') && - (github.repository == 'AMDResearch/aup-learning-cloud' || github.repository == 'MioYuuIH/aup-learning-cloud') + startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest strategy: fail-fast: false From 005985655c575caff049ec9066dcd7464123e6fa Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:50:22 +0800 Subject: [PATCH 23/58] ci(pack): derive IMAGE_REGISTRY from repository owner --- .github/workflows/pack-bundle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index e5c0411..67b88f7 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -112,7 +112,7 @@ jobs: - name: Run pack command env: GPU_TYPE: ${{ matrix.gpu_type }} - IMAGE_REGISTRY: ghcr.io/amdresearch + IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} IMAGE_TAG: ${{ steps.tag.outputs.value }} run: ./auplc-installer pack From 4afa48f2a58da07182e98038feaeb66d7a1fd296 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:15:57 +0800 Subject: [PATCH 24/58] ci(pack): lowercase repository owner for image registry --- .github/workflows/pack-bundle.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 67b88f7..fb7667b 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -100,19 +100,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} - - name: Resolve image tag + - name: Resolve image tag and registry id: tag run: | - # For a release, use the tag name (e.g. v1.0); Docker tags cannot contain '/'. RAW="${{ github.event.workflow_run.head_branch }}" SANITIZED="${RAW//\//-}" echo "value=${SANITIZED}" >> "$GITHUB_OUTPUT" echo "Resolved IMAGE_TAG: ${SANITIZED}" + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "registry=ghcr.io/${OWNER}" >> "$GITHUB_OUTPUT" - name: Run pack command env: GPU_TYPE: ${{ matrix.gpu_type }} - IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} + IMAGE_REGISTRY: ${{ steps.tag.outputs.registry }} IMAGE_TAG: ${{ steps.tag.outputs.value }} run: ./auplc-installer pack From 9603fac0ca81c1cffa8af563c5fdee966ae9715e Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:06:32 +0800 Subject: [PATCH 25/58] ci(pack): continue-on-error for release asset upload --- .github/workflows/pack-bundle.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index fb7667b..2d5de39 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -131,6 +131,7 @@ jobs: compression-level: 0 # already compressed - name: Attach bundle to GitHub Release + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From d8fcd10e8c9fb9afc9420b584317ddfe42e5937d Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:08:50 +0800 Subject: [PATCH 26/58] ci(pack): skip release upload if no release exists, don't auto-create --- .github/workflows/pack-bundle.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 2d5de39..4d52192 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -138,16 +138,14 @@ jobs: BUNDLE=$(ls auplc-bundle-*.tar.gz) TAG="${{ github.event.workflow_run.head_branch }}" - # Upload the bundle to the existing release created by the tag push. - # If the release doesn't exist yet, create it. + # Upload to the existing release. Releases are created manually with + # proper release notes before tagging; CI only attaches the bundle. if gh release view "${TAG}" &>/dev/null; then gh release upload "${TAG}" "${BUNDLE}" --clobber + echo "Bundle uploaded to release ${TAG}" else - gh release create "${TAG}" "${BUNDLE}" \ - --title "Release ${TAG}" \ - --notes "Offline deployment bundles for ${TAG}." + echo "No release found for ${TAG}, skipping upload." fi - echo "Bundle uploaded to release ${TAG}" # ── Manual: single GPU target via workflow_dispatch ── pack-manual: From 8b4540fd1332f2039a32da917a0cecdd3f57d082 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:10:12 +0800 Subject: [PATCH 27/58] ci(pack): skip GPU if bundle already exists in release --- .github/workflows/pack-bundle.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index 4d52192..c30a626 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -110,7 +110,28 @@ jobs: OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "registry=ghcr.io/${OWNER}" >> "$GITHUB_OUTPUT" + - name: Check if bundle already exists in release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.tag.outputs.value }}" + GPU="${{ matrix.gpu_type }}" + if gh release view "${TAG}" &>/dev/null; then + # Check if a bundle for this GPU type is already attached + if gh release view "${TAG}" --json assets --jq '.assets[].name' 2>/dev/null \ + | grep -q "auplc-bundle.*${GPU}"; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Bundle for ${GPU} already exists in release ${TAG}, skipping." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Run pack command + if: steps.check.outputs.skip != 'true' env: GPU_TYPE: ${{ matrix.gpu_type }} IMAGE_REGISTRY: ${{ steps.tag.outputs.registry }} @@ -118,11 +139,14 @@ jobs: run: ./auplc-installer pack - name: Verify bundle + if: steps.check.outputs.skip != 'true' run: | BUNDLE=$(ls auplc-bundle-*.tar.gz) echo "Bundle: ${BUNDLE}" echo "Size: $(du -sh "${BUNDLE}" | cut -f1)" + - name: Upload bundle as artifact + if: steps.check.outputs.skip != 'true' uses: actions/upload-artifact@v4 with: name: auplc-bundle-${{ matrix.gpu_type }} @@ -131,6 +155,7 @@ jobs: compression-level: 0 # already compressed - name: Attach bundle to GitHub Release + if: steps.check.outputs.skip != 'true' continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 2a0f2ba57b4c12de5aafa51a126b806e7232b8a9 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:05:44 +0800 Subject: [PATCH 28/58] ci(pack): remove auto-create release option from manual job --- .github/workflows/pack-bundle.yml | 34 ------------------------------- 1 file changed, 34 deletions(-) diff --git a/.github/workflows/pack-bundle.yml b/.github/workflows/pack-bundle.yml index c30a626..86a57f5 100644 --- a/.github/workflows/pack-bundle.yml +++ b/.github/workflows/pack-bundle.yml @@ -49,12 +49,6 @@ on: required: false default: 'ghcr.io/amdresearch' type: string - create_release: - description: 'Create a GitHub Release with the bundle' - required: false - default: false - type: boolean - permissions: contents: write packages: read @@ -232,31 +226,3 @@ jobs: retention-days: 7 compression-level: 0 # already compressed - - name: Create GitHub Release - if: inputs.create_release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BUNDLE=$(ls auplc-bundle-*.tar.gz) - BUNDLE_NAME=$(basename "${BUNDLE}" .tar.gz) - TAG="bundle-${{ inputs.gpu_type }}-$(date +%Y%m%d)" - - gh release create "${TAG}" "${BUNDLE}" \ - --title "Offline Bundle: ${{ inputs.gpu_type }} ($(date +%Y-%m-%d))" \ - --notes "$(cat < Date: Mon, 9 Mar 2026 14:01:06 +0800 Subject: [PATCH 29/58] feat(admin): optimize batch user/quota operations and enhance create user UX - Replace sequential per-user API calls with batch endpoints for user creation, password setting, and quota operations - Add batch set-password API endpoint with parallel bcrypt hashing via ThreadPoolExecutor for significant speedup - Optimize backend batch_set_quota to use single DB transaction instead of N separate sessions - Support unlimited quota in batch quota API - Add batch delete for selected users with confirmation modal - Add quick username generator (prefix + start number + count) - Add initial quota setting in create user modal with default from config - Add CSV export for created user credentials - Expose default_quota in quota rates API --- runtime/hub/core/authenticators/firstuse.py | 68 ++++++++ runtime/hub/core/handlers.py | 107 ++++++++++++- runtime/hub/core/quota/manager.py | 40 +++-- runtime/hub/core/quota/models.py | 3 +- runtime/hub/core/setup.py | 1 + .../admin/src/components/CreateUserModal.tsx | 147 +++++++++++++++--- .../apps/admin/src/pages/UserList.tsx | 87 +++++++++-- .../frontend/packages/shared/src/api/quota.ts | 2 +- .../frontend/packages/shared/src/api/users.ts | 10 ++ .../packages/shared/src/types/quota.ts | 1 + 10 files changed, 414 insertions(+), 52 deletions(-) diff --git a/runtime/hub/core/authenticators/firstuse.py b/runtime/hub/core/authenticators/firstuse.py index 6563dc9..284b8dc 100644 --- a/runtime/hub/core/authenticators/firstuse.py +++ b/runtime/hub/core/authenticators/firstuse.py @@ -27,6 +27,7 @@ from __future__ import annotations import bcrypt +from concurrent.futures import ThreadPoolExecutor from firstuseauthenticator import FirstUseAuthenticator from core.authenticators.models import UserPassword @@ -105,6 +106,73 @@ def set_password(self, username: str, password: str, force_change: bool = True) suffix = " (force change on next login)" if force_change else "" return f"Password set for {username}{suffix}" + def batch_set_passwords( + self, + users: list[dict], + force_change: bool = True, + ) -> dict: + """Set passwords for multiple users in a single transaction. + + Args: + users: List of dicts with 'username' and 'password' keys. + force_change: Whether to force password change on first login. + + Returns: + Dict with 'success', 'failed' counts and 'results' list. + """ + min_len = getattr(self, "min_password_length", 1) + results = {"success": 0, "failed": 0, "results": []} + + # Validate passwords first + valid_entries = [] + for entry in users: + username = entry["username"] + password = entry["password"] + if not password or len(password) < min_len: + results["failed"] += 1 + results["results"].append({ + "username": username, + "status": "failed", + "error": f"Password too short (min {min_len})", + }) + continue + valid_entries.append((username, password)) + + # Parallel bcrypt hashing (bcrypt releases GIL, threads give real speedup) + def _hash(pw: str) -> bytes: + return bcrypt.hashpw(pw.encode("utf8"), bcrypt.gensalt()) + + with ThreadPoolExecutor() as pool: + hash_results = list(pool.map(_hash, [pw for _, pw in valid_entries])) + hashed = [(username, h) for (username, _), h in zip(valid_entries, hash_results)] + + # Single DB transaction for all password updates + with session_scope() as session: + for username, password_hash in hashed: + try: + user_pw = session.query(UserPassword).filter_by(username=username).first() + if user_pw: + user_pw.password_hash = password_hash + user_pw.force_change = force_change + else: + user_pw = UserPassword( + username=username, + password_hash=password_hash, + force_change=force_change, + ) + session.add(user_pw) + results["success"] += 1 + results["results"].append({"username": username, "status": "success"}) + except Exception as e: + results["failed"] += 1 + results["results"].append({ + "username": username, + "status": "failed", + "error": str(e), + }) + + return results + def mark_force_password_change(self, username: str, force: bool = True) -> None: """Mark or unmark a user for forced password change.""" with session_scope() as session: diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 7532a26..0d2919b 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -58,6 +58,7 @@ "quota_rates": {}, "quota_enabled": False, "minimum_quota_to_start": 10, + "default_quota": 0, } @@ -66,6 +67,7 @@ def configure_handlers( quota_rates: dict[str, int] | None = None, quota_enabled: bool = False, minimum_quota_to_start: int = 10, + default_quota: int = 0, ) -> None: """Configure handler module with runtime settings.""" if accelerator_options is not None: @@ -74,6 +76,7 @@ def configure_handlers( _handler_config["quota_rates"] = quota_rates _handler_config["quota_enabled"] = quota_enabled _handler_config["minimum_quota_to_start"] = minimum_quota_to_start + _handler_config["default_quota"] = default_quota # ============================================================================= @@ -377,6 +380,75 @@ async def get(self): self.finish(json.dumps({"password": password})) +class AdminAPIBatchSetPasswordHandler(APIHandler): + """API endpoint for batch setting user passwords.""" + + @web.authenticated + async def post(self): + """Set passwords for multiple users in a single request.""" + assert self.current_user is not None + if not self.current_user.admin: + self.set_status(403) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Admin access required"})) + + try: + data = json.loads(self.request.body.decode("utf-8")) + users = data.get("users", []) + force_change = data.get("force_change", True) + + if not users or not isinstance(users, list): + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "users array is required"})) + + if len(users) > 1000: + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Maximum 1000 users per batch"})) + + # Validate entries + for entry in users: + if not isinstance(entry, dict) or "username" not in entry or "password" not in entry: + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Each entry must have username and password"})) + if entry.get("username", "").startswith("github:"): + self.set_status(400) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": f"Cannot set password for GitHub user: {entry['username']}"})) + + firstuse_auth = None + if isinstance(self.authenticator, MultiAuthenticator): + for authenticator in self.authenticator._authenticators: + if isinstance(authenticator, CustomFirstUseAuthenticator): + firstuse_auth = authenticator + break + + if not firstuse_auth: + self.set_status(500) + self.set_header("Content-Type", "application/json") + return self.finish(json.dumps({"error": "Password management not available"})) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, lambda: firstuse_auth.batch_set_passwords(users, force_change=force_change) + ) + + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(result)) + + except json.JSONDecodeError: + self.set_status(400) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"error": "Invalid JSON"})) + except Exception as e: + self.log.error(f"Failed to batch set passwords: {e}") + self.set_status(500) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"error": "Internal server error"})) + + # ============================================================================= # Quota Management Handlers # ============================================================================= @@ -531,18 +603,39 @@ async def post(self): quota_manager = get_quota_manager() admin_name = self.current_user.name + # Separate unlimited and regular users + unlimited_users = [u for u in req.users if u.unlimited is True] + unset_unlimited_users = [u for u in req.users if u.unlimited is False] + regular_users = [u for u in req.users if u.unlimited is None] + results = {"success": 0, "failed": 0, "details": []} - for user in req.users: + # Handle unlimited users + for user in unlimited_users: try: - quota_manager.set_balance(user.username, user.amount, admin_name) + quota_manager.set_unlimited(user.username, True, admin_name) results["success"] += 1 - results["details"].append({"username": user.username, "status": "success", "balance": user.amount}) + results["details"].append({"username": user.username, "status": "success", "unlimited": True}) except Exception: results["failed"] += 1 - results["details"].append( - {"username": user.username, "status": "failed", "error": "Processing error"} - ) + results["details"].append({"username": user.username, "status": "failed", "error": "Processing error"}) + + # Handle unset-unlimited users + for user in unset_unlimited_users: + try: + quota_manager.set_unlimited(user.username, False, admin_name) + results["success"] += 1 + except Exception: + results["failed"] += 1 + results["details"].append({"username": user.username, "status": "failed", "error": "Processing error"}) + + # Batch set balance for regular users + unset-unlimited users in single transaction + batch_users = [(u.username, u.amount) for u in regular_users + unset_unlimited_users] + if batch_users: + batch_result = quota_manager.batch_set_quota(batch_users, admin_name) + results["success"] += batch_result["success"] + results["failed"] += batch_result["failed"] + results["details"].extend(batch_result.get("details", [])) self.set_header("Content-Type", "application/json") self.finish(json.dumps(results)) @@ -641,6 +734,7 @@ async def get(self): "enabled": _handler_config["quota_enabled"], "rates": _handler_config["quota_rates"], "minimum_to_start": _handler_config["minimum_quota_to_start"], + "default_quota": _handler_config["default_quota"], } ) ) @@ -1079,6 +1173,7 @@ def get_handlers() -> list[tuple[str, type]]: (r"/admin/users", AdminUIHandler), (r"/admin/groups", AdminUIHandler), (r"/admin/api/set-password", AdminAPISetPasswordHandler), + (r"/admin/api/batch-set-password", AdminAPIBatchSetPasswordHandler), (r"/admin/api/generate-password", AdminAPIGeneratePasswordHandler), # Accelerator info API (r"/api/accelerators", AcceleratorsAPIHandler), diff --git a/runtime/hub/core/quota/manager.py b/runtime/hub/core/quota/manager.py index 89cb429..b24ff29 100644 --- a/runtime/hub/core/quota/manager.py +++ b/runtime/hub/core/quota/manager.py @@ -476,15 +476,37 @@ def get_all_balances(self) -> list[dict]: session.close() def batch_set_quota(self, users: list[tuple[str, int]], admin: str | None = None) -> dict: - """Set quota for multiple users at once.""" - results = {"success": 0, "failed": 0} - for username, amount in users: - try: - self.set_balance(username, amount, admin) - results["success"] += 1 - except Exception as e: - results["failed"] += 1 - print(f"Failed to set quota for {username}: {e}") + """Set quota for multiple users in a single transaction.""" + results = {"success": 0, "failed": 0, "details": []} + with self._op_lock, session_scope() as session: + for username, amount in users: + try: + uname = username.lower() + user = session.query(UserQuota).filter(UserQuota.username == uname).first() + if not user: + user = UserQuota(username=uname, balance=amount) + session.add(user) + balance_before = 0 + else: + balance_before = user.balance + user.balance = amount + + transaction = QuotaTransaction( + username=uname, + amount=amount - balance_before, + transaction_type="set", + balance_before=balance_before, + balance_after=amount, + description=f"Balance set to {amount}", + created_by=admin, + ) + session.add(transaction) + results["success"] += 1 + results["details"].append({"username": uname, "status": "success", "balance": amount}) + except Exception as e: + results["failed"] += 1 + results["details"].append({"username": username, "status": "failed", "error": str(e)}) + print(f"Failed to set quota for {username}: {e}") return results def _match_targets(self, username: str, balance: int, is_unlimited: bool, targets: dict) -> bool: diff --git a/runtime/hub/core/quota/models.py b/runtime/hub/core/quota/models.py index c6e7d2e..875d766 100644 --- a/runtime/hub/core/quota/models.py +++ b/runtime/hub/core/quota/models.py @@ -69,7 +69,8 @@ class BatchQuotaUser(BaseModel): """User entry for batch quota operation.""" username: str = Field(..., min_length=1, max_length=200, pattern=r"^[a-zA-Z0-9._@-]+$") - amount: int = Field(..., ge=-10_000_000, le=10_000_000) + amount: int = Field(default=0, ge=-10_000_000, le=10_000_000) + unlimited: bool | None = Field(default=None, description="Set unlimited quota (overrides amount)") class BatchQuotaRequest(BaseModel): diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 43b8a88..849af3d 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -126,6 +126,7 @@ async def auth_state_hook(spawner, auth_state): quota_rates=config.build_quota_rates(), quota_enabled=config.quota_enabled, minimum_quota_to_start=config.quota.minimumToStart, + default_quota=config.quota.defaultQuota, ) if not hasattr(c.JupyterHub, "extra_handlers") or c.JupyterHub.extra_handlers is None: diff --git a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx index d19d5e1..10d7709 100644 --- a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx @@ -17,14 +17,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useState } from 'react'; -import { Modal, Button, Form, Alert, Spinner, InputGroup } from 'react-bootstrap'; +import { useState, useCallback } from 'react'; +import { Modal, Button, Form, Alert, Spinner, InputGroup, Row, Col } from 'react-bootstrap'; import * as api from '@auplc/shared'; interface Props { show: boolean; onHide: () => void; onSuccess: () => void; + quotaEnabled?: boolean; + defaultQuota?: number; } interface CreatedUser { @@ -32,7 +34,7 @@ interface CreatedUser { password: string; } -export function CreateUserModal({ show, onHide, onSuccess }: Props) { +export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, defaultQuota = 0 }: Props) { const [usernames, setUsernames] = useState(''); const [password, setPassword] = useState(''); const [generateRandom, setGenerateRandom] = useState(true); @@ -42,6 +44,16 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { const [error, setError] = useState(null); const [createdUsers, setCreatedUsers] = useState([]); const [step, setStep] = useState<'input' | 'result'>('input'); + const [prefix, setPrefix] = useState(''); + const [count, setCount] = useState(10); + const [startNum, setStartNum] = useState(1); + const [quotaValue, setQuotaValue] = useState(String(defaultQuota || 0)); + + const handleGenerateNames = useCallback(() => { + if (!prefix.trim()) return; + const names = Array.from({ length: count }, (_, i) => `${prefix.trim()}${startNum + i}`); + setUsernames(names.join('\n')); + }, [prefix, count, startNum]); const generateRandomPassword = () => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; @@ -67,24 +79,35 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { throw new Error('Please enter at least one username'); } - const results: CreatedUser[] = []; + // Generate passwords for all users upfront + const userPasswords = names.map(username => ({ + username, + password: generateRandom ? generateRandomPassword() : password, + })); - for (const username of names) { - // Create user - await api.createUser(username, isAdmin); + // Batch create all users in a single API call + await api.createUsers(names, isAdmin); - // Set password - const pwd = generateRandom ? generateRandomPassword() : password; - await api.setPassword({ - username, - password: pwd, - force_change: forceChange, - }); + // Batch set all passwords in a single API call + await api.batchSetPasswords(userPasswords, forceChange); - results.push({ username, password: pwd }); + // Set initial quota if enabled and value is meaningful + if (quotaEnabled) { + const input = quotaValue.trim(); + const isUnlimited = input === '-1' || input === '∞' || input.toLowerCase() === 'unlimited'; + const amount = isUnlimited ? 0 : (parseInt(input) || 0); + if (isUnlimited || amount > 0) { + await api.batchSetQuota( + names.map(username => ({ + username, + amount, + ...(isUnlimited ? { unlimited: true } : {}), + })) + ); + } } - setCreatedUsers(results); + setCreatedUsers(userPasswords); setStep('result'); onSuccess(); } catch (err) { @@ -103,6 +126,10 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { setError(null); setCreatedUsers([]); setStep('input'); + setPrefix(''); + setCount(10); + setStartNum(1); + setQuotaValue(String(defaultQuota || 0)); onHide(); }; @@ -113,6 +140,20 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { navigator.clipboard.writeText(text); }; + const downloadCsv = () => { + const header = 'username,password\n'; + const rows = createdUsers + .map(u => `${u.username},${u.password}`) + .join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( @@ -125,6 +166,57 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) {
{error && {error}} + + Quick generate + + + setPrefix(e.target.value)} + placeholder="Prefix, e.g. student" + /> + + + + from + setStartNum(parseInt(e.target.value) || 1)} + style={{ width: 70 }} + /> + + + + + count + setCount(parseInt(e.target.value) || 1)} + style={{ width: 70 }} + /> + + + + + + + + Usernames (one per line) + {quotaEnabled && ( + + Initial Quota + setQuotaValue(e.target.value)} + placeholder="e.g. 100, or -1 for unlimited" + /> + + Leave as 0 to skip. Use -1 or "unlimited" for unlimited. + + + )} + setIsAdmin(e.target.checked)} /> +
) : (
@@ -246,8 +354,11 @@ export function CreateUserModal({ show, onHide, onSuccess }: Props) { ) : ( <> - + )} +
); } diff --git a/runtime/hub/frontend/packages/shared/src/api/quota.ts b/runtime/hub/frontend/packages/shared/src/api/quota.ts index d3b885e..feaf226 100644 --- a/runtime/hub/frontend/packages/shared/src/api/quota.ts +++ b/runtime/hub/frontend/packages/shared/src/api/quota.ts @@ -44,7 +44,7 @@ export async function setUserQuota( } export async function batchSetQuota( - users: Array<{ username: string; amount: number }> + users: Array<{ username: string; amount: number; unlimited?: boolean }> ): Promise<{ success: number; failed: number }> { return adminApiRequest<{ success: number; failed: number }>("/quota/batch", { method: "POST", diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index d73b184..70c3ed5 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -151,6 +151,16 @@ export async function setPassword( }); } +export async function batchSetPasswords( + users: Array<{ username: string; password: string }>, + force_change = true +): Promise<{ success: number; failed: number; results: Array<{ username: string; status: string; error?: string }> }> { + return adminApiRequest("/batch-set-password", { + method: "POST", + body: JSON.stringify({ users, force_change }), + }); +} + export async function generatePassword(): Promise<{ password: string }> { return adminApiRequest<{ password: string }>("/generate-password", { method: "GET", diff --git a/runtime/hub/frontend/packages/shared/src/types/quota.ts b/runtime/hub/frontend/packages/shared/src/types/quota.ts index 0630425..ecbcf35 100644 --- a/runtime/hub/frontend/packages/shared/src/types/quota.ts +++ b/runtime/hub/frontend/packages/shared/src/types/quota.ts @@ -42,6 +42,7 @@ export interface QuotaRates { rates: Record; minimum_to_start: number; enabled: boolean; + default_quota?: number; } export interface UserQuotaInfo { From fa2c3dd8f73f8ab59bdff8eb42e665d31abb39f9 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:03:45 +0800 Subject: [PATCH 30/58] style: fix ruff formatting and import ordering --- runtime/hub/core/authenticators/firstuse.py | 27 ++++++++++++--------- runtime/hub/core/handlers.py | 12 ++++++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/runtime/hub/core/authenticators/firstuse.py b/runtime/hub/core/authenticators/firstuse.py index 284b8dc..b514eca 100644 --- a/runtime/hub/core/authenticators/firstuse.py +++ b/runtime/hub/core/authenticators/firstuse.py @@ -26,8 +26,9 @@ from __future__ import annotations -import bcrypt from concurrent.futures import ThreadPoolExecutor + +import bcrypt from firstuseauthenticator import FirstUseAuthenticator from core.authenticators.models import UserPassword @@ -130,11 +131,13 @@ def batch_set_passwords( password = entry["password"] if not password or len(password) < min_len: results["failed"] += 1 - results["results"].append({ - "username": username, - "status": "failed", - "error": f"Password too short (min {min_len})", - }) + results["results"].append( + { + "username": username, + "status": "failed", + "error": f"Password too short (min {min_len})", + } + ) continue valid_entries.append((username, password)) @@ -165,11 +168,13 @@ def _hash(pw: str) -> bytes: results["results"].append({"username": username, "status": "success"}) except Exception as e: results["failed"] += 1 - results["results"].append({ - "username": username, - "status": "failed", - "error": str(e), - }) + results["results"].append( + { + "username": username, + "status": "failed", + "error": str(e), + } + ) return results diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 0d2919b..49d1486 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -416,7 +416,9 @@ async def post(self): if entry.get("username", "").startswith("github:"): self.set_status(400) self.set_header("Content-Type", "application/json") - return self.finish(json.dumps({"error": f"Cannot set password for GitHub user: {entry['username']}"})) + return self.finish( + json.dumps({"error": f"Cannot set password for GitHub user: {entry['username']}"}) + ) firstuse_auth = None if isinstance(self.authenticator, MultiAuthenticator): @@ -618,7 +620,9 @@ async def post(self): results["details"].append({"username": user.username, "status": "success", "unlimited": True}) except Exception: results["failed"] += 1 - results["details"].append({"username": user.username, "status": "failed", "error": "Processing error"}) + results["details"].append( + {"username": user.username, "status": "failed", "error": "Processing error"} + ) # Handle unset-unlimited users for user in unset_unlimited_users: @@ -627,7 +631,9 @@ async def post(self): results["success"] += 1 except Exception: results["failed"] += 1 - results["details"].append({"username": user.username, "status": "failed", "error": "Processing error"}) + results["details"].append( + {"username": user.username, "status": "failed", "error": "Processing error"} + ) # Batch set balance for regular users + unset-unlimited users in single transaction batch_users = [(u.username, u.amount) for u in regular_users + unset_unlimited_users] From 019a807e5e61018781ede4ad3068f6e6db645cc2 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:06:58 +0800 Subject: [PATCH 31/58] feat(admin): improve batch user creation error handling and duplicate feedback - Track per-user status (created/existed/failed) through the pipeline - Handle JupyterHub 409 (all users exist) gracefully instead of failing - Detect silently skipped existing users by comparing request vs response - Each step (create/password/quota) has independent error handling - Result page shows detailed status badges and warnings per user - CSV export includes status column - Clipboard copy only includes users with successful passwords --- .../admin/src/components/CreateUserModal.tsx | 182 +++++++++++++++--- 1 file changed, 155 insertions(+), 27 deletions(-) diff --git a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx index 10d7709..bee4404 100644 --- a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx @@ -17,8 +17,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useState, useCallback } from 'react'; -import { Modal, Button, Form, Alert, Spinner, InputGroup, Row, Col } from 'react-bootstrap'; +import { useState, useCallback, useMemo } from 'react'; +import { Modal, Button, Form, Alert, Spinner, InputGroup, Row, Col, Badge } from 'react-bootstrap'; import * as api from '@auplc/shared'; interface Props { @@ -32,6 +32,10 @@ interface Props { interface CreatedUser { username: string; password: string; + status: 'created' | 'existed' | 'failed'; + passwordSet: boolean; + quotaSet: boolean; + error?: string; } export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, defaultQuota = 0 }: Props) { @@ -76,38 +80,116 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, .filter(n => n.length > 0); if (names.length === 0) { - throw new Error('Please enter at least one username'); + setError('Please enter at least one username'); + setLoading(false); + return; } // Generate passwords for all users upfront - const userPasswords = names.map(username => ({ + const passwordMap = new Map( + names.map(username => [ + username, + generateRandom ? generateRandomPassword() : password, + ]) + ); + + // Initialize result tracking + const results: Map = new Map( + names.map(username => [ + username, + { username, password: passwordMap.get(username)!, status: 'created' as const, passwordSet: false, quotaSet: false }, + ]) + ); + + const warnings: string[] = []; + + // Step 1: Batch create users + let createdNames: string[] = []; + try { + const created = await api.createUsers(names, isAdmin); + // API returns only newly created users; existing ones are silently skipped + createdNames = created.map(u => u.name); + const existedNames = names.filter(n => !createdNames.includes(n)); + for (const name of existedNames) { + const r = results.get(name)!; + r.status = 'existed'; + } + if (existedNames.length > 0) { + warnings.push(`${existedNames.length} user(s) already existed: ${existedNames.join(', ')}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + // If 409 (all users exist), mark them all as existed and continue with password/quota + if (msg.includes('already exist')) { + for (const name of names) { + results.get(name)!.status = 'existed'; + } + createdNames = []; + warnings.push(`All ${names.length} user(s) already existed`); + } else { + // Fatal error - can't determine which users were created + setError(`Failed to create users: ${msg}`); + setLoading(false); + return; + } + } + + // Step 2: Set passwords (for all users - both new and existing) + const passwordEntries = names.map(username => ({ username, - password: generateRandom ? generateRandomPassword() : password, + password: passwordMap.get(username)!, })); - // Batch create all users in a single API call - await api.createUsers(names, isAdmin); - - // Batch set all passwords in a single API call - await api.batchSetPasswords(userPasswords, forceChange); + try { + const pwResult = await api.batchSetPasswords(passwordEntries, forceChange); + for (const r of pwResult.results) { + const entry = results.get(r.username); + if (entry) { + if (r.status === 'success') { + entry.passwordSet = true; + } else { + entry.error = r.error || 'Password set failed'; + } + } + } + if (pwResult.failed > 0) { + warnings.push(`${pwResult.failed} password(s) failed to set`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + warnings.push(`Password setting failed: ${msg}`); + } - // Set initial quota if enabled and value is meaningful + // Step 3: Set quota if enabled if (quotaEnabled) { const input = quotaValue.trim(); const isUnlimited = input === '-1' || input === '∞' || input.toLowerCase() === 'unlimited'; const amount = isUnlimited ? 0 : (parseInt(input) || 0); if (isUnlimited || amount > 0) { - await api.batchSetQuota( - names.map(username => ({ - username, - amount, - ...(isUnlimited ? { unlimited: true } : {}), - })) - ); + try { + await api.batchSetQuota( + names.map(username => ({ + username, + amount, + ...(isUnlimited ? { unlimited: true } : {}), + })) + ); + for (const entry of results.values()) { + entry.quotaSet = true; + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + warnings.push(`Quota setting failed: ${msg}`); + } } } - setCreatedUsers(userPasswords); + // Set warnings as non-fatal error for display + if (warnings.length > 0) { + setError(warnings.join('\n')); + } + + setCreatedUsers(Array.from(results.values())); setStep('result'); onSuccess(); } catch (err) { @@ -133,17 +215,22 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, onHide(); }; + const usersWithPasswords = useMemo( + () => createdUsers.filter(u => u.passwordSet), + [createdUsers] + ); + const copyToClipboard = () => { - const text = createdUsers + const text = usersWithPasswords .map(u => `${u.username}\t${u.password}`) .join('\n'); navigator.clipboard.writeText(text); }; const downloadCsv = () => { - const header = 'username,password\n'; + const header = 'username,password,status\n'; const rows = createdUsers - .map(u => `${u.username},${u.password}`) + .map(u => `${u.username},${u.passwordSet ? u.password : ''},${u.status}`) .join('\n'); const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -158,7 +245,7 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, - {step === 'input' ? 'Create Users' : 'Users Created'} + {step === 'input' ? 'Create Users' : 'Results'} @@ -299,9 +386,37 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, ) : (
- - Successfully created {createdUsers.length} user(s)! - + {(() => { + const newCount = createdUsers.filter(u => u.status === 'created').length; + const existedCount = createdUsers.filter(u => u.status === 'existed').length; + const failedPw = createdUsers.filter(u => !u.passwordSet).length; + return ( + <> + {newCount > 0 && ( + + {newCount} user(s) created successfully. + + )} + {existedCount > 0 && ( + + {existedCount} user(s) already existed (passwords updated). + + )} + {failedPw > 0 && ( + + {failedPw} user(s) failed to set password. + + )} + + ); + })()} + {error && ( + + {error.split('\n').map((line, i) => ( +
{line}
+ ))}
+
+ )}

Copy the credentials below and share them with the users:

@@ -311,13 +426,26 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, Username Password + Status {createdUsers.map((user) => ( {user.username} - {user.password} + {user.passwordSet ? user.password : '(failed)'} + + {user.status === 'created' ? ( + New + ) : user.status === 'existed' ? ( + Existed + ) : ( + Failed + )} + {!user.passwordSet && ( + PW fail + )} + ))} From 228f5640be2b4fce66b205e18d3482bd1f432d86 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:50:42 +0800 Subject: [PATCH 32/58] feat(admin): add batch password reset, protect admin deletion, and add DB savepoints - Add BatchPasswordModal for batch password reset of selected users with random/shared password options and CSV export - Protect admin users and current user from accidental deletion (UI filtering + execution-time safety checks) - Skip existing users during batch create (no password/quota changes) - Add per-user SQLAlchemy savepoints (begin_nested) in batch_set_passwords and batch_set_quota for partial-success error isolation - Show proper status badges (New/Skipped) and detailed feedback --- runtime/hub/core/authenticators/firstuse.py | 25 +- runtime/hub/core/quota/manager.py | 43 +-- .../src/components/BatchPasswordModal.tsx | 300 ++++++++++++++++++ .../admin/src/components/CreateUserModal.tsx | 95 +++--- .../apps/admin/src/pages/UserList.tsx | 63 +++- 5 files changed, 445 insertions(+), 81 deletions(-) create mode 100644 runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx diff --git a/runtime/hub/core/authenticators/firstuse.py b/runtime/hub/core/authenticators/firstuse.py index b514eca..1307214 100644 --- a/runtime/hub/core/authenticators/firstuse.py +++ b/runtime/hub/core/authenticators/firstuse.py @@ -149,21 +149,22 @@ def _hash(pw: str) -> bytes: hash_results = list(pool.map(_hash, [pw for _, pw in valid_entries])) hashed = [(username, h) for (username, _), h in zip(valid_entries, hash_results)] - # Single DB transaction for all password updates + # Single DB transaction with per-user savepoints with session_scope() as session: for username, password_hash in hashed: try: - user_pw = session.query(UserPassword).filter_by(username=username).first() - if user_pw: - user_pw.password_hash = password_hash - user_pw.force_change = force_change - else: - user_pw = UserPassword( - username=username, - password_hash=password_hash, - force_change=force_change, - ) - session.add(user_pw) + with session.begin_nested(): + user_pw = session.query(UserPassword).filter_by(username=username).first() + if user_pw: + user_pw.password_hash = password_hash + user_pw.force_change = force_change + else: + user_pw = UserPassword( + username=username, + password_hash=password_hash, + force_change=force_change, + ) + session.add(user_pw) results["success"] += 1 results["results"].append({"username": username, "status": "success"}) except Exception as e: diff --git a/runtime/hub/core/quota/manager.py b/runtime/hub/core/quota/manager.py index b24ff29..7f08c13 100644 --- a/runtime/hub/core/quota/manager.py +++ b/runtime/hub/core/quota/manager.py @@ -476,31 +476,32 @@ def get_all_balances(self) -> list[dict]: session.close() def batch_set_quota(self, users: list[tuple[str, int]], admin: str | None = None) -> dict: - """Set quota for multiple users in a single transaction.""" + """Set quota for multiple users in a single transaction with per-user savepoints.""" results = {"success": 0, "failed": 0, "details": []} with self._op_lock, session_scope() as session: for username, amount in users: try: - uname = username.lower() - user = session.query(UserQuota).filter(UserQuota.username == uname).first() - if not user: - user = UserQuota(username=uname, balance=amount) - session.add(user) - balance_before = 0 - else: - balance_before = user.balance - user.balance = amount - - transaction = QuotaTransaction( - username=uname, - amount=amount - balance_before, - transaction_type="set", - balance_before=balance_before, - balance_after=amount, - description=f"Balance set to {amount}", - created_by=admin, - ) - session.add(transaction) + with session.begin_nested(): + uname = username.lower() + user = session.query(UserQuota).filter(UserQuota.username == uname).first() + if not user: + user = UserQuota(username=uname, balance=amount) + session.add(user) + balance_before = 0 + else: + balance_before = user.balance + user.balance = amount + + transaction = QuotaTransaction( + username=uname, + amount=amount - balance_before, + transaction_type="set", + balance_before=balance_before, + balance_after=amount, + description=f"Balance set to {amount}", + created_by=admin, + ) + session.add(transaction) results["success"] += 1 results["details"].append({"username": uname, "status": "success", "balance": amount}) except Exception as e: diff --git a/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx b/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx new file mode 100644 index 0000000..46fcf43 --- /dev/null +++ b/runtime/hub/frontend/apps/admin/src/components/BatchPasswordModal.tsx @@ -0,0 +1,300 @@ +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +import { useState, useMemo } from 'react'; +import { Modal, Button, Form, Alert, Spinner, InputGroup, Badge } from 'react-bootstrap'; +import * as api from '@auplc/shared'; + +interface Props { + show: boolean; + usernames: string[]; + onHide: () => void; +} + +interface PasswordResult { + username: string; + password: string; + status: 'success' | 'failed'; + error?: string; +} + +export function BatchPasswordModal({ show, usernames, onHide }: Props) { + const [generateRandom, setGenerateRandom] = useState(true); + const [password, setPassword] = useState(''); + const [forceChange, setForceChange] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); + const [step, setStep] = useState<'input' | 'result'>('input'); + + const generateRandomPassword = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; + let result = ''; + for (let i = 0; i < 16; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + const handleSubmit = async () => { + setError(null); + setLoading(true); + + try { + const entries = usernames.map(username => ({ + username, + password: generateRandom ? generateRandomPassword() : password, + })); + + const response = await api.batchSetPasswords(entries, forceChange); + + const pwResults: PasswordResult[] = entries.map(entry => { + const r = response.results.find(r => r.username === entry.username); + return { + username: entry.username, + password: entry.password, + status: r?.status === 'success' ? 'success' as const : 'failed' as const, + error: r?.error, + }; + }); + + setResults(pwResults); + setStep('result'); + + if (response.failed > 0) { + setError(`${response.failed} password(s) failed to set`); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to set passwords'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setGenerateRandom(true); + setPassword(''); + setForceChange(true); + setError(null); + setResults([]); + setStep('input'); + onHide(); + }; + + const successResults = useMemo( + () => results.filter(r => r.status === 'success'), + [results] + ); + + const copyToClipboard = () => { + const text = successResults + .map(r => `${r.username}\t${r.password}`) + .join('\n'); + navigator.clipboard.writeText(text); + }; + + const downloadCsv = () => { + const header = 'username,password,status\n'; + const rows = results + .map(r => `${r.username},${r.status === 'success' ? r.password : ''},${r.status}`) + .join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passwords-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + {step === 'input' ? `Reset Passwords (${usernames.length} users)` : 'Password Reset Results'} + + + + {step === 'input' ? ( +
+ {error && {error}} + + + This will reset passwords for {usernames.length} selected user(s). + Users will need to use the new passwords to log in. + + +
+ Users:{' '} + {usernames.slice(0, 10).map(name => ( + {name} + ))} + {usernames.length > 10 && ( + +{usernames.length - 10} more + )} +
+ + + setGenerateRandom(e.target.checked)} + /> + + + {!generateRandom && ( + + Password (same for all users) + + setPassword(e.target.value)} + placeholder="Enter password" + minLength={8} + /> + + + + Minimum 8 characters + + + )} + + + setForceChange(e.target.checked)} + /> + +
+ ) : ( +
+ {(() => { + const successCount = successResults.length; + const failedCount = results.length - successCount; + return ( + <> + {successCount > 0 && ( + + {successCount} password(s) reset successfully. + + )} + {failedCount > 0 && ( + + {failedCount} password(s) failed to set. + + )} + + ); + })()} + {error && ( + + {error} + + )} +

+ Copy the new credentials and share them with the users: +

+
+ + + + + + + + + + {results.map((r) => ( + + + + + + ))} + +
UsernamePasswordStatus
{r.username} + {r.status === 'success' ? ( + {r.password} + ) : ( + (failed) + )} + + {r.status === 'success' ? ( + OK + ) : ( + Failed + )} +
+
+ {forceChange && ( + + Users will be prompted to change their password on next login. + + )} +
+ )} +
+ + {step === 'input' ? ( + <> + + + + ) : ( + <> + + + + + )} + +
+ ); +} diff --git a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx index bee4404..a1b87c9 100644 --- a/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/CreateUserModal.tsx @@ -134,48 +134,51 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, } } - // Step 2: Set passwords (for all users - both new and existing) - const passwordEntries = names.map(username => ({ - username, - password: passwordMap.get(username)!, - })); + // Step 2: Set passwords (only for newly created users) + if (createdNames.length > 0) { + const passwordEntries = createdNames.map(username => ({ + username, + password: passwordMap.get(username)!, + })); - try { - const pwResult = await api.batchSetPasswords(passwordEntries, forceChange); - for (const r of pwResult.results) { - const entry = results.get(r.username); - if (entry) { - if (r.status === 'success') { - entry.passwordSet = true; - } else { - entry.error = r.error || 'Password set failed'; + try { + const pwResult = await api.batchSetPasswords(passwordEntries, forceChange); + for (const r of pwResult.results) { + const entry = results.get(r.username); + if (entry) { + if (r.status === 'success') { + entry.passwordSet = true; + } else { + entry.error = r.error || 'Password set failed'; + } } } + if (pwResult.failed > 0) { + warnings.push(`${pwResult.failed} password(s) failed to set`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + warnings.push(`Password setting failed: ${msg}`); } - if (pwResult.failed > 0) { - warnings.push(`${pwResult.failed} password(s) failed to set`); - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - warnings.push(`Password setting failed: ${msg}`); } - // Step 3: Set quota if enabled - if (quotaEnabled) { + // Step 3: Set quota if enabled (only for newly created users) + if (quotaEnabled && createdNames.length > 0) { const input = quotaValue.trim(); const isUnlimited = input === '-1' || input === '∞' || input.toLowerCase() === 'unlimited'; const amount = isUnlimited ? 0 : (parseInt(input) || 0); if (isUnlimited || amount > 0) { try { await api.batchSetQuota( - names.map(username => ({ + createdNames.map(username => ({ username, amount, ...(isUnlimited ? { unlimited: true } : {}), })) ); - for (const entry of results.values()) { - entry.quotaSet = true; + for (const name of createdNames) { + const entry = results.get(name); + if (entry) entry.quotaSet = true; } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; @@ -387,24 +390,29 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, ) : (
{(() => { - const newCount = createdUsers.filter(u => u.status === 'created').length; - const existedCount = createdUsers.filter(u => u.status === 'existed').length; - const failedPw = createdUsers.filter(u => !u.passwordSet).length; + const newUsers = createdUsers.filter(u => u.status === 'created'); + const existedUsers = createdUsers.filter(u => u.status === 'existed'); + const failedPw = newUsers.filter(u => !u.passwordSet).length; return ( <> - {newCount > 0 && ( + {newUsers.length > 0 && ( - {newCount} user(s) created successfully. + {newUsers.length} user(s) created successfully. )} - {existedCount > 0 && ( + {existedUsers.length > 0 && ( - {existedCount} user(s) already existed (passwords updated). + {existedUsers.length} user(s) already existed and were skipped (no changes made). )} {failedPw > 0 && ( - {failedPw} user(s) failed to set password. + {failedPw} newly created user(s) failed to set password. + + )} + {newUsers.length === 0 && existedUsers.length > 0 && ( + + No new users were created. All usernames already exist in the system. )} @@ -433,18 +441,27 @@ export function CreateUserModal({ show, onHide, onSuccess, quotaEnabled = false, {createdUsers.map((user) => ( {user.username} - {user.passwordSet ? user.password : '(failed)'} + + {user.passwordSet ? ( + {user.password} + ) : user.status === 'existed' ? ( + - + ) : ( + (failed) + )} + {user.status === 'created' ? ( - New + user.passwordSet ? ( + New + ) : ( + PW failed + ) ) : user.status === 'existed' ? ( - Existed + Skipped ) : ( Failed )} - {!user.passwordSet && ( - PW fail - )} ))} diff --git a/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx b/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx index cd08c05..a2a4f62 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx @@ -25,6 +25,7 @@ import * as api from '@auplc/shared'; import { isGitHubUser, isNativeUser as isNativeUsername } from '@auplc/shared'; import { CreateUserModal } from '../components/CreateUserModal'; import { SetPasswordModal } from '../components/SetPasswordModal'; +import { BatchPasswordModal } from '../components/BatchPasswordModal'; import { EditUserModal } from '../components/EditUserModal'; import { ConfirmModal } from '../components/ConfirmModal'; @@ -79,6 +80,7 @@ const SortIcon = memo(function SortIcon({ column, sortColumn, sortDirection }: { // Memoized UserRow component to prevent unnecessary re-renders interface UserRowProps { user: User; + currentUser: string; quotaEnabled: boolean; quotaMap: Map; selectedUsers: Set; @@ -102,6 +104,7 @@ interface UserRowProps { const UserRow = memo(function UserRow({ user, + currentUser, quotaEnabled, quotaMap, selectedUsers, @@ -126,6 +129,8 @@ const UserRow = memo(function UserRow({ const isSelected = selectedUsers.has(user.name); const quota = quotaMap.get(user.name); const isEditingThisQuota = editingQuota === user.name; + // Protect admin users and the currently logged-in user from deletion + const isProtected = user.admin || user.name === currentUser; return ( @@ -252,7 +257,7 @@ const UserRow = memo(function UserRow({ Edit User - {isNativeUser(user) && user.name !== 'admin' && ( + {isNativeUser(user) && ( )} - {user.name !== 'admin' && ( + {!isProtected && ( )} +
- + {!readOnly && ( + + )} ); }); @@ -67,10 +71,20 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop const [error, setError] = useState(null); const [properties, setProperties] = useState>({}); - // Initialize state when modal opens + const isGitHubTeam = group?.source === 'github-team'; + const isSystemGroup = group?.source === 'system'; + const isUndeletable = isGitHubTeam || isSystemGroup; + + // System-managed property keys that should not be shown or edited + const RESERVED_KEYS = new Set(['source']); + + // Initialize state when modal opens (exclude reserved keys) const handleEnter = () => { if (group) { - setProperties({ ...group.properties }); + const userProps = Object.fromEntries( + Object.entries(group.properties).filter(([k]) => !RESERVED_KEYS.has(k)) + ); + setProperties(userProps); setError(null); } }; @@ -81,6 +95,11 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop return; } + if (RESERVED_KEYS.has(newPropertyKey)) { + setError(`"${newPropertyKey}" is a reserved key`); + return; + } + setProperties(prev => { if (newPropertyKey in prev) { setError('Property key already exists'); @@ -142,43 +161,74 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop return ( - Group Properties: {group.name} + + Group Properties: {group.name} + {error && setError(null)}>{error}} + {isGitHubTeam && ( + + + Synced from GitHub Teams — membership and properties are read-only. + + )} + + {isSystemGroup && ( + + System-managed group — membership and properties are read-only. + + )} + + {/* Resources (read-only, from config) */} + {group.resources && group.resources.length > 0 && ( +
+ Mapped Resources +
+ {group.resources.map(r => ( + {r} + ))} +
+ + Resource mappings are defined in values.yaml and cannot be changed from the UI. + +
+ )} + {/* Manage Properties */}
+ Properties

Properties are key-value pairs that can be used to configure group behavior.

-
-
- setNewPropertyKey(e.target.value)} - disabled={loading} - /> -
-
- setNewPropertyValue(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleAddProperty()} - disabled={loading} - /> -
-
- +
+
+ setNewPropertyKey(e.target.value)} + disabled={loading} + /> +
+
+ setNewPropertyValue(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddProperty()} + disabled={loading} + /> +
+
+ +
-
{Object.keys(properties).length === 0 ? ( @@ -190,6 +240,7 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop propKey={key} value={value} loading={loading} + readOnly={false} onRemove={handleRemoveProperty} /> )) @@ -198,9 +249,13 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop
- + {isUndeletable ? ( +
+ ) : ( + + )}
+ {githubOrg && ( + + )}
+ {/* Group behavior info */} + {githubOrg && ( + + + Groups with GitHub badge are synced from{' '} + + {githubOrg} + {' '} + organization teams. Their members are managed by GitHub and cannot be modified here. + If a manually created group shares its name with a GitHub team, it will be automatically converted + to a GitHub-managed group when a team member logs in. + + )} + {error && ( setError(null)}> {error} @@ -390,6 +445,7 @@ export function GroupList() { Group Name Members + Resources Actions diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index 70c3ed5..87e9584 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -167,9 +167,13 @@ export async function generatePassword(): Promise<{ password: string }> { }); } -export async function getGroups(): Promise { - const response = await apiRequest>("/groups"); - return Object.values(response); +export interface GroupsResponse { + groups: Group[]; + github_org: string; +} + +export async function getGroups(): Promise { + return adminApiRequest("/groups"); } export async function getGroup(groupName: string): Promise { @@ -183,7 +187,7 @@ export async function createGroup(groupName: string): Promise { } export async function deleteGroup(groupName: string): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}`, { method: "DELETE", }); } @@ -192,7 +196,7 @@ export async function updateGroup( groupName: string, data: { properties?: Record } ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}`, { method: "PATCH", body: JSON.stringify(data), }); @@ -202,7 +206,7 @@ export async function addUserToGroup( groupName: string, username: string ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { method: "POST", body: JSON.stringify({ users: [username] }), }); @@ -212,7 +216,7 @@ export async function removeUserFromGroup( groupName: string, username: string ): Promise { - return apiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { + return adminApiRequest(`/groups/${encodeURIComponent(groupName)}/users`, { method: "DELETE", body: JSON.stringify({ users: [username] }), }); diff --git a/runtime/hub/frontend/packages/shared/src/types/user.ts b/runtime/hub/frontend/packages/shared/src/types/user.ts index 28cbd47..0a69f61 100644 --- a/runtime/hub/frontend/packages/shared/src/types/user.ts +++ b/runtime/hub/frontend/packages/shared/src/types/user.ts @@ -70,4 +70,6 @@ export interface Group { name: string; users: string[]; properties: Record; + source?: "github-team" | "system" | "admin"; + resources?: string[]; } From 1d9a8b3faa6fabb67422ffe09134de2b54021167 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:33:53 +0800 Subject: [PATCH 34/58] fix: remove dead code, add release protection, and improve sync docs - Remove unused ensure_system_group() function (replaced by load_groups) - Add Release Protection button for protected groups in EditGroupModal - Add release_protection PATCH support in handlers.py - Move RESERVED_KEYS to module-level constant in EditGroupModal - Update info banner with sync timing and release protection docs - Improve lazy backfill comment in admin groups API handler - Add release_protection to updateGroup API type signature - Pre-create native-users group via load_groups at startup --- runtime/hub/core/groups.py | 25 ---------- runtime/hub/core/handlers.py | 10 +++- runtime/hub/core/setup.py | 8 +++ .../admin/src/components/EditGroupModal.tsx | 49 ++++++++++++++++--- .../apps/admin/src/pages/GroupList.tsx | 4 +- .../frontend/packages/shared/src/api/users.ts | 2 +- 6 files changed, 61 insertions(+), 37 deletions(-) diff --git a/runtime/hub/core/groups.py b/runtime/hub/core/groups.py index 46a6f6c..f059d35 100644 --- a/runtime/hub/core/groups.py +++ b/runtime/hub/core/groups.py @@ -162,31 +162,6 @@ def assign_user_to_group( log.info("Added user '%s' to group '%s'", user.name, group_name) -def ensure_system_group(group_name: str, db: object) -> None: - """Ensure a system-managed group exists with source=system. - - Called during hub startup to guarantee system groups are always present - and properly tagged, even before any user logs in. - - Args: - group_name: Name of the system group. - db: JupyterHub database session. - """ - from jupyterhub.orm import Group as ORMGroup - - orm_group = db.query(ORMGroup).filter_by(name=group_name).first() - if orm_group is None: - orm_group = ORMGroup(name=group_name) - orm_group.properties = {"source": SYSTEM_SOURCE} - db.add(orm_group) - db.commit() - log.info("Created system group '%s' on startup", group_name) - elif not orm_group.properties.get("source"): - orm_group.properties = {**orm_group.properties, "source": SYSTEM_SOURCE} - db.commit() - log.info("Backfilled source=system on group '%s'", group_name) - - def get_resources_for_user( user: JupyterHubUser, team_resource_mapping: dict[str, list[str]], diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 8d2fa89..bdf7294 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -1182,7 +1182,8 @@ async def get(self): team_resource_mapping = _handler_config.get("team_resource_mapping", {}) orm_groups = self.db.query(ORMGroup).order_by(ORMGroup.name).all() - # Backfill source for known system groups + # Lazy backfill: load_groups creates the group at startup but can't + # set properties on existing groups. Tag it here on first admin access. from core.groups import SYSTEM_SOURCE for g in orm_groups: @@ -1244,7 +1245,12 @@ async def patch(self, group_name): raise web.HTTPError(404, f"Group '{group_name}' not found") body = json.loads(self.request.body) - if "properties" in body: + + # Release protection: convert a protected group to admin-managed + if body.get("release_protection"): + orm_group.properties = {k: v for k, v in orm_group.properties.items() if k != "source"} + self.db.commit() + elif "properties" in body: new_props = body["properties"] # Preserve system-managed reserved keys reserved_keys = ("source",) diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 3b86654..750d9cc 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -84,6 +84,14 @@ def setup_hub(c: Any) -> None: c.JupyterHub.spawner_class = RemoteLabKubeSpawner + # ========================================================================= + # Pre-create System Groups + # ========================================================================= + # Ensure system-managed groups exist at startup (before any user logs in). + # Note: load_groups does NOT set properties on existing groups, so the + # source=system backfill is handled lazily in the admin groups API handler. + c.JupyterHub.load_groups = {"native-users": []} + # ========================================================================= # Configure Authenticator # ========================================================================= diff --git a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx index a212c23..3aa0452 100644 --- a/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/EditGroupModal.tsx @@ -22,6 +22,9 @@ import { Modal, Button, Form, ListGroup, Alert, Badge } from 'react-bootstrap'; import type { Group } from '@auplc/shared'; import * as api from '@auplc/shared'; +// System-managed property keys that should not be shown or edited +const RESERVED_KEYS = new Set(['source']); + // Memoized property list item const PropertyItem = memo(function PropertyItem({ propKey, @@ -73,10 +76,7 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop const isGitHubTeam = group?.source === 'github-team'; const isSystemGroup = group?.source === 'system'; - const isUndeletable = isGitHubTeam || isSystemGroup; - - // System-managed property keys that should not be shown or edited - const RESERVED_KEYS = new Set(['source']); + const isProtected = isGitHubTeam || isSystemGroup; // Initialize state when modal opens (exclude reserved keys) const handleEnter = () => { @@ -156,6 +156,32 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop } }; + const handleReleaseProtection = async () => { + if (!group) return; + + const sourceLabel = isGitHubTeam ? 'GitHub-synced' : 'system-managed'; + if (!window.confirm( + `Release protection on "${group.name}"?\n\n` + + `This will convert it from a ${sourceLabel} group to a manually managed group. ` + + `Members will become editable and the group can be deleted.\n\n` + + `Note: If a GitHub team with this name still exists, the group will be re-protected when a team member logs in.` + )) { + return; + } + + try { + setLoading(true); + setError(null); + await api.updateGroup(group.name, { release_protection: true }); + onUpdate(); + onHide(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to release protection'); + } finally { + setLoading(false); + } + }; + if (!group) return null; return ( @@ -171,13 +197,13 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop {isGitHubTeam && ( - Synced from GitHub Teams — membership and properties are read-only. + Synced from GitHub Teams — membership is read-only. )} {isSystemGroup && ( - System-managed group — membership and properties are read-only. + System-managed group — membership is read-only. )} @@ -249,8 +275,15 @@ export function EditGroupModal({ show, group, onHide, onUpdate, onDelete }: Prop
- {isUndeletable ? ( -
+ {isProtected ? ( + ) : ( {githubOrg && ( - + <> + + + )}
@@ -414,6 +446,7 @@ export function GroupList() { users (e.g. native users) to grant them the same resources. Team data is captured at login, and group membership is updated when the user starts a server — changes on GitHub may not appear until the user re-logs in and spawns. + Use "Sync Now" to immediately refresh all users' team memberships. If a manually created group shares its name with a GitHub team, it will be automatically converted to a GitHub-managed group when a team member logs in and spawns. Use "Release Protection" in group properties to convert a protected group back to manual management. @@ -426,6 +459,12 @@ export function GroupList() { )} + {syncResult && ( + setSyncResult(null)}> + {syncResult} + + )} + {/* Search */}
diff --git a/runtime/hub/frontend/packages/shared/src/api/users.ts b/runtime/hub/frontend/packages/shared/src/api/users.ts index d6b9949..0f38e9a 100644 --- a/runtime/hub/frontend/packages/shared/src/api/users.ts +++ b/runtime/hub/frontend/packages/shared/src/api/users.ts @@ -176,6 +176,18 @@ export async function getGroups(): Promise { return adminApiRequest("/groups"); } +export interface SyncGroupsResponse { + synced: number; + failed: number; + skipped: number; +} + +export async function syncGroups(): Promise { + return adminApiRequest("/groups/sync", { + method: "POST", + }); +} + export async function getGroup(groupName: string): Promise { return apiRequest(`/groups/${encodeURIComponent(groupName)}`); } From ff09fa67059c072fba87e827ba60baab90ccb7e2 Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:45:05 +0800 Subject: [PATCH 39/58] fix: inject AVAILABLE_RESOURCES via spawner options_form for resource filtering The custom spawn.html template referenced a non-existent `spawner_options` variable, so `window.AVAILABLE_RESOURCES` was never set. The React spawn app then showed all resources to every user regardless of group membership. Fix: `options_form()` now returns a ` -""" + available_resources_js = json.dumps(available_resource_names) + single_node_mode_js = "true" if self.single_node_mode else "false" - html_content = html_content.replace("", injection_script) - - self.log.debug(f"Successfully loaded template from {template_file}") - return html_content - else: - self.log.debug(f"Failed to load template from {template_file}, Fall back to basic form.") - return self._generate_fallback_form(available_resource_names) + return ( + "" + ) except Exception as e: self.log.error(f"Failed to load options form: {e}", exc_info=True) diff --git a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx index 0430049..5207e60 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx @@ -251,6 +251,9 @@ export function GroupList() { const [createError, setCreateError] = useState(null); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); + const [showInfo, setShowInfo] = useState(() => + localStorage.getItem('grouplist-hide-info') !== '1' + ); // Debounce search input useEffect(() => { @@ -353,6 +356,7 @@ export function GroupList() { setSyncResult( `Sync complete: ${result.synced} synced, ${result.failed} failed, ${result.skipped} skipped` ); + setTimeout(() => setSyncResult(null), 5000); await loadGroups(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to sync groups'); @@ -395,7 +399,7 @@ export function GroupList() { {githubOrg && ( <> + {!showInfo && ( + + )} )}
@@ -435,8 +448,8 @@ export function GroupList() {
{/* Group behavior info */} - {githubOrg && ( - + {githubOrg && showInfo && ( + { setShowInfo(false); localStorage.setItem('grouplist-hide-info', '1'); }}> Groups with GitHub badge are synced from{' '} diff --git a/runtime/hub/frontend/templates/spawn.html b/runtime/hub/frontend/templates/spawn.html index c1dbd45..102ec37 100644 --- a/runtime/hub/frontend/templates/spawn.html +++ b/runtime/hub/frontend/templates/spawn.html @@ -37,15 +37,13 @@
+ {{ spawner_options_form | safe }}
From 89d7c1b604c2ec216952ed1375b69740a7c9051a Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:06:12 +0800 Subject: [PATCH 40/58] feat(admin): collapse resource badges in group list with expand toggle --- .../apps/admin/src/pages/GroupList.tsx | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx index 5207e60..52852b1 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx @@ -103,6 +103,42 @@ interface UserOption { label: string; } +const COLLAPSED_LIMIT = 3; + +function ResourceBadges({ resources }: { resources: string[] }) { + const [expanded, setExpanded] = useState(false); + if (resources.length === 0) return --; + const visible = expanded ? resources : resources.slice(0, COLLAPSED_LIMIT); + const hidden = resources.length - COLLAPSED_LIMIT; + return ( +
+ {visible.map(r => {r})} + {!expanded && hidden > 0 && ( + setExpanded(true)} + title="Show all" + > + +{hidden} + + )} + {expanded && resources.length > COLLAPSED_LIMIT && ( + setExpanded(false)} + title="Collapse" + > + â–² + + )} +
+ ); +} + // Memoized GroupRow component with inline member management interface GroupRowProps { group: Group; @@ -211,15 +247,7 @@ const GroupRow = memo(function GroupRow({ group, onEdit, onMembersChange, loadUs /> - {group.resources && group.resources.length > 0 ? ( -
- {group.resources.map(r => ( - {r} - ))} -
- ) : ( - -- - )} + + ))} +
+
+ + {error && {error}} + + {loading ? ( +
+ +
+ ) : ( + <> + {/* Summary cards */} +
+ + + + +
+ + {/* Charts row */} +
+ {/* Usage trend */} +
+
Daily Usage (minutes)
+ {dailyUsage.length === 0 ? ( +

No data for this period

+ ) : ( + + + + d.slice(5)} /> + + [`${v} min`, 'Usage']} /> + + + + + )} +
+ + {/* Resource distribution */} +
+
Resource Distribution
+ {byResource.length === 0 ? ( +

No data for this period

+ ) : ( + + + + `${resource_type} ${(percent * 100).toFixed(0)}%` + } + labelLine={false} + > + {byResource.map((_, i) => ( + + ))} + + [`${v} min`]} /> + + + )} +
+
+ + {/* Top users table */} +
+
Top Users
+ {topUsers.length === 0 ? ( +

No data for this period

+ ) : ( + + + + + + + + + + + + {topUsers.map((u, i) => ( + + + + + + + + ))} + +
#UsernameTotal UsageSessionsAvg per Session
{i + 1}{u.username}{formatMinutes(u.total_minutes)}{u.sessions}{formatMinutes(Math.round(u.total_minutes / u.sessions))}
+ )} +
+ + )} +
+ ); +} diff --git a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx index 52852b1..c7c575b 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/GroupList.tsx @@ -19,6 +19,7 @@ import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; +import { NavBar } from '../components/NavBar'; import { Table, Button, Form, InputGroup, Alert, Spinner, Modal, Badge } from 'react-bootstrap'; import AsyncSelect from 'react-select/async'; import type { MultiValue, ActionMeta, StylesConfig } from 'react-select'; @@ -418,6 +419,7 @@ export function GroupList() { return (
+ {/* Top Controls */}
diff --git a/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx b/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx index a2a4f62..826684c 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/UserList.tsx @@ -19,6 +19,7 @@ import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; +import { NavBar } from '../components/NavBar'; import { Table, Button, Form, InputGroup, Badge, Spinner, Alert, ButtonGroup, Modal } from 'react-bootstrap'; import type { User, UserQuota, Server } from '@auplc/shared'; import * as api from '@auplc/shared'; @@ -766,6 +767,7 @@ export function UserList() { return (
+ {/* Top Controls */}
diff --git a/runtime/hub/frontend/apps/admin/vite.config.ts b/runtime/hub/frontend/apps/admin/vite.config.ts index 96988f8..db24b32 100644 --- a/runtime/hub/frontend/apps/admin/vite.config.ts +++ b/runtime/hub/frontend/apps/admin/vite.config.ts @@ -19,10 +19,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], // Base path for JupyterHub static files base: '/hub/static/admin-ui/', build: { diff --git a/runtime/hub/frontend/packages/shared/src/api/index.ts b/runtime/hub/frontend/packages/shared/src/api/index.ts index a15db3b..e5b5c35 100644 --- a/runtime/hub/frontend/packages/shared/src/api/index.ts +++ b/runtime/hub/frontend/packages/shared/src/api/index.ts @@ -23,3 +23,4 @@ export * from "./quota.js"; export * from "./accelerators.js"; export * from "./resources.js"; export * from "./git.js"; +export * from "./stats.js"; diff --git a/runtime/hub/frontend/packages/shared/src/api/stats.ts b/runtime/hub/frontend/packages/shared/src/api/stats.ts new file mode 100644 index 0000000..6be9f24 --- /dev/null +++ b/runtime/hub/frontend/packages/shared/src/api/stats.ts @@ -0,0 +1,21 @@ +// Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +import { adminApiRequest } from "./client.js"; +import type { + DashboardOverview, + StatsDistributionResponse, + StatsUsageResponse, +} from "../types/stats.js"; + +export async function getDashboardOverview(): Promise { + return adminApiRequest("/stats/overview"); +} + +export async function getUsageTimeSeries(days = 30): Promise { + return adminApiRequest(`/stats/usage?days=${days}`); +} + +export async function getDistribution(days = 30): Promise { + return adminApiRequest(`/stats/distribution?days=${days}`); +} diff --git a/runtime/hub/frontend/packages/shared/src/types/index.ts b/runtime/hub/frontend/packages/shared/src/types/index.ts index 122998e..51a3ef5 100644 --- a/runtime/hub/frontend/packages/shared/src/types/index.ts +++ b/runtime/hub/frontend/packages/shared/src/types/index.ts @@ -22,3 +22,4 @@ export * from "./quota.js"; export * from "./accelerator.js"; export * from "./resource.js"; export * from "./hub.js"; +export * from "./stats.js"; diff --git a/runtime/hub/frontend/packages/shared/src/types/stats.ts b/runtime/hub/frontend/packages/shared/src/types/stats.ts new file mode 100644 index 0000000..b53e586 --- /dev/null +++ b/runtime/hub/frontend/packages/shared/src/types/stats.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +export interface DashboardOverview { + total_users: number; + active_sessions: number; + total_usage_minutes: number; + users_this_week: number; +} + +export interface DailyUsage { + date: string; + minutes: number; + sessions: number; +} + +export interface ResourceDistribution { + resource_type: string; + minutes: number; + sessions: number; + users: number; +} + +export interface TopUser { + username: string; + total_minutes: number; + sessions: number; +} + +export interface StatsUsageResponse { + daily_usage: DailyUsage[]; +} + +export interface StatsDistributionResponse { + by_resource: ResourceDistribution[]; + top_users: TopUser[]; +} diff --git a/runtime/hub/frontend/pnpm-lock.yaml b/runtime/hub/frontend/pnpm-lock.yaml index afa1e0a..c4e9aab 100644 --- a/runtime/hub/frontend/pnpm-lock.yaml +++ b/runtime/hub/frontend/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@eslint/js': specifier: ^9.28.0 version: 9.39.2 + '@tailwindcss/vite': + specifier: ^4.1.0 + version: 4.2.2 '@types/node': specifier: ^25.2.3 version: 25.2.3 @@ -39,6 +42,9 @@ catalogs: react-dom: specifier: ^19.2.4 version: 19.2.4 + recharts: + specifier: ^2.15.0 + version: 2.15.4 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -79,10 +85,19 @@ importers: react-select: specifier: ^5.10.2 version: 5.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: 'catalog:' + version: 2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@eslint/js': specifier: 'catalog:' version: 9.39.2 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.2.2(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)) '@types/node': specifier: 'catalog:' version: 25.2.3 @@ -94,7 +109,7 @@ importers: 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.30.2)) + 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) @@ -115,7 +130,7 @@ importers: 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.30.2) + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) apps/spawn: dependencies: @@ -143,7 +158,7 @@ importers: 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.30.2)) + 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) @@ -164,7 +179,7 @@ importers: 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.30.2) + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) packages/shared: devDependencies: @@ -552,6 +567,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -708,6 +728,96 @@ packages: '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -720,6 +830,33 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -880,6 +1017,10 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -911,6 +1052,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -920,6 +1105,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -937,6 +1125,10 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1006,9 +1198,16 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1042,6 +1241,11 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1066,6 +1270,9 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1093,6 +1300,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -1154,74 +1365,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -1234,6 +1445,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1241,6 +1455,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -1311,6 +1528,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1349,6 +1576,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -1379,6 +1609,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-transition-group@4.4.5: resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -1389,6 +1625,16 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1449,6 +1695,16 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1509,6 +1765,9 @@ packages: '@types/react': optional: true + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1917,6 +2176,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@popperjs/core@2.11.8': {} '@react-aria/ssr@3.9.10(react@19.2.4)': @@ -2029,6 +2292,74 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -2050,6 +2381,30 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -2167,7 +2522,7 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -2175,7 +2530,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color @@ -2242,6 +2597,8 @@ snapshots: classnames@2.5.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2272,16 +2629,55 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} dom-helpers@5.2.1: dependencies: @@ -2290,6 +2686,11 @@ snapshots: electron-to-chromium@1.5.286: {} + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -2403,8 +2804,12 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -2431,6 +2836,9 @@ snapshots: flatted@3.3.3: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2446,6 +2854,8 @@ snapshots: globals@16.5.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} hasown@2.0.2: @@ -2467,6 +2877,8 @@ snapshots: imurmurhash@0.1.4: {} + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -2485,8 +2897,7 @@ snapshots: isexe@2.0.0: {} - jiti@2.6.1: - optional: true + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -2515,55 +2926,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.30.2: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - optional: true + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lines-and-columns@1.2.4: {} @@ -2573,6 +2983,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.23: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2581,6 +2993,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + memoize-one@6.0.0: {} minimatch@3.1.2: @@ -2641,6 +3057,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2690,6 +3114,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-lifecycles-compat@3.0.4: {} react-refresh@0.18.0: {} @@ -2725,6 +3151,14 @@ snapshots: - '@types/react' - supports-color + react-smooth@4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@babel/runtime': 7.28.6 @@ -2736,6 +3170,23 @@ snapshots: react@19.2.4: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + resolve-from@4.0.0: {} resolve@1.22.11: @@ -2803,6 +3254,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -2861,7 +3318,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2): + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2873,7 +3347,7 @@ snapshots: '@types/node': 25.2.3 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.32.0 warning@4.0.3: dependencies: diff --git a/runtime/hub/frontend/pnpm-workspace.yaml b/runtime/hub/frontend/pnpm-workspace.yaml index f87f6a2..2b3a791 100644 --- a/runtime/hub/frontend/pnpm-workspace.yaml +++ b/runtime/hub/frontend/pnpm-workspace.yaml @@ -27,3 +27,8 @@ catalog: # Build tools vite: ^7.3.1 "@vitejs/plugin-react": ^5.1.4 + + # Charts & Dashboard + recharts: ^2.15.0 + tailwindcss: ^4.1.0 + "@tailwindcss/vite": ^4.1.0 From 285f36e5513653a3545f899fc5fca2d37e7f574f Mon Sep 17 00:00:00 2001 From: ShifZhan <252984256+MioYuuIH@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:21:07 +0800 Subject: [PATCH 50/58] feat(admin): add usage dashboard with SSE live sessions and course stats - New Dashboard page: date range picker, usage trend chart (daily/weekly), course usage ranking with avg session duration, top users table - Active Now panel: SSE-based live feed of running sessions (5s refresh) - NavBar: unified tab navigation across Users/Groups/Dashboard pages; removed redundant 'Manage Groups' / 'Back to Users' buttons - stats_handlers: add StatsActiveSSEHandler, avg_minutes in distribution, ActiveSession type; remove redundant REST active endpoint - spawner stop(): fallback session recovery when usage_session_id lost after Hub restart (finds active DB session by username) - dark mode: use Bootstrap semantic classes instead of Tailwind dark variant --- runtime/hub/core/handlers.py | 4 +- runtime/hub/core/spawner/kubernetes.py | 27 +- runtime/hub/core/stats_handlers.py | 205 ++++++++++- .../apps/admin/src/components/NavBar.tsx | 2 +- runtime/hub/frontend/apps/admin/src/index.css | 4 + .../apps/admin/src/pages/Dashboard.tsx | 332 ++++++++++++------ .../apps/admin/src/pages/GroupList.tsx | 10 +- .../apps/admin/src/pages/UserList.tsx | 29 +- .../frontend/packages/shared/src/api/stats.ts | 21 +- .../packages/shared/src/types/stats.ts | 25 ++ 10 files changed, 520 insertions(+), 139 deletions(-) diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index cd3e235..80aa1c5 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -41,7 +41,7 @@ from tornado import web from core.authenticators import CustomFirstUseAuthenticator -from core.stats_handlers import StatsDistributionHandler, StatsOverviewHandler, StatsUsageHandler +from core.stats_handlers import StatsActiveSSEHandler, StatsDistributionHandler, StatsOverviewHandler, StatsUsageHandler, StatsUserHandler from core.quota import ( BatchQuotaRequest, QuotaAction, @@ -1469,6 +1469,8 @@ def get_handlers() -> list[tuple[str, type]]: (r"/admin/api/stats/overview", StatsOverviewHandler), (r"/admin/api/stats/usage", StatsUsageHandler), (r"/admin/api/stats/distribution", StatsDistributionHandler), + (r"/admin/api/stats/active/stream", StatsActiveSSEHandler), + (r"/admin/api/stats/user/([^/]+)", StatsUserHandler), # Dashboard UI (r"/admin/dashboard", AdminUIHandler), ] diff --git a/runtime/hub/core/spawner/kubernetes.py b/runtime/hub/core/spawner/kubernetes.py index 539224d..1d33f06 100644 --- a/runtime/hub/core/spawner/kubernetes.py +++ b/runtime/hub/core/spawner/kubernetes.py @@ -910,20 +910,27 @@ async def stop(self, now=False): # Clean up any leftover git token secrets await self._cleanup_git_token_secrets() - if hasattr(self, "usage_session_id") and self.usage_session_id: - session_id = self.usage_session_id - username = self.user.name - self.usage_session_id = None + username = self.user.name + try: + from core.quota import get_quota_manager - try: - from core.quota import get_quota_manager + quota_manager = get_quota_manager() + quota_rates = self.quota_rates if self.quota_enabled else None - quota_manager = get_quota_manager() - quota_rates = self.quota_rates if self.quota_enabled else None + session_id = getattr(self, "usage_session_id", None) + self.usage_session_id = None + + if session_id: duration, quota_used = quota_manager.end_usage_session(session_id, quota_rates) print(f"[USAGE] Session ended for {username}. Duration: {duration} min, Quota used: {quota_used}") - except Exception as e: - print(f"[USAGE] Error ending session for {username}: {e}") + else: + # Hub may have restarted and lost in-memory session id — find and close any active session for this user + active = quota_manager.get_active_session(username) + if active: + duration, quota_used = quota_manager.end_usage_session(active["session_id"], quota_rates) + print(f"[USAGE] Recovered session for {username}. Duration: {duration} min, Quota used: {quota_used}") + except Exception as e: + print(f"[USAGE] Error ending session for {username}: {e}") if hasattr(self, "check_timer") and self.check_timer: with contextlib.suppress(Exception): diff --git a/runtime/hub/core/stats_handlers.py b/runtime/hub/core/stats_handlers.py index 0717748..f2c02e0 100644 --- a/runtime/hub/core/stats_handlers.py +++ b/runtime/hub/core/stats_handlers.py @@ -62,7 +62,7 @@ def _query(self): class StatsUsageHandler(APIHandler): - """Daily usage time series for the trend line chart.""" + """Usage time series for the trend line chart, supporting day/week granularity.""" @web.authenticated async def get(self): @@ -76,27 +76,37 @@ async def get(self): except ValueError: days = 30 + granularity = self.get_argument("granularity", "day") + if granularity not in ("day", "week"): + granularity = "day" + loop = __import__("asyncio").get_event_loop() - result = await loop.run_in_executor(None, self._query, days) + result = await loop.run_in_executor(None, self._query, days, granularity) self.set_header("Content-Type", "application/json") self.finish(json.dumps(result)) - def _query(self, days: int): + def _query(self, days: int, granularity: str): import sqlalchemy as sa since = datetime.now() - timedelta(days=days) + if granularity == "week": + # SQLite: strftime('%Y-W%W', start_time) groups by ISO week + group_expr = "strftime('%Y-W%W', start_time)" + else: + group_expr = "DATE(start_time)" + with session_scope() as session: rows = session.execute( sa.text( - "SELECT DATE(start_time) as day, " + f"SELECT {group_expr} as period, " "COALESCE(SUM(duration_minutes), 0) as minutes, " "COUNT(*) as sessions " "FROM quota_usage_sessions " "WHERE status IN ('completed', 'cleaned_up') " "AND start_time >= :since " - "GROUP BY DATE(start_time) " - "ORDER BY day ASC" + f"GROUP BY {group_expr} " + "ORDER BY period ASC" ), {"since": since}, ).fetchall() @@ -140,7 +150,8 @@ def _query(self, days: int): "SELECT resource_type, " "COALESCE(SUM(duration_minutes), 0) as minutes, " "COUNT(*) as sessions, " - "COUNT(DISTINCT username) as users " + "COUNT(DISTINCT username) as users, " + "COALESCE(AVG(duration_minutes), 0) as avg_minutes " "FROM quota_usage_sessions " "WHERE status IN ('completed', 'cleaned_up') " "AND start_time >= :since " @@ -172,6 +183,7 @@ def _query(self, days: int): "minutes": int(row[1]), "sessions": int(row[2]), "users": int(row[3]), + "avg_minutes": round(float(row[4]), 1), } for row in resource_rows ], @@ -184,3 +196,182 @@ def _query(self, days: int): for row in top_user_rows ], } + + +class StatsUserHandler(APIHandler): + """Per-user usage detail: time series + resource breakdown + recent sessions.""" + + @web.authenticated + async def get(self, username: str): + assert self.current_user is not None + if not _require_admin(self): + return + + try: + days = int(self.get_argument("days", "30")) + days = max(1, min(days, 365)) + except ValueError: + days = 30 + + granularity = self.get_argument("granularity", "day") + if granularity not in ("day", "week"): + granularity = "day" + + loop = __import__("asyncio").get_event_loop() + result = await loop.run_in_executor(None, self._query, username, days, granularity) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(result)) + + def _query(self, username: str, days: int, granularity: str): + import sqlalchemy as sa + + since = datetime.now() - timedelta(days=days) + + if granularity == "week": + group_expr = "strftime('%Y-W%W', start_time)" + else: + group_expr = "DATE(start_time)" + + with session_scope() as session: + # Time series for this user + usage_rows = session.execute( + sa.text( + f"SELECT {group_expr} as period, " + "COALESCE(SUM(duration_minutes), 0) as minutes, " + "COUNT(*) as sessions " + "FROM quota_usage_sessions " + "WHERE username = :username " + "AND status IN ('completed', 'cleaned_up') " + "AND start_time >= :since " + f"GROUP BY {group_expr} " + "ORDER BY period ASC" + ), + {"username": username, "since": since}, + ).fetchall() + + # Resource breakdown for this user + resource_rows = session.execute( + sa.text( + "SELECT resource_type, " + "COALESCE(SUM(duration_minutes), 0) as minutes, " + "COUNT(*) as sessions " + "FROM quota_usage_sessions " + "WHERE username = :username " + "AND status IN ('completed', 'cleaned_up') " + "AND start_time >= :since " + "GROUP BY resource_type " + "ORDER BY minutes DESC" + ), + {"username": username, "since": since}, + ).fetchall() + + # Recent sessions (last 20) + session_rows = session.execute( + sa.text( + "SELECT resource_type, start_time, end_time, duration_minutes, status " + "FROM quota_usage_sessions " + "WHERE username = :username " + "AND start_time >= :since " + "ORDER BY start_time DESC " + "LIMIT 20" + ), + {"username": username, "since": since}, + ).fetchall() + + # Totals + totals_row = session.execute( + sa.text( + "SELECT COALESCE(SUM(duration_minutes), 0), COUNT(*) " + "FROM quota_usage_sessions " + "WHERE username = :username " + "AND status IN ('completed', 'cleaned_up') " + "AND start_time >= :since" + ), + {"username": username, "since": since}, + ).fetchone() + + return { + "username": username, + "total_minutes": int(totals_row[0] or 0), + "total_sessions": int(totals_row[1] or 0), + "usage": [ + {"date": str(r[0]), "minutes": int(r[1]), "sessions": int(r[2])} + for r in usage_rows + ], + "by_resource": [ + {"resource_type": r[0], "minutes": int(r[1]), "sessions": int(r[2])} + for r in resource_rows + ], + "recent_sessions": [ + { + "resource_type": r[0], + "start_time": str(r[1]), + "end_time": str(r[2]) if r[2] else None, + "duration_minutes": int(r[3]) if r[3] is not None else None, + "status": r[4], + } + for r in session_rows + ], + } + + +def _active_sessions_data() -> dict: + import sqlalchemy as sa + + with session_scope() as session: + rows = session.execute( + sa.text( + "SELECT q.username, q.resource_type, q.start_time " + "FROM quota_usage_sessions q " + "JOIN spawners s ON s.server_id IS NOT NULL " + "JOIN users u ON u.id = s.user_id AND LOWER(u.name) = LOWER(q.username) " + "WHERE q.status = 'active' " + "ORDER BY q.start_time ASC" + ) + ).fetchall() + + now = datetime.now() + return { + "active_sessions": [ + { + "username": r[0], + "resource_type": r[1], + "start_time": str(r[2]), + "elapsed_minutes": int( + (now - datetime.fromisoformat(str(r[2]))).total_seconds() / 60 + ), + } + for r in rows + ] + } + + +class StatsActiveSSEHandler(APIHandler): + """SSE stream of currently active sessions, pushed every 5 seconds.""" + + def check_xsrf_cookie(self): + # EventSource cannot send custom headers, so XSRF is skipped for this read-only GET + pass + + @web.authenticated + async def get(self): + import asyncio + + assert self.current_user is not None + if not _require_admin(self): + return + + self.set_header("Content-Type", "text/event-stream") + self.set_header("Cache-Control", "no-cache") + self.set_header("X-Accel-Buffering", "no") + self.set_header("Connection", "keep-alive") + + loop = asyncio.get_event_loop() + try: + while True: + data = await loop.run_in_executor(None, _active_sessions_data) + self.write(f"data: {json.dumps(data)}\n\n") + await self.flush() + await asyncio.sleep(5) + except Exception: + pass diff --git a/runtime/hub/frontend/apps/admin/src/components/NavBar.tsx b/runtime/hub/frontend/apps/admin/src/components/NavBar.tsx index 309edb6..4dfeac4 100644 --- a/runtime/hub/frontend/apps/admin/src/components/NavBar.tsx +++ b/runtime/hub/frontend/apps/admin/src/components/NavBar.tsx @@ -9,7 +9,7 @@ export function NavBar() { const location = useLocation(); return ( -
diff --git a/runtime/hub/frontend/apps/admin/src/pages/Dashboard.tsx b/runtime/hub/frontend/apps/admin/src/pages/Dashboard.tsx index 285d661..2c0c33d 100644 --- a/runtime/hub/frontend/apps/admin/src/pages/Dashboard.tsx +++ b/runtime/hub/frontend/apps/admin/src/pages/Dashboard.tsx @@ -51,6 +51,20 @@ function formatMinutes(minutes: number): string { return m > 0 ? `${h}h ${m}m` : `${h}h`; } +function formatResourceLabel(resourceType: string, resourceDisplay?: string | null): string { + if (resourceDisplay && resourceDisplay !== resourceType) { + return `${resourceDisplay} (${resourceType})`; + } + return resourceDisplay ?? resourceType; +} + +function formatAcceleratorLabel(acceleratorType?: string | null, acceleratorDisplay?: string | null): string { + if (acceleratorDisplay && acceleratorDisplay !== acceleratorType) { + return acceleratorType ? `${acceleratorDisplay} (${acceleratorType})` : acceleratorDisplay; + } + return acceleratorDisplay ?? acceleratorType ?? ''; +} + interface StatCardProps { title: string; value: string | number; @@ -226,32 +240,57 @@ export function Dashboard() { - {activeSessions.map((s, i) => ( - - setSelectedUser(s.username)}> - - {s.username} - - - {s.resource_type} - - {new Date(s.start_time + 'Z').toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} - - - {s.idle_warning && } - {formatMinutes(s.elapsed_minutes)} - - - - - - ))} + {activeSessions.map((s, i) => { + const showResourceCode = Boolean(s.resource_display && s.resource_display !== s.resource_type); + const acceleratorLabel = formatAcceleratorLabel(s.accelerator_type, s.accelerator_display); + const showAcceleratorCode = + Boolean(s.accelerator_display && s.accelerator_type && s.accelerator_display !== s.accelerator_type); + return ( + + setSelectedUser(s.username)}> + + {s.username} + + + +
+ {formatResourceLabel(s.resource_type, s.resource_display)} + {showResourceCode && ( + + {s.resource_type} + + )} +
+ {acceleratorLabel && ( +
+ {acceleratorLabel} + {showAcceleratorCode && ( + + {s.accelerator_type} + + )} +
+ )} + + + {new Date(s.start_time + 'Z').toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} + + + {s.idle_warning && } + {formatMinutes(s.elapsed_minutes)} + + + + + + ); + })} )} @@ -373,7 +412,7 @@ export function Dashboard() { className="tw:inline-block tw:w-2 tw:h-2 tw:rounded-full tw:shrink-0" style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} /> - {r.resource_type} + {formatResourceLabel(r.resource_type, r.resource_display)} {formatMinutes(r.minutes)} · {r.sessions} sessions · avg {formatMinutes(Math.round(r.avg_minutes))} diff --git a/runtime/hub/frontend/packages/shared/src/types/stats.ts b/runtime/hub/frontend/packages/shared/src/types/stats.ts index 68135f0..f6083a2 100644 --- a/runtime/hub/frontend/packages/shared/src/types/stats.ts +++ b/runtime/hub/frontend/packages/shared/src/types/stats.ts @@ -17,6 +17,7 @@ export interface DailyUsage { export interface ResourceDistribution { resource_type: string; + resource_display?: string; minutes: number; sessions: number; users: number; @@ -26,6 +27,9 @@ export interface ResourceDistribution { export interface ActiveSession { username: string; resource_type: string; + resource_display?: string; + accelerator_type?: string | null; + accelerator_display?: string | null; start_time: string; elapsed_minutes: number; idle_warning: boolean; @@ -60,6 +64,9 @@ export interface HourlyUsage { export interface UserSession { resource_type: string; + resource_display?: string; + accelerator_type?: string | null; + accelerator_display?: string | null; start_time: string; end_time: string | null; duration_minutes: number | null; From 0907fd14a637e306a3f7904504221d4d93fe00ff Mon Sep 17 00:00:00 2001 From: KerwinTsaiii Date: Wed, 1 Apr 2026 17:58:11 +0800 Subject: [PATCH 58/58] fix(installer): remove dummy0 default route that breaks internet connectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dummy0 interface adds a default route with metric 1000, which takes priority over the real network interface (e.g. WiFi at metric 20600), causing all traffic to route through a virtual interface with no actual connectivity. Remove the unnecessary default route from both the install step and the systemd service — dummy0 only needs to provide a stable IP for K3s node binding. Made-with: Cursor --- auplc-installer | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/auplc-installer b/auplc-installer index cdccc2f..1d4cd01 100755 --- a/auplc-installer +++ b/auplc-installer @@ -342,7 +342,6 @@ function setup_dummy_interface() { sudo ip link add dummy0 type dummy sudo ip link set dummy0 up sudo ip addr add "${K3S_NODE_IP}/32" dev dummy0 - sudo ip route add default via "${K3S_NODE_IP}" dev dummy0 metric 1000 2>/dev/null || true cat << EOF | sudo tee /etc/systemd/system/dummy-interface.service > /dev/null [Unit] @@ -353,7 +352,7 @@ After=network.target [Service] Type=oneshot RemainAfterExit=yes -ExecStart=/bin/bash -c 'ip link show dummy0 || (ip link add dummy0 type dummy && ip link set dummy0 up && ip addr add ${K3S_NODE_IP}/32 dev dummy0 && ip route add default via ${K3S_NODE_IP} dev dummy0 metric 1000 2>/dev/null || true)' +ExecStart=/bin/bash -c 'ip link show dummy0 || (ip link add dummy0 type dummy && ip link set dummy0 up && ip addr add ${K3S_NODE_IP}/32 dev dummy0)' ExecStop=/bin/bash -c 'ip link del dummy0 2>/dev/null || true' [Install]