|
|
@@ -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",
|