From de5616fd55dc4338a6616b6d3327ec699beec80e Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:49:50 +0800 Subject: [PATCH] fix(studio): add OTA permission preflight --- frontend/service/studio_scheduler/deploy.py | 83 ++- frontend/service/studio_scheduler/http_app.py | 28 +- frontend/src/adk/client.ts | 25 + frontend/src/ui/StudioUpdateControl.css | 126 ++++ frontend/src/ui/StudioUpdateControl.tsx | 132 +++- frontend/tests/studioUpdate.test.mjs | 17 + tests/cli/test_studio_release.py | 44 ++ tests/cli/test_studio_self_update.py | 129 +++- tests/cli/test_studio_update_permissions.py | 211 ++++++ .../service/studio_scheduler/test_http_app.py | 72 ++ .../studio_scheduler/test_scheduler_deploy.py | 72 ++ veadk/cli/frontend_deploy_policy.py | 8 + veadk/cli/studio_deploy_permissions.py | 60 +- veadk/cli/studio_release.py | 37 +- veadk/cli/studio_self_update.py | 62 +- veadk/cli/studio_update_permissions.py | 341 ++++++++++ .../{index-CFtkYxt5.js => index-BSfA7-Qp.js} | 624 +++++++++--------- ...XF.js => MarkdownPromptEditor-EeSyPVCy.js} | 2 +- .../{arc-BHYkO21x.js => arc-Bd-KWJD_.js} | 2 +- ...hannel-CquEleFv.js => channel-V8FC1PyG.js} | 2 +- ...{linear-BqPv-BSG.js => linear-D2RiTPZI.js} | 2 +- ...{index-0lb0M8Hb.css => index-vChqTa3c.css} | 2 +- ...7q.js => abnfDiagram-N423BO3Z-ytavltmL.js} | 2 +- ... architectureDiagram-T3A2C74G-DDnDuHF5.js} | 2 +- ...r.js => blockDiagram-VBNYF7ZC-nT3UGRP_.js} | 2 +- ...OMFY.js => c4Diagram-5PPSVZJV-mByelyM0.js} | 2 +- ...BZ6-8oFM.js => chunk-2GRJ4B5K-BMhlpUDn.js} | 2 +- ...VzujOq0R.js => chunk-2Q5K7J3B-DQ_g_FOP.js} | 2 +- ...DgrejSZ7.js => chunk-5RXB4S5H-DbJC5G9d.js} | 2 +- ...Bya697Xa.js => chunk-5VM5RSS4-W8IBP72a.js} | 2 +- ...BJsk-Dkp.js => chunk-6Q2QTUOP-BWy4vQ8l.js} | 2 +- ...UmrEe0dP.js => chunk-GF5L2VYU-BOV9J-a2.js} | 2 +- ...VzQiMWNz.js => chunk-JWPE2WC7-B2zXTT0G.js} | 2 +- ...BsFdqzPA.js => chunk-KBJHAD2P-BfrlQA_C.js} | 2 +- ...BDqyjyvh.js => chunk-RYQCIY6F-Bc3woq1C.js} | 2 +- ...U12399Ta.js => chunk-XXDRQBXY-4aIfLT7K.js} | 2 +- .../mermaid/classDiagram-JCYQIIEL-ChGm3DyL.js | 1 + .../mermaid/classDiagram-JCYQIIEL-nsAuibyY.js | 1 - .../classDiagram-v2-OCEON4UE-ChGm3DyL.js | 1 + .../classDiagram-v2-OCEON4UE-nsAuibyY.js | 1 - ...k.js => cose-bilkent-JH36ORCC-TCO9YNQt.js} | 2 +- ...gqZd54.js => cynefin-OW5HDTMX-DZwYx8KA.js} | 2 +- ...js => cynefinDiagram-MW4NZA55-DSdRlYdc.js} | 2 +- ...DC-XsMNI.js => dagre-VZM6K2ZE-CaZltdDs.js} | 2 +- ...SXtkAH.js => diagram-7IWD3JNH-ChiT9kPC.js} | 2 +- ...Vm9Je-.js => diagram-B4RE2ZJO-gnhe1XTL.js} | 2 +- ...kr75Em.js => diagram-LBJQPF4R-Pnfxyn0a.js} | 2 +- ...s8aWti.js => diagram-Q27KOJAE-DDcULphP.js} | 2 +- ..._Vp29w.js => diagram-UB23O5K3-BpUtlbYH.js} | 2 +- ...kK.js => ebnfDiagram-BXEA7PRR-0NLASbul.js} | 2 +- ...GVk2.js => erDiagram-JOGREHBK-BFBZrDgK.js} | 2 +- ...X-.js => flowDiagram-UKHOOZJN-B8lbbUMJ.js} | 2 +- ...S.js => ganttDiagram-PKOTCBZU-DmNk0elM.js} | 2 +- ...s => gitGraphDiagram-DS77QQ5N-D8t8bnkE.js} | 2 +- ...Kq.js => infoDiagram-6WML65LV-BoMq5yxg.js} | 2 +- ...s => ishikawaDiagram-WSZJBQD7-CdfT-yF9.js} | 2 +- ...js => journeyDiagram-NVQOT4AX-lXnJv34U.js} | 2 +- ...=> kanban-definition-27J2QSJJ-BmrqdoIU.js} | 2 +- ...e-BVnO3lYF.js => mermaid.core-CA8ypPul.js} | 8 +- ...> mindmap-definition-FAOFIHXS-DOb0iskj.js} | 2 +- ...qbZ.js => pegDiagram-VL7TDLO6-C3mfqu29.js} | 2 +- ...Tuu.js => pieDiagram-7S7Q4E2Y-DiASGNAe.js} | 2 +- ...s => quadrantDiagram-CIZ2JOQS-DJm-gHGj.js} | 2 +- ...s => railroadDiagram-AXF67PYL-BRnmPnOB.js} | 2 +- ...> requirementDiagram-LRYGKXZP-DFDVaceD.js} | 2 +- ....js => sankeyDiagram-W5VNT64P-B2rzdV66.js} | 2 +- ...s => sequenceDiagram-SI44F4Z6-COnTktCB.js} | 2 +- ...By.js => sizeCapture-X5ZJPWSS-BALwA7qs.js} | 2 +- ...d.js => stateDiagram-OKZ733FA-DlMFQfhX.js} | 2 +- .../stateDiagram-v2-UEYNNEHI-DP2Rdz7L.js | 1 - .../stateDiagram-v2-UEYNNEHI-kqiSvJQU.js | 1 + ...s2Ud.js => swimlanes-SLNWSIFB-C_QRwND6.js} | 4 +- .../swimlanesDiagram-ULZ7WXOC--Rr-scI_.js | 8 + .../swimlanesDiagram-ULZ7WXOC-CCn_FOiG.js | 8 - ... timeline-definition-Z64GVDOM-BIYO9h5L.js} | 2 +- ...Fu.js => vennDiagram-T6HMQDX7-DklWiDNN.js} | 2 +- ...js => wardleyDiagram-T6FBY63Y-_jEObl1h.js} | 2 +- ...js => xychartDiagram-ELKLHX3M-B_eZIbWC.js} | 2 +- veadk/webui/index.html | 4 +- veadk/webui/website-integration.js | 586 ++++++++-------- 80 files changed, 2088 insertions(+), 709 deletions(-) create mode 100644 tests/cli/test_studio_update_permissions.py create mode 100644 tests/frontend/service/studio_scheduler/test_http_app.py create mode 100644 veadk/cli/studio_update_permissions.py rename veadk/webui/assets/app/{index-CFtkYxt5.js => index-BSfA7-Qp.js} (58%) rename veadk/webui/assets/chunks/{MarkdownPromptEditor-DmV1G1XF.js => MarkdownPromptEditor-EeSyPVCy.js} (99%) rename veadk/webui/assets/chunks/{arc-BHYkO21x.js => arc-Bd-KWJD_.js} (98%) rename veadk/webui/assets/chunks/{channel-CquEleFv.js => channel-V8FC1PyG.js} (53%) rename veadk/webui/assets/chunks/{linear-BqPv-BSG.js => linear-D2RiTPZI.js} (98%) rename veadk/webui/assets/styles/{index-0lb0M8Hb.css => index-vChqTa3c.css} (91%) rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-C6f8uF7q.js => abnfDiagram-N423BO3Z-ytavltmL.js} (85%) rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-DY7RBrYC.js => architectureDiagram-T3A2C74G-DDnDuHF5.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-bfGnEaDr.js => blockDiagram-VBNYF7ZC-nT3UGRP_.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-mPM3OMFY.js => c4Diagram-5PPSVZJV-mByelyM0.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-BZ6-8oFM.js => chunk-2GRJ4B5K-BMhlpUDn.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-VzujOq0R.js => chunk-2Q5K7J3B-DQ_g_FOP.js} (66%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-DgrejSZ7.js => chunk-5RXB4S5H-DbJC5G9d.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-Bya697Xa.js => chunk-5VM5RSS4-W8IBP72a.js} (83%) rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-BJsk-Dkp.js => chunk-6Q2QTUOP-BWy4vQ8l.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-UmrEe0dP.js => chunk-GF5L2VYU-BOV9J-a2.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-VzQiMWNz.js => chunk-JWPE2WC7-B2zXTT0G.js} (78%) rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-BsFdqzPA.js => chunk-KBJHAD2P-BfrlQA_C.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-BDqyjyvh.js => chunk-RYQCIY6F-Bc3woq1C.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-U12399Ta.js => chunk-XXDRQBXY-4aIfLT7K.js} (52%) create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-ChGm3DyL.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-nsAuibyY.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-ChGm3DyL.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-nsAuibyY.js rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-CqnGnZ_k.js => cose-bilkent-JH36ORCC-TCO9YNQt.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-klgqZd54.js => cynefin-OW5HDTMX-DZwYx8KA.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-CsraXHFa.js => cynefinDiagram-MW4NZA55-DSdRlYdc.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-DC-XsMNI.js => dagre-VZM6K2ZE-CaZltdDs.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-CrSXtkAH.js => diagram-7IWD3JNH-ChiT9kPC.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-DXVm9Je-.js => diagram-B4RE2ZJO-gnhe1XTL.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-ESkr75Em.js => diagram-LBJQPF4R-Pnfxyn0a.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-BVs8aWti.js => diagram-Q27KOJAE-DDcULphP.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-Df_Vp29w.js => diagram-UB23O5K3-BpUtlbYH.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-EboexIkK.js => ebnfDiagram-BXEA7PRR-0NLASbul.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-C1u4GVk2.js => erDiagram-JOGREHBK-BFBZrDgK.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-B6TOeRX-.js => flowDiagram-UKHOOZJN-B8lbbUMJ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-D3KlzahS.js => ganttDiagram-PKOTCBZU-DmNk0elM.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-2dalhumv.js => gitGraphDiagram-DS77QQ5N-D8t8bnkE.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-BfJHDVKq.js => infoDiagram-6WML65LV-BoMq5yxg.js} (67%) rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-Bv4mJjzR.js => ishikawaDiagram-WSZJBQD7-CdfT-yF9.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-BBjWMpLu.js => journeyDiagram-NVQOT4AX-lXnJv34U.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-BpXtCJ3x.js => kanban-definition-27J2QSJJ-BmrqdoIU.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-BVnO3lYF.js => mermaid.core-CA8ypPul.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-B5QXBZ-K.js => mindmap-definition-FAOFIHXS-DOb0iskj.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-CBAXjqbZ.js => pegDiagram-VL7TDLO6-C3mfqu29.js} (86%) rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-B5OfcTuu.js => pieDiagram-7S7Q4E2Y-DiASGNAe.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-CH7i7eU6.js => quadrantDiagram-CIZ2JOQS-DJm-gHGj.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-DPLBrk6_.js => railroadDiagram-AXF67PYL-BRnmPnOB.js} (84%) rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-CwUzp3js.js => requirementDiagram-LRYGKXZP-DFDVaceD.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-G9FQGWLH.js => sankeyDiagram-W5VNT64P-B2rzdV66.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6-BmraS3eG.js => sequenceDiagram-SI44F4Z6-COnTktCB.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-fEU2-yBy.js => sizeCapture-X5ZJPWSS-BALwA7qs.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-C1y0VuZd.js => stateDiagram-OKZ733FA-DlMFQfhX.js} (96%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-DP2Rdz7L.js create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-kqiSvJQU.js rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-DrDzs2Ud.js => swimlanes-SLNWSIFB-C_QRwND6.js} (99%) create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC--Rr-scI_.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-CCn_FOiG.js rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-DK8JlCR3.js => timeline-definition-Z64GVDOM-BIYO9h5L.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-DlgdwYFu.js => vennDiagram-T6HMQDX7-DklWiDNN.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-B75iWodl.js => wardleyDiagram-T6FBY63Y-_jEObl1h.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-BpITSryf.js => xychartDiagram-ELKLHX3M-B_eZIbWC.js} (99%) diff --git a/frontend/service/studio_scheduler/deploy.py b/frontend/service/studio_scheduler/deploy.py index 3dd385618..78ba41332 100644 --- a/frontend/service/studio_scheduler/deploy.py +++ b/frontend/service/studio_scheduler/deploy.py @@ -22,6 +22,10 @@ import tempfile import time import urllib.request +from collections.abc import Iterator +from contextlib import contextmanager +from functools import partial +from inspect import Parameter, signature from pathlib import Path from typing import Any @@ -30,6 +34,60 @@ _SCAN_TIMER_NAME = "veadk-studio-cronjobs-minute" _WORKER_TIMER_NAME = "veadk-studio-cronjobs-worker-minute" _MINUTE_CRONTAB = "* * * * *" +_VEFAAS_REQUEST_TIMEOUT_SECONDS = 600 +_VEFAAS_LONG_REQUEST_METHODS = ( + "code_upload_callback", + "create_dependency_install_task", + "create_function", + "create_timer", + "get_code_upload_address", + "get_dependency_install_task_log_download_uri", + "get_dependency_install_task_status", + "get_function", + "get_release_status", + "list_functions", + "list_triggers", + "release", + "update_function", + "update_timer", +) + + +def _accepts_request_timeout(method: Any) -> bool: + try: + parameters = signature(method).parameters.values() + except (TypeError, ValueError): + return False + return any( + parameter.name == "_request_timeout" or parameter.kind is Parameter.VAR_KEYWORD + for parameter in parameters + ) + + +@contextmanager +def _extended_vefaas_request_timeout(service: Any) -> Iterator[None]: + """Keep BytePlus/VeFaaS long mutations alive for the release window.""" + client = getattr(service, "client", None) + originals: dict[str, Any] = {} + if client is not None: + for method_name in _VEFAAS_LONG_REQUEST_METHODS: + method = getattr(client, method_name, None) + if method is None or not _accepts_request_timeout(method): + continue + originals[method_name] = method + setattr( + client, + method_name, + partial( + method, + _request_timeout=_VEFAAS_REQUEST_TIMEOUT_SECONDS, + ), + ) + try: + yield + finally: + for method_name, method in originals.items(): + setattr(client, method_name, method) def scheduler_function_name(studio_application_name: str) -> str: @@ -55,6 +113,24 @@ def deploy_scheduler( environment: dict[str, str], ) -> tuple[str, str, str, str]: """Create/update independent scan and async-worker Functions and timers.""" + with _extended_vefaas_request_timeout(service): + return _deploy_scheduler( + service, + studio_application_name=studio_application_name, + package_root=package_root, + role_trn=role_trn, + environment=environment, + ) + + +def _deploy_scheduler( + service: Any, + *, + studio_application_name: str, + package_root: Path, + role_trn: str, + environment: dict[str, str], +) -> tuple[str, str, str, str]: function_name = scheduler_function_name(studio_application_name) worker_name = scheduler_worker_function_name(studio_application_name) with tempfile.TemporaryDirectory(prefix="studio_cronjob_scheduler_") as tmp: @@ -125,9 +201,10 @@ def deploy_scheduler_for_studio_update( """Update the scheduler from the same bundle used by Studio self-update.""" from volcenginesdkvefaas import GetFunctionRequest - current_function = service.client.get_function( - GetFunctionRequest(id=studio_function_id) - ) + with _extended_vefaas_request_timeout(service): + current_function = service.client.get_function( + GetFunctionRequest(id=studio_function_id) + ) current_environment = { str(item.key): str(item.value) for item in (getattr(current_function, "envs", None) or []) diff --git a/frontend/service/studio_scheduler/http_app.py b/frontend/service/studio_scheduler/http_app.py index becef11df..295d86dd9 100644 --- a/frontend/service/studio_scheduler/http_app.py +++ b/frontend/service/studio_scheduler/http_app.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json from datetime import datetime, timezone from fastapi import FastAPI @@ -52,9 +53,32 @@ async def execute_ready_runs() -> dict[str, int]: return _summary(summary) +def _timer_event(value: dict[str, object] | str | None) -> dict[str, object]: + """Normalize provider-specific timer payload shapes. + + Volcengine sends the configured JSON payload as an object, while BytePlus + currently delivers the same value as a JSON-encoded string. + """ + if value is None: + return {} + if isinstance(value, dict): + return value + try: + decoded = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError("Scheduler timer payload is not valid JSON") from error + if not isinstance(decoded, dict): + raise ValueError( # noqa: TRY004 - invalid external payload, not API misuse + "Scheduler timer payload must be a JSON object" + ) + return decoded + + @app.post("/") -async def handle_timer(event: dict[str, object] | None = None) -> dict[str, int]: - phase = str((event or {}).get("phase") or "scan") +async def handle_timer( + event: dict[str, object] | str | None = None, +) -> dict[str, int]: + phase = str(_timer_event(event).get("phase") or "scan") if phase == "scan": return await dispatch_current_minute() if phase == "execute": diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 4dba93075..40baa339e 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2840,6 +2840,15 @@ export interface StudioUpdateStatus { permissionConsoleUrl: string; } +export interface StudioUpdatePermissionStatus { + ready: boolean; + missingActions: string[]; + policyName: string; + authorizationUrl: string; + iamConsoleUrl: string; + principalName: string; +} + /** Check the configured immutable Studio main release channel. */ export async function getStudioUpdateStatus( targetVersion?: string, @@ -2854,6 +2863,22 @@ export async function getStudioUpdateStatus( return (await res.json()) as StudioUpdateStatus; } +/** Verify every IAM Action needed by OTA before starting any cloud mutation. */ +export async function getStudioUpdatePermissions(): Promise { + const res = await apiFetch("/web/studio-update/permissions"); + if (!res.ok) { + let detail = ""; + try { + const payload = (await res.json()) as { detail?: unknown }; + detail = typeof payload.detail === "string" ? payload.detail : ""; + } catch { + detail = ""; + } + throw new Error(detail || `Studio 更新权限预检失败 (${res.status})`); + } + return (await res.json()) as StudioUpdatePermissionStatus; +} + /** Stage the latest full Studio bundle and submit a VeFaaS release. */ export async function startStudioUpdate( version: string, diff --git a/frontend/src/ui/StudioUpdateControl.css b/frontend/src/ui/StudioUpdateControl.css index 63880ccce..912da5d56 100644 --- a/frontend/src/ui/StudioUpdateControl.css +++ b/frontend/src/ui/StudioUpdateControl.css @@ -610,6 +610,132 @@ text-underline-offset: 2px; } +.studio-update-permission-checking { + display: grid; + gap: 8px; + padding: 14px; + border: 1px solid hsl(var(--border)); + border-radius: 9px; + background: hsl(var(--canvas) / 0.5); +} + +.studio-update-permission-checking p { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.55; +} + +.studio-update-authorization-panel { + display: grid; + gap: 12px; + min-width: 0; +} + +.studio-update-authorization-panel .confirm-text { + margin-bottom: 0; +} + +.studio-update-authorization-principal { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--canvas) / 0.5); +} + +.studio-update-authorization-principal div { + display: grid; + gap: 3px; + min-width: 0; +} + +.studio-update-authorization-principal dt, +.studio-update-missing-actions > span { + color: hsl(var(--muted-foreground)); + font-size: 11px; +} + +.studio-update-authorization-principal dd { + overflow: hidden; + margin: 0; + color: hsl(var(--foreground)); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-update-authorization-steps { + margin: 0; + padding-left: 20px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.6; +} + +.studio-update-missing-actions { + display: grid; + gap: 7px; +} + +.studio-update-missing-actions ul { + display: grid; + gap: 4px; + max-height: 204px; + margin: 0; + padding: 9px 12px; + overflow-y: auto; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--muted) / 0.22); + list-style: none; +} + +.studio-update-missing-actions code { + color: hsl(var(--foreground)); + font-size: 11px; + overflow-wrap: anywhere; +} + +.studio-update-authorization-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + width: fit-content; + min-height: 34px; + padding: 0 12px; + border-radius: 7px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + font-size: 12px; + font-weight: 500; + text-decoration: none; +} + +.studio-update-authorization-link:hover { + background: hsl(var(--primary) / 0.88); +} + +.studio-update-authorization-link svg { + width: 14px; + height: 14px; + flex: 0 0 14px; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.studio-update-authorization-note { + margin: -4px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 11px; + line-height: 1.55; +} + .studio-update-progress-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/ui/StudioUpdateControl.tsx b/frontend/src/ui/StudioUpdateControl.tsx index 9ec6c83bd..378026c55 100644 --- a/frontend/src/ui/StudioUpdateControl.tsx +++ b/frontend/src/ui/StudioUpdateControl.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { getStudioUpdateStatus, + getStudioUpdatePermissions, startStudioUpdate, + type StudioUpdatePermissionStatus, type StudioUpdateStatus, } from "../adk/client"; import { splitReleaseNotes } from "./releaseNotes"; @@ -16,11 +18,19 @@ const COMPLETION_LOG_SETTLE_TIMEOUT_MS = 45_000; const STUDIO_UPDATE_STORAGE_KEY = "veadk.studio.pending-update"; const STUDIO_UPDATE_HANDOFF_KEY = "veadk.studio.update-handoff"; -type UpdatePhase = "idle" | "confirm" | "submitting" | "published" | "error"; +type UpdatePhase = + | "idle" + | "confirm" + | "checking-permissions" + | "permission" + | "submitting" + | "published" + | "error"; type PendingStudioUpdate = { targetVersion: string; startedAt: number }; type LogCopyState = "idle" | "copied" | "error"; const UPDATE_STEPS = [ + { id: "permissions", label: "预检 OTA 所需权限" }, { id: "resolving", label: "读取目标版本信息" }, { id: "downloading", label: "下载并校验完整更新包" }, { id: "preparing", label: "准备 VeFaaS Function 代码" }, @@ -30,6 +40,7 @@ const UPDATE_STEPS = [ ] as const; const UPDATE_STAGE_LABELS: Record = { + permissions: "预检 OTA 权限", resolving: "读取版本信息", downloading: "下载更新包", preparing: "准备 Function 代码", @@ -240,6 +251,8 @@ export function StudioUpdateControl({ ); const [dialogOpen, setDialogOpen] = useState(Boolean(initialPending)); const [message, setMessage] = useState(""); + const [permissionStatus, setPermissionStatus] = + useState(null); const [selectedVersion, setSelectedVersion] = useState( initialPending?.targetVersion ?? "", ); @@ -394,17 +407,30 @@ export function StudioUpdateControl({ handoffTargetRef.current = ""; targetVersionRef.current = targetVersion; startedAtRef.current = Date.now(); - persistPendingUpdate(targetVersion, startedAtRef.current); - setPhase("submitting"); + setPhase("checking-permissions"); setMessage(""); setLogCopyState("idle"); try { + const permissions = await getStudioUpdatePermissions(); + setPermissionStatus(permissions); + if (!permissions.ready) { + clearPendingUpdate(); + setPhase("permission"); + return; + } + setPermissionStatus(null); + persistPendingUpdate(targetVersion, startedAtRef.current); + setPhase("submitting"); const result = await startStudioUpdate(targetVersion); targetVersionRef.current = result.version; persistPendingUpdate(result.version, startedAtRef.current); setMessage("更新已提交,正在等待 VeFaaS 发布新版本"); } catch (error) { - if (error instanceof TypeError) { + if ( + error instanceof TypeError || + (error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError")) + ) { setMessage("连接已切换,正在确认新版本状态"); return; } @@ -439,6 +465,7 @@ export function StudioUpdateControl({ setVersionMenuOpen(false); setLogCopyState("idle"); setMessage(""); + setPermissionStatus(null); setSelectedVersion(targetVersionRef.current || releases[0]?.version || ""); setPhase("confirm"); }; @@ -453,7 +480,11 @@ export function StudioUpdateControl({ : `studio-update-trigger is-${phase}` } title={ - phase === "submitting" + phase === "checking-permissions" + ? "正在检查 OTA 权限" + : phase === "permission" + ? "需要 IAM 授权" + : phase === "submitting" ? "正在更新 Studio" : phase === "published" ? "Studio 已更新" @@ -462,7 +493,12 @@ export function StudioUpdateControl({ onClick={() => { if (phase === "published") { window.location.reload(); - } else if (phase === "submitting" || phase === "error") { + } else if ( + phase === "checking-permissions" || + phase === "permission" || + phase === "submitting" || + phase === "error" + ) { setDialogOpen(true); } else { setSelectedVersion(releases[0]?.version || status.latestVersion); @@ -474,7 +510,11 @@ export function StudioUpdateControl({ {variant !== "feature-link" && ( )} - {phase === "submitting" ? ( + {phase === "checking-permissions" ? ( + 检查更新权限 + ) : phase === "permission" ? ( + 需要授权 + ) : phase === "submitting" ? ( 正在更新 ) : phase === "published" ? ( 刷新使用新版 @@ -492,7 +532,9 @@ export function StudioUpdateControl({
- {phase === "error" ? ( + {phase === "checking-permissions" ? ( +
+ 正在核对 OTA 与定时任务所需的全部 IAM 权限… +

权限全部满足后才会开始下载、更新或发布云资源。

+
+ ) : phase === "permission" && permissionStatus ? ( +
+

+ 当前 Function 角色缺少 {permissionStatus.missingActions.length} 项 + OTA 更新权限,尚未执行任何云资源变更。 +

+
+
+
Function 角色
+
{permissionStatus.principalName || "当前运行角色"}
+
+ {permissionStatus.policyName && ( +
+
将更新策略
+
{permissionStatus.policyName}
+
+ )} +
+
    +
  1. 打开授权页面,确认已预填的策略名称和完整策略内容。
  2. +
  3. 点击页面中的“发起调试”,完成策略更新。
  4. +
  5. 返回此窗口,点击“我已授权,重新检查”。
  6. +
+
+ 缺少的权限 +
    + {permissionStatus.missingActions.map((action) => ( +
  • {action}
  • + ))} +
+
+ + {permissionStatus.authorizationUrl + ? "打开已预填的 IAM 授权页面" + : "前往 IAM 控制台手动配置"} + + + {!permissionStatus.authorizationUrl && ( +

+ 当前角色没有唯一可安全更新的自定义策略,请由管理员将上述权限加入该角色。 +

+ )} +
+ ) : phase === "error" ? (

{message}

@@ -705,7 +804,11 @@ export function StudioUpdateControl({ } }} > - {phase === "submitting" ? "后台运行" : phase === "confirm" ? "取消" : "关闭"} + {phase === "submitting" + ? "后台运行" + : phase === "confirm" + ? "取消" + : "关闭"} {phase === "confirm" && ( + )} {phase === "error" && (