Commit ac96b3b9 authored by zk's avatar zk
Browse files

Add asset search workspace

parent 187bf098
......@@ -2,6 +2,10 @@
## 2026-05-28
- 新增“模型镜像检索”视图,支持按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
- 检索结果按服务器聚合展示,并显示机器分组、IP、当前占用状态,支持复制 IP、SSH 命令、模型路径和镜像名称。
- 优化资源看板和模型检索的顶部工具栏,让搜索框明确跟随当前视图显示资源搜索或模型搜索。
- 收紧模型目录识别规则,只展示目录名或目录内容具备模型特征的路径,减少项目目录、脚本目录等非模型内容混入。
- 新增服务器配置定期备份,默认保存到 `data/backups/` 并保留最近 30 份。
- 新增模型资产盘点,支持展示每台服务器 `/models``/public``/data` 等目录下的模型文件/目录和 Docker images。
- 新增“刷新模型资产”入口,并支持按模型路径、模型名和镜像名称进行搜索。
......
......@@ -25,6 +25,8 @@
- 根据显存占用和算力占用综合判断每张卡是否可用。
- 主界面用水位色块展示每张卡的显存和算力占用。
- 盘点每台服务器 `/models``/public``/data` 等目录下的模型文件/目录,并展示 Docker images。
- 提供“模型镜像检索”视图,按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
- 检索结果显示服务器分组、IP、当前占用状态,并支持复制 IP、SSH 命令、模型路径和镜像名称。
- 右上角支持按服务器名称、IP、分组、标签、型号、模型路径和镜像名称进行模糊搜索。
- 型号只在新增服务器和手动刷新时重新识别,日常自动刷新只采集占用数据。
- 定期备份服务器配置文件,便于误操作后恢复。
......
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
};
......@@ -14,10 +21,16 @@ 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"),
......@@ -40,6 +53,22 @@ document.querySelector("#emptyAddBtn").addEventListener("click", () => openDialo
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;
......@@ -49,7 +78,11 @@ document.querySelectorAll(".filter").forEach((button) => {
});
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);
......@@ -76,6 +109,16 @@ async function loadServers() {
}
}
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;
......@@ -93,6 +136,42 @@ async function loadSelectedAssets() {
}
}
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);
......@@ -118,6 +197,7 @@ async function refreshAssets() {
try {
await requestJson("/api/assets/refresh", { method: "POST" });
await loadServers();
if (state.view === "assets") await searchAssets();
showToast("模型资产刷新完成");
} catch (error) {
showToast(error.message);
......@@ -127,12 +207,25 @@ async function refreshAssets() {
}
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)) {
......@@ -147,6 +240,7 @@ function renderGroups() {
els.groupFilters.querySelectorAll(".group-filter").forEach((button) => {
button.addEventListener("click", () => {
state.groupFilter = button.dataset.group;
if (state.view === "assets") searchAssets();
render();
});
});
......@@ -208,8 +302,8 @@ function renderStats() {
function renderGrid() {
const servers = filteredServers();
els.grid.innerHTML = "";
els.empty.classList.toggle("hidden", state.servers.length !== 0);
els.grid.classList.toggle("hidden", state.servers.length === 0);
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 || {};
......@@ -240,6 +334,81 @@ function renderGrid() {
}
}
function renderAssetSearch() {
if (!els.assetSearchPanel || state.view !== "assets") return;
const query = state.query.trim();
if (!query) {
els.assetSearchSummary.textContent = "输入模型名、路径或镜像名称开始查找。";
els.assetResultList.innerHTML = `
<div class="asset-search-empty">
<strong>可以搜索 Qwen、DeepSeek、vllm、镜像 tag 或完整路径</strong>
<span>结果会按服务器聚合,并显示这台机器当前卡占用情况。</span>
</div>`;
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("")
: `<div class="asset-search-empty"><strong>没有匹配结果</strong><span>可以先点“刷新模型资产”,或换一个模型名 / 镜像 tag。</span></div>`;
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 `
<article class="asset-result-card">
<div class="asset-result-head">
<div>
<div class="asset-server-title">${escapeHtml(server.name || server.host)}</div>
<div class="asset-server-meta">
<span>${escapeHtml(server.group || "未分组")}</span>
<span>${escapeHtml(server.host)}:${escapeHtml(server.port || 22)}</span>
<span>${escapeHtml(server.summary || "-")}</span>
</div>
</div>
<span class="status-pill ${stateClass}">${kindLabel(server.state)}</span>
</div>
<div class="asset-copy-row">
<button class="mini-copy" data-copy="${escapeAttr(server.host || "")}" data-copy-label="IP" type="button">复制 IP</button>
<button class="mini-copy" data-copy="${escapeAttr(sshCommand)}" data-copy-label="SSH 命令" type="button">复制 SSH</button>
</div>
<div class="asset-match-list">
${(group.matches || []).map(assetMatchHtml).join("")}
</div>
</article>`;
}
function assetMatchHtml(match) {
const label = match.type === "docker" ? "镜像" : "模型";
return `
<div class="asset-match ${match.type}">
<span class="asset-kind">${label}</span>
<div class="asset-match-main">
<strong>${highlightMatch(match.label || "-")}</strong>
<span>${highlightMatch(match.value || "-")}</span>
<em>${escapeHtml(match.meta || "")}</em>
</div>
<button class="mini-copy" data-copy="${escapeAttr(match.copyText || match.value || "")}" data-copy-label="${label}" type="button">复制</button>
</div>`;
}
function serverCardHtml(server) {
const status = server.status || {};
const assets = server.assets || {};
......@@ -480,18 +649,32 @@ function filteredServers() {
const kind = getServerKind(server);
const matchesFilter = state.filter === "all" || state.filter === kind;
const matchesGroup = state.groupFilter === "all" || serverGroup(server) === state.groupFilter;
const text = [
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 || []),
assetSearchText(server)
]
.join(" ")
.toLowerCase();
return matchesFilter && matchesGroup && (!state.query || text.includes(state.query));
...(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);
});
}
......@@ -623,6 +806,51 @@ 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"), "<mark>$1</mark>");
}
function escapeAttr(value) {
return escapeHtml(value);
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
......
......@@ -49,12 +49,22 @@
<section class="topline">
<div>
<p class="eyebrow">共享测试资源</p>
<h2>服务器占用情况</h2>
<h2 id="pageTitle">服务器占用情况</h2>
</div>
</section>
<section class="workbar" aria-label="工作区">
<div class="view-tabs" role="tablist" aria-label="视图切换">
<button class="view-tab active" id="dashboardViewBtn" type="button">资源看板</button>
<button class="view-tab" id="assetViewBtn" type="button">模型镜像检索</button>
</div>
<div class="search-cluster">
<span id="searchScope">资源搜索</span>
<div class="search-wrap">
<span aria-hidden="true"></span>
<input id="searchInput" type="search" placeholder="搜索服务器、IP、模型、镜像" />
</div>
</div>
</section>
<section class="stats" aria-label="资源统计">
......@@ -86,6 +96,23 @@
<p>配置 SSH 登录信息后,看板会定时采集显存和算力占用。</p>
<button class="primary-action compact" id="emptyAddBtn" type="button">添加服务器</button>
</section>
<section class="asset-search hidden" id="assetSearchPanel" aria-live="polite">
<div class="asset-toolbar">
<div class="asset-filter-group" role="tablist" aria-label="资产类型">
<button class="asset-filter active" data-asset-type="all" type="button">全部</button>
<button class="asset-filter" data-asset-type="model" type="button">模型</button>
<button class="asset-filter" data-asset-type="docker" type="button">镜像</button>
</div>
<div class="asset-filter-group" role="tablist" aria-label="服务器状态">
<button class="asset-state active" data-asset-state="all" type="button">全部机器</button>
<button class="asset-state" data-asset-state="free" type="button">空闲机器</button>
<button class="asset-state" data-asset-state="busy" type="button">占用机器</button>
</div>
</div>
<div class="asset-search-summary" id="assetSearchSummary">输入模型名、路径或镜像名称开始查找。</div>
<div class="asset-result-list" id="assetResultList"></div>
</section>
</main>
<aside class="detail" id="detailPanel">
......
......@@ -199,6 +199,64 @@ h2 {
flex: 0 0 auto;
}
.workbar {
display: grid;
grid-template-columns: auto minmax(280px, 420px);
gap: 12px;
align-items: center;
margin: 14px 0 0;
flex: 0 0 auto;
}
.view-tabs {
display: inline-flex;
gap: 6px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 3px;
}
.view-tab {
min-height: 30px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
padding: 0 11px;
font-size: 13px;
font-weight: 800;
}
.view-tab.active,
.view-tab:hover {
background: #e9f7f5;
color: var(--teal-dark);
}
.search-cluster {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
min-width: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.search-cluster > span {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border-right: 1px solid var(--line);
color: var(--teal-dark);
padding: 0 12px;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.eyebrow {
color: var(--teal-dark);
font-size: 12px;
......@@ -209,11 +267,11 @@ h2 {
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 100%);
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
color: var(--muted);
}
......@@ -303,9 +361,10 @@ h2 {
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
grid-auto-rows: minmax(368px, auto);
grid-auto-rows: minmax(392px, max-content);
gap: 14px;
align-items: stretch;
align-content: start;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
......@@ -316,7 +375,7 @@ h2 {
position: relative;
display: flex;
flex-direction: column;
min-height: 368px;
min-height: 392px;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
......@@ -570,7 +629,8 @@ h2 {
align-items: center;
min-width: 0;
max-width: 100%;
margin-top: 12px;
margin-top: auto;
padding-top: 12px;
}
.asset-summary {
......@@ -608,6 +668,7 @@ h2 {
}
.tag {
flex: 0 1 auto;
max-width: 100%;
border-radius: 999px;
background: #eef6f6;
......@@ -620,7 +681,7 @@ h2 {
.edit-card {
flex: 0 0 auto;
margin-left: auto;
margin-left: 0;
}
.detail {
......@@ -805,6 +866,183 @@ h2 {
color: #9f2f2f;
}
.asset-search {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
gap: 12px;
}
.asset-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.asset-filter-group {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.asset-filter,
.asset-state,
.mini-copy {
min-height: 30px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
padding: 0 10px;
font-size: 12px;
font-weight: 800;
}
.asset-filter.active,
.asset-filter:hover,
.asset-state.active,
.asset-state:hover,
.mini-copy:hover {
border-color: rgba(15, 159, 154, 0.48);
background: #e9f7f5;
color: var(--teal-dark);
}
.asset-search-summary {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.asset-result-list {
display: grid;
gap: 12px;
min-height: 0;
overflow-y: auto;
padding: 2px 8px 18px 2px;
}
.asset-result-card {
display: grid;
gap: 10px;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
padding: 12px;
}
.asset-result-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.asset-server-title {
font-size: 16px;
font-weight: 900;
}
.asset-server-meta,
.asset-copy-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.asset-server-meta span {
border-radius: 999px;
background: #eef6f6;
color: var(--teal-dark);
padding: 3px 7px;
font-size: 12px;
font-weight: 800;
}
.asset-match-list {
display: grid;
gap: 8px;
}
.asset-match {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 9px;
}
.asset-kind {
border-radius: 999px;
background: #e9f7f5;
color: var(--teal-dark);
padding: 4px 7px;
font-size: 12px;
font-weight: 900;
text-align: center;
}
.asset-match.docker .asset-kind {
background: #edf2ff;
color: #2c75d6;
}
.asset-match-main {
display: grid;
gap: 3px;
min-width: 0;
}
.asset-match-main strong,
.asset-match-main span,
.asset-match-main em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-match-main span,
.asset-match-main em,
.asset-search-empty span {
color: var(--muted);
font-size: 12px;
}
.asset-match-main em {
font-style: normal;
}
mark {
border-radius: 4px;
background: #ffec99;
color: inherit;
padding: 0 2px;
}
.asset-search-empty {
display: grid;
place-items: center;
align-content: center;
min-height: 260px;
border: 1px dashed #b8c9d1;
border-radius: 14px;
background: rgba(255, 255, 255, 0.55);
text-align: center;
padding: 20px;
}
.gpu-row {
background: #fff;
}
......@@ -1011,11 +1249,24 @@ select:focus {
}
.topline,
.workbar,
.field-row {
align-items: stretch;
flex-direction: column;
}
.workbar {
grid-template-columns: 1fr;
}
.view-tabs {
width: 100%;
}
.view-tab {
flex: 1 1 0;
}
h2 {
font-size: 24px;
}
......
......@@ -12,6 +12,7 @@ const ASSET_REFRESH_INTERVAL_MS = Number(process.env.ASSET_REFRESH_INTERVAL_MS |
const ASSET_SSH_TIMEOUT_MS = Number(process.env.ASSET_SSH_TIMEOUT_MS || 30000);
const ASSET_CONCURRENCY = clampInt(process.env.ASSET_CONCURRENCY, 1, 16, 3);
const ASSET_MAX_ITEMS = clampInt(process.env.ASSET_MAX_ITEMS, 20, 1000, 160);
const ASSET_SEARCH_MAX_RESULTS = clampInt(process.env.ASSET_SEARCH_MAX_RESULTS, 20, 2000, 300);
const ASSET_PATHS = parseCsv(process.env.ASSET_PATHS || "/models,/public,/data");
const BACKUP_INTERVAL_MS = Number(process.env.BACKUP_INTERVAL_MS || 24 * 60 * 60 * 1000);
const BACKUP_RETENTION = clampInt(process.env.BACKUP_RETENTION, 1, 365, 30);
......@@ -519,12 +520,31 @@ function buildAssetCommand() {
const paths = ASSET_PATHS.length ? ASSET_PATHS : ["/models", "/public", "/data"];
const pathList = paths.map(shellQuote).join(" ");
const perPathLimit = Math.max(20, Math.ceil(ASSET_MAX_ITEMS / paths.length));
const modelFilePattern = [
"-iname '*.gguf'",
"-o -iname '*.safetensors'",
"-o -iname 'pytorch_model*.bin'",
"-o -iname 'model*.bin'",
"-o -iname '*.onnx'",
"-o -iname '*.pt'",
"-o -iname '*.pth'",
"-o -iname '*.ckpt'",
"-o -iname 'config.json'",
"-o -iname 'tokenizer.json'",
"-o -iname 'tokenizer.model'",
"-o -iname 'generation_config.json'"
].join(" ");
const modelNamePattern = "'*qwen*' -o -iname '*deepseek*' -o -iname '*llama*' -o -iname '*chatglm*' -o -iname '*glm*' -o -iname '*baichuan*' -o -iname '*internlm*' -o -iname '*mistral*' -o -iname '*mixtral*' -o -iname '*bert*' -o -iname '*clip*' -o -iname '*whisper*' -o -iname '*stable-diffusion*' -o -iname '*sdxl*'";
const modelCommand = [
`for p in ${pathList}; do`,
`if [ -d "$p" ]; then`,
`{`,
`find "$p" -mindepth 1 -maxdepth 2 -type d ! -name '.*' ! -name '__pycache__' -printf 'MODEL\\t%p\\td\\t%TY-%Tm-%Td %TH:%TM\\n' 2>/dev/null;`,
`find "$p" -mindepth 1 -maxdepth 1 -type f \\( -iname '*.gguf' -o -iname '*.safetensors' -o -iname '*.bin' -o -iname '*.onnx' -o -iname '*.pt' -o -iname '*.pth' -o -iname '*.ckpt' \\) -printf 'MODEL\\t%p\\tf\\t%TY-%Tm-%Td %TH:%TM\\n' 2>/dev/null;`,
`find "$p" -mindepth 1 -maxdepth 2 -type d ! -name '.*' ! -name '__pycache__' | while IFS= read -r d; do`,
`if find "$d" -maxdepth 1 -type f \\( ${modelFilePattern} \\) -print -quit 2>/dev/null | grep -q . || find "$d" -maxdepth 0 \\( -iname ${modelNamePattern} \\) -print -quit 2>/dev/null | grep -q .; then`,
`mt=$(date -r "$d" '+%Y-%m-%d %H:%M' 2>/dev/null || echo ''); printf 'MODEL\\t%s\\td\\t%s\\n' "$d" "$mt";`,
`fi;`,
`done;`,
`find "$p" -mindepth 1 -maxdepth 1 -type f \\( ${modelFilePattern} \\) -printf 'MODEL\\t%p\\tf\\t%TY-%Tm-%Td %TH:%TM\\n' 2>/dev/null;`,
`} | head -n ${perPathLimit};`,
`fi;`,
`done | head -n ${ASSET_MAX_ITEMS}`
......@@ -724,6 +744,106 @@ function dedupeBy(items, keyFn) {
return result;
}
function searchAssetInventory(options) {
const query = String(options.query || "").trim().toLowerCase();
const terms = query.split(/\s+/).filter(Boolean).slice(0, 8);
const type = options.type === "model" || options.type === "docker" ? options.type : "all";
const stateFilter = ["free", "busy", "offline", "pending"].includes(options.state) ? options.state : "all";
const groupFilter = String(options.group || "all").trim();
const results = [];
let totalMatches = 0;
if (!terms.length) {
return { query, type, state: stateFilter, group: groupFilter, totalMatches: 0, results: [] };
}
for (const server of loadServers()) {
const status = statusCache.get(server.id) || createPendingStatus(server);
const serverState = serverKindFromStatus(status);
const group = normalizeGroup(server.group);
if (groupFilter !== "all" && group !== groupFilter) continue;
if (stateFilter !== "all" && serverState !== stateFilter) continue;
const assets = assetCache.get(server.id) || createPendingAssetStatus();
const matches = [];
if (type === "all" || type === "model") {
for (const item of assets.modelItems || []) {
const text = [item.name, item.path, item.root, item.type].filter(Boolean).join(" ").toLowerCase();
if (matchesTerms(text, terms)) {
matches.push({
type: "model",
label: item.name,
value: item.path,
meta: `${item.root || "-"} · ${item.type === "file" ? "文件" : "目录"}`,
copyText: item.path
});
}
}
}
if (type === "all" || type === "docker") {
for (const image of assets.dockerImages || []) {
const imageName = `${image.repository || ""}:${image.tag || ""}`;
const text = [imageName, image.imageId, image.size, image.created].filter(Boolean).join(" ").toLowerCase();
if (matchesTerms(text, terms)) {
matches.push({
type: "docker",
label: imageName,
value: image.imageId || "",
meta: [image.size, image.created].filter(Boolean).join(" · "),
copyText: imageName
});
}
}
}
if (!matches.length) continue;
totalMatches += matches.length;
results.push({
server: {
id: server.id,
name: server.name,
host: server.host,
user: server.user,
port: server.port,
group,
state: serverState,
summary: status.summary,
busyCount: status.busyCount || 0,
totalCount: status.totalCount || server.gpuCount || 0,
models: status.models || server.models || []
},
assets: {
updatedAt: assets.updatedAt,
modelCount: assets.modelCount || 0,
dockerCount: assets.dockerCount || 0,
state: assets.state,
error: assets.error || null
},
matches: matches.slice(0, 20)
});
if (totalMatches >= ASSET_SEARCH_MAX_RESULTS) break;
}
return {
query,
type,
state: stateFilter,
group: groupFilter,
totalMatches,
results: results.slice(0, 80)
};
}
function matchesTerms(text, terms) {
return terms.every((term) => text.includes(term));
}
function serverKindFromStatus(status) {
if (status.state === "offline") return "offline";
if (status.state === "pending") return "pending";
return (status.busyCount || 0) > 0 ? "busy" : "free";
}
function parseNvidiaModels(output) {
const byIndex = new Map();
const lines = String(output || "")
......@@ -1016,6 +1136,16 @@ async function handleApi(req, res) {
return;
}
if (req.method === "GET" && url.pathname === "/api/assets/search") {
sendJson(res, 200, searchAssetInventory({
query: url.searchParams.get("q") || "",
type: url.searchParams.get("type") || "all",
state: url.searchParams.get("state") || "all",
group: url.searchParams.get("group") || "all"
}));
return;
}
if (req.method === "GET" && parts[0] === "api" && parts[1] === "servers" && parts[2] && parts[3] === "assets") {
const servers = loadServers();
const server = servers.find((item) => item.id === parts[2]);
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment