feat: drag-and-drop node reordering in the admin panel
Matches Remnawave/Marzban's 'Host Sorting via Web UI' — was the one concrete UI gap flagged in the comparison research. Nodes get a persistent sort_order column (backfilled once from the existing de1-first/created_at order on migration, verified idempotent — re-running _migrate() on an already-migrated db does not reshuffle it back). Plain HTML5 drag-and-drop (dragstart/dragover/drop), no library — matches the project's no-frameworks admin.html. Drop reorders the in-memory list optimistically, re-renders immediately, then persists via POST /admin/api/nodes/reorder; a failed save reloads from the server instead of leaving the UI out of sync with the db. db.reorder_nodes() rejects any list that doesn't contain exactly the current set of node codes (no silent drops or duplicates). New nodes append at the end (MAX(sort_order)+1) instead of jumping to the front. Verified with a full test: initial order, reorder, two rejected malformed reorders, a new node appending at the end, and migration re-run not touching an already-backfilled order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
91a8a4374b
commit
2a68b2ff40
3 changed files with 98 additions and 12 deletions
59
admin.html
59
admin.html
|
|
@ -228,6 +228,11 @@
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 13px; }
|
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 13px; }
|
||||||
|
|
||||||
|
.drag-handle { cursor: grab; color: var(--muted); text-align: center; user-select: none; font-size: 15px; }
|
||||||
|
.draggable-row.dragging { opacity: 0.4; }
|
||||||
|
.draggable-row.drag-over { box-shadow: inset 0 2px 0 var(--accent); }
|
||||||
|
.draggable-row:active .drag-handle { cursor: grabbing; }
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(1,4,9,0.6); backdrop-filter: blur(2px);
|
position: fixed; inset: 0; background: rgba(1,4,9,0.6); backdrop-filter: blur(2px);
|
||||||
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100;
|
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100;
|
||||||
|
|
@ -344,7 +349,7 @@
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="table-wrap"><table><thead><tr>
|
<div class="table-wrap"><table><thead><tr>
|
||||||
<th>Локация</th><th>Адрес</th><th>Тип</th><th>Статус</th><th>Live</th><th></th>
|
<th style="width:28px"></th><th>Локация</th><th>Адрес</th><th>Тип</th><th>Статус</th><th>Live</th><th></th>
|
||||||
</tr></thead><tbody id="nodes-body"></tbody></table></div>
|
</tr></thead><tbody id="nodes-body"></tbody></table></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1051,11 +1056,54 @@ async function createGift() {
|
||||||
function copyText(t) { navigator.clipboard.writeText(t); }
|
function copyText(t) { navigator.clipboard.writeText(t); }
|
||||||
|
|
||||||
async function loadNodes() {
|
async function loadNodes() {
|
||||||
const nodes = await api("/admin/api/nodes");
|
nodesCache = await api("/admin/api/nodes");
|
||||||
nodesCache = nodes;
|
renderNodesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragSrcCode = null;
|
||||||
|
function wireNodeDragAndDrop() {
|
||||||
const body = document.getElementById("nodes-body");
|
const body = document.getElementById("nodes-body");
|
||||||
body.innerHTML = nodes.map((n, i) => `
|
body.querySelectorAll("tr.draggable-row").forEach((row) => {
|
||||||
<tr ${rowAttr(i)}>
|
row.addEventListener("dragstart", (e) => {
|
||||||
|
dragSrcCode = row.dataset.code;
|
||||||
|
row.classList.add("dragging");
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
row.addEventListener("dragend", () => {
|
||||||
|
row.classList.remove("dragging");
|
||||||
|
body.querySelectorAll("tr").forEach((r) => r.classList.remove("drag-over"));
|
||||||
|
});
|
||||||
|
row.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (row.dataset.code === dragSrcCode) return;
|
||||||
|
row.classList.add("drag-over");
|
||||||
|
});
|
||||||
|
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
|
||||||
|
row.addEventListener("drop", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
row.classList.remove("drag-over");
|
||||||
|
const targetCode = row.dataset.code;
|
||||||
|
if (!dragSrcCode || targetCode === dragSrcCode) return;
|
||||||
|
const order = nodesCache.map((n) => n.code);
|
||||||
|
const from = order.indexOf(dragSrcCode);
|
||||||
|
const to = order.indexOf(targetCode);
|
||||||
|
order.splice(to, 0, order.splice(from, 1)[0]);
|
||||||
|
nodesCache.sort((a, b) => order.indexOf(a.code) - order.indexOf(b.code));
|
||||||
|
renderNodesTable();
|
||||||
|
try {
|
||||||
|
await api("/admin/api/nodes/reorder", { method: "POST", body: JSON.stringify({ codes: order }) });
|
||||||
|
} catch (err) {
|
||||||
|
loadNodes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodesTable() {
|
||||||
|
const body = document.getElementById("nodes-body");
|
||||||
|
body.innerHTML = nodesCache.map((n, i) => `
|
||||||
|
<tr class="reveal draggable-row" style="animation-delay:${Math.min(i, 10) * 0.025}s" draggable="true" data-code="${esc(n.code)}">
|
||||||
|
<td class="drag-handle" title="Перетащи, чтобы поменять порядок">⠿</td>
|
||||||
<td>${esc(n.label)}</td><td>${esc(n.address) || "—"}${n.port && n.address ? ":" + n.port : ""}</td>
|
<td>${esc(n.label)}</td><td>${esc(n.address) || "—"}${n.port && n.address ? ":" + n.port : ""}</td>
|
||||||
<td>${n.kind === "local" ? "локальная" : n.kind === "managed" ? "управляемая" : "внешняя"}</td>
|
<td>${n.kind === "local" ? "локальная" : n.kind === "managed" ? "управляемая" : "внешняя"}</td>
|
||||||
<td>${n.status === "pending" ? '<span class="badge warn">ожидает установки</span>' : (n.enabled ? '<span class="badge ok">включена</span>' : '<span class="badge bad">выключена</span>')}</td>
|
<td>${n.status === "pending" ? '<span class="badge warn">ожидает установки</span>' : (n.enabled ? '<span class="badge ok">включена</span>' : '<span class="badge bad">выключена</span>')}</td>
|
||||||
|
|
@ -1067,6 +1115,7 @@ async function loadNodes() {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
|
wireNodeDragAndDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNodeMetrics(code) {
|
async function loadNodeMetrics(code) {
|
||||||
|
|
|
||||||
13
api.py
13
api.py
|
|
@ -742,6 +742,19 @@ def admin_nodes(request: Request):
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/api/nodes/reorder")
|
||||||
|
def admin_reorder_nodes(request: Request, body: dict = Body(...)):
|
||||||
|
require_admin(request)
|
||||||
|
codes = body.get("codes")
|
||||||
|
if not isinstance(codes, list) or not codes:
|
||||||
|
raise HTTPException(400, "codes must be a non-empty list")
|
||||||
|
try:
|
||||||
|
db.reorder_nodes(codes)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/api/nodes")
|
@app.post("/admin/api/nodes")
|
||||||
def admin_create_node(request: Request, body: dict = Body(...)):
|
def admin_create_node(request: Request, body: dict = Body(...)):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|
|
||||||
38
db.py
38
db.py
|
|
@ -104,6 +104,7 @@ _NEW_NODE_COLUMNS = {
|
||||||
"hysteria_port": "INTEGER",
|
"hysteria_port": "INTEGER",
|
||||||
"hysteria_password": "TEXT",
|
"hysteria_password": "TEXT",
|
||||||
"hysteria_obfs_password": "TEXT",
|
"hysteria_obfs_password": "TEXT",
|
||||||
|
"sort_order": "INTEGER NOT NULL DEFAULT 0",
|
||||||
}
|
}
|
||||||
|
|
||||||
_NEW_USER_COLUMNS = {
|
_NEW_USER_COLUMNS = {
|
||||||
|
|
@ -114,6 +115,7 @@ _NEW_USER_COLUMNS = {
|
||||||
def _migrate():
|
def _migrate():
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(nodes)").fetchall()}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(nodes)").fetchall()}
|
||||||
|
needs_sort_order_backfill = "sort_order" not in cols
|
||||||
for name, decl in _NEW_NODE_COLUMNS.items():
|
for name, decl in _NEW_NODE_COLUMNS.items():
|
||||||
if name not in cols:
|
if name not in cols:
|
||||||
conn.execute(f"ALTER TABLE nodes ADD COLUMN {name} {decl}")
|
conn.execute(f"ALTER TABLE nodes ADD COLUMN {name} {decl}")
|
||||||
|
|
@ -121,6 +123,12 @@ def _migrate():
|
||||||
for name, decl in _NEW_USER_COLUMNS.items():
|
for name, decl in _NEW_USER_COLUMNS.items():
|
||||||
if name not in ucols:
|
if name not in ucols:
|
||||||
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
|
conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}")
|
||||||
|
if needs_sort_order_backfill:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
conn.execute("UPDATE nodes SET sort_order=? WHERE code=?", (i, row["code"]))
|
||||||
|
|
||||||
|
|
||||||
def now_iso():
|
def now_iso():
|
||||||
|
|
@ -173,7 +181,7 @@ def list_nodes(enabled_only: bool = False):
|
||||||
q = "SELECT * FROM nodes"
|
q = "SELECT * FROM nodes"
|
||||||
if enabled_only:
|
if enabled_only:
|
||||||
q += " WHERE enabled=1"
|
q += " WHERE enabled=1"
|
||||||
q += " ORDER BY (code='de1') DESC, created_at ASC"
|
q += " ORDER BY sort_order ASC, created_at ASC"
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(q).fetchall()
|
rows = conn.execute(q).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
@ -185,12 +193,27 @@ def get_node(code: str):
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_sort_order(conn):
|
||||||
|
row = conn.execute("SELECT MAX(sort_order) m FROM nodes").fetchone()
|
||||||
|
return (row["m"] or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_nodes(codes: list):
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = {r["code"] for r in conn.execute("SELECT code FROM nodes").fetchall()}
|
||||||
|
if set(codes) != existing:
|
||||||
|
raise ValueError("reorder list must include exactly all existing node codes")
|
||||||
|
for i, code in enumerate(codes):
|
||||||
|
conn.execute("UPDATE nodes SET sort_order=? WHERE code=?", (i, code))
|
||||||
|
|
||||||
|
|
||||||
def create_node(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid=None):
|
def create_node(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid=None):
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
next_order = _next_sort_order(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO nodes (code, label, kind, status, address, port, public_key, short_id, sni, flow, shared_uuid, enabled, created_at) "
|
"INSERT INTO nodes (code, label, kind, status, address, port, public_key, short_id, sni, flow, shared_uuid, sort_order, enabled, created_at) "
|
||||||
"VALUES (?,?,?, 'active', ?,?,?,?,?,?,?,1,?)",
|
"VALUES (?,?,?, 'active', ?,?,?,?,?,?,?,?,1,?)",
|
||||||
(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid, now_iso()),
|
(code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid, next_order, now_iso()),
|
||||||
)
|
)
|
||||||
return get_node(code)
|
return get_node(code)
|
||||||
|
|
||||||
|
|
@ -203,13 +226,14 @@ def create_pending_node(label, address, port, sni, private_key, public_key, shor
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
hysteria_enabled = 1 if hysteria_password else 0
|
hysteria_enabled = 1 if hysteria_password else 0
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
next_order = _next_sort_order(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO nodes (code, label, kind, status, address, port, public_key, private_key, short_id, sni, flow, "
|
"INSERT INTO nodes (code, label, kind, status, address, port, public_key, private_key, short_id, sni, flow, "
|
||||||
"provision_token, transports_json, hysteria_enabled, hysteria_port, hysteria_password, hysteria_obfs_password, enabled, created_at) "
|
"provision_token, transports_json, hysteria_enabled, hysteria_port, hysteria_password, hysteria_obfs_password, sort_order, enabled, created_at) "
|
||||||
"VALUES (?,?, 'managed', 'pending', ?,?,?,?,?,?, 'xtls-rprx-vision', ?, ?, ?, ?, ?, ?, 0, ?)",
|
"VALUES (?,?, 'managed', 'pending', ?,?,?,?,?,?, 'xtls-rprx-vision', ?, ?, ?, ?, ?, ?, ?, 0, ?)",
|
||||||
(code, label, address, port, public_key, private_key, short_id, sni, token,
|
(code, label, address, port, public_key, private_key, short_id, sni, token,
|
||||||
transports_json if transports_json is not None else jsonmod.dumps([]),
|
transports_json if transports_json is not None else jsonmod.dumps([]),
|
||||||
hysteria_enabled, hysteria_port, hysteria_password, hysteria_obfs_password, now_iso()),
|
hysteria_enabled, hysteria_port, hysteria_password, hysteria_obfs_password, next_order, now_iso()),
|
||||||
)
|
)
|
||||||
return get_node(code), token
|
return get_node(code), token
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue