总览与客户端实例按 sim 在上 live 在下排序,实例表增加模式列。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-04 10:17:05 +08:00
parent d9bf3f7915
commit 50963a25fc
5 changed files with 184 additions and 98 deletions
+3 -1
View File
@@ -302,6 +302,8 @@ def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]:
"short_sha": "", "short_sha": "",
"branch": "", "branch": "",
"dirty": False, "dirty": False,
"mode": "",
"mode_label": "",
"msg": "", "msg": "",
"instance_id": inst.get("id"), "instance_id": inst.get("id"),
"instance_name": inst.get("name"), "instance_name": inst.get("name"),
@@ -320,7 +322,7 @@ def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]:
if data is None: if data is None:
out["msg"] = err or f"HTTP {r.status_code}" out["msg"] = err or f"HTTP {r.status_code}"
return out return out
for key in ("sha", "short_sha", "branch", "dirty", "msg", "repo"): for key in ("sha", "short_sha", "branch", "dirty", "msg", "repo", "mode", "mode_label"):
if key in data: if key in data:
out[key] = data.get(key) out[key] = data.get(key)
out["ok"] = bool(data.get("ok")) and r.status_code == 200 out["ok"] = bool(data.get("ok")) and r.status_code == 200
+32 -2
View File
@@ -255,7 +255,19 @@
status + status +
"</td>" + "</td>" +
"<td>" + "<td>" +
escapeHtml(inst.mode || "—") + (function () {
const m = String(inst.mode || "").toLowerCase();
if (m === "sim" || m === "live") {
return (
'<span class="overview-mode-tag overview-mode-tag--' +
m +
'">' +
escapeHtml(m) +
"</span>"
);
}
return escapeHtml(inst.mode || "—");
})() +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmtPnlHtml(pnl) + fmtPnlHtml(pnl) +
@@ -472,6 +484,24 @@
"</tbody></table></div>"; "</tbody></table></div>";
} }
function modeSortKey(mode) {
const m = String(mode || "").toLowerCase();
if (m === "sim") return 0;
if (m === "live") return 1;
return 2;
}
function sortInstancesByMode(list) {
return (list || []).slice().sort(function (a, b) {
const ma = modeSortKey((((a || {}).snapshot || {}).instance || {}).mode);
const mb = modeSortKey((((b || {}).snapshot || {}).instance || {}).mode);
if (ma !== mb) return ma - mb;
const ia = Number((a && a.id) || 0);
const ib = Number((b && b.id) || 0);
return ia - ib;
});
}
async function refresh() { async function refresh() {
const status = document.getElementById("overview-status"); const status = document.getElementById("overview-status");
const grid = document.getElementById("overview-grid"); const grid = document.getElementById("overview-grid");
@@ -481,7 +511,7 @@
const r = await fetch("/api/overview"); const r = await fetch("/api/overview");
const d = await r.json(); const d = await r.json();
if (!d.ok) throw new Error(d.msg || "失败"); if (!d.ok) throw new Error(d.msg || "失败");
const list = d.instances || []; const list = sortInstancesByMode(d.instances || []);
renderOverviewSummary(list); renderOverviewSummary(list);
if (!list.length) { if (!list.length) {
grid.innerHTML = grid.innerHTML =
+143 -91
View File
@@ -1,91 +1,143 @@
(function () { (function () {
function fmtVer(v) { function fmtVer(v) {
if (!v || !v.online) return v && v.msg ? String(v.msg) : "离线"; if (!v || !v.online) return v && v.msg ? String(v.msg) : "离线";
const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : ""); const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : "");
const branch = v.branch ? v.branch + " · " : ""; const branch = v.branch ? v.branch + " · " : "";
if (!short) return "未知版本"; if (!short) return "未知版本";
return branch + short + (v.dirty ? " (dirty)" : ""); return branch + short + (v.dirty ? " (dirty)" : "");
} }
async function refreshVersion(row) { function modeSortKey(mode) {
const iid = row.getAttribute("data-id"); const m = String(mode || "").toLowerCase();
const verEl = row.querySelector("[data-inst-version]"); if (m === "sim") return 0;
if (!iid || !verEl) return; if (m === "live") return 1;
verEl.textContent = "…"; return 2;
verEl.className = "update-version muted"; }
try {
const r = await fetch("/api/instances/" + iid + "/version", { function applyModeCell(row, mode) {
credentials: "same-origin", const modeEl = row.querySelector("[data-inst-mode]");
headers: { Accept: "application/json" }, const m = String(mode || "").toLowerCase();
}); row.setAttribute("data-mode", m);
const v = await r.json(); if (!modeEl) return;
if (v && v.online) { if (m === "sim" || m === "live") {
verEl.textContent = fmtVer(v); modeEl.textContent = m;
verEl.className = "update-version online"; modeEl.className = "overview-mode-tag overview-mode-tag--" + m;
} else { } else {
verEl.textContent = (v && v.msg) || "离线"; modeEl.textContent = "—";
verEl.className = "update-version offline"; modeEl.className = "overview-mode-tag muted";
} }
} catch (e) { }
verEl.textContent = "探测失败";
verEl.className = "update-version offline"; function sortInstanceRows() {
} const tbody = document.querySelector(".inst-table tbody");
} if (!tbody) return;
const rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.inst-row[data-id]"));
function setTokenVisible(input, toggleBtn, visible) { if (rows.length < 2) return;
if (!input) return; const pairs = rows.map(function (row) {
input.type = visible ? "text" : "password"; return { row: row, edit: row.nextElementSibling };
if (toggleBtn) toggleBtn.textContent = visible ? "隐藏" : "显示"; });
} pairs.sort(function (a, b) {
const ka = modeSortKey(a.row.getAttribute("data-mode"));
function bindTokenToggles(root) { const kb = modeSortKey(b.row.getAttribute("data-mode"));
(root || document).querySelectorAll(".inst-token-toggle").forEach(function (btn) { if (ka !== kb) return ka - kb;
if (btn.dataset.bound) return; const ia = Number(a.row.getAttribute("data-id") || 0);
btn.dataset.bound = "1"; const ib = Number(b.row.getAttribute("data-id") || 0);
btn.addEventListener("click", function () { return ia - ib;
const wrap = btn.closest(".inst-token-wrap"); });
const input = wrap && wrap.querySelector(".inst-token-input"); pairs.forEach(function (p) {
if (!input) return; tbody.appendChild(p.row);
setTokenVisible(input, btn, input.type === "password"); if (p.edit && p.edit.classList.contains("inst-edit-row")) {
}); tbody.appendChild(p.edit);
}); }
} });
}
function bindEditReveal() {
document.querySelectorAll(".inst-edit-check[data-reveal-token]").forEach(function (cb) { async function refreshVersion(row) {
if (cb.dataset.bound) return; const iid = row.getAttribute("data-id");
cb.dataset.bound = "1"; const verEl = row.querySelector("[data-inst-version]");
const sync = function () { if (!iid || !verEl) return;
const panel = cb.nextElementSibling; verEl.textContent = "…";
if (!panel) return; verEl.className = "update-version muted";
const input = panel.querySelector("[data-edit-token]"); try {
const toggle = panel.querySelector(".inst-token-toggle"); const r = await fetch("/api/instances/" + iid + "/version", {
// 展开编辑时明文显示 Token;收起后恢复打码 credentials: "same-origin",
setTokenVisible(input, toggle, !!cb.checked); headers: { Accept: "application/json" },
}; });
cb.addEventListener("change", sync); const v = await r.json();
sync(); if (v && v.online) {
}); verEl.textContent = fmtVer(v);
} verEl.className = "update-version online";
applyModeCell(row, v.mode || "");
function boot() { } else {
bindTokenToggles(document); verEl.textContent = (v && v.msg) || "离线";
bindEditReveal(); verEl.className = "update-version offline";
document.querySelectorAll("tr.inst-row[data-id]").forEach(function (row) { applyModeCell(row, (v && v.mode) || "");
refreshVersion(row); }
const btn = row.querySelector("[data-inst-refresh-ver]"); } catch (e) {
if (btn) { verEl.textContent = "探测失败";
btn.addEventListener("click", function (e) { verEl.className = "update-version offline";
e.preventDefault(); applyModeCell(row, "");
refreshVersion(row); }
}); }
}
}); function setTokenVisible(input, toggleBtn, visible) {
} if (!input) return;
input.type = visible ? "text" : "password";
if (document.readyState === "loading") { if (toggleBtn) toggleBtn.textContent = visible ? "隐藏" : "显示";
document.addEventListener("DOMContentLoaded", boot); }
} else {
boot(); function bindTokenToggles(root) {
} (root || document).querySelectorAll(".inst-token-toggle").forEach(function (btn) {
})(); if (btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.addEventListener("click", function () {
const wrap = btn.closest(".inst-token-wrap");
const input = wrap && wrap.querySelector(".inst-token-input");
if (!input) return;
setTokenVisible(input, btn, input.type === "password");
});
});
}
function bindEditReveal() {
document.querySelectorAll(".inst-edit-check[data-reveal-token]").forEach(function (cb) {
if (cb.dataset.bound) return;
cb.dataset.bound = "1";
const sync = function () {
const panel = cb.nextElementSibling;
if (!panel) return;
const input = panel.querySelector("[data-edit-token]");
const toggle = panel.querySelector(".inst-token-toggle");
// 展开编辑时明文显示 Token;收起后恢复打码
setTokenVisible(input, toggle, !!cb.checked);
};
cb.addEventListener("change", sync);
sync();
});
}
async function boot() {
bindTokenToggles(document);
bindEditReveal();
const rows = Array.prototype.slice.call(document.querySelectorAll("tr.inst-row[data-id]"));
await Promise.all(
rows.map(function (row) {
const btn = row.querySelector("[data-inst-refresh-ver]");
if (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault();
refreshVersion(row).then(sortInstanceRows);
});
}
return refreshVersion(row);
})
);
sortInstanceRows();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
+1 -1
View File
@@ -15,6 +15,6 @@
<div id="overview-grid"></div> <div id="overview-grid"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='hub.js') }}?v=18"></script> <script src="{{ url_for('static', filename='hub.js') }}?v=19"></script>
<script>HubOverview.boot();</script> <script>HubOverview.boot();</script>
{% endblock %} {% endblock %}
+5 -3
View File
@@ -80,6 +80,7 @@
<th>Token</th> <th>Token</th>
<th>路径</th> <th>路径</th>
<th>状态</th> <th>状态</th>
<th>模式</th>
<th>版本</th> <th>版本</th>
<th>标签</th> <th>标签</th>
<th>操作</th> <th>操作</th>
@@ -87,7 +88,7 @@
</thead> </thead>
<tbody> <tbody>
{% for inst in instances %} {% for inst in instances %}
<tr class="inst-row" data-id="{{ inst.id }}" data-name="{{ inst.name }}"> <tr class="inst-row" data-id="{{ inst.id }}" data-name="{{ inst.name }}" data-mode="">
<td class="inst-td-name"><strong>{{ inst.name }}</strong></td> <td class="inst-td-name"><strong>{{ inst.name }}</strong></td>
<td class="inst-td-url muted" title="{{ inst.base_url }}">{{ inst.base_url }}</td> <td class="inst-td-url muted" title="{{ inst.base_url }}">{{ inst.base_url }}</td>
<td class="inst-td-token"> <td class="inst-td-token">
@@ -101,6 +102,7 @@
<span class="inst-pill inst-pill--off">停用</span> <span class="inst-pill inst-pill--off">停用</span>
{% endif %} {% endif %}
</td> </td>
<td><span class="overview-mode-tag muted" data-inst-mode>—</span></td>
<td><span class="update-version muted" data-inst-version>—</span></td> <td><span class="update-version muted" data-inst-version>—</span></td>
<td class="inst-td-tags muted">{{ inst.tags or '—' }}</td> <td class="inst-td-tags muted">{{ inst.tags or '—' }}</td>
<td class="inst-td-ops"> <td class="inst-td-ops">
@@ -115,7 +117,7 @@
</td> </td>
</tr> </tr>
<tr class="inst-edit-row"> <tr class="inst-edit-row">
<td colspan="8"> <td colspan="9">
<input type="checkbox" id="inst-edit-{{ inst.id }}" class="inst-edit-check" data-reveal-token> <input type="checkbox" id="inst-edit-{{ inst.id }}" class="inst-edit-check" data-reveal-token>
<div class="inst-edit-panel"> <div class="inst-edit-panel">
<form method="post" class="settings-form inst-form" autocomplete="off"> <form method="post" class="settings-form inst-form" autocomplete="off">
@@ -168,5 +170,5 @@
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='instances_page.js') }}?v=3"></script> <script src="{{ url_for('static', filename='instances_page.js') }}?v=4"></script>
{% endblock %} {% endblock %}