Преглед изворни кода

1、修复MYSQL创建库验证;
2、优化上传文件,支持分片
3、优化控制台展示
4、增加MYSQL查询功能
5、

NGS пре 3 недеља
родитељ
комит
8b25dce911

+ 6 - 6
build.sh

@@ -35,8 +35,8 @@ APT_DEPS=(
 )
 
 echo "==> apt deps"
-sudo apt-get update -qq
-sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_DEPS[@]}"
+apt-get update -qq
+DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_DEPS[@]}"
 
 mkdir -p "$DEPS_DIR"
 
@@ -50,10 +50,10 @@ if [[ ! -e /usr/lib/x86_64-linux-gnu/libhpsocket.a \
     fi
     cmake -S "$DEPS_DIR/hpsocket-linux" -B "$DEPS_DIR/hpsocket-linux/build"
     cmake --build "$DEPS_DIR/hpsocket-linux/build" -j"$JOBS"
-    sudo cp "$DEPS_DIR/hpsocket-linux/build/libhpsocket.a" /usr/lib/x86_64-linux-gnu/
-    sudo rm -rf /usr/local/include/HPSocket
-    sudo mkdir -p /usr/local/include/HPSocket
-    sudo cp "$DEPS_DIR/hpsocket-linux"/hpsocket/* /usr/local/include/HPSocket/
+    cp "$DEPS_DIR/hpsocket-linux/build/libhpsocket.a" /usr/lib/x86_64-linux-gnu/
+    rm -rf /usr/local/include/HPSocket
+    mkdir -p /usr/local/include/HPSocket
+    cp "$DEPS_DIR/hpsocket-linux"/hpsocket/* /usr/local/include/HPSocket/
 fi
 
 if [[ ! -e /usr/local/lib/libylib.a && ! -e /usr/lib/x86_64-linux-gnu/libylib.a ]]; then

+ 72 - 0
data/www/assets/app.css

@@ -344,6 +344,61 @@ html, body {
   width: 100% !important;
   height: 100% !important;
 }
+.gpu-split {
+  display: flex;
+  gap: 0.75rem;
+  flex: 1 1 auto;
+  min-height: 160px;
+}
+.gpu-pane {
+  flex: 1 1 0;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+}
+.gpu-pane .monitor-chart {
+  min-height: 140px;
+}
+.monitor-badge-stack {
+  line-height: 1.35;
+  font-size: 0.75rem;
+}
+.resource-list {
+  margin-top: 0.65rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.45rem;
+}
+.resource-row .resource-meta {
+  display: flex;
+  justify-content: space-between;
+  gap: 0.5rem;
+  font-size: 0.75rem;
+  color: #64748b;
+}
+.resource-row .resource-name {
+  color: #334155;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.resource-meter {
+  height: 6px;
+  margin-top: 0.2rem;
+  background: #e2e8f0;
+  border-radius: 99px;
+  overflow: hidden;
+}
+.resource-meter > span {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: #d97706;
+}
+.resource-meter.is-gpu > span {
+  background: #7c3aed;
+}
 
 .soft-card .card-body { padding: 0.75rem 0.9rem; }
 .soft-card .meta {
@@ -1146,3 +1201,20 @@ html, body {
   font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
   font-size: 0.78rem;
 }
+
+.sql-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.85rem;
+  min-height: 8rem;
+  resize: vertical;
+}
+.sql-result-wrap {
+  max-height: 28rem;
+  overflow: auto;
+}
+#sql-result th,
+#sql-result td {
+  white-space: nowrap;
+  font-size: 0.8rem;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}

+ 313 - 87
data/www/assets/app.js

@@ -4,13 +4,29 @@
 
   const METRICS_INTERVAL_MS = 2000;
   const MAX_EDIT_BYTES = 10 * 1024 * 1024;
+  const FILES_PATH_KEY = "ngs.filesPath";
+
+  function readRememberedFilesPath() {
+    try {
+      const p = String(localStorage.getItem(FILES_PATH_KEY) || "").trim();
+      if (p.startsWith("/") && !p.includes("\0")) return p;
+    } catch (_) {}
+    return "/";
+  }
+
+  function rememberFilesPath(path) {
+    const p = String(path || "").trim() || "/";
+    try {
+      localStorage.setItem(FILES_PATH_KEY, p);
+    } catch (_) {}
+  }
 
   const state = {
     status: null,
     sites: [],
     databases: [],
     busy: false,
-    filesPath: "/",
+    filesPath: readRememberedFilesPath(),
     files: [],
     filesSelected: new Set(), // absolute paths in current dir
     filesLastClicked: "", // for shift-range select
@@ -905,11 +921,74 @@
       mem: makePercentChart($("#chart-mem"), "内存", "rgb(3, 105, 161)"),
       disk: makePercentChart($("#chart-disk"), "磁盘", "rgb(180, 83, 9)"),
       net: makeNetChart($("#chart-net")),
+      gpu: $("#chart-gpu") ? makePercentChart($("#chart-gpu"), "GPU 占用", "rgb(124, 58, 237)") : null,
+      gpuMem: $("#chart-gpu-mem") ? makePercentChart($("#chart-gpu-mem"), "GPU 显存", "rgb(219, 39, 119)") : null,
     };
     return state.metricCharts;
   }
 
-  function applyHistoryToCharts(items, range) {
+  function renderDiskList(disks) {
+    const host = $("#disk-list");
+    if (!host) return;
+    const list = Array.isArray(disks) ? disks : [];
+    if (!list.length) {
+      host.innerHTML = "";
+      return;
+    }
+    host.innerHTML = list
+      .map((d) => {
+        const pct = Math.max(0, Math.min(100, Number(d.percent) || 0));
+        const name = escapeHtml(d.path || "/");
+        const extra = [d.device, d.fstype].filter(Boolean).join(" · ");
+        return `<div class="resource-row">
+          <div class="resource-meta">
+            <span class="resource-name" title="${name}${extra ? " (" + escapeHtml(extra) + ")" : ""}">${name}${extra ? ` <span class="fw-normal">(${escapeHtml(extra)})</span>` : ""}</span>
+            <span>${formatBytes(d.used)} / ${formatBytes(d.total)} · ${pct.toFixed(1)}%</span>
+          </div>
+          <div class="resource-meter"><span style="width:${pct}%"></span></div>
+        </div>`;
+      })
+      .join("");
+  }
+
+  function gpuMemPercent(used, total) {
+    const t = Number(total) || 0;
+    if (t <= 0) return 0;
+    return Math.max(0, Math.min(100, ((Number(used) || 0) / t) * 100));
+  }
+
+  function renderGpuPanel(gpus, last) {
+    const wrap = $("#gpu-wrap");
+    const list = Array.isArray(gpus) ? gpus : [];
+    const hasGpu = list.length > 0 || (last && Number(last.gpu_mem_total) > 0);
+    if (wrap) wrap.hidden = !hasGpu;
+    if (!hasGpu) return;
+    const charts = state.metricCharts;
+    for (const c of [charts && charts.gpu, charts && charts.gpuMem]) {
+      if (!c) continue;
+      try {
+        c.resize();
+      } catch (_) {}
+    }
+    const g0 = list[0];
+    const name = g0 ? g0.name : "";
+    const util = Number(g0 ? g0.percent : last && last.gpu_percent) || 0;
+    const memUsed = Number(g0 ? g0.mem_used : last && last.gpu_mem_used) || 0;
+    const memTotal = Number(g0 ? g0.mem_total : last && last.gpu_mem_total) || 0;
+    const memPct = Number(g0 ? g0.mem_percent : gpuMemPercent(memUsed, memTotal)) || 0;
+    const temp = g0 && g0.temperature ? ` · ${Number(g0.temperature).toFixed(0)}°C` : "";
+    const nameBadge = $("#gpu-name-badge");
+    const gpuBadge = $("#gpu-badge");
+    const memBadge = $("#gpu-mem-badge");
+    if (nameBadge) nameBadge.textContent = name || "—";
+    if (gpuBadge) gpuBadge.textContent = `${util.toFixed(1)}%${temp}`;
+    if (memBadge) {
+      memBadge.textContent =
+        `${formatBytes(memUsed)} / ${formatBytes(memTotal)} · ${memPct.toFixed(1)}%`;
+    }
+  }
+
+  function applyHistoryToCharts(items, range, live) {
     const charts = ensureMetricCharts();
     if (!charts) return;
     const labels = items.map((p) => formatMetricLabel(p.time_ms, range));
@@ -918,7 +997,10 @@
     const disk = items.map((p) => Number(p.disk_percent) || 0);
     const rx = items.map((p) => Number(p.net_rx_bps) || 0);
     const tx = items.map((p) => Number(p.net_tx_bps) || 0);
+    const gpu = items.map((p) => Number(p.gpu_percent) || 0);
+    const gpuMem = items.map((p) => gpuMemPercent(p.gpu_mem_used, p.gpu_mem_total));
     const applyOne = (chart, data) => {
+      if (!chart) return;
       chart.data.labels = labels;
       chart.data.datasets[0].data = data;
       chart.update("none");
@@ -926,10 +1008,14 @@
     applyOne(charts.cpu, cpu);
     applyOne(charts.mem, mem);
     applyOne(charts.disk, disk);
-    charts.net.data.labels = labels;
-    charts.net.data.datasets[0].data = rx;
-    charts.net.data.datasets[1].data = tx;
-    charts.net.update("none");
+    applyOne(charts.gpu, gpu);
+    applyOne(charts.gpuMem, gpuMem);
+    if (charts.net) {
+      charts.net.data.labels = labels;
+      charts.net.data.datasets[0].data = rx;
+      charts.net.data.datasets[1].data = tx;
+      charts.net.update("none");
+    }
 
     const last = items.length ? items[items.length - 1] : null;
     if (!last) {
@@ -937,25 +1023,58 @@
       $("#mem-badge").textContent = "—";
       $("#disk-badge").textContent = "—";
       $("#net-badge").textContent = "—";
+      renderDiskList([]);
+      renderGpuPanel([], null);
       return;
     }
     $("#cpu-badge").textContent =
       `${(Number(last.cpu_percent) || 0).toFixed(1)}% · ${last.cpu_cores ?? "—"} 核`;
     $("#mem-badge").textContent =
       `${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)} · ${(Number(last.mem_percent) || 0).toFixed(1)}%`;
-    $("#disk-badge").textContent =
-      `${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)} · ${(Number(last.disk_percent) || 0).toFixed(1)}%`;
+
+    const liveDisks = live && Array.isArray(live.disks) && live.disks.length
+      ? live.disks
+      : null;
+    const primary = liveDisks
+      ? liveDisks.find((d) => d.path === "/") || liveDisks[0]
+      : last;
+    const diskUsed = primary.used ?? primary.disk_used;
+    const diskTotal = primary.total ?? primary.disk_total;
+    const diskPct = Number(primary.percent ?? last.disk_percent) || 0;
+    $("#disk-badge").textContent = liveDisks && liveDisks.length > 1
+      ? `${liveDisks.length} 块磁盘`
+      : `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)} · ${diskPct.toFixed(1)}%`;
+    renderDiskList(
+      liveDisks ||
+        [{
+          path: "/",
+          used: last.disk_used,
+          total: last.disk_total,
+          percent: last.disk_percent,
+        }]
+    );
+
+    const net = (live && live.network) || {};
+    const rxBytes = Number(net.rx_bytes != null ? net.rx_bytes : last.net_rx_bytes) || 0;
+    const txBytes = Number(net.tx_bytes != null ? net.tx_bytes : last.net_tx_bytes) || 0;
+    const rxBps = Number(net.rx_bps != null ? net.rx_bps : last.net_rx_bps) || 0;
+    const txBps = Number(net.tx_bps != null ? net.tx_bps : last.net_tx_bps) || 0;
     $("#net-badge").innerHTML =
-      `<span style="color:#0d9488">↓ ${formatRate(last.net_rx_bps)}</span>` +
-      ` · ` +
-      `<span style="color:#ea580c">↑ ${formatRate(last.net_tx_bps)}</span>`;
+      `<span style="color:#0d9488">↓ ${formatBytes(rxBytes)}</span> · ${formatRate(rxBps)}` +
+      `<br>` +
+      `<span style="color:#ea580c">↑ ${formatBytes(txBytes)}</span> · ${formatRate(txBps)}`;
+
+    renderGpuPanel((live && live.gpus) || [], last);
   }
 
   async function pollMetrics() {
     try {
       const range = state.metricsRange || "10m";
-      const data = await api(`/api/system/metrics/history?range=${encodeURIComponent(range)}`);
-      applyHistoryToCharts((data && data.items) || [], range);
+      const [hist, live] = await Promise.all([
+        api(`/api/system/metrics/history?range=${encodeURIComponent(range)}`),
+        api("/api/system/metrics").catch(() => null),
+      ]);
+      applyHistoryToCharts((hist && hist.items) || [], range, live);
     } catch (_) {}
   }
 
@@ -1242,7 +1361,68 @@
       .join("");
   }
 
+  function fillSqlDbSelect() {
+    const sel = $("#sql-db");
+    if (!sel) return;
+    const cur = sel.value;
+    const names = (state.databases || []).map((d) => d.name).filter(Boolean);
+    sel.innerHTML =
+      `<option value="">(不选库)</option>` +
+      names.map((n) => `<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`).join("");
+    if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
+  }
+
+  function renderSqlResult(data) {
+    const meta = $("#sql-meta");
+    const thead = $("#sql-result thead");
+    const tbody = $("#sql-result tbody");
+    if (!meta || !thead || !tbody) return;
+    const cols = Array.isArray(data?.columns) ? data.columns : [];
+    const rows = Array.isArray(data?.rows) ? data.rows : [];
+    const ms = data?.elapsed_ms != null ? ` · ${data.elapsed_ms} ms` : "";
+    meta.textContent = `${data?.message || "执行成功"}${ms}`;
+    meta.className = "small text-secondary mt-2";
+    if (!cols.length) {
+      thead.innerHTML = "";
+      tbody.innerHTML = "";
+      return;
+    }
+    thead.innerHTML = `<tr>${cols.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr>`;
+    if (!rows.length) {
+      tbody.innerHTML = `<tr><td colspan="${cols.length}" class="text-secondary text-center py-3">无数据</td></tr>`;
+      return;
+    }
+    tbody.innerHTML = rows
+      .map(
+        (row) =>
+          `<tr>${cols
+            .map((_, i) => `<td>${escapeHtml((row && row[i]) ?? "")}</td>`)
+            .join("")}</tr>`
+      )
+      .join("");
+  }
+
+  async function execSql(btn) {
+    const sql = ($("#sql-input")?.value || "").trim();
+    if (!sql) {
+      toast("请输入 SQL", "err");
+      return;
+    }
+    if (sql.length > 256 * 1024) {
+      toast("SQL 过长(最大 256KB)", "err");
+      return;
+    }
+    await withBusy(async () => {
+      const data = await api("/api/mysql/exec", {
+        method: "POST",
+        body: { database: $("#sql-db")?.value || "", sql },
+      });
+      renderSqlResult(data || {});
+    }, btn);
+  }
+
   function renderDatabases() {
+    fillSqlDbSelect();
     const tbody = $("#db-table tbody");
     if (!state.databases.length) {
       tbody.innerHTML = `<tr><td colspan="6" class="text-secondary text-center py-4">暂无数据库记录</td></tr>`;
@@ -1257,6 +1437,7 @@
         <td>${escapeHtml(d.host || "—")}</td>
         <td class="text-end">
           <div class="btn-group btn-group-sm">
+            <button type="button" class="btn btn-outline-primary" data-db="${escapeHtml(d.name)}" data-act="db-exec">执行</button>
             <button type="button" class="btn btn-outline-secondary" data-db="${escapeHtml(d.name)}" data-act="db-access">权限</button>
             <button type="button" class="btn btn-outline-danger" data-db="${escapeHtml(d.name)}" data-act="db-drop">删除</button>
           </div>
@@ -1627,14 +1808,25 @@
   }
 
   async function loadFiles(path) {
+    const want = path || state.filesPath || readRememberedFilesPath() || "/";
     try {
-      const data = await api(`/api/files/list?path=${encodeURIComponent(path || "/")}`);
-      const nextPath = (data && data.path) || path || "/";
+      const data = await api(`/api/files/list?path=${encodeURIComponent(want)}`);
+      const nextPath = (data && data.path) || want || "/";
       if (nextPath !== state.filesPath) clearFilesSelection();
       state.filesPath = nextPath;
+      rememberFilesPath(nextPath);
       state.files = (data && data.entries) || [];
       renderFiles();
     } catch (err) {
+      if (want !== "/") {
+        rememberFilesPath("/");
+        state.filesPath = "/";
+        try {
+          await loadFiles("/");
+          toast("上次目录不可用,已回到 /", "err");
+          return;
+        } catch (_) {}
+      }
       toast(err.message || String(err), "err");
     }
   }
@@ -2302,20 +2494,7 @@
     });
   }
 
-  function fileToBase64(file) {
-    return new Promise((resolve, reject) => {
-      const reader = new FileReader();
-      reader.onload = () => {
-        const s = String(reader.result || "");
-        const i = s.indexOf(",");
-        resolve(i >= 0 ? s.slice(i + 1) : s);
-      };
-      reader.onerror = () => reject(new Error("读取文件失败"));
-      reader.readAsDataURL(file);
-    });
-  }
-
-  const MAX_UPLOAD_BYTES = 48 * 1024 * 1024;
+  const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
 
   function normalizeRelPath(p) {
     return String(p || "")
@@ -2377,24 +2556,14 @@
       const relativePath = normalizeRelPath(it.relativePath);
       if (!relativePath || seen.has(relativePath)) continue;
       if (relativePath.split("/").includes("..")) continue;
-      if (it.file && it.file.size > MAX_UPLOAD_BYTES) {
-        state.uploadQueue.push({
-          id: `${Date.now()}-${added}-${relativePath}`,
-          relativePath,
-          file: it.file,
-          status: "err",
-          error: "单文件超过 48MB",
-        });
-      } else {
-        state.uploadQueue.push({
-          id: `${Date.now()}-${added}-${relativePath}`,
-          relativePath,
-          file: it.file || null,
-          isDir: !!it.isDir,
-          status: "pending",
-          error: "",
-        });
-      }
+      state.uploadQueue.push({
+        id: `${Date.now()}-${added}-${relativePath}`,
+        relativePath,
+        file: it.file || null,
+        isDir: !!it.isDir,
+        status: "pending",
+        error: "",
+      });
       seen.add(relativePath);
       added += 1;
     }
@@ -2477,40 +2646,74 @@
     $("#upload-progress-label").textContent = label || "";
   }
 
-  function uploadFileWithProgress(path, file, onProgress) {
-    return fileToBase64(file).then(
-      (content_base64) =>
-        new Promise((resolve, reject) => {
-          const xhr = new XMLHttpRequest();
-          xhr.open("POST", "/api/files/upload");
-          xhr.setRequestHeader("Content-Type", "application/json");
-          xhr.setRequestHeader("Accept", "application/json");
-          xhr.upload.onprogress = (e) => {
-            if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
-          };
-          xhr.onload = () => {
-            let json = null;
-            try {
-              json = JSON.parse(xhr.responseText || "{}");
-            } catch (_) {
-              reject(new Error(`无效响应 (${xhr.status})`));
-              return;
-            }
-            if (json && Number(json.code) === 401) {
-              location.replace("/login.html");
-              reject(new Error(json.msg || "未登录"));
-              return;
-            }
-            if (!json || json.code !== 200) {
-              reject(new Error((json && json.msg) || `上传失败 (${xhr.status})`));
-              return;
-            }
-            resolve(json.data);
-          };
-          xhr.onerror = () => reject(new Error("网络错误"));
-          xhr.send(JSON.stringify({ path, content_base64 }));
-        })
-    );
+  function uploadChunk(path, blob, offset, total, onProgress) {
+    return new Promise((resolve, reject) => {
+      const xhr = new XMLHttpRequest();
+      const q =
+        `path=${encodeURIComponent(path)}` +
+        `&offset=${offset}` +
+        `&total=${total}`;
+      xhr.open("POST", `/api/files/upload?${q}`);
+      xhr.setRequestHeader("Content-Type", "application/octet-stream");
+      xhr.setRequestHeader("Accept", "application/json");
+      xhr.upload.onprogress = (e) => {
+        if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
+      };
+      xhr.onload = () => {
+        let json = null;
+        try {
+          json = JSON.parse(xhr.responseText || "{}");
+        } catch (_) {
+          reject(new Error(`无效响应 (${xhr.status})`));
+          return;
+        }
+        if (json && Number(json.code) === 401) {
+          location.replace("/login.html");
+          reject(new Error(json.msg || "未登录"));
+          return;
+        }
+        if (!json || json.code !== 200) {
+          reject(new Error((json && json.msg) || `上传失败 (${xhr.status})`));
+          return;
+        }
+        resolve(json.data);
+      };
+      xhr.onerror = () => reject(new Error("网络错误"));
+      xhr.send(blob);
+    });
+  }
+
+  async function uploadChunkWithRetry(path, blob, offset, total, onProgress) {
+    let last = null;
+    for (let i = 0; i < 3; i += 1) {
+      try {
+        return await uploadChunk(path, blob, offset, total, onProgress);
+      } catch (err) {
+        last = err;
+        await new Promise((r) => setTimeout(r, 400 * (i + 1)));
+      }
+    }
+    throw last || new Error("上传失败");
+  }
+
+  async function uploadFileWithProgress(path, file, onProgress) {
+    const total = file.size || 0;
+    if (total === 0) {
+      await uploadChunkWithRetry(path, file, 0, 0, onProgress);
+      if (onProgress) onProgress(0, 0);
+      return;
+    }
+    let offset = 0;
+    while (offset < total) {
+      const end = Math.min(offset + UPLOAD_CHUNK_BYTES, total);
+      const blob = file.slice(offset, end);
+      const start = offset;
+      await uploadChunkWithRetry(path, blob, start, total, (loaded) => {
+        if (onProgress) onProgress(start + loaded, total);
+      });
+      offset = end;
+      if (onProgress) onProgress(offset, total);
+    }
   }
 
   function openUploadModal() {
@@ -2567,11 +2770,10 @@
           } else {
             setUploadProgress(completedBytes, totalBytes, `读取 ${it.relativePath}`);
             await uploadFileWithProgress(dest, it.file, (loaded, total) => {
-              const filePct = total > 0 ? loaded / total : 1;
               setUploadProgress(
-                completedBytes + (it.file.size * filePct),
+                completedBytes + loaded,
                 totalBytes,
-                `正在上传 ${it.relativePath}`
+                `正在上传 ${it.relativePath}(${formatBytes(loaded)} / ${formatBytes(total)})`
               );
             });
             completedBytes += it.file.size;
@@ -4680,6 +4882,19 @@
         }, btn);
         return;
       }
+      if (btn.dataset.act === "db-exec") {
+        const sel = $("#sql-db");
+        if (sel) {
+          fillSqlDbSelect();
+          sel.value = name;
+        }
+        const input = $("#sql-input");
+        if (input) {
+          input.focus();
+          input.scrollIntoView({ behavior: "smooth", block: "center" });
+        }
+        return;
+      }
       if (btn.dataset.act === "db-access") {
         openModal({
           title: `权限 · ${name}`,
@@ -4811,13 +5026,24 @@
       domainEl.addEventListener("change", syncRootFromDomain);
     });
 
+    $("#btn-sql-exec")?.addEventListener("click", (e) => {
+      execSql(e.currentTarget);
+    });
+    $("#sql-input")?.addEventListener("keydown", (e) => {
+      if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+        e.preventDefault();
+        execSql($("#btn-sql-exec"));
+      }
+    });
+
     $("#btn-db-create").addEventListener("click", () => {
       openModal({
         title: "新建数据库",
         bodyHtml: `
-          <div class="mb-2"><label class="form-label">库名</label><input class="form-control form-control-sm" name="name" required /></div>
-          <div class="mb-2"><label class="form-label">用户</label><input class="form-control form-control-sm" name="user" required /></div>
-          <div class="mb-2"><label class="form-label">密码</label><input class="form-control form-control-sm" name="password" required /></div>`,
+          <div class="mb-2"><label class="form-label">库名</label><input class="form-control form-control-sm" name="name" required pattern="[A-Za-z0-9_]+" maxlength="64" title="仅允许字母、数字、下划线" /></div>
+          <div class="mb-2"><label class="form-label">用户</label><input class="form-control form-control-sm" name="user" required pattern="[A-Za-z0-9_]+" maxlength="64" title="仅允许字母、数字、下划线" /></div>
+          <div class="mb-2"><label class="form-label">密码</label><input class="form-control form-control-sm" name="password" required /></div>
+          <div class="form-text">库名和用户名仅允许字母、数字、下划线,例如 blog、wp_user。不能使用中文、连字符或点号。</div>`,
         onSubmit: async (fd) => {
           await api("/api/mysql/databases", {
             method: "POST",

+ 54 - 3
data/www/index.html

@@ -98,7 +98,7 @@
                 <div class="monitor-card">
                   <div class="d-flex justify-content-between align-items-center small mb-2">
                     <span class="fw-semibold">网络</span>
-                    <span class="text-secondary" id="net-badge">—</span>
+                    <span class="text-secondary text-end monitor-badge-stack" id="net-badge">—</span>
                   </div>
                   <div class="monitor-chart"><canvas id="chart-net"></canvas></div>
                 </div>
@@ -106,10 +106,35 @@
               <div class="col-md-6">
                 <div class="monitor-card">
                   <div class="d-flex justify-content-between align-items-center small mb-2">
-                    <span class="fw-semibold">磁盘 /</span>
+                    <span class="fw-semibold">磁盘</span>
                     <span class="text-secondary" id="disk-badge">—</span>
                   </div>
                   <div class="monitor-chart"><canvas id="chart-disk"></canvas></div>
+                  <div class="resource-list" id="disk-list"></div>
+                </div>
+              </div>
+              <div class="col-md-6" id="gpu-wrap" hidden>
+                <div class="monitor-card">
+                  <div class="d-flex justify-content-between align-items-center small mb-2">
+                    <span class="fw-semibold">GPU</span>
+                    <span class="text-secondary" id="gpu-name-badge">—</span>
+                  </div>
+                  <div class="gpu-split">
+                    <div class="gpu-pane">
+                      <div class="d-flex justify-content-between align-items-center small mb-1">
+                        <span class="fw-semibold">占用</span>
+                        <span class="text-secondary" id="gpu-badge">—</span>
+                      </div>
+                      <div class="monitor-chart"><canvas id="chart-gpu"></canvas></div>
+                    </div>
+                    <div class="gpu-pane">
+                      <div class="d-flex justify-content-between align-items-center small mb-1">
+                        <span class="fw-semibold">显存</span>
+                        <span class="text-secondary" id="gpu-mem-badge">—</span>
+                      </div>
+                      <div class="monitor-chart"><canvas id="chart-gpu-mem"></canvas></div>
+                    </div>
+                  </div>
                 </div>
               </div>
             </div>
@@ -220,6 +245,32 @@
             </table>
           </div>
         </div>
+        <div class="card mt-3">
+          <div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
+            <div>
+              <strong>执行 SQL</strong>
+              <span class="small text-secondary ms-2">以 root 运行,最多返回 500 行</span>
+            </div>
+            <div class="d-flex align-items-center gap-2 flex-wrap">
+              <select class="form-select form-select-sm" id="sql-db" style="width:auto;min-width:10rem">
+                <option value="">(不选库)</option>
+              </select>
+              <button type="button" class="btn btn-sm btn-primary" id="btn-sql-exec">执行</button>
+            </div>
+          </div>
+          <div class="card-body py-2">
+            <textarea class="form-control sql-editor" id="sql-input" rows="6" spellcheck="false"
+                      placeholder="SHOW DATABASES;&#10;SELECT * FROM table_name LIMIT 100;"></textarea>
+            <div class="form-text">Ctrl+Enter 执行。DROP / DELETE 等语句请谨慎使用。</div>
+            <div id="sql-meta" class="small text-secondary mt-2"></div>
+            <div class="table-responsive mt-2 sql-result-wrap">
+              <table class="table table-sm table-hover align-middle mb-0" id="sql-result">
+                <thead></thead>
+                <tbody></tbody>
+              </table>
+            </div>
+          </div>
+        </div>
       </section>
 
       <section class="view" id="view-processes" data-view-panel="processes">
@@ -686,7 +737,7 @@ location ~ \.mjs$ {
             <div class="upload-dropzone-inner">
               <i class="bi bi-cloud-arrow-up upload-drop-icon"></i>
               <p class="mb-1">拖拽文件或文件夹到此处</p>
-              <p class="small text-secondary mb-3">也可使用下方按钮选择,选完后统一上传</p>
+              <p class="small text-secondary mb-3">支持大文件分片上传。也可使用下方按钮选择,选完后统一上传</p>
               <div class="btn-group btn-group-sm">
                 <button type="button" class="btn btn-outline-primary" id="upload-pick-files">选择文件</button>
                 <button type="button" class="btn btn-outline-primary" id="upload-pick-dir">选择目录</button>

+ 144 - 9
src/api/api_server.cpp

@@ -165,6 +165,22 @@ std::string url_param(request* req, const std::string& key,
     return def;
 }
 
+uint64_t parse_u64(const std::string& s, uint64_t def = 0) {
+    if (s.empty()) {
+        return def;
+    }
+    try {
+        size_t idx = 0;
+        const unsigned long long v = std::stoull(s, &idx, 10);
+        if (idx == 0) {
+            return def;
+        }
+        return static_cast<uint64_t>(v);
+    } catch (...) {
+        return def;
+    }
+}
+
 std::string trim_ascii(const std::string& s) {
     size_t b = 0;
     while (b < s.size() && (s[b] == ' ' || s[b] == '\t')) {
@@ -1085,8 +1101,9 @@ void h_mysql_databases(request* req, response* resp) {
             reply_err(resp, "name/user/password required");
             return;
         }
-        if (!mysql::create_database(name, user, password)) {
-            reply_err(resp, "create database failed");
+        std::string err;
+        if (!mysql::create_database(name, user, password, &err)) {
+            reply_err(resp, err.empty() ? "create database failed" : err);
             return;
         }
         reply_ok(resp, ylib::json(), "database created");
@@ -1112,6 +1129,49 @@ void h_mysql_databases_drop(request* req, response* resp) {
     reply_ok(resp, ylib::json(), "database dropped");
 }
 
+void h_mysql_exec(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const auto body = parse_body(req);
+    const std::string database = json_str(body, "database");
+    const std::string sql = json_str(body, "sql");
+    if (sql.empty()) {
+        reply_err(resp, "请输入 SQL");
+        return;
+    }
+    mysql::QueryResult result;
+    std::string err;
+    const auto t0 = std::chrono::steady_clock::now();
+    if (!mysql::exec_query(database, sql, result, &err)) {
+        reply_err(resp, err.empty() ? "执行失败" : err);
+        return;
+    }
+    const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+                        std::chrono::steady_clock::now() - t0)
+                        .count();
+    ylib::json data;
+    ylib::json columns;
+    for (const auto& c : result.columns) {
+        columns.push_back(c);
+    }
+    data["columns"] = columns;
+    ylib::json rows;
+    for (const auto& row : result.rows) {
+        ylib::json r;
+        for (const auto& cell : row) {
+            r.push_back(cell);
+        }
+        rows.push_back(r);
+    }
+    data["rows"] = rows;
+    data["row_count"] = result.row_count;
+    data["truncated"] = result.truncated;
+    data["message"] = result.message;
+    data["elapsed_ms"] = static_cast<int>(ms);
+    reply_ok(resp, data);
+}
+
 void h_mysql_databases_access(request* req, response* resp) {
     if (!require_method(req, resp, "POST")) {
         return;
@@ -1829,16 +1889,43 @@ void h_system_metrics(request* req, response* resp) {
     data["memory"] = memory;
     ylib::json disk;
     disk["path"] = m.disk.path;
+    disk["device"] = m.disk.device;
+    disk["fstype"] = m.disk.fstype;
     disk["total"] = static_cast<int64>(m.disk.total_bytes);
     disk["used"] = static_cast<int64>(m.disk.used_bytes);
     disk["percent"] = m.disk.percent;
     data["disk"] = disk;
+    ylib::json disks;
+    for (const auto& d : m.disks) {
+        ylib::json item;
+        item["path"] = d.path;
+        item["device"] = d.device;
+        item["fstype"] = d.fstype;
+        item["total"] = static_cast<int64>(d.total_bytes);
+        item["used"] = static_cast<int64>(d.used_bytes);
+        item["percent"] = d.percent;
+        disks.push_back(item);
+    }
+    data["disks"] = disks;
     ylib::json network;
     network["rx_bytes"] = static_cast<int64>(m.network.rx_bytes);
     network["tx_bytes"] = static_cast<int64>(m.network.tx_bytes);
     network["rx_bps"] = m.network.rx_bps;
     network["tx_bps"] = m.network.tx_bps;
     data["network"] = network;
+    ylib::json gpus;
+    for (const auto& g : m.gpus) {
+        ylib::json item;
+        item["index"] = g.index;
+        item["name"] = g.name;
+        item["percent"] = g.percent;
+        item["mem_used"] = static_cast<int64>(g.mem_used);
+        item["mem_total"] = static_cast<int64>(g.mem_total);
+        item["mem_percent"] = g.mem_percent;
+        item["temperature"] = g.temperature;
+        gpus.push_back(item);
+    }
+    data["gpus"] = gpus;
     reply_ok(resp, data);
 }
 
@@ -1952,6 +2039,11 @@ void h_system_metrics_history(request* req, response* resp) {
         item["disk_total"] = static_cast<int64>(p.disk_total);
         item["net_rx_bps"] = p.net_rx_bps;
         item["net_tx_bps"] = p.net_tx_bps;
+        item["net_rx_bytes"] = static_cast<int64>(p.net_rx_bytes);
+        item["net_tx_bytes"] = static_cast<int64>(p.net_tx_bytes);
+        item["gpu_percent"] = p.gpu_percent;
+        item["gpu_mem_used"] = static_cast<int64>(p.gpu_mem_used);
+        item["gpu_mem_total"] = static_cast<int64>(p.gpu_mem_total);
         arr.push_back(item);
     }
     ylib::json data;
@@ -2219,17 +2311,59 @@ void h_files_upload(request* req, response* resp) {
     if (!require_method(req, resp, "POST")) {
         return;
     }
-    auto body = parse_body(req);
-    std::string path = json_str(body, "path");
-    std::string b64 = json_str(body, "content_base64");
-    std::string content = json_str(body, "content");
-    std::string bytes = b64.empty() ? content : b64_decode(b64);
+
+    std::string content_type;
+    if (!(req->header("Content-Type", content_type) ||
+          req->header("content-type", content_type))) {
+        content_type.clear();
+    }
+
+    std::string path;
+    uint64_t offset = 0;
+    uint64_t total = 0;
+    std::string bytes;
+    const bool binary =
+        content_type.find("application/octet-stream") != std::string::npos ||
+        (!url_param(req, "path").empty() &&
+         content_type.find("json") == std::string::npos);
+
+    if (binary) {
+        path = url_param(req, "path");
+        offset = parse_u64(url_param(req, "offset", "0"));
+        total = parse_u64(url_param(req, "total", "0"));
+        bytes = req->body().to_string();
+    } else {
+        auto body = parse_body(req);
+        path = json_str(body, "path");
+        offset = parse_u64(json_str(body, "offset"));
+        total = parse_u64(json_str(body, "total"));
+        const std::string b64 = json_str(body, "content_base64");
+        const std::string content = json_str(body, "content");
+        bytes = b64.empty() ? content : b64_decode(b64);
+    }
+
+    if (path.empty()) {
+        reply_err(resp, "path required");
+        return;
+    }
+
     std::string err;
-    if (!files::write_bytes(path, bytes, err)) {
+    if (!files::write_chunk(path, offset, bytes, total, err)) {
         reply_err(resp, err);
         return;
     }
-    reply_ok(resp, ylib::json(), "uploaded");
+    ylib::json data;
+    data["path"] = path;
+    data["offset"] = static_cast<int64>(offset);
+    data["written"] = static_cast<int64>(bytes.size());
+    const uint64_t next = offset + bytes.size();
+    if (total > 0) {
+        data["total"] = static_cast<int64>(total);
+        data["done"] = next >= total;
+    } else {
+        data["done"] = true;
+    }
+    reply_ok(resp, data, "uploaded");
 }
 
 void h_files_download(request* req, response* resp) {
@@ -2338,6 +2472,7 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/mysql/databases", h_mysql_databases);
     reg(router, "/api/mysql/databases/drop", h_mysql_databases_drop);
     reg(router, "/api/mysql/databases/access", h_mysql_databases_access);
+    reg(router, "/api/mysql/exec", h_mysql_exec);
 
     reg(router, "/api/redis/status", h_redis_status);
     reg(router, "/api/redis/install", h_redis_install);

+ 101 - 0
src/files/files.cpp

@@ -3,10 +3,14 @@
 #include "../utils.h"
 
 #include <algorithm>
+#include <cerrno>
 #include <chrono>
+#include <cstring>
+#include <fcntl.h>
 #include <filesystem>
 #include <fstream>
 #include <sys/stat.h>
+#include <unistd.h>
 
 namespace fs = std::filesystem;
 
@@ -153,6 +157,103 @@ bool write_bytes(const std::string& path, const std::string& bytes,
     return static_cast<bool>(out);
 }
 
+bool write_chunk(const std::string& path, uint64_t offset,
+                 const std::string& bytes, uint64_t total, std::string& err) {
+    if (bytes.size() > kMaxUploadChunkBytes) {
+        err = "分片过大(单片超过 32MB)";
+        return false;
+    }
+    if (total > 0 && offset > total) {
+        err = "分片偏移超出文件大小";
+        return false;
+    }
+    if (total > 0 && offset + bytes.size() > total) {
+        err = "分片超出文件大小";
+        return false;
+    }
+
+    std::string abs;
+    if (!resolve_path(path, abs, err)) {
+        return false;
+    }
+    std::error_code ec;
+    fs::path parent = fs::path(abs).parent_path();
+    if (!parent.empty() && !fs::exists(parent, ec)) {
+        if (!fs::create_directories(parent, ec) || ec) {
+            err = "创建父目录失败: " + ec.message();
+            return false;
+        }
+    }
+    if (fs::is_directory(abs, ec)) {
+        err = "目标是目录: " + abs;
+        return false;
+    }
+    if (offset > 0 && !fs::exists(abs, ec)) {
+        err = "请从偏移 0 开始上传";
+        return false;
+    }
+
+    int flags = O_RDWR | O_CREAT;
+    if (offset == 0) {
+        flags |= O_TRUNC;
+    }
+    const int fd = ::open(abs.c_str(), flags, 0644);
+    if (fd < 0) {
+        err = std::string("无法写入: ") + std::strerror(errno);
+        return false;
+    }
+
+    auto fail_fd = [&](const std::string& msg) {
+        const int saved = errno;
+        ::close(fd);
+        err = msg;
+        if (saved != 0 && err.find(':') == std::string::npos) {
+            err += ": ";
+            err += std::strerror(saved);
+        }
+        return false;
+    };
+
+    if (offset == 0 && total > 0) {
+        const int rc = ::posix_fallocate(fd, 0, static_cast<off_t>(total));
+        if (rc == ENOSPC) {
+            errno = rc;
+            return fail_fd("磁盘空间不足");
+        }
+        if (rc != 0 && rc != EOPNOTSUPP && rc != EINVAL) {
+            errno = rc;
+            return fail_fd("预分配文件空间失败");
+        }
+    }
+
+    if (::lseek(fd, static_cast<off_t>(offset), SEEK_SET) == static_cast<off_t>(-1)) {
+        return fail_fd("定位写入位置失败");
+    }
+
+    const char* p = bytes.data();
+    size_t left = bytes.size();
+    while (left > 0) {
+        const ssize_t n = ::write(fd, p, left);
+        if (n < 0) {
+            if (errno == EINTR) {
+                continue;
+            }
+            if (errno == ENOSPC) {
+                return fail_fd("磁盘空间不足");
+            }
+            return fail_fd("写入失败");
+        }
+        if (n == 0) {
+            return fail_fd("写入失败");
+        }
+        p += n;
+        left -= static_cast<size_t>(n);
+    }
+    ::fsync(fd);
+    ::close(fd);
+    return true;
+}
+
 bool read_text(const std::string& path, std::string& content, std::string& err,
                size_t max_bytes) {
     content.clear();

+ 4 - 0
src/files/files.h

@@ -35,6 +35,10 @@ bool rename_path(const std::string& from, const std::string& to,
 bool delete_path(const std::string& path, bool recursive, std::string& err);
 bool write_bytes(const std::string& path, const std::string& bytes,
                  std::string& err);
+// 分片写入:offset==0 时截断/创建;total>0 时尝试预分配。单片最大 32MB。
+constexpr size_t kMaxUploadChunkBytes = 32ull * 1024ull * 1024ull;
+bool write_chunk(const std::string& path, uint64_t offset,
+                 const std::string& bytes, uint64_t total, std::string& err);
 
 }  // namespace files
 }  // namespace ngs

+ 316 - 10
src/software/mysql/mysql.cpp

@@ -4,14 +4,20 @@
 #include "../../utils.h"
 
 #include <algorithm>
+#include <array>
 #include <cctype>
 #include <chrono>
+#include <cstdio>
+#include <cstring>
 #include <cstdlib>
 #include <ctime>
 #include <fstream>
 #include <iostream>
 #include <sstream>
+#include <sys/stat.h>
+#include <sys/wait.h>
 #include <thread>
+#include <unistd.h>
 #include <vector>
 
 namespace ngs {
@@ -360,12 +366,17 @@ void print_error_log_tail(const std::string& version = "") {
             true);
 }
 
+bool is_ascii_alnum(unsigned char c) {
+    return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
+           (c >= 'a' && c <= 'z');
+}
+
 bool is_safe_ident(const std::string& s) {
     if (s.empty() || s.size() > 64) {
         return false;
     }
     for (unsigned char c : s) {
-        if (!(std::isalnum(c) || c == '_')) {
+        if (!(is_ascii_alnum(c) || c == '_')) {
             return false;
         }
     }
@@ -706,6 +717,220 @@ bool try_exec_sql(const std::string& sql, const std::string& password) {
     return rc == 0;
 }
 
+std::string cnf_quote(const std::string& s) {
+    std::string out = "\"";
+    for (char c : s) {
+        if (c == '\\' || c == '"') {
+            out.push_back('\\');
+        }
+        out.push_back(c);
+    }
+    out.push_back('"');
+    return out;
+}
+
+int capture_stdout(const std::string& cmd, std::string& output, size_t max_bytes) {
+    output.clear();
+    FILE* fp = popen(cmd.c_str(), "r");
+    if (!fp) {
+        return -1;
+    }
+    std::array<char, 4096> buf{};
+    while (fgets(buf.data(), static_cast<int>(buf.size()), fp) != nullptr) {
+        if (output.size() >= max_bytes) {
+            continue;
+        }
+        const size_t room = max_bytes - output.size();
+        const size_t n = std::strlen(buf.data());
+        output.append(buf.data(), n > room ? room : n);
+    }
+    const int status = pclose(fp);
+    if (status == -1) {
+        return -1;
+    }
+    if (WIFEXITED(status)) {
+        return WEXITSTATUS(status);
+    }
+    if (WIFSIGNALED(status)) {
+        return 128 + WTERMSIG(status);
+    }
+    return -1;
+}
+
+std::string read_small_file(const std::string& path, size_t max_bytes) {
+    std::ifstream in(path);
+    if (!in) {
+        return "";
+    }
+    std::string out;
+    std::array<char, 4096> buf{};
+    while (in && out.size() < max_bytes) {
+        in.read(buf.data(), static_cast<std::streamsize>(buf.size()));
+        const auto n = static_cast<size_t>(in.gcount());
+        if (n == 0) {
+            break;
+        }
+        const size_t room = max_bytes - out.size();
+        out.append(buf.data(), n > room ? room : n);
+    }
+    return out;
+}
+
+void trim_right_newlines(std::string& s) {
+    while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) {
+        s.pop_back();
+    }
+}
+
+std::string mysql_unescape_field(const std::string& in) {
+    std::string out;
+    out.reserve(in.size());
+    for (size_t i = 0; i < in.size(); ++i) {
+        if (in[i] == '\\' && i + 1 < in.size()) {
+            const char n = in[++i];
+            if (n == 'n') {
+                out.push_back('\n');
+            } else if (n == 't') {
+                out.push_back('\t');
+            } else if (n == '0') {
+                out.push_back('\0');
+            } else if (n == '\\') {
+                out.push_back('\\');
+            } else {
+                out.push_back(n);
+            }
+        } else {
+            out.push_back(in[i]);
+        }
+    }
+    return out;
+}
+
+std::vector<std::string> split_mysql_row(const std::string& line) {
+    std::vector<std::string> fields;
+    std::string cur;
+    for (char c : line) {
+        if (c == '\t') {
+            fields.push_back(cur == "\\N" ? "" : mysql_unescape_field(cur));
+            cur.clear();
+        } else if (c != '\r') {
+            cur.push_back(c);
+        }
+    }
+    fields.push_back(cur == "\\N" ? "" : mysql_unescape_field(cur));
+    return fields;
+}
+
+bool try_query_sql(const std::string& sql, const std::string& user,
+                   const std::string& password, const std::string& database,
+                   QueryResult& result, std::string& err) {
+    result = QueryResult{};
+    const std::string tmp_dir = join_path(work_dir(), "sql");
+    if (!ensure_dir(tmp_dir)) {
+        err = "无法创建临时目录";
+        return false;
+    }
+    const std::string id = std::to_string(::getpid()) + "-" +
+                           std::to_string(std::chrono::steady_clock::now()
+                                              .time_since_epoch()
+                                              .count());
+    const std::string sql_file = join_path(tmp_dir, "query-" + id + ".sql");
+    const std::string cnf_file = join_path(tmp_dir, "query-" + id + ".cnf");
+    {
+        std::ofstream out(sql_file, std::ios::trunc);
+        if (!out) {
+            err = "无法写入 SQL 临时文件";
+            return false;
+        }
+        out << sql;
+        if (!sql.empty() && sql.back() != '\n') {
+            out << '\n';
+        }
+    }
+    {
+        std::ofstream out(cnf_file, std::ios::trunc);
+        if (!out) {
+            remove_path(sql_file);
+            err = "无法写入连接配置";
+            return false;
+        }
+        out << "[client]\n"
+            << "user=" << user << "\n"
+            << "socket=" << socket_path() << "\n"
+            << "password=" << cnf_quote(password) << "\n"
+            << "default-character-set=utf8mb4\n";
+    }
+    ::chmod(cnf_file.c_str(), 0600);
+
+    const std::string err_file = join_path(tmp_dir, "query-" + id + ".err");
+    std::string cmd = "\"" + mysql_bin() + "\" --defaults-extra-file=\"" +
+                      cnf_file + "\" --batch --connect-timeout=8";
+    if (!database.empty()) {
+        cmd += " --database=\"" + database + "\"";
+    }
+    cmd += " < \"" + sql_file + "\" 2>\"" + err_file + "\"";
+
+    constexpr size_t kMaxOut = 512ull * 1024ull;
+    std::string output;
+    const int rc = capture_stdout(cmd, output, kMaxOut);
+    std::string stderr_text = read_small_file(err_file, 32ull * 1024ull);
+    remove_path(sql_file);
+    remove_path(cnf_file);
+    remove_path(err_file);
+    if (rc != 0) {
+        trim_right_newlines(stderr_text);
+        trim_right_newlines(output);
+        if (!stderr_text.empty()) {
+            err = stderr_text;
+        } else if (!output.empty()) {
+            err = output;
+        } else {
+            err = "执行失败 (exit=" + std::to_string(rc) + ")";
+        }
+        return false;
+    }
+
+    std::istringstream in(output);
+    std::string line;
+    constexpr int kMaxRows = 500;
+    bool header = true;
+    while (std::getline(in, line)) {
+        if (!line.empty() && line.back() == '\r') {
+            line.pop_back();
+        }
+        if (line.empty() && header) {
+            continue;
+        }
+        auto fields = split_mysql_row(line);
+        if (header) {
+            result.columns = std::move(fields);
+            header = false;
+            continue;
+        }
+        if (static_cast<int>(result.rows.size()) >= kMaxRows) {
+            result.truncated = true;
+            break;
+        }
+        if (fields.size() < result.columns.size()) {
+            fields.resize(result.columns.size());
+        }
+        result.rows.push_back(std::move(fields));
+    }
+    result.row_count = static_cast<int>(result.rows.size());
+    if (output.size() >= kMaxOut) {
+        result.truncated = true;
+    }
+    if (result.columns.empty()) {
+        result.message = "执行成功";
+    } else {
+        result.message = "返回 " + std::to_string(result.row_count) + " 行";
+        if (result.truncated) {
+            result.message += "(已截断)";
+        }
+    }
+    return true;
+}
+
 bool exec_sql_as_root(const std::string& sql) {
     // Prefer saved password, then empty (fresh initialize-insecure).
     std::vector<std::string> candidates;
@@ -730,6 +955,11 @@ bool exec_sql_as_root(const std::string& sql) {
     return false;
 }
 
+bool auth_error(const std::string& msg) {
+    return msg.find("ERROR 1045") != std::string::npos ||
+           msg.find("Access denied") != std::string::npos;
+}
+
 }  // namespace
 
 std::string install_dir() {
@@ -958,23 +1188,30 @@ bool change_root_password(const std::string& new_password) {
     return true;
 }
 
+bool fail_out(std::string* err, const std::string& msg) {
+    std::cerr << msg << "\n";
+    log_error("mysql: " + msg);
+    if (err) {
+        *err = msg;
+    }
+    return false;
+}
+
 bool create_database(const std::string& db_name,
                      const std::string& user,
-                     const std::string& password) {
+                     const std::string& password,
+                     std::string* err) {
     if (!is_installed()) {
-        std::cerr << "MySQL 未安装。\n";
-        return false;
+        return fail_out(err, "MySQL 未安装");
     }
     if (!is_safe_ident(db_name) || !is_safe_ident(user)) {
-        std::cerr << "数据库名/用户名仅允许字母、数字、下划线。\n";
-        return false;
+        return fail_out(err, "数据库名/用户名仅允许字母、数字、下划线");
     }
     if (password.empty()) {
-        std::cerr << "用户密码不能为空。\n";
-        return false;
+        return fail_out(err, "用户密码不能为空");
     }
     if (!ensure_running()) {
-        return false;
+        return fail_out(err, "MySQL 未运行,启动失败");
     }
 
     std::ostringstream sql;
@@ -995,7 +1232,7 @@ bool create_database(const std::string& db_name,
         << "FLUSH PRIVILEGES;\n";
 
     if (!exec_sql_as_root(sql.str())) {
-        return false;
+        return fail_out(err, "以 root 执行 SQL 失败,请确认服务已启动且 root 密码正确");
     }
 
     if (!save_database_account(db_name, user, password)) {
@@ -1234,6 +1471,75 @@ std::vector<DatabaseInfo> list_databases() {
     return out;
 }
 
+bool exec_query(const std::string& db_name, const std::string& sql,
+                QueryResult& out, std::string* err) {
+    out = QueryResult{};
+    if (!is_installed()) {
+        if (err) {
+            *err = "MySQL 未安装";
+        }
+        return false;
+    }
+    if (!is_running()) {
+        if (err) {
+            *err = "MySQL 未运行";
+        }
+        return false;
+    }
+    if (sql.empty()) {
+        if (err) {
+            *err = "SQL 不能为空";
+        }
+        return false;
+    }
+    if (sql.size() > 256ull * 1024ull) {
+        if (err) {
+            *err = "SQL 过长(最大 256KB)";
+        }
+        return false;
+    }
+
+    std::string database;
+    if (!db_name.empty()) {
+        if (!is_safe_ident(db_name)) {
+            if (err) {
+                *err = "数据库名仅允许字母、数字、下划线";
+            }
+            return false;
+        }
+        database = db_name;
+    }
+
+    std::vector<std::string> candidates;
+    const std::string saved = read_root_password();
+    if (!saved.empty()) {
+        candidates.push_back(saved);
+    }
+    candidates.emplace_back("");
+    if (saved != kDefaultRootPassword) {
+        candidates.emplace_back(kDefaultRootPassword);
+    }
+
+    std::string last_err;
+    for (const auto& password : candidates) {
+        std::string e;
+        if (try_query_sql(sql, "root", password, database, out, e)) {
+            return true;
+        }
+        last_err = e;
+        if (!auth_error(e)) {
+            if (err) {
+                *err = e;
+            }
+            return false;
+        }
+    }
+    if (err) {
+        *err = last_err.empty() ? "执行失败" : last_err;
+    }
+    return false;
+}
+
 bool install(const std::string& version) {
     if (is_installed()) {
         std::cout << "MySQL 已安装 (版本 " << installed_version() << ")。\n";

+ 14 - 1
src/software/mysql/mysql.h

@@ -36,13 +36,26 @@ bool stop();
 bool change_root_password(const std::string& new_password);
 bool create_database(const std::string& db_name,
                      const std::string& user,
-                     const std::string& password);
+                     const std::string& password,
+                     std::string* err = nullptr);
 bool drop_database(const std::string& db_name);
 // mode: local | fixed_ip | any
 bool set_database_access(const std::string& db_name,
                          const std::string& mode,
                          const std::string& host = "");
 std::vector<DatabaseInfo> list_databases();
+
+struct QueryResult {
+    std::vector<std::string> columns;
+    std::vector<std::vector<std::string>> rows;
+    int row_count = 0;
+    bool truncated = false;
+    std::string message;
+};
+
+bool exec_query(const std::string& db_name, const std::string& sql,
+                QueryResult& out, std::string* err = nullptr);
+
 void show_status();
 
 void menu();

+ 276 - 13
src/system/metrics.cpp

@@ -1,14 +1,20 @@
 #include "metrics.h"
 
+#include <algorithm>
 #include <chrono>
+#include <cstdio>
+#include <cstring>
 #include <fstream>
+#include <map>
 #include <mutex>
+#include <set>
 #include <sstream>
 #include <string>
 #include <thread>
 #include <unistd.h>
-
+#include <sys/stat.h>
 #include <sys/statvfs.h>
+#include <sys/types.h>
 
 namespace ngs {
 namespace system {
@@ -95,6 +101,262 @@ NetPrev& net_prev() {
     return p;
 }
 
+std::string trim_copy(std::string s) {
+    while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) {
+        s.erase(s.begin());
+    }
+    while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) {
+        s.pop_back();
+    }
+    return s;
+}
+
+std::string unescape_mount(const std::string& in) {
+    std::string out;
+    out.reserve(in.size());
+    for (size_t i = 0; i < in.size(); ++i) {
+        if (in[i] == '\\' && i + 3 < in.size() &&
+            in[i + 1] >= '0' && in[i + 1] <= '7' &&
+            in[i + 2] >= '0' && in[i + 2] <= '7' &&
+            in[i + 3] >= '0' && in[i + 3] <= '7') {
+            const int v = (in[i + 1] - '0') * 64 + (in[i + 2] - '0') * 8 +
+                          (in[i + 3] - '0');
+            out.push_back(static_cast<char>(v));
+            i += 3;
+        } else {
+            out.push_back(in[i]);
+        }
+    }
+    return out;
+}
+
+bool is_virtual_fs(const std::string& type) {
+    static const std::set<std::string> skip = {
+        "proc",       "sysfs",     "tmpfs",     "devtmpfs", "devpts",
+        "cgroup",     "cgroup2",   "pstore",    "securityfs",
+        "debugfs",    "tracefs",   "fusectl",   "mqueue",   "hugetlbfs",
+        "rpc_pipefs", "autofs",    "binfmt_misc", "configfs",
+        "nsfs",       "ramfs",     "squashfs",  "iso9660",  "overlay",
+        "overlay2",   "fuse.lxcfs"};
+    return skip.count(type) > 0;
+}
+
+bool is_counted_fs(const std::string& type, const std::string& path) {
+    if (type == "overlay" || type == "overlay2") {
+        return path == "/";
+    }
+    if (is_virtual_fs(type)) {
+        return false;
+    }
+    return true;
+}
+
+bool skip_mount_path(const std::string& path) {
+    if (path.empty() || path == "/proc" || path == "/sys" || path == "/dev" ||
+        path == "/run") {
+        return true;
+    }
+    static const char* prefixes[] = {"/proc/", "/sys/", "/dev/", "/run/",
+                                     "/snap/", "/boot/efi"};
+    for (const char* p : prefixes) {
+        const size_t n = std::strlen(p);
+        if (path.compare(0, n, p) == 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
+bool fill_disk_stat(const std::string& path, DiskMetrics& d) {
+    struct statvfs vfs {};
+    if (statvfs(path.c_str(), &vfs) != 0 || vfs.f_frsize == 0) {
+        return false;
+    }
+    const uint64_t total =
+        static_cast<uint64_t>(vfs.f_blocks) * vfs.f_frsize;
+    const uint64_t free =
+        static_cast<uint64_t>(vfs.f_bavail) * vfs.f_frsize;
+    if (total == 0) {
+        return false;
+    }
+    d.path = path;
+    d.total_bytes = total;
+    d.used_bytes = total > free ? total - free : 0;
+    d.percent = static_cast<double>(d.used_bytes) * 100.0 /
+                static_cast<double>(total);
+    return true;
+}
+
+std::vector<DiskMetrics> read_disks() {
+    std::ifstream in("/proc/mounts");
+    if (!in) {
+        DiskMetrics root;
+        fill_disk_stat("/", root);
+        root.fstype = "";
+        return root.total_bytes ? std::vector<DiskMetrics>{root}
+                                : std::vector<DiskMetrics>{};
+    }
+
+    struct Candidate {
+        std::string src;
+        std::string dst;
+        std::string type;
+        dev_t dev = 0;
+    };
+    std::map<dev_t, Candidate> best;
+    std::string line;
+    while (std::getline(in, line)) {
+        std::istringstream ss(line);
+        std::string src, dst, type;
+        if (!(ss >> src >> dst >> type)) {
+            continue;
+        }
+        src = unescape_mount(src);
+        dst = unescape_mount(dst);
+        if (!is_counted_fs(type, dst) || skip_mount_path(dst)) {
+            continue;
+        }
+        struct stat st {};
+        if (stat(dst.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
+            continue;
+        }
+        Candidate c;
+        c.src = src;
+        c.dst = dst;
+        c.type = type;
+        c.dev = st.st_dev;
+        auto it = best.find(c.dev);
+        if (it == best.end()) {
+            best[c.dev] = c;
+            continue;
+        }
+        // Prefer "/", then shorter path.
+        const std::string& old = it->second.dst;
+        if (dst == "/" || (old != "/" && dst.size() < old.size())) {
+            it->second = c;
+        }
+    }
+
+    std::vector<DiskMetrics> out;
+    out.reserve(best.size());
+    for (auto& kv : best) {
+        DiskMetrics d;
+        if (!fill_disk_stat(kv.second.dst, d)) {
+            continue;
+        }
+        d.device = kv.second.src;
+        d.fstype = kv.second.type;
+        out.push_back(d);
+    }
+    std::sort(out.begin(), out.end(),
+              [](const DiskMetrics& a, const DiskMetrics& b) {
+                  if (a.path == "/") {
+                      return true;
+                  }
+                  if (b.path == "/") {
+                      return false;
+                  }
+                  return a.path < b.path;
+              });
+    // Overlay/bind mounts of the same volume report identical capacity.
+    std::vector<DiskMetrics> uniq;
+    uniq.reserve(out.size());
+    for (const auto& d : out) {
+        auto it = std::find_if(uniq.begin(), uniq.end(), [&](const DiskMetrics& u) {
+            return d.total_bytes == u.total_bytes && d.used_bytes == u.used_bytes;
+        });
+        if (it == uniq.end()) {
+            uniq.push_back(d);
+            continue;
+        }
+        if (d.path == "/" && it->path != "/") {
+            *it = d;
+        }
+    }
+    return uniq;
+}
+
+bool nvidia_smi_exists() {
+    static int cached = -1;
+    if (cached < 0) {
+        cached = (::access("/usr/bin/nvidia-smi", X_OK) == 0 ||
+                  ::access("/usr/bin/nvidia-smi", F_OK) == 0)
+                     ? 1
+                     : 0;
+    }
+    return cached == 1;
+}
+
+std::vector<GpuMetrics> read_gpus() {
+    std::vector<GpuMetrics> out;
+    if (!nvidia_smi_exists()) {
+        return out;
+    }
+    FILE* fp = popen(
+        "nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,"
+        "memory.total,temperature.gpu --format=csv,noheader,nounits "
+        "2>/dev/null",
+        "r");
+    if (!fp) {
+        return out;
+    }
+    char buf[512];
+    while (fgets(buf, sizeof(buf), fp)) {
+        std::string line = trim_copy(buf);
+        if (line.empty()) {
+            continue;
+        }
+        std::vector<std::string> parts;
+        std::string cur;
+        for (char c : line) {
+            if (c == ',') {
+                parts.push_back(trim_copy(cur));
+                cur.clear();
+            } else {
+                cur.push_back(c);
+            }
+        }
+        parts.push_back(trim_copy(cur));
+        if (parts.size() < 6) {
+            continue;
+        }
+        // name may contain commas; last 4 fields are numeric.
+        try {
+            GpuMetrics g;
+            g.temperature = std::stod(parts[parts.size() - 1]);
+            g.mem_total = static_cast<uint64_t>(
+                std::stoull(parts[parts.size() - 2]) * 1024ull * 1024ull);
+            g.mem_used = static_cast<uint64_t>(
+                std::stoull(parts[parts.size() - 3]) * 1024ull * 1024ull);
+            g.percent = std::stod(parts[parts.size() - 4]);
+            g.index = std::stoi(parts[0]);
+            std::string name;
+            for (size_t i = 1; i + 4 < parts.size(); ++i) {
+                if (!name.empty()) {
+                    name += ", ";
+                }
+                name += parts[i];
+            }
+            g.name = name;
+            if (g.mem_total > 0) {
+                g.mem_percent = static_cast<double>(g.mem_used) * 100.0 /
+                                static_cast<double>(g.mem_total);
+            }
+            if (g.percent < 0) {
+                g.percent = 0;
+            }
+            if (g.percent > 100) {
+                g.percent = 100;
+            }
+            out.push_back(g);
+        } catch (...) {
+            continue;
+        }
+    }
+    pclose(fp);
+    return out;
+}
+
 }  // namespace
 
 Metrics sample() {
@@ -152,19 +414,18 @@ Metrics sample() {
                            static_cast<double>(m.memory.total_bytes);
     }
 
-    m.disk.path = "/";
-    struct statvfs vfs {};
-    if (statvfs("/", &vfs) == 0) {
-        const uint64_t total =
-            static_cast<uint64_t>(vfs.f_blocks) * vfs.f_frsize;
-        const uint64_t free =
-            static_cast<uint64_t>(vfs.f_bavail) * vfs.f_frsize;
-        m.disk.total_bytes = total;
-        m.disk.used_bytes = total > free ? total - free : 0;
-        if (total > 0) {
-            m.disk.percent = static_cast<double>(m.disk.used_bytes) * 100.0 /
-                             static_cast<double>(total);
+    m.disks = read_disks();
+    if (!m.disks.empty()) {
+        m.disk = m.disks.front();
+        for (const auto& d : m.disks) {
+            if (d.path == "/") {
+                m.disk = d;
+                break;
+            }
         }
+    } else {
+        m.disk.path = "/";
+        fill_disk_stat("/", m.disk);
     }
 
     uint64_t rx = 0, tx = 0;
@@ -188,6 +449,8 @@ Metrics sample() {
         prev.at = now;
     }
 
+    m.gpus = read_gpus();
+
     return m;
 }
 

+ 16 - 1
src/system/metrics.h

@@ -3,6 +3,7 @@
 
 #include <cstdint>
 #include <string>
+#include <vector>
 
 namespace ngs {
 namespace system {
@@ -20,6 +21,8 @@ struct MemMetrics {
 
 struct DiskMetrics {
     std::string path = "/";
+    std::string device;
+    std::string fstype;
     uint64_t total_bytes = 0;
     uint64_t used_bytes = 0;
     double percent = 0;
@@ -32,11 +35,23 @@ struct NetMetrics {
     double tx_bps = 0;
 };
 
+struct GpuMetrics {
+    int index = 0;
+    std::string name;
+    double percent = 0;
+    uint64_t mem_used = 0;
+    uint64_t mem_total = 0;
+    double mem_percent = 0;
+    double temperature = 0;
+};
+
 struct Metrics {
     CpuMetrics cpu;
     MemMetrics memory;
-    DiskMetrics disk;
+    DiskMetrics disk;  // primary (/) for charts / history
+    std::vector<DiskMetrics> disks;
     NetMetrics network;
+    std::vector<GpuMetrics> gpus;
 };
 
 // Sample current system metrics. CPU percent uses a short internal sleep.

+ 75 - 10
src/system/metrics_history.cpp

@@ -63,6 +63,20 @@ bool exec_db(const std::string& sql) {
     return true;
 }
 
+bool add_column_if_missing(const std::string& name, const std::string& decl) {
+    SQLITE_RESULT rows;
+    if (!db().query("PRAGMA table_info(metrics)", rows)) {
+        return false;
+    }
+    for (const auto& row : rows) {
+        auto it = row.find("name");
+        if (it != row.end() && it->second == name) {
+            return true;
+        }
+    }
+    return exec_db("ALTER TABLE metrics ADD COLUMN " + name + " " + decl);
+}
+
 bool ensure_schema() {
     const char* ddl = R"SQL(
 CREATE TABLE IF NOT EXISTS metrics (
@@ -77,11 +91,24 @@ CREATE TABLE IF NOT EXISTS metrics (
   disk_used INTEGER NOT NULL DEFAULT 0,
   disk_total INTEGER NOT NULL DEFAULT 0,
   net_rx_bps REAL NOT NULL DEFAULT 0,
-  net_tx_bps REAL NOT NULL DEFAULT 0
+  net_tx_bps REAL NOT NULL DEFAULT 0,
+  net_rx_bytes INTEGER NOT NULL DEFAULT 0,
+  net_tx_bytes INTEGER NOT NULL DEFAULT 0,
+  gpu_percent REAL NOT NULL DEFAULT 0,
+  gpu_mem_used INTEGER NOT NULL DEFAULT 0,
+  gpu_mem_total INTEGER NOT NULL DEFAULT 0
 );
 CREATE INDEX IF NOT EXISTS idx_metrics_time ON metrics(time_ms);
 )SQL";
-    return exec_db(ddl);
+    if (!exec_db(ddl)) {
+        return false;
+    }
+    add_column_if_missing("net_rx_bytes", "INTEGER NOT NULL DEFAULT 0");
+    add_column_if_missing("net_tx_bytes", "INTEGER NOT NULL DEFAULT 0");
+    add_column_if_missing("gpu_percent", "REAL NOT NULL DEFAULT 0");
+    add_column_if_missing("gpu_mem_used", "INTEGER NOT NULL DEFAULT 0");
+    add_column_if_missing("gpu_mem_total", "INTEGER NOT NULL DEFAULT 0");
+    return true;
 }
 
 bool open_db(std::string& err) {
@@ -107,17 +134,33 @@ bool open_db(std::string& err) {
 }
 
 void insert_sample(const Metrics& m, int64_t ts) {
+    double gpu_percent = 0;
+    uint64_t gpu_mem_used = 0;
+    uint64_t gpu_mem_total = 0;
+    if (!m.gpus.empty()) {
+        double util_sum = 0;
+        for (const auto& g : m.gpus) {
+            util_sum += g.percent;
+            gpu_mem_used += g.mem_used;
+            gpu_mem_total += g.mem_total;
+        }
+        gpu_percent = util_sum / static_cast<double>(m.gpus.size());
+    }
     std::ostringstream sql;
     sql.setf(std::ios::fixed);
     sql.precision(6);
     sql << "INSERT INTO metrics("
            "time_ms,cpu_percent,cpu_cores,mem_percent,mem_used,mem_total,"
-           "disk_percent,disk_used,disk_total,net_rx_bps,net_tx_bps) VALUES("
+           "disk_percent,disk_used,disk_total,net_rx_bps,net_tx_bps,"
+           "net_rx_bytes,net_tx_bytes,gpu_percent,gpu_mem_used,gpu_mem_total"
+           ") VALUES("
         << ts << "," << m.cpu.percent << "," << m.cpu.cores << ","
         << m.memory.percent << "," << m.memory.used_bytes << ","
         << m.memory.total_bytes << "," << m.disk.percent << ","
         << m.disk.used_bytes << "," << m.disk.total_bytes << ","
-        << m.network.rx_bps << "," << m.network.tx_bps << ")";
+        << m.network.rx_bps << "," << m.network.tx_bps << ","
+        << m.network.rx_bytes << "," << m.network.tx_bytes << ","
+        << gpu_percent << "," << gpu_mem_used << "," << gpu_mem_total << ")";
     exec_db(sql.str());
 }
 
@@ -261,7 +304,8 @@ bool history_query(const std::string& range, std::vector<HistoryPoint>& out,
     // Fetch raw rows then downsample in memory for longer windows.
     const std::string sql =
         "SELECT time_ms,cpu_percent,cpu_cores,mem_percent,mem_used,mem_total,"
-        "disk_percent,disk_used,disk_total,net_rx_bps,net_tx_bps "
+        "disk_percent,disk_used,disk_total,net_rx_bps,net_tx_bps,"
+        "net_rx_bytes,net_tx_bytes,gpu_percent,gpu_mem_used,gpu_mem_total "
         "FROM metrics WHERE time_ms >= " +
         std::to_string(since) + " ORDER BY time_ms ASC";
 
@@ -287,6 +331,15 @@ bool history_query(const std::string& range, std::vector<HistoryPoint>& out,
             static_cast<uint64_t>(to_i64(map_get(row, "disk_total")));
         p.net_rx_bps = to_d(map_get(row, "net_rx_bps"));
         p.net_tx_bps = to_d(map_get(row, "net_tx_bps"));
+        p.net_rx_bytes =
+            static_cast<uint64_t>(to_i64(map_get(row, "net_rx_bytes")));
+        p.net_tx_bytes =
+            static_cast<uint64_t>(to_i64(map_get(row, "net_tx_bytes")));
+        p.gpu_percent = to_d(map_get(row, "gpu_percent"));
+        p.gpu_mem_used =
+            static_cast<uint64_t>(to_i64(map_get(row, "gpu_mem_used")));
+        p.gpu_mem_total =
+            static_cast<uint64_t>(to_i64(map_get(row, "gpu_mem_total")));
         raw.push_back(p);
     }
 
@@ -304,8 +357,9 @@ bool history_query(const std::string& range, std::vector<HistoryPoint>& out,
             end = raw.size();
         }
         HistoryPoint avg = raw[i];
-        double cpu = 0, mem = 0, disk = 0, rx = 0, tx = 0;
+        double cpu = 0, mem = 0, disk = 0, rx = 0, tx = 0, gpu = 0;
         uint64_t mem_u = 0, mem_t = 0, disk_u = 0, disk_t = 0;
+        uint64_t nrx = 0, ntx = 0, gpu_u = 0, gpu_t = 0;
         int cores = raw[i].cpu_cores;
         for (size_t j = i; j < end; ++j) {
             cpu += raw[j].cpu_percent;
@@ -313,23 +367,34 @@ bool history_query(const std::string& range, std::vector<HistoryPoint>& out,
             disk += raw[j].disk_percent;
             rx += raw[j].net_rx_bps;
             tx += raw[j].net_tx_bps;
+            gpu += raw[j].gpu_percent;
             mem_u += raw[j].mem_used;
             mem_t += raw[j].mem_total;
             disk_u += raw[j].disk_used;
             disk_t += raw[j].disk_total;
+            nrx += raw[j].net_rx_bytes;
+            ntx += raw[j].net_tx_bytes;
+            gpu_u += raw[j].gpu_mem_used;
+            gpu_t += raw[j].gpu_mem_total;
             cores = raw[j].cpu_cores;
         }
         const double n = static_cast<double>(end - i);
+        const size_t cnt = end - i;
         avg.time_ms = raw[end - 1].time_ms;
         avg.cpu_percent = cpu / n;
         avg.mem_percent = mem / n;
         avg.disk_percent = disk / n;
         avg.net_rx_bps = rx / n;
         avg.net_tx_bps = tx / n;
-        avg.mem_used = static_cast<uint64_t>(mem_u / (end - i));
-        avg.mem_total = static_cast<uint64_t>(mem_t / (end - i));
-        avg.disk_used = static_cast<uint64_t>(disk_u / (end - i));
-        avg.disk_total = static_cast<uint64_t>(disk_t / (end - i));
+        avg.gpu_percent = gpu / n;
+        avg.mem_used = static_cast<uint64_t>(mem_u / cnt);
+        avg.mem_total = static_cast<uint64_t>(mem_t / cnt);
+        avg.disk_used = static_cast<uint64_t>(disk_u / cnt);
+        avg.disk_total = static_cast<uint64_t>(disk_t / cnt);
+        avg.net_rx_bytes = nrx / cnt;
+        avg.net_tx_bytes = ntx / cnt;
+        avg.gpu_mem_used = gpu_u / cnt;
+        avg.gpu_mem_total = gpu_t / cnt;
         avg.cpu_cores = cores;
         out.push_back(avg);
         i = end;

+ 5 - 0
src/system/metrics_history.h

@@ -22,6 +22,11 @@ struct HistoryPoint {
     uint64_t disk_total = 0;
     double net_rx_bps = 0;
     double net_tx_bps = 0;
+    uint64_t net_rx_bytes = 0;
+    uint64_t net_tx_bytes = 0;
+    double gpu_percent = 0;
+    uint64_t gpu_mem_used = 0;
+    uint64_t gpu_mem_total = 0;
 };
 
 // data/metrics.db — continuous sampler + queryable history.