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
7e88c254
Commit
7e88c254
authored
May 29, 2026
by
zk
Browse files
Add multi-site switcher
parent
ac96b3b9
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
269 additions
and
3 deletions
+269
-3
.gitignore
.gitignore
+1
-0
CHANGELOG.md
CHANGELOG.md
+6
-0
README.md
README.md
+20
-0
data/sites.sample.json
data/sites.sample.json
+22
-0
public/app.js
public/app.js
+56
-1
public/index.html
public/index.html
+4
-2
public/styles.css
public/styles.css
+51
-0
server.js
server.js
+109
-0
No files found.
.gitignore
View file @
7e88c254
node_modules/
node_modules/
data/servers.json
data/servers.json
data/sites.json
data/backups/
data/backups/
*.log
*.log
*.tar
*.tar
...
...
CHANGELOG.md
View file @
7e88c254
# 更新记录
# 更新记录
## 2026-05-29
-
新增多站点入口切换,页面侧边栏可以在昆山 / 天津中心、太原中心等独立部署之间跳转。
-
新增
`/api/site-config`
配置接口,支持通过
`data/sites.json`
或环境变量维护当前站点和站点列表。
-
新增
`data/sites.sample.json`
示例配置,并将真实站点入口
`data/sites.json`
作为本地运行配置忽略,避免提交真实内网地址。
## 2026-05-28
## 2026-05-28
-
新增“模型镜像检索”视图,支持按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
-
新增“模型镜像检索”视图,支持按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
...
...
README.md
View file @
7e88c254
...
@@ -27,6 +27,7 @@
...
@@ -27,6 +27,7 @@
-
盘点每台服务器
`/models`
、
`/public`
、
`/data`
等目录下的模型文件/目录,并展示 Docker images。
-
盘点每台服务器
`/models`
、
`/public`
、
`/data`
等目录下的模型文件/目录,并展示 Docker images。
-
提供“模型镜像检索”视图,按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
-
提供“模型镜像检索”视图,按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
-
检索结果显示服务器分组、IP、当前占用状态,并支持复制 IP、SSH 命令、模型路径和镜像名称。
-
检索结果显示服务器分组、IP、当前占用状态,并支持复制 IP、SSH 命令、模型路径和镜像名称。
-
支持多站点入口切换,可以在昆山/天津中心和太原中心等多套独立部署之间跳转。
-
右上角支持按服务器名称、IP、分组、标签、型号、模型路径和镜像名称进行模糊搜索。
-
右上角支持按服务器名称、IP、分组、标签、型号、模型路径和镜像名称进行模糊搜索。
-
型号只在新增服务器和手动刷新时重新识别,日常自动刷新只采集占用数据。
-
型号只在新增服务器和手动刷新时重新识别,日常自动刷新只采集占用数据。
-
定期备份服务器配置文件,便于误操作后恢复。
-
定期备份服务器配置文件,便于误操作后恢复。
...
@@ -204,6 +205,20 @@ data/servers.sample.json
...
@@ -204,6 +205,20 @@ data/servers.sample.json
服务器配置支持
`group`
分组字段。页面会根据已有服务器自动生成分组筛选入口;
`tags`
用于补充额外标签。
服务器配置支持
`group`
分组字段。页面会根据已有服务器自动生成分组筛选入口;
`tags`
用于补充额外标签。
多站点入口配置保存在:
```
text
data/sites.json
```
示例配置:
```
text
data/sites.sample.json
```
每套部署可以维护自己的
`current`
和
`sites`
。例如昆山服务可以把当前站点写成“昆山 / 天津中心”,并在
`sites`
里加入太原服务地址;太原服务则把当前站点写成“太原中心”,同时保留返回昆山服务的地址。
`data/sites.json`
属于本地运行配置,不会提交到仓库。
程序会定期把服务器配置备份到:
程序会定期把服务器配置备份到:
```
text
```
text
...
@@ -242,6 +257,11 @@ PORT=3066 POLL_INTERVAL_MS=10000 SSH_TIMEOUT_MS=20000 npm start
...
@@ -242,6 +257,11 @@ PORT=3066 POLL_INTERVAL_MS=10000 SSH_TIMEOUT_MS=20000 npm start
-
`ASSET_MAX_ITEMS`
:每台服务器最多返回的模型条目和镜像条目数量,默认
`160`
。
-
`ASSET_MAX_ITEMS`
:每台服务器最多返回的模型条目和镜像条目数量,默认
`160`
。
-
`BACKUP_INTERVAL_MS`
:服务器配置定期备份间隔,默认
`86400000`
毫秒。
-
`BACKUP_INTERVAL_MS`
:服务器配置定期备份间隔,默认
`86400000`
毫秒。
-
`BACKUP_RETENTION`
:服务器配置备份保留份数,默认
`30`
。
-
`BACKUP_RETENTION`
:服务器配置备份保留份数,默认
`30`
。
-
`SITE_ID`
:当前站点 ID,默认
`local`
。
-
`SITE_NAME`
:当前站点显示名称,默认
`本地中心`
。
-
`SITE_DESCRIPTION`
:当前站点说明,默认
`共享测试资源`
。
-
`SITE_URL`
:当前站点访问地址,默认
`/`
。
-
`SITE_LINKS`
:无
`data/sites.json`
时使用的站点列表,支持 JSON 数组,或
`名称|地址|说明;名称|地址|说明`
格式。
-
`SSH_PATH`
:自定义 SSH 程序路径。Windows 默认使用
`C:\Windows\System32\OpenSSH\ssh.exe`
。
-
`SSH_PATH`
:自定义 SSH 程序路径。Windows 默认使用
`C:\Windows\System32\OpenSSH\ssh.exe`
。
## 运维命令
## 运维命令
...
...
data/sites.sample.json
0 → 100644
View file @
7e88c254
{
"current"
:
{
"id"
:
"kunshan"
,
"name"
:
"昆山 / 天津中心"
,
"description"
:
"昆山部署,覆盖昆山和天津服务器"
,
"url"
:
"http://kunshan-monitor.example.local:3066"
},
"sites"
:
[
{
"id"
:
"kunshan"
,
"name"
:
"昆山 / 天津中心"
,
"description"
:
"昆山部署,覆盖昆山和天津服务器"
,
"url"
:
"http://kunshan-monitor.example.local:3066"
},
{
"id"
:
"taiyuan"
,
"name"
:
"太原中心"
,
"description"
:
"连接 EasyConnect 后访问"
,
"url"
:
"http://taiyuan-monitor.example.local:3066"
}
]
}
public/app.js
View file @
7e88c254
...
@@ -10,6 +10,10 @@ const state = {
...
@@ -10,6 +10,10 @@ const state = {
assetTotalMatches
:
0
,
assetTotalMatches
:
0
,
assetSearching
:
false
,
assetSearching
:
false
,
selectedId
:
null
,
selectedId
:
null
,
siteConfig
:
{
current
:
{
name
:
"
GPU/DCU 资源看板
"
,
description
:
"
共享测试资源
"
,
url
:
"
/
"
},
sites
:
[]
},
pollIntervalMs
:
10000
,
pollIntervalMs
:
10000
,
assetRefreshing
:
false
,
assetRefreshing
:
false
,
assetDetailLoading
:
false
,
assetDetailLoading
:
false
,
...
@@ -21,6 +25,9 @@ const els = {
...
@@ -21,6 +25,9 @@ const els = {
grid
:
document
.
querySelector
(
"
#serverGrid
"
),
grid
:
document
.
querySelector
(
"
#serverGrid
"
),
empty
:
document
.
querySelector
(
"
#emptyState
"
),
empty
:
document
.
querySelector
(
"
#emptyState
"
),
detail
:
document
.
querySelector
(
"
#detailPanel
"
),
detail
:
document
.
querySelector
(
"
#detailPanel
"
),
brandTitle
:
document
.
querySelector
(
"
#brandTitle
"
),
siteEyebrow
:
document
.
querySelector
(
"
#siteEyebrow
"
),
siteSwitcher
:
document
.
querySelector
(
"
#siteSwitcher
"
),
pageTitle
:
document
.
querySelector
(
"
#pageTitle
"
),
pageTitle
:
document
.
querySelector
(
"
#pageTitle
"
),
groupFilters
:
document
.
querySelector
(
"
#groupFilters
"
),
groupFilters
:
document
.
querySelector
(
"
#groupFilters
"
),
groupOptions
:
document
.
querySelector
(
"
#groupOptions
"
),
groupOptions
:
document
.
querySelector
(
"
#groupOptions
"
),
...
@@ -87,7 +94,55 @@ els.search.addEventListener("input", () => {
...
@@ -87,7 +94,55 @@ els.search.addEventListener("input", () => {
els
.
form
.
addEventListener
(
"
submit
"
,
saveServer
);
els
.
form
.
addEventListener
(
"
submit
"
,
saveServer
);
els
.
deleteBtn
.
addEventListener
(
"
click
"
,
deleteSelectedServer
);
els
.
deleteBtn
.
addEventListener
(
"
click
"
,
deleteSelectedServer
);
loadServers
();
init
();
async
function
init
()
{
await
loadSiteConfig
();
loadServers
();
}
async
function
loadSiteConfig
()
{
try
{
const
payload
=
await
requestJson
(
"
/api/site-config
"
);
state
.
siteConfig
=
{
current
:
payload
.
current
||
state
.
siteConfig
.
current
,
sites
:
payload
.
sites
||
[]
};
}
catch
(
error
)
{
console
.
warn
(
error
);
}
renderSiteConfig
();
}
function
renderSiteConfig
()
{
const
current
=
state
.
siteConfig
.
current
||
{};
if
(
els
.
brandTitle
)
els
.
brandTitle
.
textContent
=
current
.
name
||
"
GPU/DCU 资源看板
"
;
if
(
els
.
siteEyebrow
)
els
.
siteEyebrow
.
textContent
=
current
.
description
||
"
共享测试资源
"
;
if
(
!
els
.
siteSwitcher
)
return
;
const
sites
=
state
.
siteConfig
.
sites
||
[];
if
(
!
sites
.
length
)
{
els
.
siteSwitcher
.
classList
.
add
(
"
hidden
"
);
return
;
}
els
.
siteSwitcher
.
classList
.
remove
(
"
hidden
"
);
els
.
siteSwitcher
.
innerHTML
=
`
<div class="site-switcher-title">站点切换</div>
<div class="site-link-list">
${
sites
.
map
(
siteLinkHtml
).
join
(
""
)}
</div>`
;
}
function
siteLinkHtml
(
site
)
{
const
active
=
site
.
current
?
"
active
"
:
""
;
const
description
=
site
.
description
?
`<span>
${
escapeHtml
(
site
.
description
)}
</span>`
:
""
;
return
`
<a class="site-link
${
active
}
" href="
${
escapeAttr
(
site
.
url
)}
">
<strong>
${
escapeHtml
(
site
.
name
)}
</strong>
${
description
}
</a>`
;
}
async
function
loadServers
()
{
async
function
loadServers
()
{
try
{
try
{
...
...
public/index.html
View file @
7e88c254
...
@@ -12,11 +12,13 @@
...
@@ -12,11 +12,13 @@
<div
class=
"brand"
>
<div
class=
"brand"
>
<div
class=
"brand-mark"
>
G
</div>
<div
class=
"brand-mark"
>
G
</div>
<div>
<div>
<h1>
GPU/DCU 资源看板
</h1>
<h1
id=
"brandTitle"
>
GPU/DCU 资源看板
</h1>
<p
id=
"lastRefresh"
>
等待刷新
</p>
<p
id=
"lastRefresh"
>
等待刷新
</p>
</div>
</div>
</div>
</div>
<nav
class=
"site-switcher"
id=
"siteSwitcher"
aria-label=
"站点切换"
></nav>
<div
class=
"filters"
role=
"tablist"
aria-label=
"服务器筛选"
>
<div
class=
"filters"
role=
"tablist"
aria-label=
"服务器筛选"
>
<button
class=
"filter active"
data-filter=
"all"
type=
"button"
>
<button
class=
"filter active"
data-filter=
"all"
type=
"button"
>
<span>
全部
</span><strong
id=
"countAll"
>
0
</strong>
<span>
全部
</span><strong
id=
"countAll"
>
0
</strong>
...
@@ -48,7 +50,7 @@
...
@@ -48,7 +50,7 @@
<main
class=
"content"
>
<main
class=
"content"
>
<section
class=
"topline"
>
<section
class=
"topline"
>
<div>
<div>
<p
class=
"eyebrow"
>
共享测试资源
</p>
<p
class=
"eyebrow"
id=
"siteEyebrow"
>
共享测试资源
</p>
<h2
id=
"pageTitle"
>
服务器占用情况
</h2>
<h2
id=
"pageTitle"
>
服务器占用情况
</h2>
</div>
</div>
</section>
</section>
...
...
public/styles.css
View file @
7e88c254
...
@@ -102,6 +102,57 @@ h1 {
...
@@ -102,6 +102,57 @@ h1 {
font-size
:
18px
;
font-size
:
18px
;
}
}
.site-switcher
{
display
:
grid
;
gap
:
8px
;
}
.site-switcher-title
{
color
:
var
(
--muted
);
font-size
:
12px
;
font-weight
:
900
;
}
.site-link-list
{
display
:
grid
;
gap
:
7px
;
}
.site-link
{
display
:
grid
;
gap
:
2px
;
min-height
:
48px
;
border
:
1px
solid
var
(
--line
);
border-radius
:
10px
;
background
:
#fff
;
color
:
var
(
--text
);
padding
:
8px
10px
;
text-decoration
:
none
;
}
.site-link
:hover
,
.site-link.active
{
border-color
:
rgba
(
15
,
159
,
154
,
0.48
);
background
:
#e9f7f5
;
}
.site-link
strong
,
.site-link
span
{
min-width
:
0
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.site-link
strong
{
font-size
:
13px
;
}
.site-link
span
{
color
:
var
(
--muted
);
font-size
:
12px
;
}
h2
{
h2
{
margin-top
:
4px
;
margin-top
:
4px
;
font-size
:
30px
;
font-size
:
30px
;
...
...
server.js
View file @
7e88c254
...
@@ -16,9 +16,14 @@ const ASSET_SEARCH_MAX_RESULTS = clampInt(process.env.ASSET_SEARCH_MAX_RESULTS,
...
@@ -16,9 +16,14 @@ const ASSET_SEARCH_MAX_RESULTS = clampInt(process.env.ASSET_SEARCH_MAX_RESULTS,
const
ASSET_PATHS
=
parseCsv
(
process
.
env
.
ASSET_PATHS
||
"
/models,/public,/data
"
);
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_INTERVAL_MS
=
Number
(
process
.
env
.
BACKUP_INTERVAL_MS
||
24
*
60
*
60
*
1000
);
const
BACKUP_RETENTION
=
clampInt
(
process
.
env
.
BACKUP_RETENTION
,
1
,
365
,
30
);
const
BACKUP_RETENTION
=
clampInt
(
process
.
env
.
BACKUP_RETENTION
,
1
,
365
,
30
);
const
SITE_ID
=
String
(
process
.
env
.
SITE_ID
||
"
local
"
).
trim
()
||
"
local
"
;
const
SITE_NAME
=
String
(
process
.
env
.
SITE_NAME
||
"
本地中心
"
).
trim
()
||
"
本地中心
"
;
const
SITE_DESCRIPTION
=
String
(
process
.
env
.
SITE_DESCRIPTION
||
"
共享测试资源
"
).
trim
()
||
"
共享测试资源
"
;
const
SITE_URL
=
String
(
process
.
env
.
SITE_URL
||
"
/
"
).
trim
()
||
"
/
"
;
const
ROOT
=
__dirname
;
const
ROOT
=
__dirname
;
const
DATA_DIR
=
path
.
join
(
ROOT
,
"
data
"
);
const
DATA_DIR
=
path
.
join
(
ROOT
,
"
data
"
);
const
CONFIG_PATH
=
path
.
join
(
DATA_DIR
,
"
servers.json
"
);
const
CONFIG_PATH
=
path
.
join
(
DATA_DIR
,
"
servers.json
"
);
const
SITE_CONFIG_PATH
=
path
.
join
(
DATA_DIR
,
"
sites.json
"
);
const
BACKUP_DIR
=
path
.
join
(
DATA_DIR
,
"
backups
"
);
const
BACKUP_DIR
=
path
.
join
(
DATA_DIR
,
"
backups
"
);
const
PUBLIC_DIR
=
path
.
join
(
ROOT
,
"
public
"
);
const
PUBLIC_DIR
=
path
.
join
(
ROOT
,
"
public
"
);
...
@@ -133,6 +138,105 @@ function parseCsv(value) {
...
@@ -133,6 +138,105 @@ function parseCsv(value) {
.
slice
(
0
,
24
);
.
slice
(
0
,
24
);
}
}
function
loadSiteConfig
()
{
const
current
=
normalizeSite
({
id
:
SITE_ID
,
name
:
SITE_NAME
,
description
:
SITE_DESCRIPTION
,
url
:
SITE_URL
,
current
:
true
});
let
configuredSites
=
[];
const
fileConfig
=
readSiteConfigFile
();
if
(
fileConfig
)
{
if
(
fileConfig
.
current
)
{
const
fileCurrent
=
normalizeSite
(
fileConfig
.
current
);
if
(
fileCurrent
)
{
current
.
id
=
fileCurrent
.
id
||
current
.
id
;
current
.
name
=
fileCurrent
.
name
||
current
.
name
;
current
.
description
=
fileCurrent
.
description
||
current
.
description
;
current
.
url
=
fileCurrent
.
url
||
current
.
url
;
}
}
configuredSites
=
Array
.
isArray
(
fileConfig
.
sites
)
?
fileConfig
.
sites
.
map
(
normalizeSite
).
filter
(
Boolean
)
:
[];
}
else
{
configuredSites
=
parseSiteLinks
(
process
.
env
.
SITE_LINKS
);
}
const
sites
=
mergeSites
(
current
,
configuredSites
);
return
{
current
,
sites
};
}
function
readSiteConfigFile
()
{
if
(
!
fs
.
existsSync
(
SITE_CONFIG_PATH
))
return
null
;
try
{
const
raw
=
fs
.
readFileSync
(
SITE_CONFIG_PATH
,
"
utf8
"
);
const
parsed
=
JSON
.
parse
(
raw
);
if
(
Array
.
isArray
(
parsed
))
return
{
sites
:
parsed
};
return
parsed
&&
typeof
parsed
===
"
object
"
?
parsed
:
null
;
}
catch
(
error
)
{
console
.
warn
(
`Failed to read site config:
${
error
.
message
}
`
);
return
null
;
}
}
function
parseSiteLinks
(
value
)
{
const
text
=
String
(
value
||
""
).
trim
();
if
(
!
text
)
return
[];
if
(
text
[
0
]
===
"
[
"
)
{
try
{
const
parsed
=
JSON
.
parse
(
text
);
return
Array
.
isArray
(
parsed
)
?
parsed
.
map
(
normalizeSite
).
filter
(
Boolean
)
:
[];
}
catch
(
error
)
{
console
.
warn
(
`SITE_LINKS JSON parse failed:
${
error
.
message
}
`
);
return
[];
}
}
return
text
.
split
(
"
;
"
)
.
map
((
item
,
index
)
=>
{
const
parts
=
item
.
split
(
"
|
"
).
map
((
part
)
=>
part
.
trim
());
return
normalizeSite
({
id
:
parts
[
0
]
||
`site-
${
index
+
1
}
`
,
name
:
parts
[
0
],
url
:
parts
[
1
],
description
:
parts
[
2
]
});
})
.
filter
(
Boolean
);
}
function
normalizeSite
(
input
)
{
if
(
!
input
||
typeof
input
!==
"
object
"
)
return
null
;
const
name
=
String
(
input
.
name
||
""
).
trim
();
const
url
=
String
(
input
.
url
||
""
).
trim
();
if
(
!
name
||
!
url
)
return
null
;
return
{
id
:
String
(
input
.
id
||
name
).
trim
()
||
name
,
name
,
url
,
description
:
String
(
input
.
description
||
""
).
trim
(),
current
:
Boolean
(
input
.
current
)
};
}
function
mergeSites
(
current
,
sites
)
{
const
byKey
=
new
Map
();
[
current
].
concat
(
sites
||
[]).
forEach
((
site
)
=>
{
if
(
!
site
)
return
;
const
key
=
site
.
id
||
site
.
url
||
site
.
name
;
byKey
.
set
(
key
,
{
id
:
site
.
id
,
name
:
site
.
name
,
url
:
site
.
url
,
description
:
site
.
description
,
current
:
site
.
current
||
site
.
id
===
current
.
id
||
site
.
url
===
current
.
url
});
});
return
Array
.
from
(
byKey
.
values
());
}
function
normalizeServer
(
input
)
{
function
normalizeServer
(
input
)
{
if
(
!
input
||
typeof
input
!==
"
object
"
)
return
null
;
if
(
!
input
||
typeof
input
!==
"
object
"
)
return
null
;
const
host
=
String
(
input
.
host
||
""
).
trim
();
const
host
=
String
(
input
.
host
||
""
).
trim
();
...
@@ -1120,6 +1224,11 @@ async function handleApi(req, res) {
...
@@ -1120,6 +1224,11 @@ async function handleApi(req, res) {
const
url
=
new
URL
(
req
.
url
,
`http://
${
req
.
headers
.
host
}
`
);
const
url
=
new
URL
(
req
.
url
,
`http://
${
req
.
headers
.
host
}
`
);
const
parts
=
url
.
pathname
.
split
(
"
/
"
).
filter
(
Boolean
);
const
parts
=
url
.
pathname
.
split
(
"
/
"
).
filter
(
Boolean
);
if
(
req
.
method
===
"
GET
"
&&
url
.
pathname
===
"
/api/site-config
"
)
{
sendJson
(
res
,
200
,
loadSiteConfig
());
return
;
}
if
(
req
.
method
===
"
GET
"
&&
url
.
pathname
===
"
/api/servers
"
)
{
if
(
req
.
method
===
"
GET
"
&&
url
.
pathname
===
"
/api/servers
"
)
{
const
includeAssetDetails
=
url
.
searchParams
.
get
(
"
assetDetails
"
)
===
"
1
"
;
const
includeAssetDetails
=
url
.
searchParams
.
get
(
"
assetDetails
"
)
===
"
1
"
;
const
servers
=
loadServers
().
map
((
server
)
=>
publicServer
(
server
,
{
includeAssetDetails
}));
const
servers
=
loadServers
().
map
((
server
)
=>
publicServer
(
server
,
{
includeAssetDetails
}));
...
...
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