Commit 7e88c254 authored by zk's avatar zk
Browse files

Add multi-site switcher

parent ac96b3b9
node_modules/
data/servers.json
data/sites.json
data/backups/
*.log
*.tar
......
# 更新记录
## 2026-05-29
- 新增多站点入口切换,页面侧边栏可以在昆山 / 天津中心、太原中心等独立部署之间跳转。
- 新增 `/api/site-config` 配置接口,支持通过 `data/sites.json` 或环境变量维护当前站点和站点列表。
- 新增 `data/sites.sample.json` 示例配置,并将真实站点入口 `data/sites.json` 作为本地运行配置忽略,避免提交真实内网地址。
## 2026-05-28
- 新增“模型镜像检索”视图,支持按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
......
......@@ -27,6 +27,7 @@
- 盘点每台服务器 `/models``/public``/data` 等目录下的模型文件/目录,并展示 Docker images。
- 提供“模型镜像检索”视图,按模型名、路径、Docker 镜像名或 tag 查找资产所在服务器。
- 检索结果显示服务器分组、IP、当前占用状态,并支持复制 IP、SSH 命令、模型路径和镜像名称。
- 支持多站点入口切换,可以在昆山/天津中心和太原中心等多套独立部署之间跳转。
- 右上角支持按服务器名称、IP、分组、标签、型号、模型路径和镜像名称进行模糊搜索。
- 型号只在新增服务器和手动刷新时重新识别,日常自动刷新只采集占用数据。
- 定期备份服务器配置文件,便于误操作后恢复。
......@@ -204,6 +205,20 @@ data/servers.sample.json
服务器配置支持 `group` 分组字段。页面会根据已有服务器自动生成分组筛选入口;`tags` 用于补充额外标签。
多站点入口配置保存在:
```text
data/sites.json
```
示例配置:
```text
data/sites.sample.json
```
每套部署可以维护自己的 `current``sites`。例如昆山服务可以把当前站点写成“昆山 / 天津中心”,并在 `sites` 里加入太原服务地址;太原服务则把当前站点写成“太原中心”,同时保留返回昆山服务的地址。`data/sites.json` 属于本地运行配置,不会提交到仓库。
程序会定期把服务器配置备份到:
```text
......@@ -242,6 +257,11 @@ PORT=3066 POLL_INTERVAL_MS=10000 SSH_TIMEOUT_MS=20000 npm start
- `ASSET_MAX_ITEMS`:每台服务器最多返回的模型条目和镜像条目数量,默认 `160`
- `BACKUP_INTERVAL_MS`:服务器配置定期备份间隔,默认 `86400000` 毫秒。
- `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`
## 运维命令
......
{
"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"
}
]
}
......@@ -10,6 +10,10 @@ const state = {
assetTotalMatches: 0,
assetSearching: false,
selectedId: null,
siteConfig: {
current: { name: "GPU/DCU 资源看板", description: "共享测试资源", url: "/" },
sites: []
},
pollIntervalMs: 10000,
assetRefreshing: false,
assetDetailLoading: false,
......@@ -21,6 +25,9 @@ const els = {
grid: document.querySelector("#serverGrid"),
empty: document.querySelector("#emptyState"),
detail: document.querySelector("#detailPanel"),
brandTitle: document.querySelector("#brandTitle"),
siteEyebrow: document.querySelector("#siteEyebrow"),
siteSwitcher: document.querySelector("#siteSwitcher"),
pageTitle: document.querySelector("#pageTitle"),
groupFilters: document.querySelector("#groupFilters"),
groupOptions: document.querySelector("#groupOptions"),
......@@ -87,7 +94,55 @@ els.search.addEventListener("input", () => {
els.form.addEventListener("submit", saveServer);
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() {
try {
......
......@@ -12,11 +12,13 @@
<div class="brand">
<div class="brand-mark">G</div>
<div>
<h1>GPU/DCU 资源看板</h1>
<h1 id="brandTitle">GPU/DCU 资源看板</h1>
<p id="lastRefresh">等待刷新</p>
</div>
</div>
<nav class="site-switcher" id="siteSwitcher" aria-label="站点切换"></nav>
<div class="filters" role="tablist" aria-label="服务器筛选">
<button class="filter active" data-filter="all" type="button">
<span>全部</span><strong id="countAll">0</strong>
......@@ -48,7 +50,7 @@
<main class="content">
<section class="topline">
<div>
<p class="eyebrow">共享测试资源</p>
<p class="eyebrow" id="siteEyebrow">共享测试资源</p>
<h2 id="pageTitle">服务器占用情况</h2>
</div>
</section>
......
......@@ -102,6 +102,57 @@ h1 {
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 {
margin-top: 4px;
font-size: 30px;
......
......@@ -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 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 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 DATA_DIR = path.join(ROOT, "data");
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 PUBLIC_DIR = path.join(ROOT, "public");
......@@ -133,6 +138,105 @@ function parseCsv(value) {
.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) {
if (!input || typeof input !== "object") return null;
const host = String(input.host || "").trim();
......@@ -1120,6 +1224,11 @@ async function handleApi(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
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") {
const includeAssetDetails = url.searchParams.get("assetDetails") === "1";
const servers = loadServers().map((server) => publicServer(server, { includeAssetDetails }));
......
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