Ubuntu 24.04 装机与 AI 开发环境一键初始化
适用场景:Ubuntu 24.04 LTS 开发机;其中 AMD ROCm/TTM 部分针对 ASUS ROG Flow Z13 幻 X 2025(GZ302EA)/ Ryzen AI Max+ 395 / Radeon 8060S 做了自动识别和保护。
最后整理:2026-09-26。
1. 这份方案安装什么
默认一键安装:
- Git
- Docker Engine(Docker 官方仓库)
- Docker Buildx
- Docker Compose v2(
docker compose) - Miniconda(不修改 Ubuntu 系统 Python)
- CoolerControl(中文仪表盘、温度/功耗/风扇监控)
- 中文「硬件状态总览」(大卡片、显存/内存/磁盘/网络/SMART、登录后自动启动)
lm-sensors、pciutils、usbutils等基础诊断工具- Ubuntu 的"软件和更新 / 软件更新器"
如果检测到 Ryzen AI Max+ 395,还会自动:
- 安装 ROCm 7.2.4
- 把当前用户加入
render、video组 - 配置 ROCm PATH
- 将 TTM 动态 GPU 可访问内存上限设置为 96 GiB
如果 ~/下载 或 ~/Downloads 中已经存在 spark-store*.deb,会顺带安装 星火应用商店 GUI。
不会自动安装/替换 AMD DKMS 显卡驱动。Ryzen AI Max+ 395 优先使用 Ubuntu 内核自带的
amdgpu。
2. 最快使用方法
把配套脚本 ubuntu-24.04-workstation-bootstrap.sh 放到新 Ubuntu 机器,例如 ~/下载,然后运行:
bash
cd ~/下载
chmod +x ubuntu-24.04-workstation-bootstrap.sh
./ubuntu-24.04-workstation-bootstrap.sh
执行结束后:
bash
sudo reboot
重启后做一次验证:
bash
git --version
docker --version
docker compose version
conda --version
groups
systemctl status coolercontrold
AMD ROCm 机器额外验证:
bash
rocminfo | grep -E 'Name:|Marketing Name:'
amd-smi
cat /sys/module/ttm/parameters/pages_limit
幻 X 2025 正常应能看到:
text
Name: gfx1151
Marketing Name: Radeon 8060S Graphics
3. 一键脚本参数
脚本所有功能都可以通过环境变量开关。
普通 Ubuntu 开发机
直接:
bash
./ubuntu-24.04-workstation-bootstrap.sh
在非 Ryzen AI Max+ 395 机器上,ROCm 和 TTM 默认不会启用。
强制不安装 ROCm
bash
INSTALL_ROCM=0 CONFIGURE_TTM=0 ./ubuntu-24.04-workstation-bootstrap.sh
强制安装 ROCm
仅限确认兼容的 AMD GPU/APU:
bash
INSTALL_ROCM=1 ./ubuntu-24.04-workstation-bootstrap.sh
幻 X 使用 112 GiB GPU 动态共享上限
bash
TTM_GB=112 ./ubuntu-24.04-workstation-bootstrap.sh
日常开发更推荐 96 GiB;112/120 GiB 更偏专用大模型工作站。
启用幻 X 特殊启动参数
只有当该机器明确需要这些参数才能稳定启动/识别 PCIe、USB4/Thunderbolt 时才启用:
bash
APPLY_FLOW_BOOT_WORKAROUNDS=1 ./ubuntu-24.04-workstation-bootstrap.sh
会额外加入:
text
pcie_aspm=off
pci=realloc=on
thunderbolt.host_reset=0
其他电脑不要开启。
清理此前试装的软件管理器
如果机器上曾试装 Discover、GDebi、Synaptic、GNOME Software,并且确认不再使用:
bash
CLEANUP_EXPERIMENTAL_GUI=1 ./ubuntu-24.04-workstation-bootstrap.sh
默认不会删除它们。
4. 软件管理建议
Ubuntu 没有一个能像 Windows"已安装的应用"那样完整识别所有 deb + Snap + Flatpak + AppImage + 手工安装 的官方 GUI。
当前建议:
- Ubuntu App Center:普通桌面应用 / Snap
- 星火应用商店:国内软件、Wine 软件、常用桌面应用
- APT :本地
.deb、ROCm、Docker、系统组件 - 软件和更新:软件源 / PPA / 附加驱动
安装本地 .deb 推荐:
bash
sudo apt install ./xxx.deb
不要优先使用:
bash
sudo dpkg -i xxx.deb
因为 apt 会自动处理依赖。
5. 星火应用商店 GUI
Ubuntu 22.04+ 官方推荐从星火 Gitee/GitCode Release 下载与架构匹配的 .deb,然后:
bash
cd ~/下载
sudo apt install ./spark-store*.deb
如果浏览器下载链路不稳定,可从官方 Release 页面下载后再执行上面的命令。
一键脚本会自动检查:
text
~/下载/spark-store*.deb
~/Downloads/spark-store*.deb
发现安装包就自动安装。
6. Git
脚本实际执行:
bash
sudo apt update
sudo apt install -y git
验证:
bash
git --version
Git 用户名/邮箱不要写死在装机脚本里。新机器按账号配置:
bash
git config --global user.name "你的名字"
git config --global user.email "你的邮箱"
SSH 密钥:
bash
ssh-keygen -t ed25519 -C "你的邮箱"
cat ~/.ssh/id_ed25519.pub
将公钥复制到 GitLab/GitHub 即可。
7. Docker + Docker Compose
使用 Docker 官方 APT 源,不安装 Ubuntu 仓库中的旧 docker.io。
安装组件:
text
docker-ce
docker-ce-cli
containerd.io
docker-buildx-plugin
docker-compose-plugin
Compose v2 使用:
bash
docker compose version
不是旧命令:
text
docker-compose
脚本会执行:
bash
sudo usermod -aG docker "$USER"
因此安装后要注销登录或重启一次,之后可以直接:
bash
docker ps
不再需要每次 sudo docker ...。
注意:
docker用户组等价于较高系统权限,仅给可信用户加入。
8. Miniconda / Python 多环境
Miniconda 安装在:
text
~/miniconda3
不会改 Ubuntu 系统 Python。
脚本会关闭自动进入 (base):
bash
conda config --set auto_activate_base false
创建环境示例:
bash
conda create -n py312 python=3.12 -y
conda activate py312
python --version
如果遇到:
text
CondaToSNonInteractiveError
按 Conda 输出接受对应 Anaconda channel ToS,例如:
bash
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r
之后再创建环境。
9. 中文硬件状态总览 + CoolerControl
已于 2026-09-26 在当前 Ubuntu 24.04.4 台式机完成配置并验证。当前机器为 AMD Ryzen 9 5950X / NVIDIA RTX 3090 24 GiB / 64 GB 内存 / Samsung SSD 980 PRO 2TB / ASUS ROG CROSSHAIR VIII DARK HERO,不要与第 10 节的幻 X 配置混淆。
9.1 平时怎么打开
- 桌面、应用列表和 Dock →「硬件状态总览」:中文大卡片,一屏查看核心硬件状态。
- 浏览器:http://127.0.0.1:18765,仅监听本机地址。
- CoolerControl:启动默认进入「硬件状态总览」表格,不再停留在后台服务概览。
- 面板右上角可以切换深浅主题,也可打开 CoolerControl。
显示内容:
| 硬件 | 主要读数 |
|---|---|
| CPU | 封装温度、负载、封装功耗、平均频率;CoolerControl 中还可查看 CCD 温度 |
| GPU | 核心温度、热点温度、负载、显存占用、功耗、风扇占空比 |
| 内存 | 容量、占用、交换空间、四个 JC42 温度传感器;大字取最高值 |
| SSD / 磁盘 | 温度、读写速率、已挂载分区容量、剩余空间 |
| SMART | UDisks 缓存中的介质错误、寿命消耗、备用空间、累计写入、通电时长、非正常断电次数、数据更新时间 |
| 主板 | 主板、芯片组、CPU 插座、供电 VRM 温度 |
| 风扇 | 主板已上报转速的通道、GPU 风扇占空比;CoolerControl 还显示 GPU RPM |
| 网络 | 每块物理网卡连接状态及上传、下载速率 |
总览每 2 秒 采样,温度曲线保留最近约 5 分钟 ,仅保存在内存中;SMART 每 60 秒读取系统缓存,缓存时间不等于页面刷新时间。所有读数由当前内核、驱动和 CoolerControl 决定,没有暴露的项目显示"---"或不可用,不虚构健康分数。内存型号、电源内部温度/功耗等未提供。
9.2 当前已保存的 CoolerControl 配置
使用官方 API 保存,只调整显示名称、语言、主题、启动页和仪表盘:
- 硬件状态总览:CPU、GPU、主板、内存、SSD、网卡及风扇主要指标表。
- 温度趋势 · 5 分钟:重点温度曲线。
- 功耗与风扇:CPU/GPU 功耗、GPU 风扇与当前有转速的主板通道。
- 全部原始传感器 · 含待确认项:保留原始通道,便于核对。
保留原有 System 仪表盘,不修改风扇曲线、PWM、灯效、模式或 BIOS 控制。台式机的 nct6775 / jc42 模块在本次配置前已经启用,现有 /etc/modules 已记录启动加载。
读数解释:
- 本机 ASUS EC 的 T_Sensor / Water_In / Water_Out 为 −40°C,疑似未接探头;不放入重点温度视图。
- NCT6798 AUXTIN1 / AUXTIN2 出现约 127°C / 110°C,通道用途尚未核实;保留在原始读数页,不直接判断为 CPU 或主板过热。
fan2是内核通道名,未核对物理插座前不擅自命名为"CPU 风扇"。0 RPM 可能是未接入或低负载停转。- JC42 的报警阈值在本机为 0°C,原始 ALARM 不能直接作为内存过热证据;本配置不写入硬件阈值。
- 温度提示色采用一般观察值(CPU 85°C / GPU 核心 83°C / 内存 70°C / SSD 70°C),不等同于厂商故障诊断。
本次实际发现: Samsung 980 PRO 的 SMART 缓存显示累计 media_errors=868、num_err_log_entries=868、可用备用空间 68%、备用空间阈值 10%、寿命消耗 20%、通电 5257 小时;缓存未报告关键告警。累计介质错误值得关注,建议先备份重要数据,再观察计数是否增长;"20% 寿命消耗"不等于"健康度 80%",缓存数据也不能证明当前无故障。面板会持续显示这一提醒,不会仅因温度正常给出"硬盘健康"的结论。
9.3 重装后的安装方式
CoolerControl 仍使用官方仓库:
bash
curl -fsSL https://apt.coolercontrol.org/setup.sh | sudo sh
sudo apt update
sudo apt install -y coolercontrol lm-sensors python3 udisks2
sudo systemctl enable --now coolercontrold
总览脚本使用 Python 标准库,不安装 pip 包、不修改系统 Python、不开外网端口:
bash
cd ~/下载
bash ubuntu-hardware-dashboard-setup.sh
普通用户运行,不要加 sudo 。完整初始化脚本现在默认调用同目录的这个脚本;缺少脚本时,会从同目录的本 Markdown 第 16 节自动恢复。设置 INSTALL_HARDWARE_DASHBOARD=0 可跳过。
新机器第一次配置 CoolerControl,需要先打开官方桌面应用、完成登录,再正常退出以保存会话。然后执行:
bash
/usr/bin/python3 ~/.local/share/ubuntu-hardware-monitor/monitor.py --configure-coolercontrol
脚本复用当前用户官方桌面应用的本机登录,不写死账号密码或机器设备 UID,不复制授权到初始化文档。如果尚未登录,总览面板依然可运行,CPU 功耗和 GPU 热点等扩展项会标明不可用;登录后自动恢复采集。已有 CoolerControl 窗口时,先正常退出、执行上述命令,再重新打开,避免旧窗口覆盖新配置。
换机时自动发现可用传感器。AMD 显卡可读 amdgpu 的温度、负载和固定 VRAM;统一内存/GTT 上限仍按第 10 节单独验证,不能把固定 VRAM 当成 APU 可用的全部显存。内存温度、主板风扇等依赖硬件及驱动,不支持时不强行启用。幻 X 默认只监控,不接管 ASUS EC 风扇。
9.4 验证、维护与恢复
bash
systemctl --user is-enabled ubuntu-hardware-monitor.service
systemctl --user status ubuntu-hardware-monitor.service
curl -fsS http://127.0.0.1:18765/api/status | python3 -m json.tool
journalctl --user -u ubuntu-hardware-monitor.service -n 30 --no-pager
服务在当前用户登录后自动启动;关闭面板窗口不停止采集。服务进程位于 ~/.local/share/ubuntu-hardware-monitor/monitor.py,桌面启动器为 ~/.local/bin/ubuntu-hardware-monitor。当前用户已有 google-chrome 时以独立窗口打开,否则使用默认浏览器。
停止 / 重新启用:
bash
systemctl --user disable --now ubuntu-hardware-monitor.service
# 需要时恢复:
systemctl --user enable --now ubuntu-hardware-monitor.service
每次修改 CoolerControl 前,显示配置自动备份到:
text
~/.local/share/ubuntu-hardware-monitor/backups/年月日-时分秒/
本机改动前的首份备份为 20260926-155942。退出 CoolerControl 后,可以恢复指定备份中的仪表盘、启动页和显示名称:
bash
/usr/bin/python3 ~/.local/share/ubuntu-hardware-monitor/monitor.py \
--restore-coolercontrol ~/.local/share/ubuntu-hardware-monitor/backups/20260926-155942
其他机器将日期替换为自己的备份目录。该备份不包含密码或访问令牌。
10. 幻 X 2025 / Ryzen AI Max+ 395 / Radeon 8060S
10.1 确认 amdgpu
bash
lsmod | grep amdgpu
lspci -k | grep -EA4 'VGA|Display'
应该出现:
text
Kernel driver in use: amdgpu
10.2 ROCm
本机已验证可用组合:
text
Ubuntu 24.04.4
Ryzen AI Max+ 395
Radeon 8060S / gfx1151
ROCm 7.2.4
一键脚本在检测到 AI Max+ 395 后会自动安装 ROCm。
验证:
bash
ls -l /dev/kfd
rocminfo | grep -E 'Name:|Marketing Name:'
hipconfig --full
amd-smi
10.3 96 GiB 动态 GPU 共享内存
Ryzen AI Max+ 是统一内存架构。不要把它理解为传统独显的固定 VRAM。
推荐日常开发配置:
text
固定 BIOS VRAM:保持机器当前值(本机为 4 GiB)
TTM/GTT 最大动态共享:96 GiB
新内核已提示 amdgpu.gttsize deprecated,因此只保留:
text
ttm.pages_limit=25165824
96 GiB 的计算方式(4 KiB page):
text
96 × 1024 × 1024 × 1024 / 4096 = 25165824 pages
脚本会自动根据系统 page size 计算,不写死 4 KiB。
重启后验证:
bash
cat /proc/cmdline
cat /sys/module/ttm/parameters/pages_limit
sudo dmesg | grep -iE 'VRAM|GTT'
本机曾验证到:
text
4096M of VRAM memory ready
96000M of GTT memory ready
amd-smi可能只显示 4 GiB 固定 VRAM,这是正常的;统一内存/GTT 是另一层机制。
11. 幻 X 专用启动参数
这台机器此前为了解决启动/PCIe/Thunderbolt 识别问题使用过:
text
pcie_aspm=off
pci=realloc=on
thunderbolt.host_reset=0
这些不是通用 Ubuntu 参数。
只有在同型号设备确实出现对应启动问题时,才用:
bash
APPLY_FLOW_BOOT_WORKAROUNDS=1 ./ubuntu-24.04-workstation-bootstrap.sh
脚本修改 /etc/default/grub 前会自动备份,然后执行:
bash
sudo update-grub
需要重启。
12. 不建议自动化的操作
为了避免"一个脚本把正常机器装坏",以下操作不默认自动做:
- 不自动替换系统
amdgpu内核驱动。 - 不自动安装
amdgpu-dkms。 - 不自动修改 BIOS UMA Frame Buffer。
- 不自动接管 ASUS EC 风扇。
- 不自动删除 Ubuntu App Center。
- 不自动接受 Anaconda ToS。
- 不在非 Ryzen AI Max+ 395 机器上自动配置 96 GiB TTM。
- 不在普通 Ubuntu 机器上自动加入幻 X 的 PCIe/Thunderbolt 启动参数。
13. 常用验证清单
系统
bash
lsb_release -a
uname -r
free -h
AMD GPU
bash
lsmod | grep amdgpu
lspci -k | grep -EA4 'VGA|Display'
ROCm
bash
ls -l /dev/kfd
rocminfo | grep -E 'Name:|Marketing Name:'
hipconfig --full
amd-smi
TTM/GTT
bash
cat /sys/module/ttm/parameters/pages_limit
sudo dmesg | grep -iE 'VRAM|GTT'
Docker
bash
docker --version
docker compose version
docker ps
Conda
bash
conda --version
conda env list
CoolerControl
bash
systemctl status coolercontrold
14. 一键脚本完整内容
建议实际使用配套的 ubuntu-24.04-workstation-bootstrap.sh 文件;下面保留同一份脚本,方便只保存 Markdown 时恢复。
bash
#!/usr/bin/env bash
set -Eeuo pipefail
# Ubuntu workstation bootstrap
# Target: Ubuntu 24.04 LTS (amd64), also tolerates 22.04 for general tools.
# Special handling for ASUS ROG Flow Z13 GZ302EA / Ryzen AI Max+ 395.
ROCM_VERSION="${ROCM_VERSION:-7.2.4}"
INSTALL_GIT="${INSTALL_GIT:-1}"
INSTALL_DOCKER="${INSTALL_DOCKER:-1}"
INSTALL_MINICONDA="${INSTALL_MINICONDA:-1}"
INSTALL_COOLERCONTROL="${INSTALL_COOLERCONTROL:-1}"
INSTALL_HARDWARE_DASHBOARD="${INSTALL_HARDWARE_DASHBOARD:-1}"
INSTALL_ROCM="${INSTALL_ROCM:-auto}" # 1 / 0 / auto
CONFIGURE_TTM="${CONFIGURE_TTM:-auto}" # 1 / 0 / auto
TTM_GB="${TTM_GB:-96}"
APPLY_FLOW_BOOT_WORKAROUNDS="${APPLY_FLOW_BOOT_WORKAROUNDS:-0}" # only if this specific device needs them
CLEANUP_EXPERIMENTAL_GUI="${CLEANUP_EXPERIMENTAL_GUI:-0}"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\n\033[1;33mWARN: %s\033[0m\n' "$*"; }
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
[[ $EUID -ne 0 ]] || die "请以普通用户运行,不要 sudo bash 本脚本;脚本会在需要时自行 sudo。"
command -v sudo >/dev/null || die "缺少 sudo。"
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "仅支持 Ubuntu。当前: ${ID:-unknown}"
ARCH="$(dpkg --print-architecture)"
[[ "$ARCH" == "amd64" ]] || warn "当前架构为 $ARCH;ROCm/Miniconda 部分按 amd64 设计,将自动跳过不适用项。"
CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
DMI_PRODUCT="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)"
CPU_MODEL="$(lscpu 2>/dev/null | awk -F: '/Model name|型号名称/ {sub(/^[ \t]+/,"",$2); print $2; exit}')"
IS_FLOW_Z13=0
IS_RYZEN_AI_MAX=0
[[ "$DMI_PRODUCT" == *"GZ302EA"* || "$DMI_PRODUCT" == *"ROG Flow Z13"* ]] && IS_FLOW_Z13=1
[[ "$CPU_MODEL" == *"AI MAX+ 395"* ]] && IS_RYZEN_AI_MAX=1
log "系统信息"
echo "Ubuntu: ${VERSION_ID:-unknown} (${CODENAME:-unknown})"
echo "架构: $ARCH"
echo "设备: ${DMI_PRODUCT:-unknown}"
echo "CPU : ${CPU_MODEL:-unknown}"
log "基础工具"
sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt install -y \
ca-certificates curl wget gnupg git-lfs apt-transport-https \
software-properties-common software-properties-gtk update-manager \
lm-sensors pciutils usbutils jq unzip python3 udisks2
if [[ "$INSTALL_GIT" == "1" ]]; then
log "安装 Git"
sudo apt install -y git
git --version
fi
if [[ "$INSTALL_DOCKER" == "1" ]]; then
log "安装 Docker Engine + Buildx + Compose v2"
# Remove common conflicting distro packages. Ignore if absent.
CONFLICTS="$(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null | awk '{print $1}' | xargs || true)"
if [[ -n "$CONFLICTS" ]]; then
sudo apt remove -y $CONFLICTS || true
fi
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<DOCKER_EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${CODENAME}
Components: stable
Architectures: ${ARCH}
Signed-By: /etc/apt/keyrings/docker.asc
DOCKER_EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"
docker --version || true
docker compose version || true
fi
if [[ "$INSTALL_MINICONDA" == "1" && "$ARCH" == "amd64" ]]; then
log "安装 Miniconda(不覆盖系统 Python)"
MINICONDA_DIR="$HOME/miniconda3"
if [[ -x "$MINICONDA_DIR/bin/conda" ]]; then
echo "Miniconda 已存在: $MINICONDA_DIR"
else
TMP_MINICONDA="$(mktemp --suffix=.sh)"
curl -fL https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -o "$TMP_MINICONDA"
bash "$TMP_MINICONDA" -b -p "$MINICONDA_DIR"
rm -f "$TMP_MINICONDA"
fi
"$MINICONDA_DIR/bin/conda" init bash >/dev/null || true
"$MINICONDA_DIR/bin/conda" config --set auto_activate_base false || true
echo "Miniconda: $($MINICONDA_DIR/bin/conda --version)"
warn "首次使用 defaults 创建环境时,Anaconda 可能要求接受 ToS。可按终端提示接受,或自行改用 conda-forge。"
fi
if [[ "$INSTALL_COOLERCONTROL" == "1" ]]; then
log "安装 CoolerControl"
sudo apt install -y curl apt-transport-https lm-sensors
curl -fsSL https://apt.coolercontrol.org/setup.sh | sudo sh
sudo apt update
sudo apt install -y coolercontrol
sudo systemctl enable --now coolercontrold
systemctl --no-pager --full status coolercontrold | sed -n '1,12p' || true
fi
# 安装当前用户的硬件总览;优先使用同目录脚本,否则从配套 Markdown 恢复。
if [[ "$INSTALL_HARDWARE_DASHBOARD" == "1" ]]; then
log "配置中文硬件状态总览"
BOOTSTRAP_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
DASHBOARD_SETUP="$BOOTSTRAP_DIR/ubuntu-hardware-dashboard-setup.sh"
DASHBOARD_DOC="$BOOTSTRAP_DIR/Ubuntu-24.04-装机与AI开发环境一键初始化.md"
if [[ ! -f "$DASHBOARD_SETUP" && -f "$DASHBOARD_DOC" ]]; then
/usr/bin/python3 - "$DASHBOARD_DOC" "$DASHBOARD_SETUP" <<'DASHBOARD_EXTRACT_PY'
from pathlib import Path
import sys
text = Path(sys.argv[1]).read_text()
section = text.split('\n<!-- HARDWARE_DASHBOARD_INSTALLER_BEGIN -->\n', 1)[1]
script = section.split('```bash\n', 1)[1].split('\n```', 1)[0]
Path(sys.argv[2]).write_text(script + '\n')
DASHBOARD_EXTRACT_PY
fi
if [[ -f "$DASHBOARD_SETUP" ]]; then
NO_OPEN=1 bash "$DASHBOARD_SETUP"
else
warn "未找到硬件总览安装脚本。请将 ubuntu-hardware-dashboard-setup.sh 或配套 Markdown 放到同目录,再单独运行它。"
fi
fi
# Resolve auto modes.
if [[ "$INSTALL_ROCM" == "auto" ]]; then
if [[ "$ARCH" == "amd64" && "$IS_RYZEN_AI_MAX" == "1" ]]; then INSTALL_ROCM=1; else INSTALL_ROCM=0; fi
fi
if [[ "$CONFIGURE_TTM" == "auto" ]]; then
if [[ "$IS_RYZEN_AI_MAX" == "1" ]]; then CONFIGURE_TTM=1; else CONFIGURE_TTM=0; fi
fi
if [[ "$INSTALL_ROCM" == "1" ]]; then
[[ "$ARCH" == "amd64" ]] || die "当前 ROCm 安装配置仅支持 amd64。"
[[ "$CODENAME" == "noble" ]] || warn "ROCm ${ROCM_VERSION} 本脚本按 Ubuntu 24.04/noble 配置;当前 codename=$CODENAME。"
log "安装 ROCm ${ROCM_VERSION} 用户态计算栈"
sudo mkdir --parents --mode=0755 /etc/apt/keyrings
wget -qO- https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg >/dev/null
sudo tee /etc/apt/sources.list.d/rocm.list >/dev/null <<ROCM_EOF
deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/${ROCM_VERSION} noble main
deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/graphics/${ROCM_VERSION}/ubuntu noble main
ROCM_EOF
sudo tee /etc/apt/preferences.d/rocm-pin-600 >/dev/null <<'PIN_EOF'
Package: *
Pin: release o=repo.radeon.com
Pin-Priority: 600
PIN_EOF
sudo apt update
sudo apt install -y rocm
sudo usermod -aG render,video "$USER"
grep -qxF 'export PATH=/opt/rocm/bin:$PATH' "$HOME/.bashrc" || echo 'export PATH=/opt/rocm/bin:$PATH' >> "$HOME/.bashrc"
grep -qxF 'export LD_LIBRARY_PATH=/opt/rocm/lib:/opt/rocm/lib64:$LD_LIBRARY_PATH' "$HOME/.bashrc" || echo 'export LD_LIBRARY_PATH=/opt/rocm/lib:/opt/rocm/lib64:$LD_LIBRARY_PATH' >> "$HOME/.bashrc"
fi
if [[ "$CONFIGURE_TTM" == "1" ]]; then
log "配置 AMD APU TTM/GTT 上限为 ${TTM_GB} GiB"
[[ -f /etc/default/grub ]] || die "未找到 /etc/default/grub。"
sudo cp -a /etc/default/grub "/etc/default/grub.bootstrap.$(date +%Y%m%d-%H%M%S).bak"
PAGE_SIZE="$(getconf PAGESIZE)"
PAGES_LIMIT="$(( TTM_GB * 1024 * 1024 * 1024 / PAGE_SIZE ))"
sudo python3 - "$PAGES_LIMIT" "$APPLY_FLOW_BOOT_WORKAROUNDS" <<'PY'
import re, sys
from pathlib import Path
p = Path('/etc/default/grub')
text = p.read_text()
pages = sys.argv[1]
workarounds = sys.argv[2] == '1'
m = re.search(r'^GRUB_CMDLINE_LINUX_DEFAULT="([^"]*)"', text, re.M)
if not m:
raise SystemExit('找不到 GRUB_CMDLINE_LINUX_DEFAULT')
args = m.group(1).split()
# Remove old TTM/GTT values; gttsize is deprecated on newer kernels.
args = [a for a in args if not (a.startswith('ttm.pages_limit=') or a.startswith('amdgpu.gttsize='))]
args.append(f'ttm.pages_limit={pages}')
if workarounds:
for a in ('pcie_aspm=off','pci=realloc=on','thunderbolt.host_reset=0'):
if a not in args:
args.append(a)
new = 'GRUB_CMDLINE_LINUX_DEFAULT="' + ' '.join(args) + '"'
text = text[:m.start()] + new + text[m.end():]
p.write_text(text)
print(new)
PY
sudo update-grub
echo "TTM pages_limit=$PAGES_LIMIT (${TTM_GB} GiB, page size ${PAGE_SIZE})"
fi
if [[ "$CLEANUP_EXPERIMENTAL_GUI" == "1" ]]; then
log "可选清理:Discover / GDebi / Synaptic / GNOME Software"
sudo apt purge -y \
plasma-discover plasma-discover-common plasma-discover-backend-snap \
plasma-discover-backend-flatpak plasma-discover-backend-fwupd plasma-discover-notifier \
gdebi gdebi-core synaptic gnome-software gnome-software-common \
gnome-software-plugin-snap gnome-software-plugin-flatpak || true
sudo apt autoremove --purge -y
fi
log "如果 ~/下载 或 ~/Downloads 中已有星火应用商店 deb,则自动安装"
shopt -s nullglob
SPARK_FILES=("$HOME/下载"/spark-store*.deb "$HOME/Downloads"/spark-store*.deb)
if (( ${#SPARK_FILES[@]} > 0 )); then
sudo apt install -y "${SPARK_FILES[0]}" || warn "星火应用商店安装失败,请检查该 deb 是否完整。"
else
echo "未发现 spark-store*.deb,跳过。Ubuntu 22.04+ 可从星火官方 Gitee/GitCode Release 下载后重跑脚本。"
fi
shopt -u nullglob
log "最终验证"
printf '%-24s %s\n' "Git" "$(git --version 2>/dev/null || echo N/A)"
printf '%-24s %s\n' "Docker" "$(docker --version 2>/dev/null || echo N/A)"
printf '%-24s %s\n' "Docker Compose" "$(docker compose version 2>/dev/null || echo N/A)"
printf '%-24s %s\n' "Conda" "$($HOME/miniconda3/bin/conda --version 2>/dev/null || echo N/A)"
printf '%-24s %s\n' "CoolerControl daemon" "$(systemctl is-active coolercontrold 2>/dev/null || echo N/A)"
printf '%-24s %s\n' "ROCm" "$(/opt/rocm/bin/rocminfo 2>/dev/null | awk '/Name:[[:space:]]+gfx/ {print $2; exit}' || true)"
cat <<'DONE'
安装流程完成。
建议现在重启一次,原因:
- Docker 用户组需要重新登录后生效;
- ROCm render/video 用户组需要重新登录后生效;
- 如果修改了 GRUB/TTM,必须重启才能生效。
重启后可验证:
docker ps
docker compose version
groups
rocminfo | grep -E 'Name:|Marketing Name:'
amd-smi
cat /sys/module/ttm/parameters/pages_limit
systemctl status coolercontrold
systemctl --user status ubuntu-hardware-monitor.service
# 应用列表 / 桌面 / Dock:硬件状态总览
# 浏览器:http://127.0.0.1:18765
DONE
15. 参考的官方安装路径
本文中的关键安装流程遵循以下官方项目的当前安装方式:
- Docker Engine:Docker 官方 Ubuntu APT repository
- Docker Compose:Docker Compose Plugin
- ROCm:AMD ROCm Ubuntu native package manager(7.2.4)
- CoolerControl:
apt.coolercontrol.org官方 Debian/Ubuntu repository - Miniconda:Anaconda 官方 Miniconda Linux installer
- 星火应用商店:Ubuntu 22.04+ 下载 Release
.deb后使用apt install ./spark-store*.deb
建议未来重装时先看本文顶部日期。如果间隔较久,尤其是 ROCm 主版本发生变化,可以只调整脚本顶部的:
bash
ROCM_VERSION="7.2.4"
其余步骤保持不变。
16. 硬件状态总览安装脚本完整内容
配套文件为 ubuntu-hardware-dashboard-setup.sh。下面保留同一份可独立运行的脚本。只有本 Markdown 时,也可用以下命令恢复它:
bash
python3 - <<'RESTORE_DASHBOARD_PY'
from pathlib import Path
p = Path.home() / '下载/Ubuntu-24.04-装机与AI开发环境一键初始化.md'
section = p.read_text().split('\n<!-- HARDWARE_DASHBOARD_INSTALLER_BEGIN -->\n', 1)[1]
script = section.split('```bash\n', 1)[1].split('\n```', 1)[0]
out = p.with_name('ubuntu-hardware-dashboard-setup.sh')
out.write_text(script + '\n')
print(out)
RESTORE_DASHBOARD_PY
bash ~/下载/ubuntu-hardware-dashboard-setup.sh
官方资料:CoolerControl 仪表盘、UDisks NVMe SMART 字段说明。
bash
#!/usr/bin/env bash
# 中文硬件总览:普通用户安装,兼容 Ubuntu 24.04;无需 pip、sudo 或联网下载。
set -Eeuo pipefail
[[ $EUID -ne 0 ]] || { echo '请以桌面登录用户运行,不要加 sudo。' >&2; exit 1; }
command -v /usr/bin/python3 >/dev/null
command -v systemctl >/dev/null
monitor_dir="$HOME/.local/share/ubuntu-hardware-monitor"
mkdir -p "$monitor_dir" "$HOME/.local/bin" "$HOME/.local/share/applications" "$HOME/.config/systemd/user"
if [[ -f "$monitor_dir/monitor.py" ]]; then
cp -a "$monitor_dir/monitor.py" "$monitor_dir/monitor.py.$(date +%Y%m%d-%H%M%S).bak"
fi
cat > "$monitor_dir/monitor.py" <<'PY_MONITOR_EOF'
#!/usr/bin/env python3
"""个人 Ubuntu 硬件总览;只读采集,仅依赖 Python 标准库。"""
import csv
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.parse
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = Path(__file__).resolve().parent
CC = 'http://localhost:11987'
PORT = 18765
def read(path, default=''):
try:
return Path(path).read_text().strip()
except OSError:
return default
def number(value):
try:
return float(value)
except (ValueError, TypeError):
return None
def run(args):
try:
return subprocess.check_output(args, timeout=3, stderr=subprocess.DEVNULL, text=True)
except (OSError, subprocess.SubprocessError):
return ''
def cc_request(path, body=None, method=None):
# 复用当前用户在官方桌面端保存的授权,不复制或输出令牌。
conf = read(Path.home() / '.config/org.coolercontrol.CoolerControl/CoolerControl.conf')
section = re.search(r'\[daemonAccessTokens\]\n([^\[]*)', conf)
token = re.search(r'^localhost%3A11987=@ByteArray\(([^)]+)\)', section[1], re.M) if section else None
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token[1]
cookie = re.search(r'networkCookies=.*?@ByteArray\((cc=[^;]+);', conf)
if cookie:
headers['Cookie'] = cookie[1]
req = urllib.request.Request(CC + path, data=json.dumps(body).encode() if body is not None else None,
headers=headers, method=method)
with urllib.request.urlopen(req, timeout=3) as response:
data = response.read()
return json.loads(data) if data else None
def device_name(device):
name = device['name']
driver = (device.get('info') or {}).get('driver_info') or {}
locations = driver.get('locations') or []
address = next((s.rsplit('/', 1)[-1] for s in locations if re.search(r'0-001[89ab]$', s)), '')
if name == 'jc42':
return '内存温度传感器 ' + (address or str(device['type_index']))
return {'asusec': '华硕主板 / EC', 'nct6798': '主板风扇 / NCT6798',
'nvme': 'NVMe 固态硬盘', 'iwlwifi_1': '无线网卡'}.get(name,
'有线网卡' if name.startswith('r8169') else name)
def label_name(label):
return {'CPU Temp Tctl': 'CPU 封装温度', 'CPU Temp Tccd1': 'CPU CCD1 温度',
'CPU Temp Tccd2': 'CPU CCD2 温度', 'GPU Temp': 'GPU 核心温度',
'GPU Temp Hotspot': 'GPU 热点温度', 'Cpu': 'CPU 插座温度',
'Motherboard': '主板温度', 'Chipset': '芯片组温度', 'Vrm': '供电 VRM 温度',
'Composite': 'SSD 综合温度', 'Sensor 1': 'SSD 传感器 1',
'Sensor 2': 'SSD 传感器 2', 'CPU Load': 'CPU 使用率', 'GPU Load': 'GPU 使用率',
'GPU Power': 'GPU 功耗', 'power0': 'CPU 封装功耗',
'CPU Freq Avg': 'CPU 平均频率', 'CPU Freq Max': 'CPU 最高频率',
'CPU Freq Min': 'CPU 最低频率', 'freq_graphics': 'GPU 核心频率',
'freq_memory': 'GPU 显存频率'}.get(label, label)
def configure_cooler():
cc_request('/verify-session', {}, 'POST')
devices = cc_request('/devices')['devices']
ui = cc_request('/settings/ui')
overrides = cc_request('/settings/overrides')
for d in devices:
d['name'] = overrides.get('devices', {}).get(d['uid'], {}).get('device_name') or d['name']
if d['type'] == 'Hwmon':
locations = ((d.get('info') or {}).get('driver_info') or {}).get('locations', [])
chip = next((read(Path(loc) / 'name') for loc in locations if loc.startswith('/sys/class/hwmon/')), '')
if chip:
d['name'] = chip
backup = Path.home() / '.local/share/ubuntu-hardware-monitor/backups' / dt.datetime.now().strftime('%Y%m%d-%H%M%S')
backup.mkdir(parents=True, exist_ok=True)
(backup / 'config-ui.json').write_text(json.dumps(ui, ensure_ascii=False, indent=2))
(backup / 'name-overrides.json').write_text(json.dumps(overrides, ensure_ascii=False, indent=2))
thermal, main, powerfan = [], [], []
for d in devices:
uid, info = d['uid'], d.get('info') or {}
cc_request(f'/settings/devices/{uid}/overrides', {'name': device_name(d)}, 'PUT')
for key, detail in info.get('temps', {}).items():
label = detail.get('label') or key
# 不把用途未确认的 Super-I/O 温度纳入重点视图,原始视图仍保留。
known = d['type'] in ('CPU', 'GPU') or d['name'] in ('asusec', 'nvme', 'jc42') or d['name'].startswith(('iwlwifi', 'r8169'))
if known:
channel = {'deviceUID': uid, 'channelName': key}
main.append(channel)
thermal.append(channel)
cc_request(f'/settings/devices/{uid}/channels/{urllib.parse.quote(key, safe="")}/overrides',
{'label': label_name(label)}, 'PUT')
# CPU/GPU 的负载/功耗来自 status,而非可控制风扇的 channels 声明。
for d in cc_request('/status', {})['devices']:
for c in (d.get('status_history') or [{}])[-1].get('channels', []):
if d['type'] in ('CPU', 'GPU') or c.get('rpm', 0) > 0:
channel = {'deviceUID': d['uid'], 'channelName': c['name']}
main.append(channel)
if c.get('watts') is not None or c.get('rpm') is not None:
powerfan.append(channel)
cc_request(f'/settings/devices/{d["uid"]}/channels/{urllib.parse.quote(c["name"], safe="")}/overrides',
{'label': label_name(c['name'])}, 'PUT')
presets = [('硬件状态总览', 'Table', main, ['Temp', 'Load', 'Duty', 'RPM', 'Watts', 'Freq']),
('温度趋势 · 5 分钟', 'Time Chart', thermal, ['Temp']),
('功耗与风扇', 'Table', powerfan, ['Watts', 'RPM', 'Duty']),
('全部原始传感器 · 含待确认项', 'Table', [], ['Temp', 'RPM', 'Watts', 'Load', 'Duty', 'Freq'])]
ids = []
for name, chart, channels, types in presets:
uid = str(uuid.uuid5(uuid.NAMESPACE_URL, 'ubuntu-hardware-monitor/' + name))
ids.append(uid)
dashboard = {'uid': uid, 'name': name, 'chartType': chart, 'timeRangeSeconds': 300,
'autoScaleDegree': False, 'autoScaleFrequency': True, 'autoScaleWatts': True,
'degreeMax': 100, 'degreeMin': 0, 'frequencyMax': 10000, 'frequencyMin': 0,
'wattsMax': 800, 'wattsMin': 0, 'dataTypes': types, 'selectedTags': [], 'deviceChannelNames': channels}
ui['dashboards'] = [x for x in ui.get('dashboards', []) if x['uid'] != uid] + [dashboard]
ui.update(homeDashboard=ids[0], startupPage='dashboards', time24=True, themeMode='dark', language='zh')
cc_request('/settings/ui', ui, 'PUT')
saved = cc_request('/settings/ui')
assert saved['homeDashboard'] == ids[0] and saved['startupPage'] == 'dashboards'
print('CoolerControl 已保存四个中文仪表盘;备份:' + str(backup))
def restore_cooler(backup):
cc_request('/verify-session', {}, 'POST')
folder = Path(backup)
original = json.loads((folder / 'name-overrides.json').read_text())['devices']
current = cc_request('/settings/overrides')['devices']
for uid in current.keys() | original.keys():
saved = original.get(uid, {})
cc_request(f'/settings/devices/{uid}/overrides', {'name': saved.get('name')}, 'PUT')
for channel in current.get(uid, {}).get('channels', {}).keys() | saved.get('channels', {}).keys():
cc_request(f'/settings/devices/{uid}/channels/{urllib.parse.quote(channel, safe="")}/overrides',
{'label': saved.get('channels', {}).get(channel, {}).get('label')}, 'PUT')
cc_request('/settings/ui', json.loads((folder / 'config-ui.json').read_text()), 'PUT')
print('已恢复显示配置:' + str(folder))
def hwmon():
result = []
for hw in sorted(Path('/sys/class/hwmon').glob('hwmon*')):
chip = read(hw / 'name')
for kind, unit, scale in [('temp', '°C', 1000), ('fan', 'RPM', 1), ('power', 'W', 1000000), ('in', 'V', 1000), ('curr', 'A', 1000)]:
files = list(hw.glob(kind + '*_input'))
if kind == 'power' and not files:
files = list(hw.glob('power*_average'))
for f in sorted(files):
raw = number(read(f))
if raw is None:
continue
base = f.name.rsplit('_', 1)[0]
label = read(hw / (base + '_label'), base)
value = raw / scale
note = ''
if kind == 'temp' and value <= -39:
note = '疑似未接探头 / 无效读数'
elif chip.startswith('nct') and kind in ('temp', 'in'):
note = '通道用途或电压比例待核对主板资料'
elif kind == 'fan' and value == 0:
note = '停转或未接入;不能单独判定故障'
result.append(dict(chip=chip, label=label, value=value, unit=unit, note=note,
source=str(f), key=str(hw.resolve()) + '/' + base))
return result
class Sampler:
def __init__(self):
self.snapshot = {}
self.previous = {}
self.histories = {}
self.devices = []
self.last_discovery = 0
self.last_time = time.monotonic()
self.smart = []
self.last_smart = 0
self.cpu_name = next((l.split(':', 1)[1].strip() for l in read('/proc/cpuinfo').splitlines() if l.startswith('model name')), 'CPU')
self.disks = [p for p in Path('/sys/block').iterdir() if not p.name.startswith(('loop', 'ram', 'zram', 'dm-', 'md'))]
def delta(self, key, value, elapsed):
previous = self.previous.get(key)
self.previous[key] = value
return max(0, (value - previous) / elapsed) if previous is not None else 0
def sample(self):
now = time.monotonic()
elapsed = max(.01, now - self.last_time)
self.last_time = now
raw = hwmon()
warnings = []
if now - self.last_smart > 60:
self.smart = self.read_smart()
self.last_smart = now
for drive in self.smart:
if drive.get('media_errors', 0) > 0:
warnings.append(f"{drive['name']}:SMART 累计记录 {drive['media_errors']} 次介质 / 数据完整性错误。建议先备份重要数据,再观察计数是否增长;累计计数不等于当前故障。")
if drive.get('critical'):
warnings.append(f"{drive['name']}:SMART 报告关键告警:{drive['critical']}")
cc_data = []
try:
if not self.devices or now - self.last_discovery > 60:
self.devices = cc_request('/devices')['devices']
self.last_discovery = now
statuses = {d['uid']: d for d in cc_request('/status', {})['devices']}
for d in self.devices:
status = (statuses.get(d['uid'], {}).get('status_history') or [{}])[-1]
stamp = status.get('timestamp')
if stamp and (dt.datetime.now(dt.timezone.utc) - dt.datetime.fromisoformat(stamp)).total_seconds() > 15:
raise ValueError('stale sensors')
cc_data.append((d, status))
except Exception:
cc_data = []
warnings.append('CoolerControl 未连接或数据过期:已使用内核传感器,CPU 功耗 / GPU 热点等扩展项可能不可用。')
def temps(chip):
return [r for r in raw if r['chip'].startswith(chip) and r['unit'] == '°C' and r['value'] > -39]
def pick(rows, label=None):
return next((r['value'] for r in rows if label is None or r['label'] == label), None)
cpu_counts = [int(x) for x in read('/proc/stat').splitlines()[0].split()[1:9]]
total = sum(cpu_counts)
idle = sum(cpu_counts[3:5])
delta_total = self.delta('cpu_total', total, 1)
delta_idle = self.delta('cpu_idle', idle, 1)
cpu = dict(name=self.cpu_name, load=round(100 * (1 - delta_idle / delta_total), 1) if delta_total else 0,
temp=pick(temps('k10temp'), 'Tctl') or pick(temps('coretemp')), watts=None, freq=None)
gpus = []
rows = run(['nvidia-smi', '--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total,power.draw,fan.speed', '--format=csv,noheader,nounits'])
for row in csv.reader(rows.splitlines()):
if len(row) == 7:
values = [number(x.strip()) for x in row[1:]]
gpus.append(dict(name=row[0].strip(), temp=values[0], load=values[1], used=values[2], total=values[3], watts=values[4], fan=values[5], hotspot=None))
for card in Path('/sys/class/drm').glob('card[0-9]*'):
if '-' in card.name or read(card / 'device/vendor') != '0x1002':
continue
device = card / 'device'
mem = lambda field: (number(read(device / field)) or 0) / 1048576
gpus.append(dict(name='AMD GPU / ' + card.name, temp=pick(temps('amdgpu')), load=number(read(device / 'gpu_busy_percent')),
used=mem('mem_info_vram_used'), total=mem('mem_info_vram_total'), watts=None, fan=None, hotspot=pick(temps('amdgpu'), 'junction')))
for d, status in cc_data:
ts = {x['name']: x['temp'] for x in status.get('temps', [])}
channels = {x['name']: x for x in status.get('channels', [])}
if d['type'] == 'CPU':
cpu['watts'] = next((c['watts'] for c in channels.values() if c.get('watts') is not None), None)
cpu['freq'] = channels.get('CPU Freq Avg', {}).get('freq')
if cpu['temp'] is None:
cpu['temp'] = next(iter(ts.values()), None)
elif d['type'] == 'GPU':
gpu = next((g for g in gpus if g['name'] == d['name']), None)
if gpu:
gpu['hotspot'] = ts.get('GPU Temp Hotspot')
mem = {k: int(v.split()[0]) * 1024 for k, v in (line.split(':', 1) for line in read('/proc/meminfo').splitlines())}
ram = dict(total=mem['MemTotal'], used=mem['MemTotal'] - mem['MemAvailable'], swap_total=mem['SwapTotal'], swap_used=mem['SwapTotal'] - mem['SwapFree'], temps=temps('jc42'))
disks = []
for p in self.disks:
size = int(read(p / 'size', '0')) * 512
if not size:
continue
stat = read(p / 'stat').split()
if len(stat) < 7:
continue
disks.append(dict(name=p.name, model=read(p / 'device/model', p.name), size=size,
read=self.delta(p.name + 'read', int(stat[2]) * 512, elapsed),
write=self.delta(p.name + 'write', int(stat[6]) * 512, elapsed)))
mounts = []
seen = set()
for line in read('/proc/mounts').splitlines():
fields = line.split()
if not fields[0].startswith('/dev/') or fields[0].startswith('/dev/loop'):
continue
mount = fields[1].replace('\\040', ' ')
if fields[0] in seen:
continue
seen.add(fields[0])
try:
usage = shutil.disk_usage(mount)
mounts.append(dict(mount=mount, total=usage.total, used=usage.used, free=usage.free))
except OSError:
pass
networks = []
for p in Path('/sys/class/net').iterdir():
if not (p / 'device').exists():
continue
rx = int(read(p / 'statistics/rx_bytes', '0'))
tx = int(read(p / 'statistics/tx_bytes', '0'))
networks.append(dict(name=p.name, state=read(p / 'operstate'), rx=self.delta(p.name + 'rx', rx, elapsed), tx=self.delta(p.name + 'tx', tx, elapsed)))
board = [r for r in temps('asusec') if r['label'] in ('Motherboard', 'Chipset', 'VRM', 'CPU')]
fans = [r for r in raw if r['unit'] == 'RPM']
for g in gpus:
if g['fan'] is not None:
fans.append(dict(chip=g['name'], label='GPU 风扇占空比', value=g['fan'], unit='%', note='0% 可能为低负载停转'))
series = {'CPU': cpu['temp'], 'GPU': gpus[0]['temp'] if gpus else None, 'SSD': pick(temps('nvme'), 'Composite')}
for key, value in series.items():
history = self.histories.setdefault(key, [])
history.append(value)
del history[:-150]
return dict(time=dt.datetime.now().strftime('%H:%M:%S'), timestamp=time.time(), cpu=cpu, gpus=gpus, ram=ram,
disks=disks, mounts=mounts, networks=networks, board=board, fans=fans,
ssdtemps=temps('nvme') + temps('drivetemp'), raw=raw, history=self.histories.copy(),
board_name=read('/sys/class/dmi/id/board_name'), warnings=warnings, cooler=bool(cc_data),
smart=self.smart, uptime=int(float(read('/proc/uptime', '0').split()[0])))
def read_smart(self):
# 读取 UDisks 已缓存的 SMART,不运行自检或强制唤醒硬盘。
result = []
for disk in self.disks:
if not int(read(disk / 'size', '0')):
continue
try:
info = run(['udisksctl', 'info', '-b', '/dev/' + disk.name])
match = re.search(r"Drive:\s+'([^']+)'", info)
if not match:
continue
drive = match[1]
iface = 'org.freedesktop.UDisks2.NVMe.Controller' if disk.name.startswith('nvme') else 'org.freedesktop.UDisks2.Drive.Ata'
data = json.loads(run(['busctl', '--json=short', 'call', 'org.freedesktop.UDisks2', drive,
'org.freedesktop.DBus.Properties', 'GetAll', 's', iface]))['data'][0]
props = {k: v['data'] for k, v in data.items()}
if not props.get('SmartUpdated'):
continue
item = dict(name=disk.name, updated=props['SmartUpdated'], hours=props.get('SmartPowerOnHours'),
critical=props.get('SmartCriticalWarning') or ('SMART 失败' if props.get('SmartFailing') else []))
if disk.name.startswith('nvme'):
attrs = json.loads(run(['busctl', '--json=short', 'call', 'org.freedesktop.UDisks2', drive,
iface, 'SmartGetAttributes', 'a{sv}', '0']))['data'][0]
item.update({k: v['data'] for k, v in attrs.items()})
else:
item.update(hours=(props.get('SmartPowerOnSeconds') or 0) // 3600,
bad_sectors=props.get('SmartNumBadSectors'))
result.append(item)
except (ValueError, KeyError, IndexError, TypeError):
continue
return result
def loop(self):
while True:
started = time.monotonic()
try:
self.snapshot = self.sample()
except Exception as exc:
print('采样失败:' + type(exc).__name__, flush=True)
time.sleep(max(.1, 2 - (time.monotonic() - started)))
HTML = r'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>硬件状态总览</title>
<style>
:root{color-scheme:dark;--bg:#101620;--card:#1b2432;--line:#2b384a;--dim:#a3b2c5;--text:#edf4ff;--blue:#6cb3ff;--green:#6ce5bb;--amber:#ffca75}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,"Noto Sans CJK SC",sans-serif}main{max-width:1550px;margin:auto;padding:30px 38px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;gap:24px}h1{font-size:28px;letter-spacing:1px;margin:0}p{margin:4px 0;color:var(--dim)}a,button{background:#25364a;border:1px solid #3b516c;color:var(--text);padding:8px 14px;border-radius:9px;text-decoration:none;cursor:pointer;font:inherit}.actions{display:flex;gap:8px;align-items:center}.status{font-size:13px;color:var(--green)}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:17px}.card{background:var(--card);border:1px solid var(--line);border-radius:15px;padding:21px 23px;min-width:0}h2{font-size:15px;font-weight:600;margin:0 0 8px;color:var(--dim)}.big{font-size:42px;font-weight:650;line-height:1.35;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.unit{font-size:18px;font-weight:400;color:var(--dim);margin-left:5px;letter-spacing:0}.sub{color:var(--dim);font-size:12px;min-height:36px;overflow-wrap:anywhere;margin-top:8px}.rows{margin-top:14px}.row{display:flex;justify-content:space-between;gap:12px;margin:7px 0;font-size:14px}.row span{color:var(--dim)}.row b{font-variant-numeric:tabular-nums;text-align:right;font-weight:500}.bar{height:5px;background:#304055;border-radius:5px;margin:9px 0;overflow:hidden}.bar i{display:block;height:100%;background:var(--blue);border-radius:5px}.wide{grid-column:span 2}.chart{width:100%;height:140px;display:block;margin-top:12px}.legend{display:flex;gap:18px;color:var(--dim);font-size:13px}.notice{padding:12px 16px;border:1px solid #645334;background:#2b281f;border-radius:10px;color:#ffcf8b;margin-bottom:16px;font-size:13px}.muted{color:var(--dim);font-size:12px}details{margin-top:18px}summary{cursor:pointer;color:var(--dim);padding:10px 0}table{width:100%;border-collapse:collapse;font-size:13px}td,th{padding:9px;text-align:left;border-bottom:1px solid var(--line)}th{color:var(--dim)}.tablewrap{overflow-x:auto}.warm{color:var(--amber)}footer{margin:18px 0;color:var(--dim);font-size:12px}.offline{opacity:.5}body.light{color-scheme:light;--bg:#eef3f8;--card:#fff;--line:#dbe4ef;--dim:#52647b;--text:#16263b;--blue:#267bd3;--green:#13795b}body.light a,body.light button{background:#e5edf7;color:var(--text);border-color:#c3d3e7}body.light .bar{background:#dce6f1}@media(max-width:1100px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}main{padding:24px}}@media(max-width:600px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{display:block}.actions{margin-top:14px}.big{font-size:36px}}
</style><main><header><div><h1>硬件状态总览</h1><p id="machine">正在读取本机硬件...</p></div><div class="actions"><span id="status" class="status">连接中</span><button id="theme">切换主题</button><a href="http://localhost:11987/#/monitoring" target="_blank" rel="noreferrer">CoolerControl ↗</a></div></header><div id="notices"></div><section class="grid" id="cards"></section><details><summary>查看全部内核传感器 / 待确认读数</summary><p class="muted">保留驱动原始名称。AUXTIN、未接探头及未校准电压不用于自动判定硬件健康。</p><div class="tablewrap"><table><thead><tr><th>芯片</th><th>传感器</th><th>当前值</th><th>说明</th></tr></thead><tbody id="raw"></tbody></table></div></details><footer>每 2 秒刷新 · 只读监控 · 最近约 5 分钟温度曲线 · "---"代表未提供数据<br>温度颜色为提示值(CPU 85°C / GPU 核心 83°C / 内存 70°C / SSD 70°C),并非厂商故障诊断。SMART 显示系统缓存;未进行硬件自检。内存型号和电源内部状态未提供。</footer></main>
<script>
const $=id=>document.getElementById(id), esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fmt=(v,n=1)=>v==null?'---':Number(v).toFixed(n), gib=v=>fmt(v==null?null:v/2**30), rate=v=>v>=1048576?fmt(v/1048576)+' MiB/s':fmt(v/1024)+' KiB/s';
const row=(a,b)=>`<div class="row"><span>${esc(a)}</span><b>${b}</b></div>`, bar=v=>`<div class="bar"><i style="width:${Math.min(100,Math.max(0,v||0))}%"></i></div>`;
const big=(v,u,limit)=>`<div class="big ${v!=null&&v>=limit?'warm':''}">${fmt(v)}<span class="unit">${u}</span></div>`;
const card=(title,body,sub='',wide=false)=>`<article class="card ${wide?'wide':''}"><h2>${esc(title)}</h2>${body}${sub?`<div class="sub">${esc(sub)}</div>`:''}</article>`;
function chart(h){let lines='';const colors=['#6cb3ff','#ba9cff','#6ce5bb'];Object.entries(h).forEach(([name,values],k)=>{let segment=[];const draw=()=>{if(segment.length)lines+=`<polyline fill="none" stroke="${colors[k]}" stroke-width="2" points="${segment.join(' ')}"/>`;segment=[]};values.forEach((v,i)=>{if(v==null){draw();return}segment.push(`${i/149*700},${135-Math.min(110,Math.max(0,v))/110*125}`)});draw()});return `<svg class="chart" viewBox="0 0 700 145" preserveAspectRatio="none" role="img" aria-label="最近五分钟温度曲线"><path d="M0 135H700 M0 78H700 M0 21H700" stroke="#35445a" stroke-dasharray="4 5" fill="none"/>${lines}</svg><div class="legend"><span style="color:#6cb3ff">● CPU</span><span style="color:#ba9cff">● GPU</span><span style="color:#6ce5bb">● SSD</span><span>0---110°C · 新数据从左向右填充</span></div>`}
function render(d){$('machine').textContent=`${d.board_name||'Ubuntu 工作站'} · 已运行 ${Math.floor(d.uptime/3600)} 小时 ${Math.floor(d.uptime%3600/60)} 分钟`;let cards=[];const c=d.cpu,r=d.ram;
cards.push(card('CPU · 处理器',big(c.temp,'°C',85)+row('使用率',fmt(c.load)+' %')+bar(c.load)+row('封装功耗',fmt(c.watts)+' W')+row('平均频率',c.freq==null?'---':fmt(c.freq/1000,2)+' GHz'),c.name));
for(const g of d.gpus)cards.push(card('GPU · 显卡',big(g.temp,'°C',83)+row('使用率',fmt(g.load,0)+' %')+bar(g.load)+row('显存',`${fmt(g.used==null?null:g.used/1024,1)} / ${fmt(g.total==null?null:g.total/1024,1)} GiB`)+row('功耗 / 热点',`${fmt(g.watts)} W / ${fmt(g.hotspot)} °C`),g.name));
const ramtemp=r.temps.length?Math.max(...r.temps.map(x=>x.value)):null;cards.push(card('内存 · RAM',big(ramtemp,'°C',70)+row('内存占用',`${gib(r.used)} / ${gib(r.total)} GiB`)+bar(r.used/r.total*100)+row('使用率',fmt(r.used/r.total*100)+' %')+row('交换空间',`${gib(r.swap_used)} / ${gib(r.swap_total)} GiB`),r.temps.length?`${r.temps.length} 个内存温度传感器 · 大字显示最高值`:'此硬件未提供内存温度传感器'));
const ssd=d.ssdtemps.find(x=>x.label==='Composite')||d.ssdtemps[0];const disk=d.disks[0];const root=d.mounts.find(x=>x.mount==='/');cards.push(card('存储 · SSD / 磁盘',big(ssd?.value,'°C',70)+(root?row('系统盘占用',`${gib(root.used)} / ${gib(root.total)} GiB`)+bar(root.used/root.total*100):'')+row('总读取',rate(d.disks.reduce((s,x)=>s+x.read,0)))+row('总写入',rate(d.disks.reduce((s,x)=>s+x.write,0))),disk?disk.model:'未检测到磁盘'));
cards.push(card('主板与内存温度',d.board.map(x=>row(({Motherboard:'主板',Chipset:'芯片组',VRM:'供电 VRM',CPU:'CPU 插座'})[x.label]||x.label,fmt(x.value)+' °C')).join('')+r.temps.map((x,i)=>row(`内存传感器 ${i+1}`,fmt(x.value)+' °C')).join(''),d.board.length?'内存传感器编号不等同于主板 DIMM 槽位编号':'主板专用传感器未提供'));
const active=d.fans.filter(x=>x.value>0||x.unit==='%');cards.push(card('散热 · 风扇',active.map(x=>row(x.unit==='%'?'GPU 风扇':`${x.chip} / ${x.label}`,fmt(x.value,0)+' '+x.unit)).join('')||'<p>暂无非零转速读数</p>',`另有 ${d.fans.filter(x=>x.value===0&&x.unit==='RPM').length} 个通道为 0 RPM;可能未接入或停转。风扇由当前固件 / 配置管理。`));
cards.push(card('温度趋势',chart(d.history),'',true));
cards.push(card('网络 · 实时流量',d.networks.map(n=>row(n.name,n.state==='up'?'已连接':'未连接')+row('↓ 下载 / ↑ 上传',`${rate(n.rx)} / ${rate(n.tx)}`)).join(''),'只统计物理网卡,避免 Docker / VPN 重复计数'));
cards.push(card('磁盘与分区',d.mounts.map(m=>row(m.mount,`${gib(m.free)} GiB 可用`)+bar(m.used/m.total*100)).join('')+d.ssdtemps.map(x=>row(`${x.chip} / ${x.label}`,fmt(x.value)+' °C')).join(''),'仅统计已挂载分区;磁盘温度与 SMART 状态分开显示。'));
cards.push(card('磁盘 SMART · 缓存记录',d.smart.length?d.smart.map(s=>row('磁盘',esc(s.name))+row('关键告警',s.critical.length?'<span class="warm">'+esc(s.critical)+'</span>':'缓存未报告关键告警')+row('累计介质 / 完整性错误',s.media_errors==null?'---':`<span class="${s.media_errors>0?'warm':''}">${s.media_errors} 次</span>`)+row('寿命消耗 / 可用备用空间',fmt(s.percent_used,0)+' % / '+fmt(s.avail_spare,0)+' %')+row('累计写入 / 通电',gib(s.total_data_written)+' GiB / '+fmt(s.hours,0)+' 小时')+row('非正常断电',fmt(s.unsafe_shutdowns,0)+' 次')+row('SMART 数据时间',esc(new Date(s.updated*1000).toLocaleString('zh-CN')))).join(''):'<p>此硬盘未提供可读取的 SMART 缓存。</p>','寿命消耗是厂商估算,不等于健康评分。每分钟读取 UDisks 缓存;数据时间可能早于当前时间。',true));
$('cards').innerHTML=cards.join('');$('raw').innerHTML=d.raw.map(x=>`<tr><td>${esc(x.chip)}</td><td>${esc(x.label)}</td><td>${fmt(x.value,x.unit==='RPM'?0:2)} ${esc(x.unit)}</td><td>${esc(x.note)}</td></tr>`).join('');$('notices').innerHTML=d.warnings.map(w=>`<div class="notice">${esc(w)}</div>`).join('');}
let theme=localStorage.getItem('hardware-theme')||'dark';document.body.classList.toggle('light',theme==='light');$('theme').onclick=()=>{theme=theme==='dark'?'light':'dark';localStorage.setItem('hardware-theme',theme);document.body.classList.toggle('light',theme==='light')};
async function refresh(){try{const response=await fetch('/api/status',{cache:'no-store',signal:AbortSignal.timeout(6000)});if(!response.ok)throw Error();const d=await response.json();if(!d.timestamp||Date.now()/1000-d.timestamp>15)throw Error();render(d);$('status').textContent=`● 实时 · ${d.time}`;$('status').style.color='';$('cards').classList.remove('offline')}catch{$('status').textContent='● 采集已中断';$('status').style.color='#ffca75';$('cards').classList.add('offline');$('notices').innerHTML='<div class="notice">暂时无法获取最新数据;以下数值可能已经过期。请检查硬件总览用户服务。</div>'}finally{setTimeout(refresh,2000)}}refresh();
</script></html>'''
def serve():
sampler = Sampler()
threading.Thread(target=sampler.loop, daemon=True).start()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.headers.get('Host') not in (f'127.0.0.1:{PORT}', f'localhost:{PORT}'):
self.send_error(403)
return
if self.path == '/':
body, mime = HTML.encode(), 'text/html; charset=utf-8'
elif self.path == '/api/status':
body, mime = json.dumps(sampler.snapshot, ensure_ascii=False).encode(), 'application/json; charset=utf-8'
else:
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', mime)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
print(f'硬件状态总览:http://127.0.0.1:{PORT}', flush=True)
ThreadingHTTPServer(('127.0.0.1', PORT), Handler).serve_forever()
if __name__ == '__main__':
if '--configure-coolercontrol' in sys.argv:
try:
configure_cooler()
except Exception as exc:
print('CoolerControl 配置失败:' + type(exc).__name__ + '。请检查服务和官方桌面端登录状态。', file=sys.stderr)
sys.exit(1)
elif '--restore-coolercontrol' in sys.argv:
restore_cooler(sys.argv[sys.argv.index('--restore-coolercontrol') + 1])
else:
serve()
PY_MONITOR_EOF
chmod 600 "$monitor_dir/monitor.py"
cat > "$HOME/.config/systemd/user/ubuntu-hardware-monitor.service" <<'SERVICE_EOF'
[Unit]
Description=Ubuntu hardware dashboard (read-only)
[Service]
Type=simple
ExecStart=/usr/bin/python3 %h/.local/share/ubuntu-hardware-monitor/monitor.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
UMask=0077
[Install]
WantedBy=default.target
SERVICE_EOF
cat > "$HOME/.local/bin/ubuntu-hardware-monitor" <<'LAUNCH_EOF'
#!/usr/bin/env bash
set -e
systemctl --user start ubuntu-hardware-monitor.service
if command -v google-chrome >/dev/null 2>&1; then
exec google-chrome --app=http://127.0.0.1:18765 --class=UbuntuHardwareMonitor --new-window
fi
exec xdg-open http://127.0.0.1:18765
LAUNCH_EOF
chmod 755 "$HOME/.local/bin/ubuntu-hardware-monitor"
cat > "$monitor_dir/icon.svg" <<'ICON_EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect x="8" y="8" width="112" height="112" rx="25" fill="#192b43"/><rect x="28" y="32" width="72" height="54" rx="7" fill="none" stroke="#78b9ff" stroke-width="6"/><path d="M33 62h15l9-18 12 31 10-17h17M48 100h32M64 87v13" fill="none" stroke="#6ce5bb" stroke-width="6" stroke-linejoin="round"/></svg>
ICON_EOF
cat > "$HOME/.local/share/applications/ubuntu-hardware-monitor.desktop" <<DESKTOP_EOF
[Desktop Entry]
Type=Application
Name=硬件状态总览
Name[en]=Hardware Dashboard
Comment=CPU、GPU、内存、硬盘、主板温度与实时状态
Exec="$HOME/.local/bin/ubuntu-hardware-monitor"
Icon=$monitor_dir/icon.svg
Terminal=false
Categories=System;Monitor;
StartupWMClass=UbuntuHardwareMonitor
DESKTOP_EOF
if command -v xdg-user-dir >/dev/null; then
monitor_desktop="$(xdg-user-dir DESKTOP)"
if [[ -d "$monitor_desktop" && "$monitor_desktop" != "$HOME" ]]; then
cp "$HOME/.local/share/applications/ubuntu-hardware-monitor.desktop" "$monitor_desktop/ubuntu-hardware-monitor.desktop"
chmod +x "$monitor_desktop/ubuntu-hardware-monitor.desktop"
gio set "$monitor_desktop/ubuntu-hardware-monitor.desktop" metadata::trusted true 2>/dev/null || true
fi
fi
if command -v gsettings >/dev/null; then
/usr/bin/python3 - <<'FAVORITES_EOF'
import ast, subprocess
try:
apps = ast.literal_eval(subprocess.check_output(['gsettings','get','org.gnome.shell','favorite-apps'],text=True).strip())
if 'ubuntu-hardware-monitor.desktop' not in apps:
apps.append('ubuntu-hardware-monitor.desktop')
subprocess.run(['gsettings','set','org.gnome.shell','favorite-apps',str(apps)],check=True)
except (ValueError, subprocess.SubprocessError):
pass
FAVORITES_EOF
fi
systemctl --user daemon-reload
systemctl --user enable ubuntu-hardware-monitor.service
systemctl --user restart ubuntu-hardware-monitor.service
if [[ "${CONFIGURE_COOLERCONTROL_UI:-1}" == 1 ]]; then
if ! /usr/bin/python3 "$monitor_dir/monitor.py" --configure-coolercontrol; then
echo '总览面板已安装。CoolerControl 配置未完成:请先打开官方桌面应用并登录、正常退出,然后执行:'
echo '/usr/bin/python3 ~/.local/share/ubuntu-hardware-monitor/monitor.py --configure-coolercontrol'
fi
fi
echo '硬件状态总览:http://127.0.0.1:18765(仅本机访问,登录后自动启动服务)'
if [[ "${NO_OPEN:-0}" != 1 ]]; then
nohup "$HOME/.local/bin/ubuntu-hardware-monitor" >/dev/null 2>&1 &
fi