const state = { servers: [], view: "dashboard", filter: "all", groupFilter: "all", query: "", assetType: "all", assetState: "all", assetResults: [], assetTotalMatches: 0, assetSearching: false, selectedId: null, pollIntervalMs: 10000, assetRefreshing: false, assetDetailLoading: false, assetSearchTimer: null, timer: null }; const els = { grid: document.querySelector("#serverGrid"), empty: document.querySelector("#emptyState"), detail: document.querySelector("#detailPanel"), pageTitle: document.querySelector("#pageTitle"), groupFilters: document.querySelector("#groupFilters"), groupOptions: document.querySelector("#groupOptions"), lastRefresh: document.querySelector("#lastRefresh"), search: document.querySelector("#searchInput"), searchScope: document.querySelector("#searchScope"), stats: document.querySelector(".stats"), assetSearchPanel: document.querySelector("#assetSearchPanel"), assetSearchSummary: document.querySelector("#assetSearchSummary"), assetResultList: document.querySelector("#assetResultList"), toast: document.querySelector("#toast"), dialog: document.querySelector("#serverDialog"), form: document.querySelector("#serverForm"), dialogTitle: document.querySelector("#dialogTitle"), deleteBtn: document.querySelector("#deleteServerBtn"), fields: { id: document.querySelector("#serverId"), name: document.querySelector("#serverName"), host: document.querySelector("#serverHost"), user: document.querySelector("#serverUser"), port: document.querySelector("#serverPort"), command: document.querySelector("#serverCommand"), group: document.querySelector("#serverGroup"), tags: document.querySelector("#serverTags") } }; document.querySelector("#addServerBtn").addEventListener("click", () => openDialog()); document.querySelector("#emptyAddBtn").addEventListener("click", () => openDialog()); document.querySelector("#closeDialogBtn").addEventListener("click", () => els.dialog.close()); document.querySelector("#refreshBtn").addEventListener("click", manualRefresh); document.querySelector("#assetRefreshBtn").addEventListener("click", refreshAssets); document.querySelector("#dashboardViewBtn").addEventListener("click", () => setView("dashboard")); document.querySelector("#assetViewBtn").addEventListener("click", () => setView("assets")); document.querySelectorAll(".asset-filter").forEach((button) => { button.addEventListener("click", () => { state.assetType = button.dataset.assetType; document.querySelectorAll(".asset-filter").forEach((item) => item.classList.toggle("active", item === button)); searchAssets(); }); }); document.querySelectorAll(".asset-state").forEach((button) => { button.addEventListener("click", () => { state.assetState = button.dataset.assetState; document.querySelectorAll(".asset-state").forEach((item) => item.classList.toggle("active", item === button)); searchAssets(); }); }); document.querySelectorAll(".filter").forEach((button) => { button.addEventListener("click", () => { state.filter = button.dataset.filter; document.querySelectorAll(".filter").forEach((item) => item.classList.toggle("active", item === button)); render(); }); }); els.search.addEventListener("input", () => { state.query = els.search.value.trim().toLowerCase(); if (state.view === "assets") { queueAssetSearch(); } else { render(); } }); els.form.addEventListener("submit", saveServer); els.deleteBtn.addEventListener("click", deleteSelectedServer); loadServers(); async function loadServers() { try { const payload = await requestJson("/api/servers"); state.servers = payload.servers || []; state.pollIntervalMs = payload.pollIntervalMs || state.pollIntervalMs; state.assetRefreshing = Boolean(payload.assetRefreshing); els.lastRefresh.textContent = payload.lastRefresh ? `更新 ${formatTime(payload.lastRefresh)}` : "等待刷新"; if (!state.selectedId && state.servers[0]) state.selectedId = state.servers[0].id; if (state.selectedId && !state.servers.some((server) => server.id === state.selectedId)) { state.selectedId = state.servers[0]?.id || null; } render(); loadSelectedAssets(); scheduleNextLoad(); } catch (error) { showToast(error.message); scheduleNextLoad(); } } function setView(view) { state.view = view; document.querySelector("#dashboardViewBtn").classList.toggle("active", view === "dashboard"); document.querySelector("#assetViewBtn").classList.toggle("active", view === "assets"); els.search.placeholder = view === "assets" ? "搜索模型、路径、镜像 tag" : "搜索服务器、IP、模型、镜像"; els.searchScope.textContent = view === "assets" ? "模型搜索" : "资源搜索"; if (view === "assets") searchAssets(); render(); } async function loadSelectedAssets() { if (!state.selectedId || state.assetDetailLoading) return; state.assetDetailLoading = true; try { const payload = await requestJson(`/api/servers/${encodeURIComponent(state.selectedId)}/assets`); const server = state.servers.find((item) => item.id === state.selectedId); if (server && payload.assets) { server.assets = payload.assets; renderDetail(); } } catch (error) { console.warn(error); } finally { state.assetDetailLoading = false; } } function queueAssetSearch() { window.clearTimeout(state.assetSearchTimer); state.assetSearchTimer = window.setTimeout(searchAssets, 220); render(); } async function searchAssets() { if (state.view !== "assets") return; const query = state.query.trim(); if (!query) { state.assetResults = []; state.assetTotalMatches = 0; state.assetSearching = false; renderAssetSearch(); return; } state.assetSearching = true; renderAssetSearch(); try { const params = new URLSearchParams({ q: query, type: state.assetType, state: state.assetState, group: state.groupFilter }); const payload = await requestJson(`/api/assets/search?${params.toString()}`); state.assetResults = payload.results || []; state.assetTotalMatches = payload.totalMatches || 0; } catch (error) { showToast(error.message); } finally { state.assetSearching = false; renderAssetSearch(); } } function scheduleNextLoad() { window.clearTimeout(state.timer); state.timer = window.setTimeout(loadServers, state.pollIntervalMs); } async function manualRefresh() { const button = document.querySelector("#refreshBtn"); button.disabled = true; try { await requestJson("/api/refresh", { method: "POST" }); await loadServers(); showToast("刷新完成"); } catch (error) { showToast(error.message); } finally { button.disabled = false; } } async function refreshAssets() { const button = document.querySelector("#assetRefreshBtn"); button.disabled = true; try { await requestJson("/api/assets/refresh", { method: "POST" }); await loadServers(); if (state.view === "assets") await searchAssets(); showToast("模型资产刷新完成"); } catch (error) { showToast(error.message); } finally { button.disabled = false; } } function render() { renderViewShell(); renderStats(); renderGroups(); renderGrid(); renderAssetSearch(); renderDetail(); } function renderViewShell() { const showingAssets = state.view === "assets"; els.pageTitle.textContent = showingAssets ? "模型镜像检索" : "服务器占用情况"; els.searchScope.textContent = showingAssets ? "模型搜索" : "资源搜索"; els.stats.classList.toggle("hidden", showingAssets); els.groupFilters.classList.toggle("hidden", false); els.grid.classList.toggle("hidden", showingAssets || state.servers.length === 0); els.empty.classList.toggle("hidden", showingAssets || state.servers.length !== 0); els.assetSearchPanel.classList.toggle("hidden", !showingAssets); } function renderGroups() { const groups = groupSummaries(); if (!groups.some((group) => group.name === state.groupFilter)) { state.groupFilter = "all"; } if (els.groupFilters) { els.groupFilters.innerHTML = [ groupButtonHtml("all", "全部分组", state.servers.length), ...groups.map((group) => groupButtonHtml(group.name, group.name, group.count)) ].join(""); els.groupFilters.querySelectorAll(".group-filter").forEach((button) => { button.addEventListener("click", () => { state.groupFilter = button.dataset.group; if (state.view === "assets") searchAssets(); render(); }); }); } if (els.groupOptions) { const defaults = ["通信中兴组", "政府联想组", "企业浪潮组", "金融华三组", "深度组", "未分组"]; const names = [...new Set([...defaults, ...groups.map((group) => group.name)])]; els.groupOptions.innerHTML = names.map((name) => ``).join(""); } } function groupSummaries() { const byGroup = new Map(); for (const server of state.servers) { const group = serverGroup(server); byGroup.set(group, (byGroup.get(group) || 0) + 1); } return [...byGroup.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => a.name.localeCompare(b.name, "zh-CN")); } function groupButtonHtml(value, label, count) { const active = state.groupFilter === value ? " active" : ""; return ` `; } function renderStats() { const totals = state.servers.reduce( (acc, server) => { const status = server.status || {}; acc.cards += status.totalCount || server.gpuCount || 0; acc.busyCards += status.busyCount || 0; acc.freeCards += status.freeCount || 0; if (getServerKind(server) === "free") acc.freeServers += 1; if (getServerKind(server) === "busy") acc.busyServers += 1; if (getServerKind(server) === "offline") acc.offlineServers += 1; return acc; }, { cards: 0, busyCards: 0, freeCards: 0, freeServers: 0, busyServers: 0, offlineServers: 0 } ); setText("#statServers", state.servers.length); setText("#statCards", totals.cards); setText("#statFreeCards", totals.freeCards); setText("#statBusyCards", totals.busyCards); setText("#countAll", state.servers.length); setText("#countFree", totals.freeServers); setText("#countBusy", totals.busyServers); setText("#countOffline", totals.offlineServers); const assetButton = document.querySelector("#assetRefreshBtn"); if (assetButton) assetButton.disabled = state.assetRefreshing; } function renderGrid() { const servers = filteredServers(); els.grid.innerHTML = ""; els.empty.classList.toggle("hidden", state.view === "assets" || state.servers.length !== 0); els.grid.classList.toggle("hidden", state.view === "assets" || state.servers.length === 0); for (const server of servers) { const status = server.status || {}; const busyPercent = status.totalCount ? Math.round(((status.busyCount || 0) / status.totalCount) * 100) : 0; const card = document.createElement("article"); card.className = `server-card ${serverOccupancyClass(server)} ${server.id === state.selectedId ? "selected" : ""}`; card.tabIndex = 0; card.innerHTML = serverCardHtml(server); card.addEventListener("click", () => { state.selectedId = server.id; render(); loadSelectedAssets(); }); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); state.selectedId = server.id; render(); loadSelectedAssets(); } }); card.querySelector(".edit-card").addEventListener("click", (event) => { event.stopPropagation(); openDialog(server); }); card.style.setProperty("--busy", `${busyPercent}%`); els.grid.appendChild(card); } } function renderAssetSearch() { if (!els.assetSearchPanel || state.view !== "assets") return; const query = state.query.trim(); if (!query) { els.assetSearchSummary.textContent = "输入模型名、路径或镜像名称开始查找。"; els.assetResultList.innerHTML = `
可以搜索 Qwen、DeepSeek、vllm、镜像 tag 或完整路径 结果会按服务器聚合,并显示这台机器当前卡占用情况。
`; return; } if (state.assetSearching) { els.assetSearchSummary.textContent = `正在查找“${query}”...`; return; } els.assetSearchSummary.textContent = state.assetResults.length ? `找到 ${state.assetTotalMatches} 条匹配,分布在 ${state.assetResults.length} 台服务器。` : `没有找到“${query}”相关的模型或镜像。`; els.assetResultList.innerHTML = state.assetResults.length ? state.assetResults.map(assetResultGroupHtml).join("") : `
没有匹配结果可以先点“刷新模型资产”,或换一个模型名 / 镜像 tag。
`; els.assetResultList.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", (event) => { event.stopPropagation(); copyText(button.dataset.copy, button.dataset.copyLabel || "内容"); }); }); } function assetResultGroupHtml(group) { const server = group.server || {}; const stateClass = `status-${server.state || "pending"}`; const sshCommand = `ssh ${server.user || "root"}@${server.host}`; return `
${escapeHtml(server.name || server.host)}
${escapeHtml(server.group || "未分组")} ${escapeHtml(server.host)}:${escapeHtml(server.port || 22)} ${escapeHtml(server.summary || "-")}
${kindLabel(server.state)}
${(group.matches || []).map(assetMatchHtml).join("")}
`; } function assetMatchHtml(match) { const label = match.type === "docker" ? "镜像" : "模型"; return `
${label}
${highlightMatch(match.label || "-")} ${highlightMatch(match.value || "-")} ${escapeHtml(match.meta || "")}
`; } function serverCardHtml(server) { const status = server.status || {}; const assets = server.assets || {}; const kind = getServerKind(server); const serverLevel = serverOccupancyClass(server); const totalCount = status.totalCount || server.gpuCount || 0; const tags = [...new Set([serverGroup(server), ...(server.tags?.length ? server.tags : [totalCount ? `${totalCount}卡` : "自动识别"])])]; return `
${escapeHtml(server.name)}
${escapeHtml(server.user ? `${server.user}@${server.host}` : server.host)}:${server.port}
${escapeHtml(modelSummary(server))}
${kindLabel(kind)}
${totalCount ? `${status.busyCount || 0}/${totalCount}` : "识别中"}
${escapeHtml(status.summary || "等待刷新")} ${status.updatedAt ? formatTime(status.updatedAt) : "未采集"}
${gpuChips(status.gpus || [], totalCount, kind)}
模型 ${assets.modelCount || 0} 镜像 ${assets.dockerCount || 0} ${assetUpdatedText(assets)}
${tags.map((tag) => `${escapeHtml(tag)}`).join("")}
`; } function gpuChips(gpus, count, serverKind) { const list = gpus.length ? gpus : Array.from({ length: count }, (_, index) => ({ index, state: "unknown" })); return list .slice(0, count) .map((gpu) => { const cls = serverKind === "offline" ? "offline" : gpu.state || "unknown"; const chipLevel = gpuOccupancyClass(gpu); const compute = formatPercent(gpu.utilization); const vram = formatPercent(gpu.memoryUtilization); const computeLevel = normalizePercent(gpu.utilization); const vramLevel = normalizePercent(gpu.memoryUtilization); const computeClass = occupancyClass(gpu.utilization); const vramClass = occupancyClass(gpu.memoryUtilization); return ` #${escapeHtml(gpu.index)} 显存 ${escapeHtml(vram)} 算力 ${escapeHtml(compute)} `; }) .join(""); } function renderDetail() { const server = state.servers.find((item) => item.id === state.selectedId); if (!server) { els.detail.innerHTML = `

选择一台服务器

查看每张 GPU/DCU 卡的占用、显存、温度和连接状态。

`; return; } const status = server.status || {}; const assets = server.assets || {}; const kind = getServerKind(server); const totalCount = status.totalCount || server.gpuCount || 0; els.detail.innerHTML = `

${totalCount ? `${escapeHtml(totalCount)}卡服务器` : "自动识别卡数"} · ${commandLabel(server.command)}

${escapeHtml(server.name)}

状态${kindLabel(kind)}
占用${totalCount ? `${status.busyCount || 0}/${totalCount}` : "识别中"}
分组${escapeHtml(serverGroup(server))}
地址${escapeHtml(server.host)}:${server.port}
型号${escapeHtml(modelSummary(server))}
延迟${status.latencyMs ? `${status.latencyMs}ms` : "-"}
${status.error ? `
错误${escapeHtml(status.error)}
` : ""}
${(status.gpus || []).map(gpuRowHtml).join("")}
${assetPanelHtml(assets)} `; document.querySelector("#detailEditBtn").addEventListener("click", () => openDialog(server)); } function assetPanelHtml(assets) { const modelItems = assets.modelItems || []; const dockerImages = assets.dockerImages || []; const modelList = modelItems.length ? modelItems.slice(0, 80).map(modelItemHtml).join("") : assets.modelCount ? `
正在加载模型详情
` : `
未发现模型目录或文件
`; const dockerList = dockerImages.length ? dockerImages.slice(0, 80).map(dockerImageHtml).join("") : assets.dockerCount ? `
正在加载镜像详情
` : `
未发现 Docker 镜像
`; return `

模型资产

模型路径与镜像

${assetUpdatedText(assets)}
${assets.error ? `
${escapeHtml(assets.error)}
` : ""}
挂载目录模型${modelItems.length}
${modelList}
Docker Images${dockerImages.length}
${dockerList}
`; } function modelItemHtml(item) { return `
${escapeHtml(item.name || "-")} ${escapeHtml(item.path || "-")} ${escapeHtml(item.root || "-")}${item.type === "file" ? " · 文件" : " · 目录"}
`; } function dockerImageHtml(image) { return `
${escapeHtml(image.repository || "-")}:${escapeHtml(image.tag || "-")} ${escapeHtml(image.imageId || "-")} · ${escapeHtml(image.size || "-")} ${escapeHtml(image.created || "-")}
`; } function gpuRowHtml(gpu) { const utilization = normalizePercent(gpu.utilization); const memoryUtilization = normalizePercent(gpu.memoryUtilization); const utilizationClass = occupancyClass(gpu.utilization); const memoryUtilizationClass = occupancyClass(gpu.memoryUtilization); const memory = gpu.memoryTotalMiB ? `${gpu.memoryUsedMiB || 0}/${gpu.memoryTotalMiB} MiB` : gpu.memoryUtilization !== null && gpu.memoryUtilization !== undefined ? `${gpu.memoryUtilization}%` : "-"; return `
卡 #${gpu.index}${gpu.model ? ` · ${escapeHtml(gpu.model)}` : ""} ${gpuStateLabel(gpu.state)}
显存
${formatPercent(gpu.memoryUtilization)}
算力
${formatPercent(gpu.utilization)}
显存 ${escapeHtml(memory)} 温度 ${gpu.temperatureC ?? "-"}℃ 功耗 ${gpu.powerW ?? "-"}W
`; } function normalizePercent(value) { const number = Number(value); if (!Number.isFinite(number)) return 0; return Math.max(0, Math.min(100, number)); } function formatPercent(value) { if (value === null || value === undefined || value === "") return "-"; const number = Number(value); if (!Number.isFinite(number)) return "-"; return `${Number.isInteger(number) ? number : number.toFixed(1)}%`; } function occupancyClass(value) { const percent = normalizePercent(value); if (percent >= 80) return "level-critical"; if (percent >= 40) return "level-warning"; if (percent >= 10) return "level-low"; return "level-free"; } function gpuOccupancyClass(gpu) { return occupancyClass(Math.max(normalizePercent(gpu.memoryUtilization), normalizePercent(gpu.utilization))); } function serverOccupancyClass(server) { const gpus = server.status?.gpus || []; if (!gpus.length) return "level-free"; const max = gpus.reduce((value, gpu) => Math.max(value, normalizePercent(gpu.memoryUtilization), normalizePercent(gpu.utilization)), 0); return occupancyClass(max); } function filteredServers() { return state.servers.filter((server) => { const kind = getServerKind(server); const matchesFilter = state.filter === "all" || state.filter === kind; const matchesGroup = state.groupFilter === "all" || serverGroup(server) === state.groupFilter; return matchesFilter && matchesGroup && matchesDashboardQuery(server, state.query); }); } function matchesDashboardQuery(server, query) { const terms = String(query || "").trim().toLowerCase().split(/\s+/).filter(Boolean); if (!terms.length) return true; const fields = [ server.name, server.host, server.user, serverGroup(server), modelSummary(server), ...(server.tags || []) ].map((value) => String(value || "").toLowerCase()); const text = fields.join(" "); const tokens = fields.flatMap((field) => field.match(/[a-z0-9]+/g) || []); return terms.every((term) => { if (term.includes(".") || /[\u4e00-\u9fff]/.test(term)) { return text.includes(term); } if (/^[a-z0-9]+$/.test(term)) { return tokens.some((token) => token === term || (term.length <= 4 && token.startsWith(term))); } return text.includes(term); }); } function assetSearchText(server) { const assets = server.assets || {}; if (assets.searchText) return assets.searchText; const modelText = (assets.modelItems || []).flatMap((item) => [item.name, item.path, item.root]); const dockerText = (assets.dockerImages || []).flatMap((image) => [image.repository, image.tag, image.imageId]); return [...modelText, ...dockerText].filter(Boolean).join(" "); } function assetUpdatedText(assets) { if (!assets || !assets.updatedAt) return "未盘点"; if (assets.state === "failed") return "盘点失败"; return `盘点 ${formatTime(assets.updatedAt)}`; } function serverGroup(server) { return String(server.group || "未分组").trim() || "未分组"; } function getServerKind(server) { const status = server.status || {}; if (status.state === "offline") return "offline"; if (status.state === "pending") return "pending"; return (status.busyCount || 0) > 0 ? "busy" : "free"; } function kindLabel(kind) { return { free: "空闲", busy: "占用", offline: "离线", pending: "刷新中" }[kind] || "未知"; } function gpuStateLabel(kind) { return { free: "空闲", busy: "占用", offline: "离线", unknown: "未知" }[kind] || "未知"; } function commandLabel(command) { return command === "nvidia-smi" ? "NVIDIA GPU" : "海光 DCU"; } function modelSummary(server) { const models = server.status?.models?.length ? server.status.models : (server.status?.gpus || []).map((gpu) => gpu.model).filter(Boolean); const unique = [...new Set(models)]; if (!unique.length) return "型号识别中"; return unique.length === 1 ? unique[0] : unique.join(" / "); } function openDialog(server) { const editing = Boolean(server); els.dialogTitle.textContent = editing ? "编辑服务器" : "添加服务器"; els.deleteBtn.classList.toggle("hidden", !editing); els.fields.id.value = server?.id || ""; els.fields.name.value = server?.name || ""; els.fields.host.value = server?.host || ""; els.fields.user.value = server?.user || "root"; els.fields.port.value = server?.port || 22; els.fields.command.value = server?.command || "hy-smi"; els.fields.group.value = server?.group || ""; els.fields.tags.value = (server?.tags || []).join(", "); els.dialog.showModal(); els.fields.name.focus(); } async function saveServer(event) { event.preventDefault(); const id = els.fields.id.value; const body = { name: els.fields.name.value, host: els.fields.host.value, user: els.fields.user.value, port: Number(els.fields.port.value || 22), command: els.fields.command.value || "hy-smi", group: els.fields.group.value, tags: els.fields.tags.value }; try { const payload = await requestJson(id ? `/api/servers/${encodeURIComponent(id)}` : "/api/servers", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); state.selectedId = payload.server.id; els.dialog.close(); await loadServers(); showToast("已保存"); } catch (error) { showToast(error.message); } } async function deleteSelectedServer() { const id = els.fields.id.value; if (!id) return; try { await requestJson(`/api/servers/${encodeURIComponent(id)}`, { method: "DELETE" }); state.selectedId = null; els.dialog.close(); await loadServers(); showToast("已删除"); } catch (error) { showToast(error.message); } } async function requestJson(url, options) { const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || `请求失败 ${response.status}`); } return payload; } function showToast(message) { els.toast.textContent = message; els.toast.classList.remove("hidden"); window.clearTimeout(els.toastTimer); els.toastTimer = window.setTimeout(() => els.toast.classList.add("hidden"), 2600); } function setText(selector, value) { document.querySelector(selector).textContent = value; } function formatTime(value) { return new Date(value).toLocaleTimeString("zh-CN", { hour12: false }); } async function copyText(text, label) { if (!text) return; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); } else { fallbackCopyText(text); } showToast(`${label}已复制`); } catch (error) { try { fallbackCopyText(text); showToast(`${label}已复制`); } catch { showToast("复制失败"); } } } function fallbackCopyText(text) { const input = document.createElement("textarea"); input.value = text; input.setAttribute("readonly", ""); input.style.position = "fixed"; input.style.left = "-9999px"; document.body.appendChild(input); input.select(); document.execCommand("copy"); document.body.removeChild(input); } function highlightMatch(value) { const text = escapeHtml(value); const query = state.query.trim(); if (!query) return text; const firstTerm = query.split(/\s+/).filter(Boolean)[0]; if (!firstTerm) return text; const escapedTerm = firstTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return text.replace(new RegExp(`(${escapedTerm})`, "ig"), "$1"); } function escapeAttr(value) { return escapeHtml(value); } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }