diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4e867..903766e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## 2026-04-06 +### 依赖健康卡开始显示服务部署位置 + +- `collector` 的 `/v2/integrations/health` 现在会统一带出 `deployment_scope / deployment_label`,明确说明依赖当前跑在 `服务器 / NAS / Windows / NAS 隧道 / 未启用` 哪一侧。 +- 工作台里的依赖健康卡已经开始展示 `部署:服务器`、`部署:Windows` 这类信息,和 `ASR 在线 · GPU` 一起出现,后续迁服务时不需要再靠命令行手查。 +- 当前这套口径已经覆盖 `n8n / huobao / asr / cutvideo / live_recorder / local_model`。 + ### 工作台依赖健康现在会显示 ASR 真实运行模式 - `collector` 的 `/v2/integrations/health` 现在会带出 ASR 的 `language_mode / runtime_device_mode / runtime_compute_type_mode / active_device / active_compute_type / model_name`。 diff --git a/collector-service/app/core_main.py b/collector-service/app/core_main.py index 8cb35e4..3e6af45 100644 --- a/collector-service/app/core_main.py +++ b/collector-service/app/core_main.py @@ -947,6 +947,25 @@ def cutvideo_route_mode(base_url: str) -> str: return "direct" +def integration_deployment_payload(key: str, base_url: str, *, route_mode: str = "") -> dict[str, str]: + normalized = (base_url or "").strip() + if not normalized: + return {"deployment_scope": "not_configured", "deployment_label": "未配置"} + if key == "local_model": + return {"deployment_scope": "disabled", "deployment_label": "未启用"} + if key == "asr": + return {"deployment_scope": "windows", "deployment_label": "Windows"} + if key == "live_recorder": + return {"deployment_scope": "nas", "deployment_label": "NAS"} + if key == "cutvideo": + if route_mode == "fnos_tunnel": + return {"deployment_scope": "nas_tunnel", "deployment_label": "NAS 隧道"} + return {"deployment_scope": "windows", "deployment_label": "Windows"} + if key in {"n8n", "huobao"}: + return {"deployment_scope": "server", "deployment_label": "服务器"} + return {"deployment_scope": "external", "deployment_label": "外部服务"} + + def disk_usage_payload(path: Path) -> dict[str, Any]: probe = path if path.exists() else path.parent try: @@ -3301,6 +3320,7 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> return { "local_model": { "base_url": LOCAL_OPENAI_BASE_URL, + **integration_deployment_payload("local_model", LOCAL_OPENAI_BASE_URL), **probe_http(LOCAL_OPENAI_BASE_URL, "/models"), }, "cutvideo": { @@ -3311,17 +3331,21 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> "upload_error": cutvideo_uploads.get("error", ""), "upload_url": cutvideo_uploads.get("url", ""), "route_mode": cutvideo_route_mode(CUTVIDEO_BASE_URL), + **integration_deployment_payload("cutvideo", CUTVIDEO_BASE_URL, route_mode=cutvideo_route_mode(CUTVIDEO_BASE_URL)), }, "huobao": { "base_url": HUOBAO_BASE_URL, + **integration_deployment_payload("huobao", HUOBAO_BASE_URL), **probe_http(HUOBAO_BASE_URL, "/health"), }, "n8n": { "base_url": N8N_BASE_URL, + **integration_deployment_payload("n8n", N8N_BASE_URL), **probe_http(N8N_BASE_URL, "/healthz"), }, "asr": { "base_url": ASR_HTTP_BASE_URL, + **integration_deployment_payload("asr", ASR_HTTP_BASE_URL), "configured": asr_probe.get("configured", False), "reachable": asr_probe.get("reachable", False), "status_code": int(asr_probe.get("status_code") or 0), @@ -3336,6 +3360,7 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> }, "live_recorder": { "base_url": LIVE_RECORDER_BASE_URL, + **integration_deployment_payload("live_recorder", LIVE_RECORDER_BASE_URL), **probe_http(LIVE_RECORDER_BASE_URL, "/api/healthz"), }, } diff --git a/tests/test_production_baseline.py b/tests/test_production_baseline.py index 364d775..a46323c 100644 --- a/tests/test_production_baseline.py +++ b/tests/test_production_baseline.py @@ -326,6 +326,71 @@ class ProductionBaselineTests(unittest.TestCase): self.assertEqual(payload["asr"]["language_mode"], "auto") self.assertEqual(payload["asr"]["model_name"], "base") + def test_integrations_health_exposes_deployment_labels(self) -> None: + ctx = self._seed_context("deployment_labels", exhausted=False) + headers = {"Authorization": f"Bearer {ctx['token']}"} + original_n8n = self.core.N8N_BASE_URL + original_huobao = self.core.HUOBAO_BASE_URL + original_asr = self.core.ASR_HTTP_BASE_URL + original_live_recorder = self.core.LIVE_RECORDER_BASE_URL + original_cutvideo = self.core.CUTVIDEO_BASE_URL + original_probe_http = self.core.probe_http + original_probe_http_json = getattr(self.core, "probe_http_json", None) + try: + self.core.N8N_BASE_URL = "http://127.0.0.1:25670" + self.core.HUOBAO_BASE_URL = "http://127.0.0.1:25678" + self.core.ASR_HTTP_BASE_URL = "http://192.168.31.18:8088" + self.core.LIVE_RECORDER_BASE_URL = "http://192.168.31.188:19106" + self.core.CUTVIDEO_BASE_URL = "http://192.168.31.188:19186" + + def fake_probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]: + return { + "configured": True, + "reachable": True, + "status_code": 200, + "error": "", + "url": f"{url.rstrip('/')}/{path.lstrip('/')}" if path else url, + } + + def fake_probe_http_json(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]: + detail = fake_probe_http(url, path=path, timeout=timeout) + detail["json"] = { + "service": "storyforge-windows-asr", + "model_name": "base", + "language": "auto", + "device": "auto", + "compute_type": "auto", + "active_device": "cuda", + "active_compute_type": "int8_float16", + } + return detail + + self.core.probe_http = fake_probe_http + self.core.probe_http_json = fake_probe_http_json + response = self.client.get("/v2/integrations/health", headers=headers) + finally: + self.core.N8N_BASE_URL = original_n8n + self.core.HUOBAO_BASE_URL = original_huobao + self.core.ASR_HTTP_BASE_URL = original_asr + self.core.LIVE_RECORDER_BASE_URL = original_live_recorder + self.core.CUTVIDEO_BASE_URL = original_cutvideo + self.core.probe_http = original_probe_http + if original_probe_http_json is None: + try: + delattr(self.core, "probe_http_json") + except AttributeError: + pass + else: + self.core.probe_http_json = original_probe_http_json + + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["n8n"]["deployment_label"], "服务器") + self.assertEqual(payload["huobao"]["deployment_label"], "服务器") + self.assertEqual(payload["asr"]["deployment_label"], "Windows") + self.assertEqual(payload["live_recorder"]["deployment_label"], "NAS") + self.assertEqual(payload["cutvideo"]["deployment_label"], "NAS 隧道") + def test_collector_deploy_script_exposes_health_retry_controls(self) -> None: script_path = ROOT / "scripts" / "deploy_fnos_storyforge_collector.sh" content = script_path.read_text(encoding="utf-8") diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 1f4b2e4..17a51f6 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -3929,6 +3929,8 @@ function getIntegrationDetail(key) { url: String(raw?.url || raw?.base_url || ""), baseUrl: String(raw?.base_url || ""), routeMode: String(raw?.route_mode || ""), + deploymentScope: String(raw?.deployment_scope || ""), + deploymentLabel: String(raw?.deployment_label || ""), supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true, uploadStatusCode: Number(raw?.upload_status_code || 0), uploadError: String(raw?.upload_error || ""), @@ -4093,10 +4095,12 @@ function getIntegrationCards() { const runtimeBadge = getAsrRuntimeBadge(detail) || "待热身"; const computeLabel = detail.activeComputeType || detail.runtimeComputeTypeMode || "auto"; const languageLabel = detail.languageMode || "auto"; - extra = `当前转写:${runtimeBadge} · ${computeLabel} · 语言 ${languageLabel}`; + extra = `部署:${detail.deploymentLabel || "待确认"} · 当前转写:${runtimeBadge} · ${computeLabel} · 语言 ${languageLabel}`; if (detail.modelName) { extra += ` · 当前模型:${detail.modelName}`; } + } else if (detail.deploymentLabel) { + extra = `部署:${detail.deploymentLabel}`; } if (detail.available && !detail.configured && isSuperAdmin()) { actions = [ diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index cd5b1c8..ad9ae78 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -1489,8 +1489,11 @@ test("integration cards surface ASR runtime mode and model details", () => { assert.match(detailSource, /activeComputeType:/); assert.match(detailSource, /languageMode:/); assert.match(detailSource, /modelName:/); + assert.match(detailSource, /deploymentScope:/); + assert.match(detailSource, /deploymentLabel:/); assert.match(statusSource, /detail\.key === "asr"/); assert.match(statusSource, /在线 ·/); + assert.match(cardsSource, /部署:/); assert.match(cardsSource, /当前转写:/); assert.match(cardsSource, /当前模型:/); });