From 2a68b2ff40bbdeb19cced6579b4b2f1823f7e99a Mon Sep 17 00:00:00 2001 From: savsis Date: Sat, 12 Sep 2026 10:55:15 +0500 Subject: [PATCH] feat: drag-and-drop node reordering in the admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin.html | 59 +++++++++++++++++++++++++++++++++++++++++++++++++----- api.py | 13 ++++++++++++ db.py | 38 ++++++++++++++++++++++++++++------- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/admin.html b/admin.html index 4120cbb..34713d4 100644 --- a/admin.html +++ b/admin.html @@ -228,6 +228,11 @@ .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 { 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; @@ -344,7 +349,7 @@
- +
ЛокацияАдресТипСтатусLiveЛокацияАдресТипСтатусLive
@@ -1051,11 +1056,54 @@ async function createGift() { function copyText(t) { navigator.clipboard.writeText(t); } async function loadNodes() { - const nodes = await api("/admin/api/nodes"); - nodesCache = nodes; + nodesCache = await api("/admin/api/nodes"); + renderNodesTable(); +} + +let dragSrcCode = null; +function wireNodeDragAndDrop() { const body = document.getElementById("nodes-body"); - body.innerHTML = nodes.map((n, i) => ` - + body.querySelectorAll("tr.draggable-row").forEach((row) => { + 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) => ` + + ⠿ ${esc(n.label)}${esc(n.address) || "—"}${n.port && n.address ? ":" + n.port : ""} ${n.kind === "local" ? "локальная" : n.kind === "managed" ? "управляемая" : "внешняя"} ${n.status === "pending" ? 'ожидает установки' : (n.enabled ? 'включена' : 'выключена')} @@ -1067,6 +1115,7 @@ async function loadNodes() { `).join(""); + wireNodeDragAndDrop(); } async function loadNodeMetrics(code) { diff --git a/api.py b/api.py index 7301389..09094d9 100644 --- a/api.py +++ b/api.py @@ -742,6 +742,19 @@ def admin_nodes(request: Request): 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") def admin_create_node(request: Request, body: dict = Body(...)): require_admin(request) diff --git a/db.py b/db.py index fd59685..65ba369 100644 --- a/db.py +++ b/db.py @@ -104,6 +104,7 @@ _NEW_NODE_COLUMNS = { "hysteria_port": "INTEGER", "hysteria_password": "TEXT", "hysteria_obfs_password": "TEXT", + "sort_order": "INTEGER NOT NULL DEFAULT 0", } _NEW_USER_COLUMNS = { @@ -114,6 +115,7 @@ _NEW_USER_COLUMNS = { def _migrate(): with get_conn() as conn: 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(): if name not in cols: conn.execute(f"ALTER TABLE nodes ADD COLUMN {name} {decl}") @@ -121,6 +123,12 @@ def _migrate(): for name, decl in _NEW_USER_COLUMNS.items(): if name not in ucols: 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(): @@ -173,7 +181,7 @@ def list_nodes(enabled_only: bool = False): q = "SELECT * FROM nodes" if enabled_only: 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: rows = conn.execute(q).fetchall() return [dict(r) for r in rows] @@ -185,12 +193,27 @@ def get_node(code: str): 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): with get_conn() as conn: + next_order = _next_sort_order(conn) conn.execute( - "INSERT INTO nodes (code, label, kind, status, address, port, public_key, short_id, sni, flow, shared_uuid, enabled, created_at) " - "VALUES (?,?,?, 'active', ?,?,?,?,?,?,?,1,?)", - (code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid, now_iso()), + "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,?)", + (code, label, kind, address, port, public_key, short_id, sni, flow, shared_uuid, next_order, now_iso()), ) 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) hysteria_enabled = 1 if hysteria_password else 0 with get_conn() as conn: + next_order = _next_sort_order(conn) conn.execute( "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) " - "VALUES (?,?, 'managed', 'pending', ?,?,?,?,?,?, 'xtls-rprx-vision', ?, ?, ?, ?, ?, ?, 0, ?)", + "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, ?)", (code, label, address, port, public_key, private_key, short_id, sni, token, 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