修复中控
This commit is contained in:
+2
-1
@@ -14,8 +14,9 @@
|
|||||||
# - 若该用户已存在且设置 NAV_ADMIN_UPDATE_PASSWORD=1:用 NAV_ADMIN_PASSWORD 覆盖其密码(改完请删去或置 0,避免每次启动重置)。
|
# - 若该用户已存在且设置 NAV_ADMIN_UPDATE_PASSWORD=1:用 NAV_ADMIN_PASSWORD 覆盖其密码(改完请删去或置 0,避免每次启动重置)。
|
||||||
# NAV_ADMIN_UPDATE_PASSWORD=1
|
# NAV_ADMIN_UPDATE_PASSWORD=1
|
||||||
|
|
||||||
# 数据库(默认 SQLite 文件在当前工作目录)
|
# 数据库(默认 SQLite 文件在当前工作目录;生产建议放 instance/ 且勿提交 Git)
|
||||||
# NAV_DATABASE_URL=sqlite:///nav_local.db
|
# NAV_DATABASE_URL=sqlite:///nav_local.db
|
||||||
|
# NAV_DATABASE_URL=sqlite:///instance/nav_local.db
|
||||||
|
|
||||||
# 仅 python app.py 直连启动时生效
|
# 仅 python app.py 直连启动时生效
|
||||||
# NAV_HOST=0.0.0.0
|
# NAV_HOST=0.0.0.0
|
||||||
|
|||||||
@@ -93,6 +93,103 @@ def _apply_session_cookie_settings(app: Flask) -> None:
|
|||||||
app.config["REMEMBER_COOKIE_SECURE"] = False
|
app.config["REMEMBER_COOKIE_SECURE"] = False
|
||||||
|
|
||||||
|
|
||||||
|
_HUB_SESSION_COOKIE = "hub_sess"
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_api_login(base: str, username: str, password: str) -> tuple[str | None, str | None]:
|
||||||
|
"""服务端登录中控,返回 (session_token, error_detail)。"""
|
||||||
|
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base.rstrip('/')}/api/auth/login",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
err_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
detail = json.loads(err_body).get("detail", err_body)
|
||||||
|
except Exception:
|
||||||
|
detail = str(e)
|
||||||
|
return None, detail or "中控登录失败"
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"无法连接中控: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "中控返回非 JSON"
|
||||||
|
if not data.get("ok"):
|
||||||
|
return None, data.get("detail") or "登录失败"
|
||||||
|
token = (data.get("session_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
return None, "中控未返回 session_token,请升级云端 hub 后重试"
|
||||||
|
return token, None
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_instance_open_url(
|
||||||
|
base: str, session_token: str, exchange_id: str, next_path: str, *, embed: bool = True
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""已登录中控会话下签发实例 SSO 打开链接。"""
|
||||||
|
params = {"exchange_id": str(exchange_id), "next": next_path or "/"}
|
||||||
|
if embed:
|
||||||
|
params["embed"] = "1"
|
||||||
|
q = urlencode(params)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base.rstrip('/')}/api/instance/open-url?{q}",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cookie": f"{_HUB_SESSION_COOKIE}={session_token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
err_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
detail = json.loads(err_body).get("detail", err_body)
|
||||||
|
except Exception:
|
||||||
|
detail = str(e)
|
||||||
|
return None, detail or "无法签发实例链接"
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"无法连接中控: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "中控返回非 JSON"
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
if not data.get("ok") or not url:
|
||||||
|
return None, data.get("detail") or "无法签发实例链接"
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_hub_embed_service(body: dict) -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""返回 (base_origin, default_next_path, error_detail)。"""
|
||||||
|
sid = body.get("service_id")
|
||||||
|
base = (body.get("base_url") or "").strip().rstrip("/")
|
||||||
|
next_path = (body.get("next") or "/monitor").strip() or "/monitor"
|
||||||
|
if not next_path.startswith("/"):
|
||||||
|
next_path = "/" + next_path
|
||||||
|
|
||||||
|
if sid:
|
||||||
|
svc = db.session.get(Service, int(sid))
|
||||||
|
if not svc or not svc.is_hub_embed():
|
||||||
|
return None, None, "服务不存在或未标记为中控"
|
||||||
|
base = svc.build_origin()
|
||||||
|
if not body.get("next") or next_path == "/monitor":
|
||||||
|
p = (svc.path or "/monitor").strip() or "/monitor"
|
||||||
|
next_path = p if p.startswith("/") else "/" + p
|
||||||
|
|
||||||
|
if not base:
|
||||||
|
return None, None, "缺少 base_url"
|
||||||
|
return base, next_path, None
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32)
|
app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32)
|
||||||
@@ -178,65 +275,71 @@ def create_app() -> Flask:
|
|||||||
避免浏览器在跨站 iframe 里丢弃 Set-Cookie。
|
避免浏览器在跨站 iframe 里丢弃 Set-Cookie。
|
||||||
"""
|
"""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
sid = body.get("service_id")
|
|
||||||
base = (body.get("base_url") or "").strip().rstrip("/")
|
|
||||||
next_path = (body.get("next") or "/monitor").strip() or "/monitor"
|
|
||||||
if not next_path.startswith("/"):
|
|
||||||
next_path = "/" + next_path
|
|
||||||
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
|
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
|
||||||
password = body.get("password")
|
password = body.get("password")
|
||||||
if password is None:
|
if password is None:
|
||||||
password = os.environ.get("NAV_HUB_PASSWORD") or ""
|
password = os.environ.get("NAV_HUB_PASSWORD") or ""
|
||||||
password = str(password)
|
password = str(password)
|
||||||
|
|
||||||
if sid:
|
base, next_path, err = _resolve_hub_embed_service(body)
|
||||||
svc = db.session.get(Service, int(sid))
|
if err:
|
||||||
if not svc or not svc.is_hub_embed():
|
return jsonify({"ok": False, "detail": err}), 400
|
||||||
return jsonify({"ok": False, "detail": "服务不存在或未标记为中控"}), 400
|
|
||||||
base = svc.build_origin()
|
|
||||||
if not next_path or next_path == "/monitor":
|
|
||||||
p = (svc.path or "/monitor").strip() or "/monitor"
|
|
||||||
next_path = p if p.startswith("/") else "/" + p
|
|
||||||
|
|
||||||
if not base:
|
|
||||||
return jsonify({"ok": False, "detail": "缺少 base_url"}), 400
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
|
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
|
||||||
|
|
||||||
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
|
token, err = _hub_api_login(base, username, password)
|
||||||
req = urllib.request.Request(
|
if err:
|
||||||
f"{base}/api/auth/login",
|
status = 401 if "登录" in err or "401" in err else 502
|
||||||
data=payload,
|
return jsonify({"ok": False, "detail": err}), status
|
||||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
err_body = e.read().decode("utf-8", errors="replace")
|
|
||||||
detail = json.loads(err_body).get("detail", err_body)
|
|
||||||
except Exception:
|
|
||||||
detail = str(e)
|
|
||||||
return jsonify({"ok": False, "detail": detail or "中控登录失败"}), 401
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "detail": f"无法连接中控: {e}"}), 502
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return jsonify({"ok": False, "detail": "中控返回非 JSON"}), 502
|
|
||||||
if not data.get("ok"):
|
|
||||||
return jsonify({"ok": False, "detail": data.get("detail") or "登录失败"}), 401
|
|
||||||
token = data.get("session_token")
|
|
||||||
if not token:
|
|
||||||
return jsonify({"ok": False, "detail": "中控未返回 session_token,请升级云端 hub 后重试"}), 502
|
|
||||||
|
|
||||||
q = urlencode({"token": token, "next": next_path})
|
q = urlencode({"token": token, "next": next_path})
|
||||||
embed_url = f"{base}/embed-auth?{q}"
|
embed_url = f"{base}/embed-auth?{q}"
|
||||||
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
|
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
|
||||||
|
|
||||||
|
@app.route("/api/embed/hub-instance-url", methods=["POST"])
|
||||||
|
@csrf.exempt
|
||||||
|
@login_required
|
||||||
|
def api_embed_hub_instance_url():
|
||||||
|
"""
|
||||||
|
本地导航代签实例 SSO:服务端登录中控后调用 /api/instance/open-url。
|
||||||
|
用于 LocalNav iframe 直接打开实例(避免中控内再嵌一层 iframe)。
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
exchange_id = str(body.get("exchange_id") or "").strip()
|
||||||
|
next_path = (body.get("next") or "/").strip() or "/"
|
||||||
|
if not next_path.startswith("/"):
|
||||||
|
next_path = "/" + next_path
|
||||||
|
if not exchange_id:
|
||||||
|
return jsonify({"ok": False, "detail": "缺少 exchange_id"}), 400
|
||||||
|
|
||||||
|
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
|
||||||
|
password = body.get("password")
|
||||||
|
if password is None:
|
||||||
|
password = os.environ.get("NAV_HUB_PASSWORD") or ""
|
||||||
|
password = str(password)
|
||||||
|
|
||||||
|
base, _, err = _resolve_hub_embed_service(body)
|
||||||
|
if err:
|
||||||
|
return jsonify({"ok": False, "detail": err}), 400
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
|
||||||
|
|
||||||
|
token, err = _hub_api_login(base, username, password)
|
||||||
|
if err:
|
||||||
|
status = 401 if "登录" in err or "401" in err else 502
|
||||||
|
return jsonify({"ok": False, "detail": err}), status
|
||||||
|
|
||||||
|
url, err = _hub_instance_open_url(
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
exchange_id,
|
||||||
|
next_path,
|
||||||
|
embed=(body.get("embed") or "1").strip().lower() not in ("0", "false", "no"),
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return jsonify({"ok": False, "detail": err}), 502
|
||||||
|
return jsonify({"ok": True, "url": url, "exchange_id": exchange_id, "next": next_path})
|
||||||
|
|
||||||
# ---------- 分组管理 ----------
|
# ---------- 分组管理 ----------
|
||||||
@app.route("/admin/groups")
|
@app.route("/admin/groups")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
Binary file not shown.
@@ -54,7 +54,10 @@ class Service(db.Model):
|
|||||||
proto = (self.scheme or "http").strip().lower()
|
proto = (self.scheme or "http").strip().lower()
|
||||||
if proto not in ("http", "https"):
|
if proto not in ("http", "https"):
|
||||||
proto = "http"
|
proto = "http"
|
||||||
return f"{proto}://{self.host}:{self.port}"
|
port = int(self.port or (443 if proto == "https" else 80))
|
||||||
|
if (proto == "https" and port == 443) or (proto == "http" and port == 80):
|
||||||
|
return f"{proto}://{self.host}"
|
||||||
|
return f"{proto}://{self.host}:{port}"
|
||||||
|
|
||||||
def build_url(self) -> str:
|
def build_url(self) -> str:
|
||||||
p = (self.path or "/").strip()
|
p = (self.path or "/").strip()
|
||||||
|
|||||||
@@ -348,6 +348,19 @@ a:hover {
|
|||||||
background: rgba(61, 139, 253, 0.1);
|
background: rgba(61, 139, 253, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#frame-back-hub {
|
||||||
|
color: #9ecbff;
|
||||||
|
border-color: rgba(61, 139, 253, 0.45);
|
||||||
|
background: rgba(61, 139, 253, 0.12);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frame-back-hub:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #3d8bfd;
|
||||||
|
background: rgba(61, 139, 253, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.frame-title {
|
.frame-title {
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
+269
-5
@@ -106,6 +106,15 @@
|
|||||||
<button type="button" class="btn-frame-back" id="frame-back-overview" title="返回服务总览">
|
<button type="button" class="btn-frame-back" id="frame-back-overview" title="返回服务总览">
|
||||||
总览
|
总览
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-frame-back"
|
||||||
|
id="frame-back-hub"
|
||||||
|
title="返回复盘中控监控区"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
← 返回中控
|
||||||
|
</button>
|
||||||
<span class="frame-title" id="current-service-name"></span>
|
<span class="frame-title" id="current-service-name"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-toolbar-actions">
|
<div class="frame-toolbar-actions">
|
||||||
@@ -118,6 +127,15 @@
|
|||||||
>
|
>
|
||||||
中控登录
|
中控登录
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-toolbar-refresh"
|
||||||
|
id="frame-instance-sso"
|
||||||
|
title="通过本地导航代签实例 SSO(需 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
实例免密
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-wrap">
|
<div class="frame-wrap">
|
||||||
<iframe id="svc-frame" title="内嵌服务" hidden></iframe>
|
<iframe id="svc-frame" name="svc-frame" title="内嵌服务" hidden></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,18 +199,55 @@
|
|||||||
var btnRefresh = document.getElementById("frame-refresh");
|
var btnRefresh = document.getElementById("frame-refresh");
|
||||||
var btnForceRefresh = document.getElementById("frame-force-refresh");
|
var btnForceRefresh = document.getElementById("frame-force-refresh");
|
||||||
var btnBack = document.getElementById("frame-back-overview");
|
var btnBack = document.getElementById("frame-back-overview");
|
||||||
|
var btnBackHub = document.getElementById("frame-back-hub");
|
||||||
var btnHubLogin = document.getElementById("frame-hub-login");
|
var btnHubLogin = document.getElementById("frame-hub-login");
|
||||||
|
var btnInstanceSso = document.getElementById("frame-instance-sso");
|
||||||
var currentBaseUrl = "";
|
var currentBaseUrl = "";
|
||||||
var currentOpenUrl = "";
|
var currentOpenUrl = "";
|
||||||
var currentEmbedKind = "";
|
var currentEmbedKind = "";
|
||||||
var currentServiceId = "";
|
var currentServiceId = "";
|
||||||
var currentOrigin = "";
|
var currentOrigin = "";
|
||||||
var currentNextPath = "/monitor";
|
var currentNextPath = "/monitor";
|
||||||
|
var currentViewMode = "service";
|
||||||
|
var hubReturnState = null;
|
||||||
|
var instanceNavCtx = null;
|
||||||
|
|
||||||
|
function normalizeOrigin(raw) {
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
var u = new URL(raw.indexOf("://") >= 0 ? raw : "http://" + raw);
|
||||||
|
var port = u.port;
|
||||||
|
if (u.protocol === "https:" && (!port || port === "443")) {
|
||||||
|
return u.protocol + "//" + u.hostname;
|
||||||
|
}
|
||||||
|
if (u.protocol === "http:" && (!port || port === "80")) {
|
||||||
|
return u.protocol + "//" + u.hostname;
|
||||||
|
}
|
||||||
|
return u.origin;
|
||||||
|
} catch (e) {
|
||||||
|
return String(raw).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function originsCompatible(expected, actual) {
|
||||||
|
var e = normalizeOrigin(expected);
|
||||||
|
var a = normalizeOrigin(actual);
|
||||||
|
if (!e || !a) return true;
|
||||||
|
return e === a;
|
||||||
|
}
|
||||||
|
|
||||||
function isHubEmbed(kind) {
|
function isHubEmbed(kind) {
|
||||||
return (kind || "").toLowerCase() === "hub";
|
return (kind || "").toLowerCase() === "hub";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleInstanceBackBtn(show) {
|
||||||
|
if (btnBackHub) btnBackHub.hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInstanceSsoBtn(show) {
|
||||||
|
if (btnInstanceSso) btnInstanceSso.hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleHubLoginBtn(show) {
|
function toggleHubLoginBtn(show) {
|
||||||
if (btnHubLogin) btnHubLogin.hidden = !show;
|
if (btnHubLogin) btnHubLogin.hidden = !show;
|
||||||
}
|
}
|
||||||
@@ -202,6 +257,48 @@
|
|||||||
frame.src = url;
|
frame.src = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iframeLooksLikeHub(href) {
|
||||||
|
if (!href) return false;
|
||||||
|
var hubOrigin = normalizeOrigin(currentOrigin);
|
||||||
|
var h = String(href);
|
||||||
|
if (hubOrigin && h.indexOf(hubOrigin) !== 0) return false;
|
||||||
|
return (
|
||||||
|
/\/monitor(\?|#|$)/.test(h) ||
|
||||||
|
h.indexOf("/embed-auth") >= 0 ||
|
||||||
|
(h.indexOf("/login") >= 0 && h.indexOf("embed=1") >= 0) ||
|
||||||
|
h.indexOf("/settings") >= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHubInstanceBackBtn() {
|
||||||
|
if (!isHubEmbed(currentEmbedKind) || frameStack.hidden || !currentBaseUrl) {
|
||||||
|
toggleInstanceBackBtn(false);
|
||||||
|
toggleInstanceSsoBtn(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var onHub = false;
|
||||||
|
try {
|
||||||
|
onHub = iframeLooksLikeHub(frame.contentWindow.location.href);
|
||||||
|
} catch (e) {
|
||||||
|
onHub = false;
|
||||||
|
}
|
||||||
|
if (onHub) {
|
||||||
|
currentViewMode = "hub";
|
||||||
|
toggleInstanceBackBtn(false);
|
||||||
|
toggleInstanceSsoBtn(false);
|
||||||
|
toggleHubLoginBtn(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentViewMode = "hub-instance";
|
||||||
|
toggleInstanceBackBtn(true);
|
||||||
|
toggleHubLoginBtn(false);
|
||||||
|
toggleInstanceSsoBtn(!!(instanceNavCtx && instanceNavCtx.exchangeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame) {
|
||||||
|
frame.addEventListener("load", syncHubInstanceBackBtn);
|
||||||
|
}
|
||||||
|
|
||||||
function hubLoginViaProxy(done) {
|
function hubLoginViaProxy(done) {
|
||||||
if (!currentServiceId && !currentOrigin) {
|
if (!currentServiceId && !currentOrigin) {
|
||||||
if (done) done(false, "未选择中控服务");
|
if (done) done(false, "未选择中控服务");
|
||||||
@@ -234,12 +331,127 @@
|
|||||||
|
|
||||||
window.addEventListener("message", function (ev) {
|
window.addEventListener("message", function (ev) {
|
||||||
var data = ev.data;
|
var data = ev.data;
|
||||||
if (!data || data.type !== "hub:login-ok") return;
|
if (!data || !data.type) return;
|
||||||
if (data.embed_auth_url) {
|
if (data.type === "hub:login-ok") {
|
||||||
applyIframeUrl(data.embed_auth_url);
|
if (data.embed_auth_url) {
|
||||||
|
applyIframeUrl(data.embed_auth_url);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type === "hub:open-instance-nav") {
|
||||||
|
instanceNavCtx = {
|
||||||
|
exchangeId: String(data.exchangeId || ""),
|
||||||
|
nextPath: data.nextPath || "/",
|
||||||
|
title: data.title || "交易所实例",
|
||||||
|
serviceId: currentServiceId,
|
||||||
|
};
|
||||||
|
currentViewMode = "hub-instance";
|
||||||
|
if (data.title) nameEl.textContent = data.title;
|
||||||
|
toggleHubLoginBtn(false);
|
||||||
|
toggleInstanceBackBtn(true);
|
||||||
|
toggleInstanceSsoBtn(!!instanceNavCtx.exchangeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type !== "hub:open-instance") return;
|
||||||
|
if (frameStack.hidden || !currentServiceId) return;
|
||||||
|
if (!originsCompatible(currentOrigin, ev.origin)) {
|
||||||
|
console.warn(
|
||||||
|
"[LocalNav] hub:open-instance origin 不匹配,已忽略",
|
||||||
|
normalizeOrigin(currentOrigin),
|
||||||
|
normalizeOrigin(ev.origin)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ev.source) {
|
||||||
|
ev.source.postMessage({ type: "hub:open-instance-ack", ok: true }, ev.origin || "*");
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
hubReturnState = {
|
||||||
|
openUrl: currentOpenUrl,
|
||||||
|
baseUrl: currentBaseUrl,
|
||||||
|
name: nameEl.textContent,
|
||||||
|
embedKind: currentEmbedKind,
|
||||||
|
serviceId: currentServiceId,
|
||||||
|
origin: currentOrigin,
|
||||||
|
nextPath: currentNextPath,
|
||||||
|
};
|
||||||
|
instanceNavCtx = {
|
||||||
|
exchangeId: String(data.exchangeId || ""),
|
||||||
|
nextPath: data.nextPath || "/",
|
||||||
|
title: data.title || "交易所实例",
|
||||||
|
serviceId: currentServiceId,
|
||||||
|
};
|
||||||
|
currentViewMode = "hub-instance";
|
||||||
|
nameEl.textContent = instanceNavCtx.title;
|
||||||
|
toggleHubLoginBtn(false);
|
||||||
|
toggleInstanceBackBtn(true);
|
||||||
|
applyIframeUrl(data.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function refreshInstanceViaProxy(done) {
|
||||||
|
if (!instanceNavCtx || !instanceNavCtx.exchangeId) {
|
||||||
|
if (done) done(false, null, "缺少实例上下文");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch("/api/embed/hub-instance-url", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
service_id: parseInt(instanceNavCtx.serviceId, 10) || undefined,
|
||||||
|
exchange_id: instanceNavCtx.exchangeId,
|
||||||
|
next: instanceNavCtx.nextPath || "/",
|
||||||
|
embed: "1",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
return { ok: r.ok, j: j };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok && res.j.ok && res.j.url) {
|
||||||
|
if (done) done(true, res.j.url, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (done) done(false, null, (res.j && res.j.detail) || "无法重新打开实例");
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (done) done(false, null, String(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToHubMonitor() {
|
||||||
|
var st = hubReturnState;
|
||||||
|
currentViewMode = "hub";
|
||||||
|
instanceNavCtx = null;
|
||||||
|
hubReturnState = null;
|
||||||
|
toggleInstanceBackBtn(false);
|
||||||
|
toggleInstanceSsoBtn(false);
|
||||||
|
if (st) {
|
||||||
|
currentOpenUrl = st.openUrl || currentOpenUrl;
|
||||||
|
currentBaseUrl = st.baseUrl || st.openUrl || currentBaseUrl;
|
||||||
|
currentEmbedKind = st.embedKind || currentEmbedKind;
|
||||||
|
currentServiceId = st.serviceId || currentServiceId;
|
||||||
|
currentOrigin = st.origin || currentOrigin;
|
||||||
|
currentNextPath = st.nextPath || currentNextPath;
|
||||||
|
nameEl.textContent = st.name || nameEl.textContent;
|
||||||
|
}
|
||||||
|
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
|
||||||
|
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
|
||||||
|
hubLoginViaProxy(function (ok) {
|
||||||
|
if (!ok) applyIframeUrl(currentOpenUrl || currentBaseUrl);
|
||||||
|
syncHubInstanceBackBtn();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyIframeUrl(currentOpenUrl || currentBaseUrl);
|
||||||
|
syncHubInstanceBackBtn();
|
||||||
|
}
|
||||||
|
|
||||||
function setActive(el) {
|
function setActive(el) {
|
||||||
links.forEach(function (a) {
|
links.forEach(function (a) {
|
||||||
a.classList.remove("active");
|
a.classList.remove("active");
|
||||||
@@ -264,6 +476,10 @@
|
|||||||
currentServiceId = meta.serviceId || "";
|
currentServiceId = meta.serviceId || "";
|
||||||
currentOrigin = meta.origin || "";
|
currentOrigin = meta.origin || "";
|
||||||
currentNextPath = meta.nextPath || "/monitor";
|
currentNextPath = meta.nextPath || "/monitor";
|
||||||
|
currentViewMode = isHubEmbed(currentEmbedKind) ? "hub" : "service";
|
||||||
|
instanceNavCtx = null;
|
||||||
|
hubReturnState = null;
|
||||||
|
toggleInstanceBackBtn(false);
|
||||||
nameEl.textContent = name || "";
|
nameEl.textContent = name || "";
|
||||||
dashboard.hidden = true;
|
dashboard.hidden = true;
|
||||||
frameStack.hidden = false;
|
frameStack.hidden = false;
|
||||||
@@ -307,12 +523,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reloadUrl() {
|
function reloadUrl() {
|
||||||
|
if (currentViewMode === "hub-instance") {
|
||||||
|
refreshInstanceViaProxy(function (ok, url, err) {
|
||||||
|
if (ok && url) applyIframeUrl(url);
|
||||||
|
else if (err) window.alert(err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
var u = currentOpenUrl || currentBaseUrl;
|
var u = currentOpenUrl || currentBaseUrl;
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
frame.src = buildCacheBustUrl(u, false);
|
frame.src = buildCacheBustUrl(u, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forceReloadUrl() {
|
function forceReloadUrl() {
|
||||||
|
if (currentViewMode === "hub-instance") {
|
||||||
|
refreshInstanceViaProxy(function (ok, url, err) {
|
||||||
|
if (!ok || !url) {
|
||||||
|
if (err) window.alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame.src = "about:blank";
|
||||||
|
frame.onload = function () {
|
||||||
|
frame.onload = null;
|
||||||
|
frame.src = buildCacheBustUrl(url, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
var u = currentOpenUrl || currentBaseUrl;
|
var u = currentOpenUrl || currentBaseUrl;
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
frame.src = "about:blank";
|
frame.src = "about:blank";
|
||||||
@@ -328,11 +565,15 @@
|
|||||||
currentEmbedKind = "";
|
currentEmbedKind = "";
|
||||||
currentServiceId = "";
|
currentServiceId = "";
|
||||||
currentOrigin = "";
|
currentOrigin = "";
|
||||||
|
currentViewMode = "service";
|
||||||
|
instanceNavCtx = null;
|
||||||
|
hubReturnState = null;
|
||||||
frame.src = "about:blank";
|
frame.src = "about:blank";
|
||||||
frame.hidden = true;
|
frame.hidden = true;
|
||||||
frameStack.hidden = true;
|
frameStack.hidden = true;
|
||||||
dashboard.hidden = false;
|
dashboard.hidden = false;
|
||||||
toggleHubLoginBtn(false);
|
toggleHubLoginBtn(false);
|
||||||
|
toggleInstanceSsoBtn(false);
|
||||||
setActive(null);
|
setActive(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +609,24 @@
|
|||||||
btnHubLogin.disabled = true;
|
btnHubLogin.disabled = true;
|
||||||
hubLoginViaProxy(function (ok, err) {
|
hubLoginViaProxy(function (ok, err) {
|
||||||
btnHubLogin.disabled = false;
|
btnHubLogin.disabled = false;
|
||||||
if (!ok && err) window.alert(err);
|
if (!ok && err) {
|
||||||
|
window.alert("中控登录失败:\n" + err + "\n\n请检查 LocalNav .env 的 NAV_HUB_USERNAME / NAV_HUB_PASSWORD 是否与云端 hub .env 一致。");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnInstanceSso) {
|
||||||
|
btnInstanceSso.addEventListener("click", function () {
|
||||||
|
btnInstanceSso.disabled = true;
|
||||||
|
refreshInstanceViaProxy(function (ok, url, err) {
|
||||||
|
btnInstanceSso.disabled = false;
|
||||||
|
if (ok && url) {
|
||||||
|
applyIframeUrl(url);
|
||||||
|
syncHubInstanceBackBtn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err) window.alert("实例免密失败:\n" + err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -384,6 +642,12 @@
|
|||||||
btnBack.addEventListener("click", function () {
|
btnBack.addEventListener("click", function () {
|
||||||
showDashboard();
|
showDashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (btnBackHub) {
|
||||||
|
btnBackHub.addEventListener("click", function () {
|
||||||
|
returnToHubMonitor();
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+11
-1
@@ -264,7 +264,17 @@ HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
|||||||
|
|
||||||
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
|
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
|
||||||
|
|
||||||
**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。
|
**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接(避免「导航 → 中控 → 实例」三层嵌套导致 Cookie 失效、反复要密码)。工具栏会出现 **「← 中控」** 返回监控区;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`)。
|
||||||
|
|
||||||
|
四实例 `.env` 建议:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_COOKIE_SECURE=true
|
||||||
|
APP_ALLOW_HUB_EMBED=true
|
||||||
|
HUB_EMBED_PARENT_ORIGINS=https://你的中控域名,http://192.168.x.x:5070
|
||||||
|
```
|
||||||
|
|
||||||
|
(`5070` 换成本地导航实际地址;与中控相同的 `HUB_BRIDGE_TOKEN` 必填。)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user