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