Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
zk
gpu-dcu-monitor
Commits
ac96b3b9
Commit
ac96b3b9
authored
May 28, 2026
by
zk
Browse files
Add asset search workspace
parent
187bf098
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
671 additions
and
29 deletions
+671
-29
CHANGELOG.md
CHANGELOG.md
+4
-0
README.md
README.md
+2
-0
public/app.js
public/app.js
+243
-15
public/index.html
public/index.html
+31
-4
public/styles.css
public/styles.css
+259
-8
server.js
server.js
+132
-2
No files found.
CHANGELOG.md
View file @
ac96b3b9
...
...
@@ -2,6 +2,10 @@
## 2026-05-28
-
新增“模型镜像检索”视图,支持按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
-
检索结果按服务器聚合展示,并显示机器分组、IP、当前占用状态,支持复制 IP、SSH 命令、模型路径和镜像名称。
-
优化资源看板和模型检索的顶部工具栏,让搜索框明确跟随当前视图显示资源搜索或模型搜索。
-
收紧模型目录识别规则,只展示目录名或目录内容具备模型特征的路径,减少项目目录、脚本目录等非模型内容混入。
-
新增服务器配置定期备份,默认保存到
`data/backups/`
并保留最近 30 份。
-
新增模型资产盘点,支持展示每台服务器
`/models`
、
`/public`
、
`/data`
等目录下的模型文件/目录和 Docker images。
-
新增“刷新模型资产”入口,并支持按模型路径、模型名和镜像名称进行搜索。
...
...
README.md
View file @
ac96b3b9
...
...
@@ -25,6 +25,8 @@
-
根据显存占用和算力占用综合判断每张卡是否可用。
-
主界面用水位色块展示每张卡的显存和算力占用。
-
盘点每台服务器
`/models`
、
`/public`
、
`/data`
等目录下的模型文件/目录,并展示 Docker images。
-
提供“模型镜像检索”视图,按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
-
检索结果显示服务器分组、IP、当前占用状态,并支持复制 IP、SSH 命令、模型路径和镜像名称。
-
右上角支持按服务器名称、IP、分组、标签、型号、模型路径和镜像名称进行模糊搜索。
-
型号只在新增服务器和手动刷新时重新识别,日常自动刷新只采集占用数据。
-
定期备份服务器配置文件,便于误操作后恢复。
...
...
public/app.js
View file @
ac96b3b9
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
(
"
.
"
)
||
/
[\u
4e00-
\u
9fff
]
/
.
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
,
"
&
"
)
...
...
public/index.html
View file @
ac96b3b9
...
...
@@ -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"
>
...
...
public/styles.css
View file @
ac96b3b9
...
...
@@ -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
,
1
fr
);
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
),
1
fr
));
grid-auto-rows
:
minmax
(
3
68
px
,
auto
);
grid-auto-rows
:
minmax
(
3
92
px
,
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
:
3
68
px
;
min-height
:
3
92
px
;
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
,
1
fr
)
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
:
1
fr
;
}
.view-tabs
{
width
:
100%
;
}
.view-tab
{
flex
:
1
1
0
;
}
h2
{
font-size
:
24px
;
}
...
...
server.js
View file @
ac96b3b9
...
...
@@ -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
]);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment