diff --git a/.gitignore b/.gitignore index 918a86f..adb7476 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Build output **/bin/ **/dist/ +*.tsbuildinfo tmp/ # macOS @@ -15,9 +16,15 @@ tmp/ # Local config overrides *.local -# Go test output +# Test and coverage output *.out -coverage.out +*.profraw +/coverage/ +/apps/rlark-ui/coverage/ + +# Python cache +__pycache__/ +*.py[cod] # Dependencies **/node_modules/ diff --git a/Makefile b/Makefile index 652c8e4..ba87d2f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,12 @@ # make # build everything (lint-go, go-vet, go-build) # make docker-build # build rlark image and load into local docker +GREP = grep -P +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + GREP = grep -E +endif + ##@ Code style .PHONY: lint-go lint-web fmt-go fmt-web @@ -28,21 +34,34 @@ fmt-web: ## Format web UI (prettier) ##@ Go targets -.PHONY: build go-tidy +.PHONY: build test-go test-web go-tidy build: ## Build all binaries (apps/rlark) $(MAKE) -C apps/rlark build $(MAKEOVERRIDES) +test-go: ## Run Go unit tests with coverage + @mkdir -p coverage/go + @for module in api apps/rlark apps/embodied-runtime; do \ + name=$$(echo $$module | tr '/' '-'); \ + coverage=$$(cd $$module && realpath --relative-to=. $(CURDIR)/coverage/go/$$name.out); \ + (cd $$module && go test ./... -v -count=1 -short -covermode=atomic -coverprofile=$$coverage) || exit $$?; \ + done + +test-web: ## Run web UI unit tests with coverage + npm ci --prefix apps/rlark-ui + npm run test:coverage --prefix apps/rlark-ui + go-tidy: ## Run go mod tidy on all workspace modules - @for dir in $$(grep '^\t\./' go.work | sed 's/^\t//'); do \ - (cd $$dir && go mod tidy); \ + @for dir in $$( $(GREP) '^\t\./' go.work | sed 's/^\t//' ); do \ + echo "Running go mod tidy in $$dir"; \ + (cd $$dir && go mod tidy -x); \ done ##@ Code generation .PHONY: generate generate-crd generate-crd-schema-docs proto -generate: generate-crd generate-crd-schema-docs ## Generate CRD manifests, clients, and schema docs +generate: generate-crd-schema-docs ## Generate CRD manifests, clients, and schema docs generate-crd: ## Generate CRD manifests and clients $(MAKE) -C api generate-crd $(MAKEOVERRIDES) diff --git a/NOTICE b/NOTICE index b1e90ff..38d47a0 100644 --- a/NOTICE +++ b/NOTICE @@ -12,7 +12,6 @@ This product includes software developed by third parties: - Bun ORM (https://github.com/uptrace/bun) - BSD 2-Clause License - React (https://github.com/facebook/react) - MIT License - gVisor (https://github.com/google/gvisor) - Apache License 2.0 -- remotedialer (https://github.com/rancher/remotedialer) - Apache License 2.0 - RClone (https://github.com/rclone/rclone) - MIT License For full license texts of third-party dependencies, see the respective diff --git a/README.md b/README.md index 2baaae0..af3dec6 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@
- Documentation - 中文文档 + Documentation + 中文文档 Go Version TypeScript Kubernetes @@ -18,46 +18,65 @@ Manage cross-cluster embodied intelligence workloads through a unified cloud-native platform spanning cloud GPU training, cross-cluster collaboration, and edge device deployment across heterogeneous resources such as GPU clusters, robot arms, sensors, and cameras. -> **Explore the complete documentation on [Read the Docs](https://rlark-ci-test.readthedocs.io/en/latest/)** — start with the [Quick Start](https://rlark-ci-test.readthedocs.io/en/latest/quickstart/), then continue with the [platform user guide](https://rlark-ci-test.readthedocs.io/en/latest/user-guide/) or [administrator guide](https://rlark-ci-test.readthedocs.io/en/latest/admin-guide/). +> **Explore the complete documentation on [Read the Docs](https://rlark.readthedocs.io/en/latest/)** — start with the [Quick Start](https://rlark.readthedocs.io/en/latest/quickstart/), then continue with the [platform user guide](https://rlark.readthedocs.io/en/latest/user-guide/) or [administrator guide](https://rlark.readthedocs.io/en/latest/admin-guide/). ## What's NEW! -- [2026/08] RLark is now open-source. +- [2026/09] RLark is now open-source. ## Key Capabilities -- **Embodied AI Workload Orchestration**: From cloud GPU training (RL/LLM) to edge deployment (robot arm, sensor, camera), unified declarative Job/Workflow/Task abstraction across the full pipeline +- **Embodied AI Workload Orchestration**: From cloud GPU training (RL/LLM) to edge deployment (robot arm, sensor, camera), unified declarative Job/Task abstraction across the full pipeline - **Multi-Runtime Data Plane**: Kubernetes provides unified management for cloud GPU clusters and edge devices across the complete training-to-deployment lifecycle; Docker and Raw runtime support will extend coverage to lightweight edge scenarios where Kubernetes is not suitable - **Cross-Cluster Resource Abstraction**: Unify multi-site GPU clusters and edge devices via Domain (virtual network domain) and Node (compute node) CRDs, with the control plane running on kcp -- **Declarative Training Jobs**: Multi-layer abstraction (Job/Workflow/Task) with DAG-based training pipelines and declarative Ray cluster definition +- **Declarative Training Jobs**: Job/Task abstraction with declarative distributed training and Ray cluster definition - **Cross-Cluster Pod Networking**: Virtual network based on TUN devices + gVisor netstack + SSH tunnels, enabling Pod-to-Pod communication without NAT traversal — cloud GPUs and edge robots communicate directly - **Certificate System**: Dual-layer X.509 + SSH certificates for Agent access, Domain-scoped cross-cluster forwarding authentication, and user SSH authentication - **Observability**: Prometheus metrics, real-time Pod log streaming, and web management UI +## Roadmap + +### Available Today + +- Kubernetes-based management of cloud GPU clusters and edge devices +- Declarative Job/Task orchestration for distributed training and Ray workloads +- Cross-cluster resource management through Domain and Node CRDs +- Cross-cluster Pod networking over TUN devices, gVisor netstack, and SSH tunnels +- X.509 and SSH certificate-based authentication +- Prometheus metrics, real-time Pod logs, and a web management UI + +### Planned + +- Docker runtime support for lightweight data planes +- Raw runtime support for hosts and edge devices without a container orchestrator +- More complete account, role, and permission management +- Continued web UI usability and workflow improvements +- Cross-cluster network throughput, latency, and resource-efficiency optimizations + ## Architecture Overview ![System Architecture](apps/rlark/docs/images/architecture.png) ## Quick Start -Follow the [Quick Start Guide on Read the Docs](https://rlark-ci-test.readthedocs.io/en/latest/quickstart/) to choose one of the verified flows: +Follow the [Quick Start Guide on Read the Docs](https://rlark.readthedocs.io/en/latest/quickstart/) to choose one of the verified flows: - **One-click CLI**: deploy the control plane and two kind data-plane clusters, then verify cross-cluster Pod networking. - **UI-based flow**: create clusters and a Domain in the web console, deploy two kind data planes, schedule a Job across them, and verify connectivity. ## Documentation -The complete, searchable, and versioned documentation is published on **[Read the Docs](https://rlark-ci-test.readthedocs.io/en/latest/)**. Use these rendered guides as the primary entry points: +The complete, searchable, and versioned documentation is published on **[Read the Docs](https://rlark.readthedocs.io/en/latest/)**. Use these rendered guides as the primary entry points: | Guide | Description | |-------|-------------| -| [Quick Start](https://rlark-ci-test.readthedocs.io/en/latest/quickstart/) | Verified one-click and UI-based local deployment flows | -| [Core Concepts](https://rlark-ci-test.readthedocs.io/en/latest/concepts/) | Domain, Job, Task, Workflow, and other concepts | -| [Platform User Guide](https://rlark-ci-test.readthedocs.io/en/latest/user-guide/) | Web console, clusters, jobs, workflows, storage, and SSH keys | -| [Administrator Guide](https://rlark-ci-test.readthedocs.io/en/latest/admin-guide/) | Control plane, data plane, networking, security, and operations | -| [Developer Guide](https://rlark-ci-test.readthedocs.io/en/latest/developer-guide/) | Local development, project layout, debugging, and extensions | -| [API Reference](https://rlark-ci-test.readthedocs.io/en/latest/api/reference/) | Gateway REST API routes and behavior | -| [Architecture](https://rlark-ci-test.readthedocs.io/en/latest/architecture/) | Components, interactions, and data flows | +| [Quick Start](https://rlark.readthedocs.io/en/latest/quickstart/) | Verified one-click and UI-based local deployment flows | +| [Core Concepts](https://rlark.readthedocs.io/en/latest/concepts/) | Domain, Job, Task, and other concepts | +| [Platform User Guide](https://rlark.readthedocs.io/en/latest/user-guide/) | Web console, clusters, jobs, storage, and SSH keys | +| [Administrator Guide](https://rlark.readthedocs.io/en/latest/admin-guide/) | Control plane, data plane, networking, security, and operations | +| [Developer Guide](https://rlark.readthedocs.io/en/latest/developer-guide/) | Local development, project layout, debugging, and extensions | +| [API Reference](https://rlark.readthedocs.io/en/latest/api/reference/) | Gateway REST API routes and behavior | +| [Architecture](https://rlark.readthedocs.io/en/latest/architecture/) | Components, interactions, and data flows | Repository-specific references remain available alongside the code: @@ -69,7 +88,7 @@ Repository-specific references remain available alongside the code: | [Go SDK](sdks/embodied-runtime-go/README.md) | Go client for embodied-runtime gRPC stubs | | [Proto Definitions](proto/embodied-runtime/README.md) | gRPC service definitions for embodied-runtime | -> Prefer Chinese? Visit the **[中文 Read the Docs 站点](https://rlark-ci-test.readthedocs.io/zh-cn/latest/)**. +> Prefer Chinese? Visit the **[中文 Read the Docs 站点](https://rlark.readthedocs.io/zh-cn/latest/)**. ## Tech Stack diff --git a/README.zh-CN.md b/README.zh-CN.md index 8017327..44e5bca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,8 +5,8 @@
- Documentation - 中文文档 + Documentation + 中文文档 Go Version TypeScript Kubernetes @@ -18,46 +18,65 @@ 以云原生方式统一纳管跨集群具身智能工作负载,覆盖云端 GPU 训练、跨集群协同与端侧设备部署,连接 GPU 集群、机械臂、传感器和摄像头等异构资源。 -> **前往 [Read the Docs 中文站](https://rlark-ci-test.readthedocs.io/zh-cn/latest/) 阅读完整文档** — 从[快速开始](https://rlark-ci-test.readthedocs.io/zh-cn/latest/quickstart/)入门,并继续查阅[平台使用指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/user-guide/)或[管理员指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/admin-guide/)。 +> **前往 [Read the Docs 中文站](https://rlark.readthedocs.io/zh-cn/latest/) 阅读完整文档** — 从[快速开始](https://rlark.readthedocs.io/zh-cn/latest/quickstart/)入门,并继续查阅[平台使用指南](https://rlark.readthedocs.io/zh-cn/latest/user-guide/)或[管理员指南](https://rlark.readthedocs.io/zh-cn/latest/admin-guide/)。 ## 最新动态 -- [2026/08] RLark 现已开源。 +- [2026/09] RLark 现已开源。 ## 核心能力 -- **具身智能工作负载编排**:从云端 GPU 训练(RL/LLM)到端侧部署(机械臂、传感器、摄像头),统一的声明式 Job/Workflow/Task 抽象覆盖全链路 +- **具身智能工作负载编排**:从云端 GPU 训练(RL/LLM)到端侧部署(机械臂、传感器、摄像头),统一的声明式 Job/Task 抽象覆盖全链路 - **多运行时数据面**:基于 Kubernetes 统一纳管云端 GPU 集群与端侧设备,覆盖训练到具身设备部署的完整链路;面向不适合部署 Kubernetes 的轻量端侧场景,后续将扩展 Docker 和 Raw 运行时支持 - **跨集群资源抽象**:通过 Domain(虚拟网络域)和 Node(计算节点)CRD 统一管理多地 GPU 集群和端侧设备,控制面运行在 kcp 之上 -- **声明式训练任务**:Job/Workflow/Task 多层抽象,支持 DAG 编排的训练流水线,声明式定义 Ray 集群 +- **声明式训练任务**:Job/Task 抽象,支持声明式分布式训练与 Ray 集群定义 - **跨集群 Pod 网络**:基于 TUN 设备 + gVisor 协议栈 + SSH 隧道的虚拟网络,Pod 跨集群通信无需 NAT 穿透 — 云端 GPU 与端侧机器人直接通信 - **证书体系**:X.509 + SSH 双层证书,支持 Agent 接入、Domain 范围的跨集群转发鉴权、用户 SSH 登录鉴权 - **可观测性**:Prometheus 指标暴露、Pod 日志实时查询、Web 管理界面 +## Roadmap + +### 已实现 + +- 基于 Kubernetes 统一纳管云端 GPU 集群与端侧设备 +- 面向分布式训练和 Ray 工作负载的声明式 Job/Task 编排 +- 通过 Domain 和 Node CRD 管理跨集群资源 +- 基于 TUN 设备、gVisor 协议栈和 SSH 隧道的跨集群 Pod 网络 +- 基于 X.509 和 SSH 证书的身份认证 +- Prometheus 指标、Pod 实时日志和 Web 管理界面 + +### 未来计划 + +- 面向轻量数据面的 Docker 运行时支持 +- 面向无容器编排环境主机和端侧设备的 Raw 运行时支持 +- 更完善的账号、角色与权限管理 +- 持续优化 Web UI 易用性和操作流程 +- 优化跨集群网络吞吐、时延与资源效率 + ## 架构概览 -![系统架构](apps/rlark/docs/images/architecture.png) +![系统架构](apps/rlark/docs/images/architecture-zh.png) ## 快速开始 -请前往 Read the Docs 的[快速开始指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/quickstart/),选择一种已经过验证的流程: +完整步骤请查看 Read the Docs 的[快速开始](https://rlark.readthedocs.io/zh-cn/latest/quickstart/): - **一键 CLI 方式**:部署控制面和两个 kind 数据面集群,并验证 Pod 跨集群网络。 - **UI 交互方式**:在 Web 控制台创建集群和 Domain,部署两个 kind 数据面,将一个 Job 调度到两个集群并验证网络连通性。 ## 文档索引 -完整、可搜索且支持版本管理的文档发布在 **[Read the Docs 中文站](https://rlark-ci-test.readthedocs.io/zh-cn/latest/)**。面向用户的内容请优先从以下渲染后的指南进入: +完整文档见 **[Read the Docs 中文站](https://rlark.readthedocs.io/zh-cn/latest/)**: | 指南 | 说明 | |------|------| -| [快速开始](https://rlark-ci-test.readthedocs.io/zh-cn/latest/quickstart/) | 已验证的一键和 UI 本地部署流程 | -| [核心概念](https://rlark-ci-test.readthedocs.io/zh-cn/latest/concepts/) | Domain、Job、Task、Workflow 等概念解释 | -| [平台使用指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/user-guide/) | Web 控制台、集群、任务、工作流、存储和 SSH 密钥 | -| [管理员指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/admin-guide/) | 控制面、数据面、网络、安全和运维 | -| [开发者指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/developer-guide/) | 本地开发、项目结构、调试和扩展 | -| [API 参考](https://rlark-ci-test.readthedocs.io/zh-cn/latest/api/reference/) | Gateway REST API 路由与行为 | -| [架构设计](https://rlark-ci-test.readthedocs.io/zh-cn/latest/architecture/) | 组件、交互关系和数据流 | +| [快速开始](https://rlark.readthedocs.io/zh-cn/latest/quickstart/) | 已验证的一键和 UI 本地部署流程 | +| [核心概念](https://rlark.readthedocs.io/zh-cn/latest/concepts/) | Domain、Job、Task 等概念解释 | +| [平台使用指南](https://rlark.readthedocs.io/zh-cn/latest/user-guide/) | Web 控制台、集群、任务、存储和 SSH 密钥 | +| [管理员指南](https://rlark.readthedocs.io/zh-cn/latest/admin-guide/) | 控制面、数据面、网络、安全和运维 | +| [开发者指南](https://rlark.readthedocs.io/zh-cn/latest/developer-guide/) | 本地开发、项目结构、调试和扩展 | +| [API 参考](https://rlark.readthedocs.io/zh-cn/latest/api/reference/) | Gateway REST API 路由与行为 | +| [架构设计](https://rlark.readthedocs.io/zh-cn/latest/architecture/) | 组件、交互关系和数据流 | 与具体代码配套的说明继续保留仓库内入口: @@ -69,7 +88,7 @@ | [Go SDK](sdks/embodied-runtime-go/README.md) | Go 语言 gRPC 客户端 | | [Proto 定义](proto/embodied-runtime/README.md) | gRPC 服务接口定义 | -> English documentation is available on the **[English Read the Docs site](https://rlark-ci-test.readthedocs.io/en/latest/)**. +> English documentation is available on the **[English Read the Docs site](https://rlark.readthedocs.io/en/latest/)**. ## 技术栈 diff --git a/api/Makefile b/api/Makefile index e5e406e..e8f8c00 100644 --- a/api/Makefile +++ b/api/Makefile @@ -45,7 +45,7 @@ clean-samples: ## Remove sample CRs rm -rf $(SAMPLES_DIR) $(CONTROLLER_GEN): - GOBIN=$(shell go env GOPATH)/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5 + GOBIN=$(shell go env GOPATH)/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.18.0 ##@ Help diff --git a/api/config/crd/bases/rlinf.io_jobs.yaml b/api/config/crd/bases/rlinf.io_jobs.yaml index d081a67..7873edc 100644 --- a/api/config/crd/bases/rlinf.io_jobs.yaml +++ b/api/config/crd/bases/rlinf.io_jobs.yaml @@ -52,6 +52,24 @@ spec: type: string stopped: type: boolean + tags: + items: + description: |- + JobTag 表示一个任务标签,由 key 和多个 value 组成。 + key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; + 每个标签最多包含 10 个不同的 value。 + properties: + key: + type: string + values: + items: + type: string + type: array + required: + - key + - values + type: object + type: array tasks: items: properties: @@ -111,10 +129,14 @@ spec: additionalProperties: format: int32 type: integer + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests + instead.' type: object pvcStorageMap: additionalProperties: type: string + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName + instead.' type: object replicas: format: int32 @@ -8916,6 +8938,24 @@ spec: type: string sshPublicKey: type: string + tags: + items: + description: |- + JobTag 表示一个任务标签,由 key 和多个 value 组成。 + key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; + 每个标签最多包含 10 个不同的 value。 + properties: + key: + type: string + values: + items: + type: string + type: array + required: + - key + - values + type: object + type: array tensorBoardDir: type: string required: diff --git a/api/config/crd/bases/rlinf.io_nodes.yaml b/api/config/crd/bases/rlinf.io_nodes.yaml index 0b8d3bc..4a77756 100644 --- a/api/config/crd/bases/rlinf.io_nodes.yaml +++ b/api/config/crd/bases/rlinf.io_nodes.yaml @@ -159,6 +159,20 @@ spec: type: array reason: type: string + storage: + description: NodeStorageStatus reports kubelet filesystem usage in + bytes. + properties: + availableBytes: + format: int64 + type: integer + capacityBytes: + format: int64 + type: integer + usedBytes: + format: int64 + type: integer + type: object used: additionalProperties: anyOf: diff --git a/api/config/crd/bases/rlinf.io_pods.yaml b/api/config/crd/bases/rlinf.io_pods.yaml index 1dad983..33b7133 100644 --- a/api/config/crd/bases/rlinf.io_pods.yaml +++ b/api/config/crd/bases/rlinf.io_pods.yaml @@ -76,6 +76,12 @@ spec: node: type: string phase: + enum: + - Pending + - Running + - Succeeded + - Failed + - Unknown type: string type: object type: object diff --git a/api/config/crd/bases/rlinf.io_tasks.yaml b/api/config/crd/bases/rlinf.io_tasks.yaml index bbb01ae..8121a33 100644 --- a/api/config/crd/bases/rlinf.io_tasks.yaml +++ b/api/config/crd/bases/rlinf.io_tasks.yaml @@ -103,10 +103,14 @@ spec: additionalProperties: format: int32 type: integer + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests + instead.' type: object pvcStorageMap: additionalProperties: type: string + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName + instead.' type: object replicas: format: int32 @@ -8769,6 +8773,24 @@ spec: type: string sshPublicKey: type: string + tags: + items: + description: |- + JobTag 表示一个任务标签,由 key 和多个 value 组成。 + key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; + 每个标签最多包含 10 个不同的 value。 + properties: + key: + type: string + values: + items: + type: string + type: array + required: + - key + - values + type: object + type: array tensorBoardDir: type: string required: @@ -8835,18 +8857,17 @@ spec: - type type: object type: array - message: - type: string - observedNodes: - items: - type: string - type: array events: description: |- Events 在 Task 处于 Pending 期间由控制面 Task reconciler 从各节点 Node.status.events 聚合而来,包含 DiskPressure 等 Warning 事件及镜像 拉取/调度相关事件。Task 离开 Pending 后被清空。 items: + description: |- + NodeEvent represents a Kubernetes Event observed on a node that is relevant + for surfacing to operators (e.g. DiskPressure warnings, FailedScheduling, + image pull failures). The node-agent collects Warning events plus a small + set of Normal scheduling/pulling events and writes them to Node.status.events. properties: count: format: int32 @@ -8871,6 +8892,12 @@ spec: - type type: object type: array + message: + type: string + observedNodes: + items: + type: string + type: array phase: type: string pullProgress: @@ -8879,6 +8906,8 @@ spec: ContainerCreating(尚未 Running)期间由 cluster-agent 聚合写入。Pod 进入 Running 后被清空。供前端展示镜像拉取进度/速度。 items: + description: PullProgress captures the progress of an in-flight + image pull on a node. properties: downloaded: format: int64 diff --git a/api/config/crd/bases/rlinf.io_workflows.yaml b/api/config/crd/bases/rlinf.io_workflows.yaml index b5caedf..7aba640 100644 --- a/api/config/crd/bases/rlinf.io_workflows.yaml +++ b/api/config/crd/bases/rlinf.io_workflows.yaml @@ -63,6 +63,24 @@ spec: type: string stopped: type: boolean + tags: + items: + description: |- + JobTag 表示一个任务标签,由 key 和多个 value 组成。 + key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; + 每个标签最多包含 10 个不同的 value。 + properties: + key: + type: string + values: + items: + type: string + type: array + required: + - key + - values + type: object + type: array tasks: items: properties: @@ -122,10 +140,14 @@ spec: additionalProperties: format: int32 type: integer + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests + instead.' type: object pvcStorageMap: additionalProperties: type: string + description: 'Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName + instead.' type: object replicas: format: int32 @@ -9412,6 +9434,24 @@ spec: type: string sshPublicKey: type: string + tags: + items: + description: |- + JobTag 表示一个任务标签,由 key 和多个 value 组成。 + key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; + 每个标签最多包含 10 个不同的 value。 + properties: + key: + type: string + values: + items: + type: string + type: array + required: + - key + - values + type: object + type: array tensorBoardDir: type: string required: @@ -9421,6 +9461,8 @@ spec: type: object type: object type: array + stopped: + type: boolean type: object status: properties: @@ -9495,6 +9537,13 @@ spec: type: object type: array phase: + enum: + - Pending + - Running + - Stopping + - Stopped + - Succeeded + - Failed type: string startTime: format: date-time diff --git a/api/config/samples/rlinf.io_v1alpha1_task.yaml b/api/config/samples/rlinf.io_v1alpha1_task.yaml index a4f65bb..2e92dbe 100644 --- a/api/config/samples/rlinf.io_v1alpha1_task.yaml +++ b/api/config/samples/rlinf.io_v1alpha1_task.yaml @@ -12,9 +12,6 @@ spec: workload: kind: Deployment replicas: 2 - pvcStorageMap: - data-pvc: "standard" - model-pvc: "ssd" template: metadata: labels: @@ -47,11 +44,23 @@ spec: nvidia.com/gpu: "1" volumes: - name: data-vol - persistentVolumeClaim: - claimName: data-pvc + ephemeral: + volumeClaimTemplate: + spec: + accessModes: [ReadWriteOnce] + storageClassName: standard + resources: + requests: + storage: 20Gi - name: model-vol - persistentVolumeClaim: - claimName: model-pvc + ephemeral: + volumeClaimTemplate: + spec: + accessModes: [ReadWriteOnce] + storageClassName: ssd + resources: + requests: + storage: 10Gi status: phase: Running observedNodes: @@ -124,4 +133,4 @@ status: startTime: "2025-06-09T10:10:00Z" completionTime: "2025-06-09T10:12:00Z" message: simulator startup failed because the GPU driver is unavailable - retryCount: 2 \ No newline at end of file + retryCount: 2 diff --git a/api/hack/generate-clients.sh b/api/hack/generate-clients.sh index d7143e4..cecf301 100755 --- a/api/hack/generate-clients.sh +++ b/api/hack/generate-clients.sh @@ -29,7 +29,7 @@ set -o pipefail SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" # Pin code-generator version to match k8s.io/apimachinery in go.mod -export KUBE_CODEGEN_TAG="v0.36.1" +export KUBE_CODEGEN_TAG="v0.36.3" # source the code-gen helpers source "${SCRIPT_ROOT}/hack/kube_codegen.sh" @@ -65,4 +65,4 @@ kube::codegen::gen_client \ --with-applyconfig \ "${WATCH_FLAGS[@]:+"${WATCH_FLAGS[@]}"}" -echo ">>> Done! Generated code is in ${OUTPUT_DIR}" \ No newline at end of file +echo ">>> Done! Generated code is in ${OUTPUT_DIR}" diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobspec.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobspec.go index d996312..ccafeb9 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobspec.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobspec.go @@ -24,6 +24,7 @@ type JobSpecApplyConfiguration struct { Stopped *bool `json:"stopped,omitempty"` Tasks []JobTaskTemplateApplyConfiguration `json:"tasks,omitempty"` SSHPublicKey *string `json:"sshPublicKey,omitempty"` + Tags []JobTagApplyConfiguration `json:"tags,omitempty"` } // JobSpecApplyConfiguration constructs a declarative configuration of the JobSpec type for use with @@ -68,3 +69,16 @@ func (b *JobSpecApplyConfiguration) WithSSHPublicKey(value string) *JobSpecApply b.SSHPublicKey = &value return b } + +// WithTags adds the given value to the Tags field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Tags field. +func (b *JobSpecApplyConfiguration) WithTags(values ...*JobTagApplyConfiguration) *JobSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTags") + } + b.Tags = append(b.Tags, *values[i]) + } + return b +} diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtag.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtag.go new file mode 100644 index 0000000..6ca9780 --- /dev/null +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtag.go @@ -0,0 +1,50 @@ +/* +Copyright 2024 The RLInf Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// JobTagApplyConfiguration represents a declarative configuration of the JobTag type for use +// with apply. +// +// JobTag 表示一个任务标签,由 key 和多个 value 组成。 +// key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; +// 每个标签最多包含 10 个不同的 value。 +type JobTagApplyConfiguration struct { + Key *string `json:"key,omitempty"` + Values []string `json:"values,omitempty"` +} + +// JobTagApplyConfiguration constructs a declarative configuration of the JobTag type for use with +// apply. +func JobTag() *JobTagApplyConfiguration { + return &JobTagApplyConfiguration{} +} + +// WithKey sets the Key field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Key field is set to the value of the last call. +func (b *JobTagApplyConfiguration) WithKey(value string) *JobTagApplyConfiguration { + b.Key = &value + return b +} + +// WithValues adds the given values to the Values field in the declarative configuration +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +func (b *JobTagApplyConfiguration) WithValues(values ...string) *JobTagApplyConfiguration { + b.Values = append(b.Values, values...) + return b +} diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtasktemplate.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtasktemplate.go index c9d56cc..456919c 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtasktemplate.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/jobtasktemplate.go @@ -152,3 +152,16 @@ func (b *JobTaskTemplateApplyConfiguration) WithSSHPublicKey(value string) *JobT b.TaskSpecApplyConfiguration.SSHPublicKey = &value return b } + +// WithTags adds the given value to the Tags field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Tags field. +func (b *JobTaskTemplateApplyConfiguration) WithTags(values ...*JobTagApplyConfiguration) *JobTaskTemplateApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTags") + } + b.TaskSpecApplyConfiguration.Tags = append(b.TaskSpecApplyConfiguration.Tags, *values[i]) + } + return b +} diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/kubernetesworkloadspec.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/kubernetesworkloadspec.go index b3446f1..4ae215d 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/kubernetesworkloadspec.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/kubernetesworkloadspec.go @@ -25,11 +25,13 @@ import ( // KubernetesWorkloadSpecApplyConfiguration represents a declarative configuration of the KubernetesWorkloadSpec type for use // with apply. type KubernetesWorkloadSpecApplyConfiguration struct { - Kind *rlarkiov1alpha1.KubernetesWorkloadKind `json:"kind,omitempty"` - Replicas *int32 `json:"replicas,omitempty"` - Template *v1.PodTemplateSpec `json:"template,omitempty"` - PvcStorageMap map[string]string `json:"pvcStorageMap,omitempty"` - PvcSizeGbMap map[string]int32 `json:"pvcSizeGbMap,omitempty"` + Kind *rlarkiov1alpha1.KubernetesWorkloadKind `json:"kind,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` + Template *v1.PodTemplateSpec `json:"template,omitempty"` + // Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName instead. + PvcStorageMap map[string]string `json:"pvcStorageMap,omitempty"` + // Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests instead. + PvcSizeGbMap map[string]int32 `json:"pvcSizeGbMap,omitempty"` } // KubernetesWorkloadSpecApplyConfiguration constructs a declarative configuration of the KubernetesWorkloadSpec type for use with diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodeevent.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodeevent.go index 74418fc..c639d63 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodeevent.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodeevent.go @@ -18,20 +18,30 @@ limitations under the License. package v1alpha1 import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // NodeEventApplyConfiguration represents a declarative configuration of the NodeEvent type for use // with apply. +// +// NodeEvent represents a Kubernetes Event observed on a node that is relevant +// for surfacing to operators (e.g. DiskPressure warnings, FailedScheduling, +// image pull failures). The node-agent collects Warning events plus a small +// set of Normal scheduling/pulling events and writes them to Node.status.events. type NodeEventApplyConfiguration struct { - Type *string `json:"type,omitempty"` - Reason *string `json:"reason,omitempty"` - Message *string `json:"message,omitempty"` - LastTime *metav1.Time `json:"lastTime,omitempty"` - Count *int32 `json:"count,omitempty"` - Source *string `json:"source,omitempty"` - ObjectKind *string `json:"objectKind,omitempty"` - ObjectName *string `json:"objectName,omitempty"` + Type *string `json:"type,omitempty"` + // Warning / Normal + Reason *string `json:"reason,omitempty"` + // DiskPressure, FailedScheduling, Pulling, etc. + Message *string `json:"message,omitempty"` + LastTime *v1.Time `json:"lastTime,omitempty"` + // 最近一次发生时间 + Count *int32 `json:"count,omitempty"` + Source *string `json:"source,omitempty"` + // 事件来源组件,如 kubelet + ObjectKind *string `json:"objectKind,omitempty"` + // 涉及对象类型:Node / Pod + ObjectName *string `json:"objectName,omitempty"` } // NodeEventApplyConfiguration constructs a declarative configuration of the NodeEvent type for use with @@ -67,7 +77,7 @@ func (b *NodeEventApplyConfiguration) WithMessage(value string) *NodeEventApplyC // WithLastTime sets the LastTime field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the LastTime field is set to the value of the last call. -func (b *NodeEventApplyConfiguration) WithLastTime(value metav1.Time) *NodeEventApplyConfiguration { +func (b *NodeEventApplyConfiguration) WithLastTime(value v1.Time) *NodeEventApplyConfiguration { b.LastTime = &value return b } diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestatus.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestatus.go index 5a9942f..f1ff8b0 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestatus.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestatus.go @@ -25,17 +25,21 @@ import ( // NodeStatusApplyConfiguration represents a declarative configuration of the NodeStatus type for use // with apply. type NodeStatusApplyConfiguration struct { - Phase *rlarkiov1alpha1.NodePhase `json:"phase,omitempty"` - Reason *string `json:"reason,omitempty"` - NodeInfo *NodeInfoApplyConfiguration `json:"nodeInfo,omitempty"` - Addresses []v1.NodeAddress `json:"addresses,omitempty"` - DiskPressure *bool `json:"diskPressure,omitempty"` - Allocatable *v1.ResourceList `json:"allocatable,omitempty"` + Phase *rlarkiov1alpha1.NodePhase `json:"phase,omitempty"` + Reason *string `json:"reason,omitempty"` + NodeInfo *NodeInfoApplyConfiguration `json:"nodeInfo,omitempty"` + Addresses []v1.NodeAddress `json:"addresses,omitempty"` + DiskPressure *bool `json:"diskPressure,omitempty"` + Storage *NodeStorageStatusApplyConfiguration `json:"storage,omitempty"` + Allocatable *v1.ResourceList `json:"allocatable,omitempty"` // 需要预留系统组件 agent Capacity *v1.ResourceList `json:"capacity,omitempty"` Used *v1.ResourceList `json:"used,omitempty"` PullProgress []PullProgressApplyConfiguration `json:"pullProgress,omitempty"` - Events []NodeEventApplyConfiguration `json:"events,omitempty"` + // Events 由数据面 node-agent 上报节点相关 Kubernetes Event(如 DiskPressure + // 等 Warning 事件及镜像拉取/调度相关事件)。控制面 Task reconciler 在 Task + // 处于 Pending 期间聚合各节点事件到 Task.status.events,供前端展示。 + Events []NodeEventApplyConfiguration `json:"events,omitempty"` } // NodeStatusApplyConfiguration constructs a declarative configuration of the NodeStatus type for use with @@ -86,6 +90,14 @@ func (b *NodeStatusApplyConfiguration) WithDiskPressure(value bool) *NodeStatusA return b } +// WithStorage sets the Storage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Storage field is set to the value of the last call. +func (b *NodeStatusApplyConfiguration) WithStorage(value *NodeStorageStatusApplyConfiguration) *NodeStatusApplyConfiguration { + b.Storage = value + return b +} + // WithAllocatable sets the Allocatable field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Allocatable field is set to the value of the last call. @@ -113,9 +125,12 @@ func (b *NodeStatusApplyConfiguration) WithUsed(value v1.ResourceList) *NodeStat // WithPullProgress adds the given value to the PullProgress field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the PullProgress field. -func (b *NodeStatusApplyConfiguration) WithPullProgress(values ...PullProgressApplyConfiguration) *NodeStatusApplyConfiguration { +func (b *NodeStatusApplyConfiguration) WithPullProgress(values ...*PullProgressApplyConfiguration) *NodeStatusApplyConfiguration { for i := range values { - b.PullProgress = append(b.PullProgress, values[i]) + if values[i] == nil { + panic("nil value passed to WithPullProgress") + } + b.PullProgress = append(b.PullProgress, *values[i]) } return b } @@ -123,9 +138,12 @@ func (b *NodeStatusApplyConfiguration) WithPullProgress(values ...PullProgressAp // WithEvents adds the given value to the Events field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Events field. -func (b *NodeStatusApplyConfiguration) WithEvents(values ...NodeEventApplyConfiguration) *NodeStatusApplyConfiguration { +func (b *NodeStatusApplyConfiguration) WithEvents(values ...*NodeEventApplyConfiguration) *NodeStatusApplyConfiguration { for i := range values { - b.Events = append(b.Events, values[i]) + if values[i] == nil { + panic("nil value passed to WithEvents") + } + b.Events = append(b.Events, *values[i]) } return b } diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestoragestatus.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestoragestatus.go new file mode 100644 index 0000000..1402f43 --- /dev/null +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/nodestoragestatus.go @@ -0,0 +1,58 @@ +/* +Copyright 2024 The RLInf Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// NodeStorageStatusApplyConfiguration represents a declarative configuration of the NodeStorageStatus type for use +// with apply. +// +// NodeStorageStatus reports kubelet filesystem usage in bytes. +type NodeStorageStatusApplyConfiguration struct { + CapacityBytes *int64 `json:"capacityBytes,omitempty"` + UsedBytes *int64 `json:"usedBytes,omitempty"` + AvailableBytes *int64 `json:"availableBytes,omitempty"` +} + +// NodeStorageStatusApplyConfiguration constructs a declarative configuration of the NodeStorageStatus type for use with +// apply. +func NodeStorageStatus() *NodeStorageStatusApplyConfiguration { + return &NodeStorageStatusApplyConfiguration{} +} + +// WithCapacityBytes sets the CapacityBytes field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CapacityBytes field is set to the value of the last call. +func (b *NodeStorageStatusApplyConfiguration) WithCapacityBytes(value int64) *NodeStorageStatusApplyConfiguration { + b.CapacityBytes = &value + return b +} + +// WithUsedBytes sets the UsedBytes field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UsedBytes field is set to the value of the last call. +func (b *NodeStorageStatusApplyConfiguration) WithUsedBytes(value int64) *NodeStorageStatusApplyConfiguration { + b.UsedBytes = &value + return b +} + +// WithAvailableBytes sets the AvailableBytes field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AvailableBytes field is set to the value of the last call. +func (b *NodeStorageStatusApplyConfiguration) WithAvailableBytes(value int64) *NodeStorageStatusApplyConfiguration { + b.AvailableBytes = &value + return b +} diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/pullprogress.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/pullprogress.go index 182a512..d4a5d89 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/pullprogress.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/pullprogress.go @@ -19,12 +19,14 @@ package v1alpha1 // PullProgressApplyConfiguration represents a declarative configuration of the PullProgress type for use // with apply. +// +// PullProgress captures the progress of an in-flight image pull on a node. type PullProgressApplyConfiguration struct { - Image *string `json:"image"` - Downloaded *int64 `json:"downloaded"` - Total *int64 `json:"total"` - Speed *float64 `json:"speed"` - Status *string `json:"status"` + Image *string `json:"image,omitempty"` + Downloaded *int64 `json:"downloaded,omitempty"` + Total *int64 `json:"total,omitempty"` + Speed *float64 `json:"speed,omitempty"` + Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` } diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskspec.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskspec.go index f4f9c66..99e375a 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskspec.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskspec.go @@ -40,6 +40,8 @@ type TaskSpecApplyConfiguration struct { RunScript *string `json:"runScript,omitempty"` // Ray 集群就绪后执行的脚本(仅 head 节点) SSHPublicKey *string `json:"sshPublicKey,omitempty"` + // 注入到 Pod authorized_keys 的 SSH 公钥 + Tags []JobTagApplyConfiguration `json:"tags,omitempty"` } // TaskSpecApplyConfiguration constructs a declarative configuration of the TaskSpec type for use with @@ -149,3 +151,16 @@ func (b *TaskSpecApplyConfiguration) WithSSHPublicKey(value string) *TaskSpecApp b.SSHPublicKey = &value return b } + +// WithTags adds the given value to the Tags field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Tags field. +func (b *TaskSpecApplyConfiguration) WithTags(values ...*JobTagApplyConfiguration) *TaskSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTags") + } + b.Tags = append(b.Tags, *values[i]) + } + return b +} diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskstatus.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskstatus.go index ac0ead1..3b8377c 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskstatus.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/taskstatus.go @@ -34,8 +34,14 @@ type TaskStatusApplyConfiguration struct { Message *string `json:"message,omitempty"` RetryCount *int32 `json:"retryCount,omitempty"` TensorBoardProxy *string `json:"tensorBoardProxy,omitempty"` - PullProgress []PullProgressApplyConfiguration `json:"pullProgress,omitempty"` - Events []NodeEventApplyConfiguration `json:"events,omitempty"` + // PullProgress 由数据面节点上的 image pull monitor 上报,仅在 Pod 处于 + // ContainerCreating(尚未 Running)期间由 cluster-agent 聚合写入。Pod 进入 + // Running 后被清空。供前端展示镜像拉取进度/速度。 + PullProgress []PullProgressApplyConfiguration `json:"pullProgress,omitempty"` + // Events 在 Task 处于 Pending 期间由控制面 Task reconciler 从各节点 + // Node.status.events 聚合而来,包含 DiskPressure 等 Warning 事件及镜像 + // 拉取/调度相关事件。Task 离开 Pending 后被清空。 + Events []NodeEventApplyConfiguration `json:"events,omitempty"` } // TaskStatusApplyConfiguration constructs a declarative configuration of the TaskStatus type for use with @@ -116,7 +122,7 @@ func (b *TaskStatusApplyConfiguration) WithTensorBoardProxy(value string) *TaskS } // WithPullProgress adds the given value to the PullProgress field in the declarative configuration -// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the PullProgress field. func (b *TaskStatusApplyConfiguration) WithPullProgress(values ...*PullProgressApplyConfiguration) *TaskStatusApplyConfiguration { for i := range values { @@ -129,7 +135,7 @@ func (b *TaskStatusApplyConfiguration) WithPullProgress(values ...*PullProgressA } // WithEvents adds the given value to the Events field in the declarative configuration -// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Events field. func (b *TaskStatusApplyConfiguration) WithEvents(values ...*NodeEventApplyConfiguration) *TaskStatusApplyConfiguration { for i := range values { diff --git a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/workflowspec.go b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/workflowspec.go index e940460..e248e81 100644 --- a/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/workflowspec.go +++ b/api/kubeclients/applyconfiguration/rlark.io/v1alpha1/workflowspec.go @@ -21,6 +21,7 @@ package v1alpha1 // with apply. type WorkflowSpecApplyConfiguration struct { JobTemplates []WorkflowJobTemplateApplyConfiguration `json:"jobTemplates,omitempty"` + Stopped *bool `json:"stopped,omitempty"` } // WorkflowSpecApplyConfiguration constructs a declarative configuration of the WorkflowSpec type for use with @@ -41,3 +42,11 @@ func (b *WorkflowSpecApplyConfiguration) WithJobTemplates(values ...*WorkflowJob } return b } + +// WithStopped sets the Stopped field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Stopped field is set to the value of the last call. +func (b *WorkflowSpecApplyConfiguration) WithStopped(value bool) *WorkflowSpecApplyConfiguration { + b.Stopped = &value + return b +} diff --git a/api/kubeclients/applyconfiguration/utils.go b/api/kubeclients/applyconfiguration/utils.go index fcdff70..d111805 100644 --- a/api/kubeclients/applyconfiguration/utils.go +++ b/api/kubeclients/applyconfiguration/utils.go @@ -65,6 +65,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &rlarkiov1alpha1.JobSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("JobStatus"): return &rlarkiov1alpha1.JobStatusApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("JobTag"): + return &rlarkiov1alpha1.JobTagApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("JobTaskStatus"): return &rlarkiov1alpha1.JobTaskStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("JobTaskTemplate"): @@ -83,14 +85,16 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &rlarkiov1alpha1.NodeSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("NodeStatus"): return &rlarkiov1alpha1.NodeStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("PullProgress"): - return &rlarkiov1alpha1.PullProgressApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("NodeStorageStatus"): + return &rlarkiov1alpha1.NodeStorageStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Pod"): return &rlarkiov1alpha1.PodApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("PodSpec"): return &rlarkiov1alpha1.PodSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("PodStatus"): return &rlarkiov1alpha1.PodStatusApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("PullProgress"): + return &rlarkiov1alpha1.PullProgressApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RawTaskSpec"): return &rlarkiov1alpha1.RawTaskSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Task"): diff --git a/api/rlark.io/v1alpha1/annotations.go b/api/rlark.io/v1alpha1/annotations.go index 0f5b9ec..67ad28f 100644 --- a/api/rlark.io/v1alpha1/annotations.go +++ b/api/rlark.io/v1alpha1/annotations.go @@ -1,6 +1,9 @@ package v1alpha1 const ( + WorkflowLabel = "rlinf.io/workflow" + WorkflowStoppedByAnnotation = "rlinf.io/workflow-stopped-by" + RayRoleAnnotation = "rlark.io/ray-role" RayHeadTaskNameAnnotation = "rlark.io/ray-head-task-name" RayTotalNodesAnnotation = "rlark.io/ray-total-nodes" diff --git a/api/rlark.io/v1alpha1/job_types.go b/api/rlark.io/v1alpha1/job_types.go index 6cc2019..a4bd2fc 100644 --- a/api/rlark.io/v1alpha1/job_types.go +++ b/api/rlark.io/v1alpha1/job_types.go @@ -18,11 +18,20 @@ type JobTaskTemplate struct { TaskSpec `json:",inline"` } +// JobTag 表示一个任务标签,由 key 和多个 value 组成。 +// key 和每个 value 长度均不超过 10 个字符;一个任务最多 10 个标签; +// 每个标签最多包含 10 个不同的 value。 +type JobTag struct { + Key string `json:"key"` + Values []string `json:"values"` +} + type JobSpec struct { Domain string `json:"domain,omitempty"` Stopped bool `json:"stopped,omitempty"` Tasks []JobTaskTemplate `json:"tasks,omitempty"` SSHPublicKey string `json:"sshPublicKey,omitempty"` + Tags []JobTag `json:"tags,omitempty"` } type JobTaskStatus struct { diff --git a/api/rlark.io/v1alpha1/node_types.go b/api/rlark.io/v1alpha1/node_types.go index 34537c2..1847795 100644 --- a/api/rlark.io/v1alpha1/node_types.go +++ b/api/rlark.io/v1alpha1/node_types.go @@ -40,12 +40,20 @@ type NodeSpec struct { Unschedulable bool `json:"unschedulable,omitempty"` } +// NodeStorageStatus reports kubelet filesystem usage in bytes. +type NodeStorageStatus struct { + CapacityBytes int64 `json:"capacityBytes,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + AvailableBytes int64 `json:"availableBytes,omitempty"` +} + type NodeStatus struct { Phase NodePhase `json:"phase,omitempty"` Reason string `json:"reason,omitempty"` NodeInfo NodeInfo `json:"nodeInfo,omitempty"` Addresses []corev1.NodeAddress `json:"addresses,omitempty"` DiskPressure *bool `json:"diskPressure,omitempty"` + Storage *NodeStorageStatus `json:"storage,omitempty"` Allocatable corev1.ResourceList `json:"allocatable,omitempty"` // 需要预留系统组件 agent Capacity corev1.ResourceList `json:"capacity,omitempty"` Used corev1.ResourceList `json:"used,omitempty"` diff --git a/api/rlark.io/v1alpha1/pod_types.go b/api/rlark.io/v1alpha1/pod_types.go index bdeb8f4..aa4a465 100644 --- a/api/rlark.io/v1alpha1/pod_types.go +++ b/api/rlark.io/v1alpha1/pod_types.go @@ -2,6 +2,7 @@ package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +// +kubebuilder:validation:Enum=Pending;Running;Succeeded;Failed;Unknown type PodPhase string const ( @@ -9,12 +10,17 @@ const ( PodPhaseRunning PodPhase = "Running" PodPhaseSucceeded PodPhase = "Succeeded" PodPhaseFailed PodPhase = "Failed" + PodPhaseUnknown PodPhase = "Unknown" ) const ( PodLabelTaskName = "rlark.io/task-name" + PodLabelTaskUID = "rlark.io/task-uid" PodLabelLocalPodName = "rlark.io/local-pod-name" PodLabelLocalPodNamespace = "rlark.io/local-pod-namespace" + PodLabelLocalPodUID = "rlark.io/local-pod-uid" + PodLabelAgentScope = "rlark.io/agent-scope" + PodLabelDomain = "rlark.io/domain" ) // PodSpec 包含 Pod 的标识和引用信息,由数据面上报时设置。 diff --git a/api/rlark.io/v1alpha1/task_types.go b/api/rlark.io/v1alpha1/task_types.go index 118932e..0211db2 100644 --- a/api/rlark.io/v1alpha1/task_types.go +++ b/api/rlark.io/v1alpha1/task_types.go @@ -37,11 +37,13 @@ const ( ) type KubernetesWorkloadSpec struct { - Kind KubernetesWorkloadKind `json:"kind,omitempty"` - Replicas *int32 `json:"replicas,omitempty"` - Template corev1.PodTemplateSpec `json:"template,omitempty"` - PvcStorageMap map[string]string `json:"pvcStorageMap,omitempty"` - PvcSizeGbMap map[string]int32 `json:"pvcSizeGbMap,omitempty"` + Kind KubernetesWorkloadKind `json:"kind,omitempty"` + Replicas *int32 `json:"replicas,omitempty"` + Template corev1.PodTemplateSpec `json:"template,omitempty"` + // Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName instead. + PvcStorageMap map[string]string `json:"pvcStorageMap,omitempty"` + // Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests instead. + PvcSizeGbMap map[string]int32 `json:"pvcSizeGbMap,omitempty"` } type DockerTaskSpec struct { @@ -86,6 +88,7 @@ type TaskSpec struct { PrepareScript string `json:"prepareScript,omitempty"` // Ray 集群启动前执行的脚本 RunScript string `json:"runScript,omitempty"` // Ray 集群就绪后执行的脚本(仅 head 节点) SSHPublicKey string `json:"sshPublicKey,omitempty"` // 注入到 Pod authorized_keys 的 SSH 公钥 + Tags []JobTag `json:"tags,omitempty"` } type TaskStatus struct { diff --git a/api/rlark.io/v1alpha1/types_test.go b/api/rlark.io/v1alpha1/types_test.go new file mode 100644 index 0000000..4675c8b --- /dev/null +++ b/api/rlark.io/v1alpha1/types_test.go @@ -0,0 +1,31 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" +) + +func TestWorkflowStoppedJSON(t *testing.T) { + wf := Workflow{Spec: WorkflowSpec{Stopped: true}} + data, err := json.Marshal(wf) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"metadata":{},"spec":{"stopped":true},"status":{}}` { + t.Fatalf("unexpected JSON: %s", data) + } + + var decoded Workflow + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !decoded.Spec.Stopped { + t.Fatal("spec.stopped was not preserved") + } +} + +func TestProposalPhaseValues(t *testing.T) { + if PodPhaseUnknown != "Unknown" || WorkflowPhaseStopping != "Stopping" || WorkflowPhaseStopped != "Stopped" { + t.Fatal("proposal phase values changed") + } +} diff --git a/api/rlark.io/v1alpha1/workflow_types.go b/api/rlark.io/v1alpha1/workflow_types.go index 5f580e3..d0c9273 100644 --- a/api/rlark.io/v1alpha1/workflow_types.go +++ b/api/rlark.io/v1alpha1/workflow_types.go @@ -2,11 +2,14 @@ package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +// +kubebuilder:validation:Enum=Pending;Running;Stopping;Stopped;Succeeded;Failed type WorkflowPhase string const ( WorkflowPhasePending WorkflowPhase = "Pending" WorkflowPhaseRunning WorkflowPhase = "Running" + WorkflowPhaseStopping WorkflowPhase = "Stopping" + WorkflowPhaseStopped WorkflowPhase = "Stopped" WorkflowPhaseSucceeded WorkflowPhase = "Succeeded" WorkflowPhaseFailed WorkflowPhase = "Failed" ) @@ -19,6 +22,7 @@ type WorkflowJobTemplate struct { type WorkflowSpec struct { JobTemplates []WorkflowJobTemplate `json:"jobTemplates,omitempty"` + Stopped bool `json:"stopped,omitempty"` } type WorkflowJobStatus struct { diff --git a/api/rlark.io/v1alpha1/zz_generated.deepcopy.go b/api/rlark.io/v1alpha1/zz_generated.deepcopy.go index e41e5f8..d8a3da6 100644 --- a/api/rlark.io/v1alpha1/zz_generated.deepcopy.go +++ b/api/rlark.io/v1alpha1/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha1 import ( corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -487,6 +487,13 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]JobTag, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobSpec. @@ -534,6 +541,26 @@ func (in *JobStatus) DeepCopy() *JobStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JobTag) DeepCopyInto(out *JobTag) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobTag. +func (in *JobTag) DeepCopy() *JobTag { + if in == nil { + return nil + } + out := new(JobTag) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JobTaskStatus) DeepCopyInto(out *JobTaskStatus) { *out = *in @@ -739,6 +766,11 @@ func (in *NodeStatus) DeepCopyInto(out *NodeStatus) { *out = new(bool) **out = **in } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = new(NodeStorageStatus) + **out = **in + } if in.Allocatable != nil { in, out := &in.Allocatable, &out.Allocatable *out = make(corev1.ResourceList, len(*in)) @@ -784,6 +816,21 @@ func (in *NodeStatus) DeepCopy() *NodeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeStorageStatus) DeepCopyInto(out *NodeStorageStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeStorageStatus. +func (in *NodeStorageStatus) DeepCopy() *NodeStorageStatus { + if in == nil { + return nil + } + out := new(NodeStorageStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Pod) DeepCopyInto(out *Pod) { *out = *in @@ -1002,6 +1049,13 @@ func (in *TaskSpec) DeepCopyInto(out *TaskSpec) { *out = new(string) **out = **in } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]JobTag, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpec. diff --git a/apps/embodied-runtime/docs/examples.md b/apps/embodied-runtime/docs/examples.md index 245d0b5..4a9e553 100644 --- a/apps/embodied-runtime/docs/examples.md +++ b/apps/embodied-runtime/docs/examples.md @@ -19,6 +19,8 @@ End-to-end deployment and usage walkthroughs for the most common embodied-runtim - [R1 — USB robot via host device passthrough](#r1--usb-robot-via-host-device-passthrough) - [R2 — Network robot via host macvlan](#r2--network-robot-via-host-macvlan) - [R3 — ROS-managed robot](#r3--ros-managed-robot) +- [Unsupported devices](#unsupported-devices) + - [U1 — Access an unsupported device through the host network](#u1--access-an-unsupported-device-through-the-host-network) - [Combined — robot + camera on one node](#combined--robot--camera-on-one-node) - [Notes](#notes) - [ROS isolation](#ros-isolation) @@ -66,8 +68,9 @@ End-to-end deployment and usage walkthroughs for the most common embodied-runtim | R1 | USB robot direct | `host_devices` | none | open `/dev/ttyUSBx` directly | | R2 | Network robot via macvlan | `host_macvlans` + webhook | none | reach robot by IP over macvlan | | R3 | ROS-managed robot | `ros` / `ros2` (pod mode) | ros[-2]-controller | `rosctr` CLI / `RobotClient` SDK / REST | +| U1 | Unsupported network device | none | none | use a vendor SDK or protocol directly through `hostNetwork` | -In every scenario the workload pod requests `rlinf.io/device` (or `rlinf.io/device-` when `config.model` is set). The device plugin's `Allocate` then injects: +C1–R3 use embodied-runtime's native capabilities. The workload pod requests `rlinf.io/device` (or `rlinf.io/device-` when `config.model` is set), and the device plugin's `Allocate` then injects: - The socket directory `/var/run/rlark` (read-only) — controller gRPC sockets. - The CLI directory `/opt/rlinf/bin` (read-only) — `rosctr`, `camctr`. @@ -650,6 +653,41 @@ kubectl exec -it ros2-task -- /opt/rlinf/bin/rosctr env franka-robot-1 # shows --- +## Unsupported devices + +### U1 — Access an unsupported device through the host network + +**When to use.** embodied-runtime does not yet provide a controller, device model, or SDK for the hardware, but the device is reachable from the host network and the workload image already contains the vendor SDK, driver, or protocol client. This is a compatibility path until native support is available. + +Set `hostNetwork: true` on the workload pod and use a `nodeSelector` to place it on a node that can reach the device: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: unsupported-device-task + namespace: default +spec: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + nodeSelector: + kubernetes.io/hostname: worker-1 + containers: + - name: app + image: registry.example.com/vendor/device-sdk:latest + command: ["sh", "-c", "./device-client --address 192.168.10.20"] + tolerations: + - key: rlinf.io/robot + operator: Exists + effect: NoSchedule +``` + +This pod does not request `rlinf.io/device` and does not receive embodied-runtime device discovery, resource isolation, controllers, CLIs, SDKs, or environment injection. The workload image is responsible for device drivers, connection settings, and lifecycle management. + +`hostNetwork` shares the host network namespace, which can cause port conflicts and reduces network isolation. Use it only on trusted data planes and dedicated device nodes, and constrain scheduling with node labels, taints, and tolerations. Prefer the native `host_devices`, `host_macvlans`, ROS, or Camera controller path whenever it supports the device. + +--- + ## Combined — robot + camera on one node Different robot and camera types can be freely combined. The canonical embodied-AI data-collection node has a Franka arm **and** one or more cameras, and a training / teleop pod that drives the robot while streaming the wrist camera. Enable the ROS controller and the camera controller in the same release: diff --git a/apps/embodied-runtime/docs/examples.zh-CN.md b/apps/embodied-runtime/docs/examples.zh-CN.md index 90b7707..c323596 100644 --- a/apps/embodied-runtime/docs/examples.zh-CN.md +++ b/apps/embodied-runtime/docs/examples.zh-CN.md @@ -19,6 +19,8 @@ embodied-runtime 最常见场景的端到端部署与使用样例。每个场景 - [R1 — USB 机器人宿主设备透传](#r1--usb-机器人宿主设备透传) - [R2 — 通过宿主 macvlan 接入网络机器人](#r2--通过宿主-macvlan-接入网络机器人) - [R3 — ROS 托管机器人](#r3--ros-托管机器人) +- [未适配设备](#未适配设备) + - [U1 — 通过宿主机网络访问未适配设备](#u1--通过宿主机网络访问未适配设备) - [组合 — 同一节点上的机器人 + 摄像头](#组合--同一节点上的机器人--摄像头) - [注意事项](#注意事项) - [ROS 隔离](#ros-隔离) @@ -66,8 +68,9 @@ embodied-runtime 最常见场景的端到端部署与使用样例。每个场景 | R1 | USB 机器人直连 | `host_devices` | 无 | 直接打开 `/dev/ttyUSBx` | | R2 | 通过 macvlan 接入网络机器人 | `host_macvlans` + webhook | 无 | 通过 macvlan 用 IP 访问机器人 | | R3 | ROS 托管机器人 | `ros` / `ros2`(pod 模式) | ros[-2]-controller | `rosctr` CLI / `RobotClient` SDK / REST | +| U1 | 未适配的网络设备 | 无 | 无 | 通过 `hostNetwork` 使用厂商 SDK 或协议直接访问 | -每个场景中,业务 Pod 都申请 `rlinf.io/device`(设置了 `config.model` 时为 `rlinf.io/device-`)。device plugin 的 `Allocate` 随后注入: +C1–R3 使用 embodied-runtime 的原生能力,业务 Pod 需要申请 `rlinf.io/device`(设置了 `config.model` 时为 `rlinf.io/device-`)。device plugin 的 `Allocate` 随后注入: - socket 目录 `/var/run/rlark`(只读)—— 控制器 gRPC socket。 - CLI 目录 `/opt/rlinf/bin`(只读)—— `rosctr`、`camctr`。 @@ -650,6 +653,41 @@ kubectl exec -it ros2-task -- /opt/rlinf/bin/rosctr env franka-robot-1 # 显 --- +## 未适配设备 + +### U1 — 通过宿主机网络访问未适配设备 + +**适用场景。** embodied-runtime 尚未提供对应 controller、设备模型或 SDK,但设备已能从宿主机网络访问,业务镜像也已包含厂商 SDK、驱动或协议客户端。此方式让业务 Pod 直接使用宿主机网络,是原生适配完成前的兼容方案。 + +在业务 Pod 中设置 `hostNetwork: true`,并使用 `nodeSelector` 将其调度到能访问设备的节点: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: unsupported-device-task + namespace: default +spec: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + nodeSelector: + kubernetes.io/hostname: worker-1 + containers: + - name: app + image: registry.example.com/vendor/device-sdk:latest + command: ["sh", "-c", "./device-client --address 192.168.10.20"] + tolerations: + - key: rlinf.io/robot + operator: Exists + effect: NoSchedule +``` + +该 Pod 不需要申请 `rlinf.io/device`,也不会获得 embodied-runtime 的设备发现、资源隔离、controller、CLI、SDK 或环境变量注入。设备驱动、连接参数与生命周期管理由业务镜像负责。 + +`hostNetwork` 会让 Pod 与宿主机共享网络命名空间,可能产生端口冲突并降低网络隔离能力。仅在可信数据面和专用设备节点使用,并通过节点标签、污点和容忍限制调度范围。若设备已能通过 `host_devices`、`host_macvlans`、ROS 或 Camera controller 接入,应优先使用对应的原生方案。 + +--- + ## 组合 — 同一节点上的机器人 + 摄像头 不同类型的机器人与摄像头可自由组合。典型的具身智能数据采集节点:一台 Franka 机械臂 **加** 一或多台摄像头,外加一个训练 / 遥操作 Pod,边驱动机器人边推流腕部摄像头。在同一 release 中同时启用 ROS 控制器与摄像头控制器: diff --git a/apps/embodied-runtime/pkg/deviceplugin/device_server_test.go b/apps/embodied-runtime/pkg/deviceplugin/device_server_test.go index 1d19210..bc9bf9e 100644 --- a/apps/embodied-runtime/pkg/deviceplugin/device_server_test.go +++ b/apps/embodied-runtime/pkg/deviceplugin/device_server_test.go @@ -121,11 +121,9 @@ func TestNewDeviceServer_EmptyHasNoMacvlans(t *testing.T) { } // TestEnrichForSetup_DropsInvalid verifies the per-Setup enrich+validate path -// drops configs that cannot be completed. EnrichMACVLANConfig is best-effort -// (a hard no-op on non-Linux; a no-op when the host is unreachable on Linux), -// so on the test host enrichment leaves the config unchanged and the result -// is driven by the pure ValidateMACVLANConfig: a fully-specified config -// passes; network/broadcast/empty IPs and missing name are rejected. +// for configs that do not require host-network discovery. Network-address +// placeholders are covered by netmac validation and enrichment tests because +// their result here depends on the runner's interfaces and privileges. func TestEnrichForSetup_DropsInvalid(t *testing.T) { cases := []struct { name string @@ -133,7 +131,6 @@ func TestEnrichForSetup_DropsInvalid(t *testing.T) { want bool }{ {"good", netmac.MACVLANConfig{Name: "good", HostNIC: "eno1", IP: "172.16.0.100/24"}, true}, - {"network-addr", netmac.MACVLANConfig{Name: "bad-net", HostNIC: "eno1", IP: "172.16.0.0/24"}, false}, {"broadcast", netmac.MACVLANConfig{Name: "bad-bcast", HostNIC: "eno1", IP: "172.16.0.255/24"}, false}, {"no-ip", netmac.MACVLANConfig{Name: "bad-noip", HostNIC: "eno1", IP: ""}, false}, {"no-name", netmac.MACVLANConfig{HostNIC: "eno1", IP: "172.16.0.100/24"}, false}, diff --git a/apps/embodied-runtime/pkg/ros2controller/httpserver.go b/apps/embodied-runtime/pkg/ros2controller/httpserver.go index d592c68..2234edb 100644 --- a/apps/embodied-runtime/pkg/ros2controller/httpserver.go +++ b/apps/embodied-runtime/pkg/ros2controller/httpserver.go @@ -322,6 +322,7 @@ func (s *HTTPServer) handleProxy(w http.ResponseWriter, r *http.Request) { mountPrefix := httpproto.ProxyMountPrefix("robots", robotID) rewritePath := strings.TrimPrefix(r.URL.Path, mountPrefix) + //nolint:staticcheck // Ignore staticcheck warnings for the Director function in the reverse proxy proxy := &httputil.ReverseProxy{ Transport: s.transport, Director: func(req *http.Request) { diff --git a/apps/embodied-runtime/pkg/roscontroller/httpserver.go b/apps/embodied-runtime/pkg/roscontroller/httpserver.go index 50694d3..084f521 100644 --- a/apps/embodied-runtime/pkg/roscontroller/httpserver.go +++ b/apps/embodied-runtime/pkg/roscontroller/httpserver.go @@ -396,6 +396,7 @@ func (s *HTTPServer) handleProxy(w http.ResponseWriter, r *http.Request) { mountPrefix := httpproto.ProxyMountPrefix("robots", robotID) rewritePath := strings.TrimPrefix(r.URL.Path, mountPrefix) + //nolint:staticcheck // Ignore staticcheck warnings for the Director function in the reverse proxy proxy := &httputil.ReverseProxy{ Transport: s.transport, Director: func(req *http.Request) { diff --git a/apps/rlark-ui/README.md b/apps/rlark-ui/README.md index 74f0f50..3094b98 100644 --- a/apps/rlark-ui/README.md +++ b/apps/rlark-ui/README.md @@ -38,12 +38,11 @@ The frontend uses exactly one data mode so pages never mix mock and backend data - Admin dashboard:`/admin` 默认进入管理工作台,集中展示集群、节点、任务、存储等核心指标,以及待处理事项、常用管理操作和近期资源动态 - Overview:平台健康、资源使用、活跃工作流和事件流 -- Workflows:Workflow 列表、详情和 DAG 执行视图 - Jobs:Job 列表与 Task 执行进度 - Tasks:任务类型、角色、节点和状态 - Nodes:节点默认按集群和节点名称排序,展示物理位置、GPU/具身设备总量、空闲量与型号;从总览地图点击城市可直接进入对应位置筛选结果 - Cluster detail:集群详情中的节点表与 Nodes 使用相同的位置、资源数量、空闲状态和型号口径 -- Node metadata:容量与可分配量由 Agent 从 Kubernetes Node 自动上报;管理员维护的位置、节点分类、GPU 型号和具身设备型号存储在 KCP Node CR,并在 Agent 状态同步时保留。节点详情会同时列出 CPU、内存、GPU 以及所有 `rlinf.io/device*` 端侧设备资源 +- Node metadata:容量与可分配量由 Agent 从 Kubernetes Node 自动上报;管理员维护的位置、节点分类、GPU 型号和具身设备型号存储在 KCP Node CR,并在 Agent 状态同步时保留。节点详情会同时列出 CPU、内存、磁盘、GPU 以及所有 `rlinf.io/device*` 端侧设备资源。CPU、内存、GPU 和端侧设备显示活跃 Pod 的请求量;磁盘通过 kubelet Stats Summary API 显示 nodefs 与独立 imagefs 的真实已用、总量和剩余量,并使用 GiB(二进制)换算。真实占用达到 90% 或 kubelet 上报 `DiskPressure=True` 时,磁盘卡片和任务 Worker 节点列显示红色预警。Mock 环境可在 `gpu-cloud-01` 节点及 `robot-policy-training` 任务的 actor Worker 查看该预警 - Jobs:列表以 `rlark.io/display-name` 展示名为主,并展示和支持复制系统生成的 Kubernetes `metadata.name` 任务 ID;旧任务未配置展示名时回退到资源 ID - Lists:集群、节点、任务、工作流、存储和 SSH 公钥主列表支持点击表头切换升序与降序;分页基于排序后的完整筛选结果 - Time:创建时间、停止时间等统一转换为中国标准时间(`Asia/Shanghai`),格式为 `YYYY-MM-DD HH:mm:ss` @@ -56,9 +55,9 @@ The frontend uses exactly one data mode so pages never mix mock and backend data 设备数量不应手工标注:GPU 容量由 NVIDIA Device Plugin 注册,具身设备容量由 embodied-runtime Device Plugin 以 `rlinf.io/device` 或 `rlinf.io/device-` 资源注册。`status.used` 表示活跃 Pod 声明的资源请求量,不等同于 metrics-server 提供的实时硬件利用率。 -Administrators maintain city, category, GPU-model, and device-model metadata on the KCP Node CR through the Admin node page. The Agent preserves these fields while reporting data-plane Kubernetes state. Device counts must come from the NVIDIA or embodied-runtime Device Plugin rather than manual labels. The Agent reports active Pod requests as `status.used`; this is scheduler reservation data, not live hardware utilization from metrics-server. +Administrators maintain city, category, GPU-model, and device-model metadata on the KCP Node CR through the Admin node page. The Agent preserves these fields while reporting data-plane Kubernetes state. Device counts must come from the NVIDIA or embodied-runtime Device Plugin rather than manual labels. The Agent reports active Pod requests as `status.used`; this is scheduler reservation data, not live hardware utilization from metrics-server. Disk is the exception: the Agent reads kubelet Stats Summary and reports real capacity, used, and available bytes for nodefs and any dedicated imagefs. Node details display those values in binary GiB, and both the disk card and job Worker node column warn when usage reaches 90% or kubelet reports `DiskPressure=True`. In Mock mode, this warning is available on node `gpu-cloud-01` and the actor Worker of job `robot-policy-training`. -The primary Cluster, Node, Job, Workflow, Storage, and SSH Key lists support ascending and descending sorting by clicking their column headers. Sorting is applied before pagination. +The primary Cluster, Node, Job, Storage, and SSH Key lists support ascending and descending sorting by clicking their column headers. Sorting is applied before pagination. The `/admin` entry opens an administration dashboard with platform metrics, items requiring attention, common management actions, and recent resource activity. diff --git a/apps/rlark-ui/package.json b/apps/rlark-ui/package.json index c032fab..f64bd14 100644 --- a/apps/rlark-ui/package.json +++ b/apps/rlark-ui/package.json @@ -15,7 +15,9 @@ "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0", - "test": "tsc src/utils/terminalKeyboard.ts src/utils/nodeVisibility.ts src/utils/imageReference.ts src/utils/nodeBatchMetadata.ts src/utils/nodeResources.ts src/utils/jobPhase.ts src/utils/job.ts src/utils/crd.ts src/utils/resourceAvailability.ts --ignoreConfig --outDir dist/test --target ES2022 --module ES2022 --moduleResolution Bundler --lib ES2022,DOM && node --test tests/*.test.mjs" + "test": "npm run test:compile && node --test tests/*.test.mjs", + "test:coverage": "npm run test:compile && NODE_V8_COVERAGE=coverage/v8 node --test tests/*.test.mjs", + "test:compile": "tsc -p tsconfig.test.json" }, "dependencies": { "@vitejs/plugin-react": "^4.7.0", diff --git a/apps/rlark-ui/public/rlark-logo-en-dark.png b/apps/rlark-ui/public/rlark-logo-en-dark.png index 1f9b9ad..ab1642b 100644 Binary files a/apps/rlark-ui/public/rlark-logo-en-dark.png and b/apps/rlark-ui/public/rlark-logo-en-dark.png differ diff --git a/apps/rlark-ui/public/rlark-logo-en-dark.svg b/apps/rlark-ui/public/rlark-logo-en-dark.svg new file mode 100644 index 0000000..03d1e55 --- /dev/null +++ b/apps/rlark-ui/public/rlark-logo-en-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/rlark-ui/public/rlark-logo-en-light.png b/apps/rlark-ui/public/rlark-logo-en-light.png index 7e3c966..ab1642b 100644 Binary files a/apps/rlark-ui/public/rlark-logo-en-light.png and b/apps/rlark-ui/public/rlark-logo-en-light.png differ diff --git a/apps/rlark-ui/public/rlark-logo-zh-dark.png b/apps/rlark-ui/public/rlark-logo-zh-dark.png index 73c1dcb..6e3980e 100644 Binary files a/apps/rlark-ui/public/rlark-logo-zh-dark.png and b/apps/rlark-ui/public/rlark-logo-zh-dark.png differ diff --git a/apps/rlark-ui/public/rlark-logo-zh-dark.svg b/apps/rlark-ui/public/rlark-logo-zh-dark.svg new file mode 100644 index 0000000..9a13bc7 --- /dev/null +++ b/apps/rlark-ui/public/rlark-logo-zh-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/rlark-ui/public/rlark-logo-zh-light.png b/apps/rlark-ui/public/rlark-logo-zh-light.png index bdb0a67..6e3980e 100644 Binary files a/apps/rlark-ui/public/rlark-logo-zh-light.png and b/apps/rlark-ui/public/rlark-logo-zh-light.png differ diff --git a/apps/rlark-ui/src/App.tsx b/apps/rlark-ui/src/App.tsx index e26c496..44373f2 100644 --- a/apps/rlark-ui/src/App.tsx +++ b/apps/rlark-ui/src/App.tsx @@ -4,7 +4,12 @@ import type { Job } from "./data"; import { copy, type Lang, type Theme } from "./i18n"; import { navItems } from "./constants"; import type { Page } from "./types"; -import { useIsAdminPath, parseRoute } from "./utils/route"; +import { + filesPath, + hasTerminalSession, + parseRoute, + useIsAdminPath, +} from "./utils/route"; import { Logo, Header, PlatformFooter } from "./components/shared"; import { TerminalPage } from "./components/terminal"; import { Overview } from "./pages/Overview"; @@ -22,6 +27,7 @@ import { UserLogin } from "./pages/Login"; import { SSHKeysPage } from "./pages/SSHKeys"; import { AdminApp } from "./admin/AdminApp"; import { useBackendMode, usePersistentState } from "./hooks"; +import { clearAuthSession, hasAuthSession, UNAUTHORIZED_EVENT } from "./api"; type NavigateOptions = { replace?: boolean; @@ -52,9 +58,12 @@ export default function App() { const [userLoggedIn, setUserLoggedIn] = useState( () => import.meta.env.DEV || - (typeof sessionStorage !== "undefined" && - sessionStorage.getItem("rlark-user-auth") === "1"), + (typeof sessionStorage !== "undefined" && hasAuthSession()), ); + const terminalLoggedIn = + import.meta.env.DEV || + (typeof sessionStorage !== "undefined" && + hasTerminalSession(sessionStorage)); const [userName, setUserName] = useState( () => sessionStorage.getItem("rlark-user-name") || "user", ); @@ -110,6 +119,12 @@ export default function App() { return () => window.removeEventListener("popstate", onPop); }, []); + useEffect(() => { + const onUnauthorized = () => setUserLoggedIn(false); + window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + }, []); + useEffect(() => { if (!jobSubmitNotice) return; const timer = window.setTimeout(() => setJobSubmitNotice(""), 4000); @@ -117,7 +132,7 @@ export default function App() { }, [jobSubmitNotice]); if (isAdmin) return ; - if (!userLoggedIn) { + if (!userLoggedIn && !(isTerminal && terminalLoggedIn)) { return ( { @@ -253,8 +268,7 @@ export default function App() { userName={userName} onCreate={() => setCreateOpen(true)} onLogout={() => { - sessionStorage.removeItem("rlark-user-auth"); - sessionStorage.removeItem("rlark-user-name"); + clearAuthSession(); setUserLoggedIn(false); }} /> @@ -339,6 +353,9 @@ export default function App() { navigate("storageClass", name, { replace: !name }) } onCreate={() => setStorageCreateOpen(true)} + onBrowseFiles={(cluster, storageClass) => + window.open(filesPath(cluster, storageClass), "_blank") + } refreshKey={storageRefreshKey} /> )} @@ -362,7 +379,7 @@ export default function App() { onJobClick={(name) => navigate("jobs", name)} /> )} - {page === "ssh-keys" && } + {page === "ssh-keys" && } {createOpen && ( diff --git a/apps/rlark-ui/src/admin/Addons.tsx b/apps/rlark-ui/src/admin/Addons.tsx index fc82575..8c819e3 100644 --- a/apps/rlark-ui/src/admin/Addons.tsx +++ b/apps/rlark-ui/src/admin/Addons.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { Package } from "lucide-react"; -import type { Copy, Lang } from "../i18n"; +import type { Lang } from "../i18n"; -export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) { +export function AddonsPage({ lang }: { lang: Lang }) { const zh = lang === "zh"; const [clusters, setClusters] = useState<{ id: string; name: string }[]>([]); const [catalog, setCatalog] = useState([]); @@ -10,30 +10,28 @@ export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) { const [clusterFilter, setClusterFilter] = useState(""); const [page, setPage] = useState(1); const pageSize = 10; - const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [installAddonName, setInstallAddonName] = useState(""); const [configInstalled, setConfigInstalled] = useState(null); useEffect(() => { - fetch("/api/v1/clusters") - .then((r) => r.json()) - .then((data) => setClusters(data.data || [])) + clustersApi + .list<{ id: string; name: string }>() + .then(setClusters) .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, []); useEffect(() => { - fetch("/api/v1/addons") - .then((r) => r.json()) - .then((data) => setCatalog(data.data || [])) + addonsApi + .catalog() + .then(setCatalog) .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, []); const fetchInstalled = () => { - const q = clusterFilter ? `?cluster=${clusterFilter}` : ""; - fetch(`/api/v1/installed-addons${q}`) - .then((r) => r.json()) - .then((data) => setInstalled(data.data || [])) + addonsApi + .installed(clusterFilter || undefined) + .then(setInstalled) .catch((e) => setError(e instanceof Error ? e.message : String(e))); }; @@ -107,7 +105,7 @@ export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) { return (
@@ -216,22 +214,17 @@ export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) { {a.status?.message || "-"} -
+
@@ -356,18 +332,11 @@ export function AddonInstallPage({ if (!installCluster) return; setLoading(true); setError(""); - fetch(`/api/v1/clusters/${installCluster}/addons`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + addonsApi + .install(installCluster, { addonName: addon.name, version: addon.version, values, - }), - }) - .then((r) => { - if (!r.ok) throw new Error("Install failed"); - return r.json(); }) .then(() => { setLoading(false); @@ -381,7 +350,7 @@ export function AddonInstallPage({ return (
diff --git a/apps/rlark-ui/src/admin/AdminDashboard.tsx b/apps/rlark-ui/src/admin/AdminDashboard.tsx index 143b18a..0faedcd 100644 --- a/apps/rlark-ui/src/admin/AdminDashboard.tsx +++ b/apps/rlark-ui/src/admin/AdminDashboard.tsx @@ -5,7 +5,6 @@ import { ArrowRight, Boxes, CheckCircle2, - CloudCog, Database, HardDrive, Image, @@ -20,6 +19,7 @@ import type { Copy } from "../i18n"; import type { CRDDomain, CRDJob, CRDNode } from "../types"; import { useAutoRefresh } from "../hooks"; import { formatChinaDateTime } from "../utils/time"; +import { RefreshOverlay } from "../components/shared"; type DashboardData = { nodes: CRDNode[]; @@ -35,16 +35,12 @@ const emptyData: DashboardData = { storageClasses: [], }; -async function fetchItems(url: string): Promise { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); - if (Array.isArray(data)) return data; - if (Array.isArray(data.items)) return data.items; - if (data.data && typeof data.data === "object") { - return Object.values(data.data) as T[]; - } - return []; +function jobDisplayName(job: CRDJob) { + return ( + job.metadata.annotations?.["rlark.io/display-name"] ?? + job.metadata.labels?.["rlark.io/display-name"] ?? + job.metadata.name + ); } export function AdminDashboard({ @@ -57,6 +53,7 @@ export function AdminDashboard({ const zh = c.nav.overview === "总览"; const [data, setData] = useState(emptyData); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(""); const [updatedAt, setUpdatedAt] = useState(null); @@ -65,10 +62,12 @@ export function AdminDashboard({ setError(""); try { const [nodes, jobs, domains, storageClasses] = await Promise.all([ - fetchItems("/api/v1/rlinf.io/v1alpha1/nodes"), - fetchItems("/api/v1/rlinf.io/v1alpha1/jobs"), - fetchItems("/api/v1/rlinf.io/v1alpha1/domains"), - fetchItems<{ name?: string }>("/api/v1/storage/storageclass"), + nodesApi.list(), + jobsApi.list(), + domainsApi.list(), + storageClassesApi + .list<{ name?: string }>() + .then((items) => Object.values(items)), ]); setData({ nodes, jobs, domains, storageClasses }); setUpdatedAt(new Date()); @@ -81,6 +80,16 @@ export function AdminDashboard({ useAutoRefresh(fetchDashboard, 15000); + const handleRefresh = async () => { + if (refreshing) return; + setRefreshing(true); + try { + await fetchDashboard(false); + } finally { + setRefreshing(false); + } + }; + const summary = useMemo(() => { const clusterNames = new Set( data.nodes.map((node) => node.metadata.namespace).filter(Boolean), @@ -116,7 +125,8 @@ export function AdminDashboard({ ...data.jobs.map((job) => ({ id: `job-${job.metadata.name}`, type: zh ? "任务" : "Job", - name: job.metadata.name, + name: jobDisplayName(job), + resourceName: job.metadata.name, state: job.status?.phase ?? "Pending", time: job.metadata.creationTimestamp, target: "jobs", @@ -125,6 +135,7 @@ export function AdminDashboard({ id: `node-${node.metadata.name}`, type: zh ? "节点" : "Node", name: node.metadata.name, + resourceName: node.metadata.name, state: node.status?.phase ?? "Offline", time: node.metadata.creationTimestamp, target: "clusters-nodes", @@ -220,7 +231,10 @@ export function AdminDashboard({ ]; return ( -
+
@@ -254,12 +268,19 @@ export function AdminDashboard({
@@ -369,7 +390,7 @@ export function AdminDashboard({
- {job.metadata.name} + {jobDisplayName(job)} {zh ? "任务等待调度" : "Job pending scheduling"} @@ -410,7 +431,7 @@ export function AdminDashboard({ {recentItems.map((item) => (
+
); } +import { domainsApi, jobsApi, nodesApi, storageClassesApi } from "../backend"; diff --git a/apps/rlark-ui/src/admin/AdminPage.tsx b/apps/rlark-ui/src/admin/AdminPage.tsx index 91f005c..ebe1b06 100644 --- a/apps/rlark-ui/src/admin/AdminPage.tsx +++ b/apps/rlark-ui/src/admin/AdminPage.tsx @@ -30,7 +30,7 @@ import { updateNodeModelMetadata, } from "../utils/nodeBatchMetadata"; import { useAutoRefresh } from "../hooks"; -import { MetricCard, StatusBadge } from "../components/shared"; +import { MetricCard, RefreshOverlay, StatusBadge } from "../components/shared"; import { NodeResourceBrowser } from "../components/NodeResourceBrowser"; import { ClusterDetailReal, NodeDetailReal } from "../pages/Clusters"; @@ -59,10 +59,7 @@ export function ClustersOverviewAdminPage({ copy: c }: { copy: Copy }) { if (isInitial) setLoading(true); setError(""); try { - const resp = await fetch("/api/v1/rlinf.io/v1alpha1/nodes"); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setNodes(data.items ?? []); + setNodes(await nodesApi.list()); } catch (e) { setNodes([]); setError(e instanceof Error ? e.message : String(e)); @@ -178,7 +175,10 @@ export function ClustersOverviewAdminPage({ copy: c }: { copy: Copy }) { } return ( -
+
@@ -320,6 +320,10 @@ export function ClustersOverviewAdminPage({ copy: c }: { copy: Copy }) { })}
+
); } @@ -951,10 +955,7 @@ export function AdminPage({ if (isInitial) setLoading(true); setError(""); try { - const resp = await fetch("/api/v1/rlinf.io/v1alpha1/nodes"); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setNodes(data.items ?? []); + setNodes(await nodesApi.list()); } catch (e) { setNodes([]); setError(e instanceof Error ? e.message : String(e)); @@ -1049,16 +1050,7 @@ export function AdminPage({ const patch = { metadata: { labels: labelPatch, annotations: annotationPatch }, }; - const resp = await fetch( - `/api/v1/rlinf.io/v1alpha1/nodes/${nodeName}?namespace=${encodeURIComponent(namespace)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify(patch), - }, - ); - if (!resp.ok) - throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); + await nodesApi.patch(nodeName, patch, namespace); setNodes((prev) => prev.map((n) => n.metadata.name === nodeName @@ -1111,16 +1103,11 @@ export function AdminPage({ setError(""); try { const patch = { spec: { unschedulable: !node.spec.unschedulable } }; - const resp = await fetch( - `/api/v1/rlinf.io/v1alpha1/nodes/${node.metadata.name}?namespace=${encodeURIComponent(node.metadata.namespace ?? "")}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify(patch), - }, + await nodesApi.patch( + node.metadata.name, + patch, + node.metadata.namespace ?? "", ); - if (!resp.ok) - throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); setNodes((prev) => prev.map((n) => n.metadata.name === node.metadata.name @@ -1195,20 +1182,16 @@ export function AdminPage({ modelMetadata.removedAnnotationKeys.forEach((key) => { annotationPatch[key] = null; }); - const resp = await fetch( - `/api/v1/rlinf.io/v1alpha1/nodes/${encodeURIComponent(node.metadata.name)}?namespace=${encodeURIComponent(node.metadata.namespace ?? "")}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ + try { + await nodesApi.patch( + node.metadata.name, + { metadata: { labels: labelPatch, annotations: annotationPatch }, - }), - }, - ); - if (!resp.ok) { - throw new Error( - `${node.metadata.name}: HTTP ${resp.status} ${await resp.text()}`, + }, + node.metadata.namespace ?? "", ); + } catch (error) { + throw new Error(`${node.metadata.name}: ${String(error)}`); } return { node, labels, annotations }; }), @@ -1318,18 +1301,14 @@ export function AdminPage({ try { await Promise.all( selectedNodes.map(async (node) => { - const resp = await fetch( - `/api/v1/rlinf.io/v1alpha1/nodes/${encodeURIComponent(node.metadata.name)}?namespace=${encodeURIComponent(node.metadata.namespace ?? "")}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ spec: { unschedulable } }), - }, - ); - if (!resp.ok) { - throw new Error( - `${node.metadata.name}: HTTP ${resp.status} ${await resp.text()}`, + try { + await nodesApi.patch( + node.metadata.name, + { spec: { unschedulable } }, + node.metadata.namespace ?? "", ); + } catch (error) { + throw new Error(`${node.metadata.name}: ${String(error)}`); } }), ); @@ -1612,10 +1591,9 @@ export function AdminPage({ : "Multiple current values" : batchCategories.length ? batchCategories - .map((category) => - category === "robot" && zh - ? "具身节点" - : categoryLabels[category][zh ? "zh" : "en"], + .map( + (category) => + categoryLabels[category][zh ? "zh" : "en"], ) .join("、") : zh @@ -1638,7 +1616,7 @@ export function AdminPage({
{(["cloud", "edge", "robot"] as NodeCategory[]).map( (category) => ( @@ -1651,16 +1629,12 @@ export function AdminPage({ aria-pressed={batchCategories.includes(category)} onClick={() => setBatchCategories((current) => - current.includes(category) - ? current.filter((item) => item !== category) - : [...current, category], + current.includes(category) ? [] : [category], ) } > {zh - ? category === "robot" - ? "具身节点" - : categoryLabels[category].zh + ? categoryLabels[category].zh : categoryLabels[category].en} ), @@ -1668,8 +1642,8 @@ export function AdminPage({
{zh - ? "可多选。例如 GPU 服务器选择“云算力”,机器人本体可同时选择“端算力”和“具身节点”。" - : "Multiple selections are allowed. For example, choose Cloud for GPU servers; a robot may be both Edge and Embodied."} + ? "单选。例如 GPU 服务器选择「云算力」,机器人本体选择「端真机」。" + : "Single selection. For example, choose Cloud for GPU servers, Robot for robot devices."} )} @@ -1851,3 +1825,4 @@ export function AdminPage({
); } +import { nodesApi } from "../backend"; diff --git a/apps/rlark-ui/src/admin/CreateCluster.tsx b/apps/rlark-ui/src/admin/CreateCluster.tsx index 1aecd63..c62f338 100644 --- a/apps/rlark-ui/src/admin/CreateCluster.tsx +++ b/apps/rlark-ui/src/admin/CreateCluster.tsx @@ -1,36 +1,44 @@ import { useEffect, useState } from "react"; -import { Check, ChevronRight, Shield } from "lucide-react"; -import type { Copy, Lang } from "../i18n"; +import { + Check, + ChevronRight, + Copy, + FileCode2, + KeyRound, + Server, + Shield, +} from "lucide-react"; +import type { Lang } from "../i18n"; import type { AgentCertListItem, SignAgentCertResponse } from "../types"; +import { + certificatesApi, + systemConfigApi, + type DeploymentConfig, +} from "../backend"; +import { buildDeployYaml } from "../utils/deployYaml"; -export function CreateClusterPage({ - copy: c, - lang, -}: { - copy: Copy; - lang: Lang; -}) { +export function CreateClusterPage({ lang }: { lang: Lang }) { const [clusterId, setClusterId] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); const [certList, setCertList] = useState([]); - const [certListLoading, setCertListLoading] = useState(true); + const [, setCertListLoading] = useState(true); const [expandedCluster, setExpandedCluster] = useState(null); const [expandedResult, setExpandedResult] = useState(null); const [expandedCopied, setExpandedCopied] = useState(false); + const [deploymentConfig, setDeploymentConfig] = useState( + {}, + ); const zh = lang === "zh"; const fetchCertList = async () => { setCertListLoading(true); try { - const resp = await fetch("/api/v1/certificates/agent"); - if (resp.ok) { - setCertList(await resp.json()); - } + setCertList(await certificatesApi.list()); } catch { } finally { setCertListLoading(false); @@ -39,6 +47,12 @@ export function CreateClusterPage({ useEffect(() => { fetchCertList(); + systemConfigApi + .get({ refresh: true }) + .then((config) => { + setDeploymentConfig(config.deployment || {}); + }) + .catch(() => {}); }, []); const handleSign = async () => { @@ -47,16 +61,7 @@ export function CreateClusterPage({ setError(""); setResult(null); try { - const resp = await fetch("/api/v1/certificates/agent", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cluster_id: clusterId.trim() }), - }); - if (!resp.ok) { - const body = await resp.text(); - throw new Error(`HTTP ${resp.status}: ${body}`); - } - setResult(await resp.json()); + setResult(await certificatesApi.sign(clusterId.trim())); fetchCertList(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -65,36 +70,7 @@ export function CreateClusterPage({ } }; - const buildDeployYaml = ( - r: SignAgentCertResponse, - ) => `apiVersion: rlark.io/v1alpha1 -kind: DeployConfig -plane: data -control-plane-address: ${r.server_addr} - -cert: - ca-cert: | -${r.ca_cert - .split("\n") - .map((l: string) => " " + l) - .join("\n")} - agent-cert: | -${r.agent_cert - .split("\n") - .map((l: string) => " " + l) - .join("\n")} - agent-key: | -${r.agent_key - .split("\n") - .map((l: string) => " " + l) - .join("\n")} - -kubernetes: - kubeconfig: /path/to/kubeconfig.yaml - agent-image: rlark-agent:latest -`; - - const deployYaml = result ? buildDeployYaml(result) : ""; + const deployYaml = result ? buildDeployYaml(result, deploymentConfig) : ""; const handleCopy = () => { navigator.clipboard.writeText(deployYaml).then(() => { @@ -112,25 +88,23 @@ kubernetes: setExpandedCluster(cid); setExpandedResult(null); try { - const resp = await fetch( - `/api/v1/certificates/agent/${encodeURIComponent(cid)}`, - ); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - setExpandedResult(await resp.json()); + setExpandedResult(await certificatesApi.get(cid)); } catch {} }; const handleExpandedCopy = () => { if (!expandedResult) return; - navigator.clipboard.writeText(buildDeployYaml(expandedResult)).then(() => { - setExpandedCopied(true); - setTimeout(() => setExpandedCopied(false), 2000); - }); + navigator.clipboard + .writeText(buildDeployYaml(expandedResult, deploymentConfig)) + .then(() => { + setExpandedCopied(true); + setTimeout(() => setExpandedCopied(false), 2000); + }); }; return ( -
-
+
+
@@ -145,40 +119,69 @@ kubernetes:
-
-
- - -
+
+
+
+ 01 +
+ + {zh ? "命名并签发身份" : "Name and issue identity"} + + + {zh + ? "名称会成为集群在控制面中的唯一标识" + : "The name becomes the cluster identity in the control plane"} + +
+
+
+ + +
- {error &&
{error}
} + {error &&
{error}
} +
+ + + {zh + ? "每个集群使用独立证书" + : "Dedicated certificate per cluster"} + + + + {zh + ? "生成 Kubernetes Agent 部署配置" + : "Generates Kubernetes Agent deployment"} + +
+
{result && ( -
+
@@ -191,33 +194,39 @@ kubernetes: {zh ? "服务器" : "Server"}: {result.server_addr}
-
+
- - {zh - ? "部署配置 YAML(可直接复制到 deploy-conf.yaml)" - : "Deploy YAML (copy to deploy-conf.yaml)"} - +
+ + {zh ? "部署配置 YAML" : "Deployment YAML"} +
{deployYaml}
-
+
)}
{certList.length > 0 && ( -
-
+
+
{zh ? "已签发集群" : "Signed Clusters"}

{zh ? "已签发集群" : "Signed Clusters"}

+

+ {zh + ? "展开集群可重新获取按当前默认值生成的部署 YAML。" + : "Expand a cluster to regenerate deployment YAML with current defaults."} +

+ {certList.length}
{certList.map((item) => ( @@ -229,7 +238,9 @@ kubernetes: } onClick={() => handleExpand(item.cluster_id)} > - + + + {item.cluster_id} {new Date(item.created_at).toLocaleString( @@ -245,17 +256,24 @@ kubernetes: />
{expandedCluster === item.cluster_id && ( -
+
{expandedResult ? ( - <> +
- - {zh ? "部署配置 YAML" : "Deploy YAML"} - +
+ + + {zh ? "部署配置 YAML" : "Deployment YAML"} + +
-
{buildDeployYaml(expandedResult)}
- +
+                          {buildDeployYaml(expandedResult, deploymentConfig)}
+                        
+
) : ( -

{zh ? "加载中..." : "Loading..."}

+
+ + {zh + ? "正在获取证书与部署配置..." + : "Loading certificate and deployment configuration..."} +
)}
)}
))}
-
+ )}
); diff --git a/apps/rlark-ui/src/api.ts b/apps/rlark-ui/src/api.ts new file mode 100644 index 0000000..38fece8 --- /dev/null +++ b/apps/rlark-ui/src/api.ts @@ -0,0 +1,127 @@ +export const AUTH_TOKEN_KEY = "rlark-auth-token"; +export const AUTH_ROLE_KEY = "rlark-auth-role"; +export const UNAUTHORIZED_EVENT = "rlark:unauthorized"; + +export type AuthRole = "admin" | "user"; + +export interface LoginResponse { + ok: boolean; + role: AuthRole; + token: string; + expiresAt: string; +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + public body?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export function storeAuthSession(token: string, role: AuthRole) { + sessionStorage.setItem(AUTH_TOKEN_KEY, token); + sessionStorage.setItem(AUTH_ROLE_KEY, role); +} + +export function clearAuthSession() { + sessionStorage.removeItem(AUTH_TOKEN_KEY); + sessionStorage.removeItem(AUTH_ROLE_KEY); + sessionStorage.removeItem("rlark-user-auth"); + sessionStorage.removeItem("rlark-user-name"); + sessionStorage.removeItem("rlark-admin-auth"); + sessionStorage.removeItem("rlark-admin-user-name"); +} + +export function hasAuthSession(role?: AuthRole) { + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + const storedRole = sessionStorage.getItem(AUTH_ROLE_KEY); + return Boolean(token && (!role || storedRole === role)); +} + +async function authenticatedFetch( + input: RequestInfo | URL, + init: RequestInit = {}, +) { + const headers = new Headers(init.headers); + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(input, { ...init, headers }); + if (response.status === 401) { + clearAuthSession(); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + } + return response; +} + +async function responseError(response: Response) { + const text = await response.text(); + let body: unknown = text; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + // Keep non-JSON error bodies as text. + } + const detail = + body && typeof body === "object" && "error" in body + ? String((body as { error: unknown }).error) + : text; + return new ApiError( + response.status, + detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`, + body, + ); +} + +export async function request( + input: RequestInfo | URL, + init: Omit & { body?: BodyInit | object | null } = {}, +) { + const headers = new Headers(init.headers); + let body = init.body; + if ( + body != null && + typeof body === "object" && + !(body instanceof Blob) && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) && + !(body instanceof ArrayBuffer) && + !ArrayBuffer.isView(body) + ) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(body); + } + const response = await authenticatedFetch(input, { + ...init, + headers, + body: body as BodyInit | null | undefined, + }); + if (!response.ok) throw await responseError(response); + return response; +} + +export async function requestJson( + input: RequestInfo | URL, + init: Omit & { body?: BodyInit | object | null } = {}, +) { + const response = await request(input, init); + return (await response.json()) as T; +} + +export function withQuery( + path: string, + values: Record, +) { + const query = new URLSearchParams(); + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== "") query.set(key, String(value)); + }); + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; +} diff --git a/apps/rlark-ui/src/backend.ts b/apps/rlark-ui/src/backend.ts new file mode 100644 index 0000000..edd0a2e --- /dev/null +++ b/apps/rlark-ui/src/backend.ts @@ -0,0 +1,388 @@ +import { request, requestJson, withQuery, type LoginResponse } from "./api.js"; +import type { + AgentCertListItem, + CRDDomain, + CRDJob, + CRDNode, + CRDPod, + CRDTask, + CRDWorkflow, + SignAgentCertResponse, +} from "./types.js"; + +type ItemList = { items?: T[] }; +type DataResponse = { data?: T }; +const crdRoot = "/api/v1/rlinf.io/v1alpha1"; +const resourcePath = (resource: string, name?: string) => + `${crdRoot}/${resource}${name ? `/${encodeURIComponent(name)}` : ""}`; + +export interface SystemConfigResponse { + ssh?: { jumpHost?: string; jumpPort?: string }; + sshJumpHost?: string; + sshJumpPort?: string; + log?: { + backend?: string; + config?: { + endpoint?: string; + project?: string; + logstore?: string; + accessKeyId?: string; + accessKeySecret?: string; + }; + }; + deployment?: DeploymentConfig; +} + +export interface DeploymentConfig { + apiVersion?: string; + kind?: string; + plane?: "data"; + controlPlaneAddress?: string; + sshAddress?: string; + insecureSkipTlsVerify?: boolean; + kubernetes?: { + kubeconfig?: string; + agentImage?: string; + image?: string; + imagePullPolicy?: "" | "Always" | "IfNotPresent" | "Never"; + imagePullSecrets?: string[]; + containerdSocket?: string; + }; +} + +let systemConfigCache: SystemConfigResponse | undefined; +let systemConfigRequest: Promise | undefined; + +export interface SSHKeyItem { + index: number; + user: string; + public_key: string; + added_at: string; +} + +export interface ImageRegistryItem { + id: string; + name: string; + registry: string; + username: string; + clusterSelection: { + mode: "None" | "Selected" | "All"; + clusters: string[]; + }; +} + +export interface LocalizedText { + zh: string; + en: string; +} + +export interface ApiReferenceEndpoint { + method: string; + path: string; + description: LocalizedText; + example: unknown; +} + +export interface ApiReferenceSection { + id: string; + title: LocalizedText; + description: LocalizedText; + endpoints: ApiReferenceEndpoint[] | null; +} + +export interface ApiReferenceResponse { + title: LocalizedText; + description: LocalizedText; + sections: ApiReferenceSection[]; +} + +export const authApi = { + login(username: string, password: string) { + return requestJson("/api/v1/auth/login", { + method: "POST", + body: { username, password }, + }); + }, +}; + +export const apiReferenceApi = { + get() { + return requestJson("/api/v1/api-reference"); + }, +}; + +export const clustersApi = { + async list() { + const response = await requestJson>("/api/v1/clusters"); + return response.data ?? []; + }, + async get(id: string) { + const response = await requestJson>( + `/api/v1/clusters/${encodeURIComponent(id)}`, + ); + return response.data; + }, +}; + +function resourceApi(resource: string) { + return { + async list(query: Record = {}) { + const response = await requestJson>( + withQuery(resourcePath(resource), query), + ); + return response.items ?? []; + }, + get(name: string, query: Record = {}) { + return requestJson(withQuery(resourcePath(resource, name), query)); + }, + create(body: object) { + return requestJson(resourcePath(resource), { method: "POST", body }); + }, + replace(name: string, body: object) { + return requestJson(resourcePath(resource, name), { + method: "PUT", + body, + }); + }, + patch(name: string, body: object, namespace?: string) { + return requestJson( + withQuery(resourcePath(resource, name), { namespace }), + { method: "PATCH", body }, + ); + }, + remove(name: string) { + return request(resourcePath(resource, name), { method: "DELETE" }); + }, + }; +} + +export const nodesApi = resourceApi("nodes"); +export const tasksApi = resourceApi("tasks"); +export const podsApi = { + ...resourceApi("pods"), + events(name: string) { + return requestJson(`${resourcePath("pods", name)}/events`); + }, +}; +export const domainsApi = resourceApi("domains"); +export const workflowsApi = { + ...resourceApi("workflows"), + setStopped(name: string, stopped: boolean) { + return request(resourcePath("workflows", name), { + method: "PATCH", + body: { spec: { stopped } }, + }); + }, +}; + +export const jobsApi = { + ...resourceApi("jobs"), + async listTags() { + const response = await requestJson>( + `${resourcePath("jobs")}/tags`, + ); + return response.items ?? []; + }, + setStopped(name: string, stopped: boolean) { + return request(resourcePath("jobs", name), { + method: "PATCH", + body: { spec: { stopped } }, + }); + }, + logs(name: string, query: Record) { + return requestJson( + withQuery(`${resourcePath("jobs", name)}/logs`, query), + ); + }, + logLabelValues(name: string, query: Record) { + return requestJson( + withQuery(`${resourcePath("jobs", name)}/logs/label-values`, query), + ); + }, +}; + +export const systemConfigApi = { + get(options: { refresh?: boolean } = {}) { + if (!options.refresh && systemConfigCache) { + return Promise.resolve(systemConfigCache); + } + if (!options.refresh && systemConfigRequest) return systemConfigRequest; + + const pending = requestJson("/api/v1/system-config") + .then((config) => { + systemConfigCache = config; + return config; + }) + .finally(() => { + if (systemConfigRequest === pending) systemConfigRequest = undefined; + }); + systemConfigRequest = pending; + return pending; + }, + async update(body: object) { + const config = await requestJson( + "/api/v1/system-config", + { method: "PUT", body }, + ); + systemConfigCache = config; + return config; + }, +}; + +export const sshKeysApi = { + list(signal?: AbortSignal) { + return requestJson("/api/v1/ssh-user-keys", { signal }); + }, + create(user: string, publicKey: string) { + return request("/api/v1/ssh-user-keys", { + method: "POST", + body: { user, public_key: publicKey }, + }); + }, + remove(user: string, index: number) { + return request(withQuery(`/api/v1/ssh-user-keys/${index}`, { user }), { + method: "DELETE", + }); + }, +}; + +export const certificatesApi = { + list() { + return requestJson("/api/v1/certificates/agent"); + }, + sign(clusterId: string) { + return requestJson("/api/v1/certificates/agent", { + method: "POST", + body: { cluster_id: clusterId }, + }); + }, + get(clusterId: string) { + return requestJson( + `/api/v1/certificates/agent/${encodeURIComponent(clusterId)}`, + ); + }, +}; + +export const imagesApi = { + async list() { + const response = await requestJson>("/api/v1/images"); + return response.items ?? []; + }, +}; + +export const imageRegistriesApi = { + list() { + return requestJson("/api/v1/image-registries"); + }, + create(body: object) { + return request("/api/v1/image-registries", { method: "POST", body }); + }, + update(id: string, body: object) { + return request(`/api/v1/image-registries/${encodeURIComponent(id)}`, { + method: "PUT", + body, + }); + }, + remove(id: string) { + return request(`/api/v1/image-registries/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + }, +}; + +export const storageClassesApi = { + async list(cluster?: string) { + const response = await requestJson>>( + withQuery("/api/v1/storage/storageclass", { clusters: cluster }), + ); + return response.data ?? {}; + }, + create(body: object) { + return request("/api/v1/storage/storageclass", { method: "POST", body }); + }, + update(name: string, body: object) { + return request(`/api/v1/storage/storageclass/${encodeURIComponent(name)}`, { + method: "PUT", + body, + }); + }, + remove(name: string) { + return request(`/api/v1/storage/storageclass/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + }, +}; + +const storageObjectPath = (storageClass: string, cluster: string) => + `/api/v1/storage/storageclass/${encodeURIComponent(storageClass)}/${encodeURIComponent(cluster)}`; + +export const storageObjectsApi = { + list(storageClass: string, cluster: string, prefix: string) { + return requestJson( + withQuery(`${storageObjectPath(storageClass, cluster)}/list`, { + prefix, + maxKeys: 100, + }), + ); + }, + upload(storageClass: string, cluster: string, formData: FormData) { + return request(`${storageObjectPath(storageClass, cluster)}/upload`, { + method: "POST", + body: formData, + }); + }, + download(storageClass: string, cluster: string, key: string) { + return requestJson( + withQuery( + `${storageObjectPath(storageClass, cluster)}/object/${encodeURIComponent(key)}`, + { expire: 3600 }, + ), + ); + }, + remove(storageClass: string, cluster: string, key: string) { + return request( + `${storageObjectPath(storageClass, cluster)}/object/${encodeURIComponent(key)}`, + { method: "DELETE" }, + ); + }, +}; + +export const addonsApi = { + async catalog() { + const response = await requestJson>("/api/v1/addons"); + return response.data ?? []; + }, + async installed(cluster?: string) { + const response = await requestJson>( + withQuery("/api/v1/installed-addons", { cluster }), + ); + return response.data ?? []; + }, + install(clusterId: string, body: object) { + return request(`/api/v1/clusters/${encodeURIComponent(clusterId)}/addons`, { + method: "POST", + body, + }); + }, + update(clusterId: string, name: string, body: object) { + return request( + `/api/v1/clusters/${encodeURIComponent(clusterId)}/addons/${encodeURIComponent(name)}`, + { method: "PUT", body }, + ); + }, + remove(clusterId: string, name: string) { + return request( + `/api/v1/clusters/${encodeURIComponent(clusterId)}/addons/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ); + }, +}; + +export const terminalApi = { + createSocket(podName: string) { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return new WebSocket( + `${protocol}//${location.host}${resourcePath("pods", podName)}/terminal`, + ); + }, +}; diff --git a/apps/rlark-ui/src/components/ColumnFilterPopover.tsx b/apps/rlark-ui/src/components/ColumnFilterPopover.tsx new file mode 100644 index 0000000..3118541 --- /dev/null +++ b/apps/rlark-ui/src/components/ColumnFilterPopover.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from "react"; + +interface ColumnFilterPopoverProps { + label: string; + options: Array<{ value: string; label: string }>; + selected: string[]; + onChange: (selected: string[]) => void; + anchorRect: DOMRect | null; + onClose: () => void; + zh?: boolean; +} + +// 表头列多选筛选弹层。单栏 checkbox 列表,与 TagFilterPopover 风格一致 +// 但只服务"单值多选"场景(状态、类型、集群等),不需要 key→values 双栏。 +export function ColumnFilterPopover({ + label, + options, + selected, + onChange, + anchorRect, + onClose, + zh = true, +}: ColumnFilterPopoverProps) { + const popoverRef = useRef(null); + + useEffect(() => { + if (!popoverRef.current) return; + const handler = (e: MouseEvent) => { + if (!popoverRef.current?.contains(e.target as Node)) onClose(); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + const style: React.CSSProperties = {}; + if (anchorRect) { + const popoverWidth = 240; + const left = Math.min( + Math.max(anchorRect.left, 16), + window.innerWidth - popoverWidth - 16, + ); + style.left = left; + style.top = anchorRect.bottom + 6; + } + + const allSelected = options.length > 0 && selected.length === options.length; + const noneSelected = selected.length === 0; + + const toggleValue = (v: string) => { + if (selected.includes(v)) { + onChange(selected.filter((x) => x !== v)); + } else { + onChange([...selected, v]); + } + }; + + const toggleAll = () => { + if (allSelected) { + onChange([]); + } else { + onChange(options.map((o) => o.value)); + } + }; + + const reset = () => onChange([]); + + return ( +
+
+ {label} + +
+
+ {options.length === 0 ? ( +
+ {zh ? "暂无可选项" : "No options"} +
+ ) : ( + options.map((opt) => { + const checked = selected.includes(opt.value); + return ( + + ); + }) + )} +
+
+ + +
+
+ ); +} diff --git a/apps/rlark-ui/src/components/JobTagPopover.tsx b/apps/rlark-ui/src/components/JobTagPopover.tsx new file mode 100644 index 0000000..996bd97 --- /dev/null +++ b/apps/rlark-ui/src/components/JobTagPopover.tsx @@ -0,0 +1,90 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import type { JobTag } from "../data"; + +interface JobTagPopoverProps { + /** 全部标签 */ + tags: JobTag[]; + /** 触发元素(通常是 "+N" 按钮)的位置,用于定位弹层 */ + anchorRect: DOMRect; + zh?: boolean; + onClose: () => void; +} + +const GAP = 6; +const VIEWPORT_MARGIN = 12; + +/** + * 全部标签浮层:优先在触发元素下方展开; + * 下方空间不足时自动翻转到上方,左右越界时收回视口内, + * 保证靠近屏幕底部的行也能完整展示。 + */ +export function JobTagPopover({ + tags, + anchorRect, + zh = true, + onClose, +}: JobTagPopoverProps) { + const popoverRef = useRef(null); + const [position, setPosition] = useState({ + top: anchorRect.bottom + GAP, + left: anchorRect.left, + }); + + // 渲染后测量浮层实际尺寸,再根据视口剩余空间调整位置 + useLayoutEffect(() => { + const el = popoverRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + + let top = anchorRect.bottom + GAP; + if (top + rect.height > window.innerHeight - VIEWPORT_MARGIN) { + const above = anchorRect.top - GAP - rect.height; + if (above >= VIEWPORT_MARGIN) { + top = above; + } else { + top = Math.max( + VIEWPORT_MARGIN, + window.innerHeight - VIEWPORT_MARGIN - rect.height, + ); + } + } + + let left = anchorRect.left; + if (left + rect.width > window.innerWidth - VIEWPORT_MARGIN) { + left = Math.max( + VIEWPORT_MARGIN, + window.innerWidth - VIEWPORT_MARGIN - rect.width, + ); + } + + setPosition({ top, left }); + }, [anchorRect]); + + return ( +
+
+ {zh ? "全部标签" : "All tags"} + +
+
+ {tags.map((tag) => ( + + {tag.key}: {tag.value} + + ))} +
+
+ ); +} diff --git a/apps/rlark-ui/src/components/NodeResourceBrowser.tsx b/apps/rlark-ui/src/components/NodeResourceBrowser.tsx index a475e12..69dbfc9 100644 --- a/apps/rlark-ui/src/components/NodeResourceBrowser.tsx +++ b/apps/rlark-ui/src/components/NodeResourceBrowser.tsx @@ -5,20 +5,20 @@ import type { Copy } from "../i18n"; import type { CRDNode, NodeCategory } from "../types"; import { categoryLabels, - getNodeCategory, getNodeCategories, getNodeLocation, getNodeResourceSummary, hasNodeCategory, } from "../utils/nodes"; import { - compareSortValues, + ColumnFilterButton, PageToolbar, Pagination, - SortButton, + RefreshOverlay, StatusBadge, - type SortDirection, + useColumnFilter, } from "./shared"; +import { ColumnFilterPopover } from "./ColumnFilterPopover"; type CategoryFilter = "all" | NodeCategory; @@ -58,27 +58,14 @@ export function NodeResourceBrowser({ const zh = c.nav.overview === "总览"; const [category, setCategory] = useState(initialCategory); const [query, setQuery] = useState(initialQuery); - const [phaseFilter, setPhaseFilter] = useState<"All" | Phase>("All"); + // 表头列多选筛选;空数组 = 全部 + const [typeFilter, setTypeFilter] = useState([]); + const [phaseFilter, setPhaseFilter] = useState([]); + const [clusterFilter, setClusterFilter] = useState([]); + const [locationFilter, setLocationFilter] = useState([]); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const [sort, setSort] = useState<{ - key: - | "name" - | "type" - | "phase" - | "cluster" - | "location" - | "ip" - | "resource" - | "task"; - direction: SortDirection; - }>({ key: "cluster", direction: "asc" }); - const toggleSort = (key: typeof sort.key) => - setSort((current) => ({ - key, - direction: - current.key === key && current.direction === "asc" ? "desc" : "asc", - })); + const { openKey, anchorRect, openFor, close } = useColumnFilter(); const categoryCounts = useMemo(() => { const counts: Record = { @@ -94,6 +81,12 @@ export function NodeResourceBrowser({ return counts; }, [nodes]); + // 节点集群归属(与表头"所属集群"一致) + const clusterOf = (node: CRDNode) => + node.metadata.namespace ?? + node.metadata.labels?.["rlark.io/cluster-id"] ?? + ""; + const filteredNodes = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); return nodes @@ -110,50 +103,42 @@ export function NodeResourceBrowser({ const searchable = `${node.metadata.name} ${node.metadata.namespace ?? ""} ${node.spec.agentType ?? ""} ${address} ${taskName} ${location}`.toLowerCase(); const phase = (node.status?.phase ?? "Offline") as Phase; + const typeHit = + typeFilter.length === 0 || + getNodeCategories(node).some((c) => typeFilter.includes(c)); + const phaseHit = + phaseFilter.length === 0 || phaseFilter.includes(phase); + const clusterHit = + clusterFilter.length === 0 || + clusterFilter.includes(clusterOf(node) || "—"); + const locationHit = + locationFilter.length === 0 || + locationFilter.includes(location || "—"); return ( (category === "all" || hasNodeCategory(node, category)) && - (phaseFilter === "All" || phase === phaseFilter) && + typeHit && + phaseHit && + clusterHit && + locationHit && (!normalizedQuery || searchable.includes(normalizedQuery)) ); }) - .sort((a, b) => { - const value = (node: CRDNode): string | number => { - const labels = node.metadata.labels ?? {}; - const address = - node.status?.addresses?.find((item) => item.type === "InternalIP") - ?.address ?? - node.status?.addresses?.[0]?.address ?? - ""; - const workload = nodeWorkloads[node.metadata.name]; - if (sort.key === "name") return node.metadata.name; - if (sort.key === "type") return getNodeCategory(node); - if (sort.key === "phase") return node.status?.phase ?? "Offline"; - if (sort.key === "cluster") - return ( - node.metadata.namespace ?? labels["rlark.io/cluster-id"] ?? "" - ); - if (sort.key === "location") return getNodeLocation(node); - if (sort.key === "ip") return address; - if (sort.key === "resource") - return ( - Number.parseFloat(getNodeResourceSummary(node, zh).primary) || 0 - ); - return workload?.jobs.length ?? 0; - }; - const order = compareSortValues( - value(a), - value(b), - sort.direction, - zh ? "zh-CN" : "en", - ); - return ( - order || - a.metadata.name.localeCompare(b.metadata.name, zh ? "zh-CN" : "en", { - numeric: true, - }) - ); - }); - }, [category, nodeWorkloads, nodes, phaseFilter, query, sort, zh]); + .sort((a, b) => + a.metadata.name.localeCompare(b.metadata.name, zh ? "zh-CN" : "en", { + numeric: true, + }), + ); + }, [ + category, + clusterFilter, + locationFilter, + nodeWorkloads, + nodes, + phaseFilter, + query, + typeFilter, + zh, + ]); const totalPages = Math.max(1, Math.ceil(filteredNodes.length / pageSize)); const currentPage = Math.min(page, totalPages); @@ -180,10 +165,49 @@ export function NodeResourceBrowser({ onSelectionChange(next); }; - useEffect(() => setPage(1), [category, pageSize, phaseFilter, query]); + useEffect( + () => setPage(1), + [ + category, + clusterFilter, + locationFilter, + pageSize, + phaseFilter, + query, + typeFilter, + ], + ); useEffect(() => setCategory(initialCategory), [initialCategory]); useEffect(() => setQuery(initialQuery), [initialQuery]); + // 表头筛选选项:从当前节点集合去重 + const typeOptions = categoryOrder.map((v) => ({ + value: v, + label: zh ? categoryLabels[v].zh : categoryLabels[v].en, + })); + // 状态选项:从当前节点集合去重(节点实际只有 Online/Offline,但保留弹性) + const phaseOptions = useMemo(() => { + const labelOf = (p: string) => + p === "Online" ? c.status.Online : p === "Offline" ? c.status.Offline : p; + const set = new Set(); + nodes.forEach((n) => set.add(n.status?.phase ?? "Offline")); + return [...set].sort().map((v) => ({ value: v, label: labelOf(v) })); + }, [nodes, c]); + const clusterOptions = useMemo(() => { + const set = new Set(); + nodes.forEach((n) => set.add(clusterOf(n) || "—")); + return [...set] + .sort((a, b) => a.localeCompare(b, zh ? "zh-CN" : "en")) + .map((v) => ({ value: v, label: v })); + }, [nodes, zh]); + const locationOptions = useMemo(() => { + const set = new Set(); + nodes.forEach((n) => set.add(getNodeLocation(n) || "—")); + return [...set] + .sort((a, b) => a.localeCompare(b, zh ? "zh-CN" : "en")) + .map((v) => ({ value: v, label: v })); + }, [nodes, zh]); + const tabs: Array<{ value: CategoryFilter; label: string; @@ -238,16 +262,12 @@ export function NodeResourceBrowser({ copy={c} onRefresh={onRefresh} refreshing={refreshing} - filterValue={phaseFilter} - onFilterChange={(value) => setPhaseFilter(value as "All" | Phase)} - filterOptions={[ - { value: "All", label: zh ? "全部状态" : "All statuses" }, - { value: "Online", label: c.status.Online }, - { value: "Offline", label: c.status.Offline }, - ]} /> -
+
{tabs.find((tab) => tab.value === category)?.label} @@ -292,54 +312,30 @@ export function NodeResourceBrowser({ className={`node-resource-table-head${onToggleScheduling ? " has-admin-actions" : ""}${selectable ? " has-selection" : ""}`} > {selectable && } - toggleSort("name")} - /> - {zh ? "节点名称" : "Node"} + toggleSort("type")} + selectedCount={typeFilter.length} + onClick={openFor("type")} /> - toggleSort("phase")} + selectedCount={phaseFilter.length} + onClick={openFor("phase")} /> - toggleSort("cluster")} + selectedCount={clusterFilter.length} + onClick={openFor("cluster")} /> - toggleSort("location")} - /> - toggleSort("ip")} - /> - toggleSort("resource")} - /> - toggleSort("task")} + selectedCount={locationFilter.length} + onClick={openFor("location")} /> + {zh ? "节点 IP" : "Node IP"} + {zh ? "资源与空闲" : "Resources"} + {zh ? "任务" : "Task"} {onToggleScheduling ? (zh ? "调度管理" : "Scheduling") : ""} @@ -534,6 +530,10 @@ export function NodeResourceBrowser({ )}
+
+ + {openKey === "type" && ( + + )} + {openKey === "phase" && ( + + )} + {openKey === "cluster" && ( + + )} + {openKey === "location" && ( + + )}
); } diff --git a/apps/rlark-ui/src/components/OverviewChinaMap.tsx b/apps/rlark-ui/src/components/OverviewChinaMap.tsx index fa3e0c1..9640568 100644 --- a/apps/rlark-ui/src/components/OverviewChinaMap.tsx +++ b/apps/rlark-ui/src/components/OverviewChinaMap.tsx @@ -479,7 +479,7 @@ export function OverviewChinaMap({ )} - {cities.map((city, index) => { + {cities.map((city) => { const point = project(city.lon, city.lat); return ( b.toString(16).padStart(2, "0")).join( + "", + ); + return `tag-new-${hex}`; +} + +interface TagEditRow { + id: string; + key: string; + values: string[]; + input: string; + existing?: boolean; + /** 该行立即占用一个标签计数:"添加标签"按钮创建的行与默认空行 */ + counted?: boolean; +} + +function blankRow(counted = false): TagEditRow { + return { + id: newTagId(), + key: "", + values: [], + input: "", + existing: false, + counted, + }; +} + +function collectKeys(suggestions: TagSuggestion[]): string[] { + return suggestions.map((suggestion) => suggestion.key); +} + +function collectValuesForKey( + suggestions: TagSuggestion[], + key: string, +): string[] { + return suggestions.find((suggestion) => suggestion.key === key)?.values ?? []; +} + +interface TagEditorProps { + tags: JobTag[]; + onChange: (tags: JobTag[]) => void; + suggestions?: TagSuggestion[]; + zh?: boolean; + compact?: boolean; + sectioned?: boolean; +} + +export function TagEditor({ + tags, + onChange, + suggestions = [], + zh = true, + compact = false, + sectioned = false, +}: TagEditorProps) { + const allSuggestionKeys = collectKeys(suggestions); + const [editRows, setEditRows] = useState(() => { + const initial = rowsFromTags(tags); + // 默认空行与已有标签一并计数:已有标签已达上限时不再展示默认空行 + return sectioned && initial.length < MAX_TAGS + ? [...initial, blankRow(true)] + : initial; + }); + + useEffect(() => { + setEditRows((rows) => { + if (rows.length > 0) return rows; + const next = rowsFromTags(tags); + return sectioned && next.length > 0 && next.length < MAX_TAGS + ? [...next, blankRow(true)] + : next; + }); + }, [tags, sectioned]); + + const [errorMsg, setErrorMsg] = useState(""); + const errorTimerRef = useRef(undefined); + + const showError = (msg: string) => { + setErrorMsg(msg); + if (errorTimerRef.current) window.clearTimeout(errorTimerRef.current); + errorTimerRef.current = window.setTimeout(() => setErrorMsg(""), 3000); + }; + + const toTags = (rows: typeof editRows): JobTag[] => + rows.flatMap((row) => + row.values.map((value) => ({ + id: `${row.id}-${value}`, + key: row.key.trim(), + value, + })), + ); + + const tagKeyCount = (rows: typeof editRows) => + new Set( + rows + .filter((row) => row.key.trim() && row.values.length > 0) + .map((row) => row.key.trim()), + ).size; + + // 已占用的标签槽位:添加按钮创建的行立即计数,其余行填写后计数 + const tagSlotCount = (rows: typeof editRows) => + rows.filter((row) => row.counted || row.key.trim() || row.values.length > 0) + .length; + + const isDuplicateKey = (rows: typeof editRows, id: string) => { + const row = rows.find((r) => r.id === id); + if (!row || !row.key.trim()) return false; + return rows.some((r) => r.id !== id && r.key.trim() === row.key.trim()); + }; + + const applyChanges = (nextRows: typeof editRows) => { + const validRows = nextRows.filter( + (row) => row.key.trim() && row.values.length, + ); + const valid = toTags(validRows); + if (tagKeyCount(validRows) > MAX_TAGS) { + showError( + zh + ? `一个任务最多添加 ${MAX_TAGS} 个标签。` + : `A job can have at most ${MAX_TAGS} tags.`, + ); + return false; + } + // 安全兜底:有效行之间不允许存在重复的标签键 + const validKeyCounts = new Map(); + for (const row of validRows) { + const k = row.key.trim(); + validKeyCounts.set(k, (validKeyCounts.get(k) ?? 0) + 1); + } + for (const [k, count] of validKeyCounts) { + if (count > 1) { + showError( + zh + ? `标签键「${k}」重复,请先修改。` + : `Tag key "${k}" is duplicated.`, + ); + return false; + } + } + for (const row of validRows) { + if (row.values.length > MAX_VALUES_PER_KEY) { + showError( + zh + ? `标签键「${row.key.trim()}」的标签值不能超过 ${MAX_VALUES_PER_KEY} 个。` + : `Tag key "${row.key.trim()}" can have at most ${MAX_VALUES_PER_KEY} values.`, + ); + return false; + } + } + onChange(valid); + return true; + }; + + const availableKeys = (currentRowId: string) => { + const usedKeys = new Set( + editRows + .filter((row) => row.id !== currentRowId) + .map((row) => row.key.trim()) + .filter(Boolean), + ); + return allSuggestionKeys.filter((key) => !usedKeys.has(key)); + }; + + const availableValuesFor = (key: string) => { + const values = collectValuesForKey(suggestions, key); + const row = editRows.find((item) => item.key.trim() === key.trim()); + return [...new Set([...values, ...(row?.values ?? [])])]; + }; + + const addRow = () => { + if (tagSlotCount(editRows) >= MAX_TAGS) { + showError( + zh + ? `最多添加 ${MAX_TAGS} 个标签。` + : `At most ${MAX_TAGS} tags allowed.`, + ); + return; + } + setEditRows((rows) => [...rows, blankRow(true)]); + }; + + const updateKey = (id: string, key: string) => { + const next = editRows.map((row) => (row.id === id ? { ...row, key } : row)); + setEditRows(next); + applyChanges(next); + }; + + const updateInput = (id: string, input: string) => { + setEditRows((rows) => + rows.map((row) => (row.id === id ? { ...row, input } : row)), + ); + }; + + const addValue = (id: string, value: string) => { + const trimmed = value.trim(); + if (!trimmed) return; + const row = editRows.find((item) => item.id === id); + if (!row || !row.key.trim()) return; + if (isDuplicateKey(editRows, id)) { + showError( + zh + ? `标签键「${row.key.trim()}」与其他标签键重复,请先修改。` + : `Tag key "${row.key.trim()}" conflicts with another row.`, + ); + return; + } + if (row.values.includes(trimmed)) { + showError( + zh + ? `标签「${row.key.trim()}:${trimmed}」已存在,不能重复添加。` + : `Tag "${row.key.trim()}:${trimmed}" already exists.`, + ); + return; + } + if (row.values.length >= MAX_VALUES_PER_KEY) { + showError( + zh + ? `标签键「${row.key.trim()}」的标签值不能超过 ${MAX_VALUES_PER_KEY} 个。` + : `Tag key "${row.key.trim()}" can have at most ${MAX_VALUES_PER_KEY} values.`, + ); + return; + } + if (tagKeyCount(editRows) >= MAX_TAGS && !row.values.length) { + showError( + zh + ? `一个任务最多添加 ${MAX_TAGS} 个标签。` + : `A job can have at most ${MAX_TAGS} tags.`, + ); + return; + } + const next = editRows.map((item) => + item.id === id + ? { ...item, values: [...item.values, trimmed], input: "" } + : item, + ); + if (applyChanges(next)) setEditRows(next); + }; + + const removeValue = (id: string, value: string) => { + const next = editRows.map((row) => + row.id === id + ? { ...row, values: row.values.filter((item) => item !== value) } + : row, + ); + setEditRows(next); + applyChanges(next); + }; + + const removeRow = (id: string) => { + const next = editRows.filter((row) => row.id !== id); + setEditRows(next); + applyChanges(next); + }; + + const renderRow = (row: TagEditRow) => ( + updateKey(row.id, value)} + onAddValue={(value) => addValue(row.id, value)} + onChangeInput={(value) => updateInput(row.id, value)} + onRemoveValue={(value) => removeValue(row.id, value)} + onRemove={() => removeRow(row.id)} + zh={zh} + sectioned={sectioned} + /> + ); + + if (sectioned) { + const existingRows = editRows.filter((row) => row.existing); + const newRows = editRows.filter((row) => !row.existing); + return ( +
+ {errorMsg &&
{errorMsg}
} +
+
+ {zh ? "已有标签" : "Existing tags"} +
+ {existingRows.length > 0 ? ( +
{existingRows.map(renderRow)}
+ ) : ( +
+ {zh ? "暂无标签" : "No tags yet"} +
+ )} +
+
+
+ {zh ? "添加标签" : "Add tags"} +
+
{newRows.map(renderRow)}
+ +
+
+ ); + } + + return ( +
+ {errorMsg &&
{errorMsg}
} +
{editRows.map(renderRow)}
+ +
+ ); +} + +function rowsFromTags(tags: JobTag[]): TagEditRow[] { + const rows = new Map(); + for (const tag of tags) { + const row = rows.get(tag.key); + if (row) { + row.values.push(tag.value); + } else { + rows.set(tag.key, { + id: tag.id || newTagId(), + key: tag.key, + values: [tag.value], + input: "", + existing: true, + }); + } + } + return [...rows.values()]; +} + +function TagRow({ + row, + keys, + values, + duplicate, + onChangeKey, + onAddValue, + onChangeInput, + onRemoveValue, + onRemove, + zh, + sectioned = false, +}: { + row: TagEditRow; + keys: string[]; + values: string[]; + duplicate: boolean; + onChangeKey: (value: string) => void; + onAddValue: (value: string) => void; + onChangeInput: (value: string) => void; + onRemoveValue: (value: string) => void; + onRemove: () => void; + zh: boolean; + sectioned?: boolean; +}) { + return ( +
+ {sectioned && ( + + * + {zh ? "标签" : "Tag"} + + )} +
+ + + {duplicate + ? zh + ? "标签键与其他行重复" + : "Duplicate key" + : sectioned + ? zh + ? "请输入标签键,不超过10个字符" + : "Enter or select key (≤10 chars)" + : zh + ? "请输入或选择标签键,不超过 10 个字符" + : "Enter or select key (≤10 chars)"} + +
+ : +
+
+ {row.values.map((value) => ( + + {value} + + + ))} + !row.values.includes(value))} + placeholder="" + onChange={onChangeInput} + onSelect={onAddValue} + onEnter={onAddValue} + disabled={ + !row.key.trim() || row.values.length >= MAX_VALUES_PER_KEY + } + chevron={sectioned} + /> +
+ + {row.values.length >= MAX_VALUES_PER_KEY + ? "" + : sectioned + ? zh + ? "请输入标签值不超过10个字符,回车确认" + : "Enter value (≤10 chars), press Enter" + : zh + ? "输入后按回车添加标签值" + : "Press Enter to add value"} + +
+ +
+ ); +} + +function Combobox({ + value, + options, + placeholder, + onChange, + onSelect, + onEnter, + disabled = false, + chevron = false, +}: { + value: string; + options: string[]; + placeholder: string; + onChange: (value: string) => void; + onSelect?: (value: string) => void; + onEnter?: (value: string) => void; + disabled?: boolean; + chevron?: boolean; +}) { + const [focused, setFocused] = useState(false); + const [highlight, setHighlight] = useState(-1); + const inputRef = useRef(null); + const listRef = useRef(null); + const [anchor, setAnchor] = useState< + | undefined + | { + left: number; + top: number; + minWidth: number; + maxHeight: number; + openUp: boolean; + } + >(undefined); + // 与输入内容完全相同的选项不展示,避免出现重复的下拉提示 + const filtered = options.filter( + (option) => + option !== value && option.toLowerCase().includes(value.toLowerCase()), + ); + const showDropdown = focused && filtered.length > 0; + + /** + * 下拉列表通过 portal 渲染到 body 并用 fixed 定位, + * 避免被弹窗 overflow: hidden 或相邻表单区块遮盖; + * 输入框下方空间不足时向上翻转。 + */ + const updateAnchor = useCallback(() => { + const input = inputRef.current; + if (!input) return; + const rect = input.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const openUp = spaceBelow < 150 && spaceAbove > spaceBelow; + const available = Math.max(openUp ? spaceAbove : spaceBelow, 60) - 8; + setAnchor({ + left: rect.left, + top: openUp ? rect.top : rect.bottom + 4, + minWidth: rect.width, + maxHeight: Math.min(200, available), + openUp, + }); + }, []); + + useLayoutEffect(() => { + if (!showDropdown) return; + updateAnchor(); + window.addEventListener("resize", updateAnchor); + // capture 阶段监听,覆盖弹窗内部滚动容器的滚动 + window.addEventListener("scroll", updateAnchor, true); + return () => { + window.removeEventListener("resize", updateAnchor); + window.removeEventListener("scroll", updateAnchor, true); + }; + }, [showDropdown, updateAnchor]); + + // 键盘导航时保证高亮项在可滚动区域内可见 + useEffect(() => { + if (highlight < 0 || !listRef.current) return; + const item = listRef.current.children[highlight] as HTMLElement | undefined; + item?.scrollIntoView({ block: "nearest" }); + }, [highlight]); + + return ( +
+ { + onChange(event.target.value); + setHighlight(-1); + }} + onFocus={() => { + setFocused(true); + setHighlight(-1); + }} + onBlur={() => setFocused(false)} + onKeyDown={(event) => { + if (event.key === "ArrowDown" && showDropdown) { + event.preventDefault(); + setHighlight((current) => + Math.min(current + 1, filtered.length - 1), + ); + } else if (event.key === "ArrowUp" && showDropdown) { + event.preventDefault(); + setHighlight((current) => Math.max(current - 1, -1)); + } else if (event.key === "Enter") { + event.preventDefault(); + const selected = highlight >= 0 ? filtered[highlight] : value; + if (selected) onEnter?.(selected); + setFocused(false); + } + }} + /> + {chevron && ( + + )} + {showDropdown && + anchor && + createPortal( +
    + {filtered.map((option, index) => ( +
  • { + event.preventDefault(); + onSelect?.(option); + setFocused(false); + }} + > + {option} +
  • + ))} +
, + document.body, + )} +
+ ); +} diff --git a/apps/rlark-ui/src/components/TagFilterPopover.tsx b/apps/rlark-ui/src/components/TagFilterPopover.tsx new file mode 100644 index 0000000..4e283ae --- /dev/null +++ b/apps/rlark-ui/src/components/TagFilterPopover.tsx @@ -0,0 +1,329 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Search } from "lucide-react"; + +interface TagFilterPopoverProps { + /** 当前所有可用标签(来自全量 jobs) */ + allTags: Array<{ key: string; values: string[] }>; + /** 当前选中的筛选条件:key -> values[] 的映射 */ + selection: Record; + /** 选择变更回调 */ + onChange: (selection: Record) => void; + /** 重置筛选条件 */ + onReset: () => void; + /** 是否包含该标签的任务被视为命中 */ + zh?: boolean; + /** 触发器元素(通常是表头 label),用于定位弹层 */ + anchorRect: DOMRect | null; + /** 关闭时调用 */ + onClose: () => void; +} + +export function TagFilterPopover({ + allTags, + selection, + onChange, + onReset, + zh = true, + anchorRect, + onClose, +}: TagFilterPopoverProps) { + const [search, setSearch] = useState(""); + const [hoveredKey, setHoveredKey] = useState(null); + const [valuePage, setValuePage] = useState(1); + const popoverRef = useRef(null); + + // 按 key 分组并固定 key/value 的展示顺序。 + const grouped = useMemo(() => { + const map = new Map(); + for (const tag of allTags) { + const values = map.get(tag.key) ?? []; + for (const value of tag.values) { + if (!values.includes(value)) values.push(value); + } + map.set(tag.key, values); + } + return new Map( + [...map] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => [ + key, + values.sort((left, right) => left.localeCompare(right)), + ]), + ); + }, [allTags]); + + // 应用搜索过滤(key 或 value 模糊匹配) + const filteredKeys = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return [...grouped.keys()]; + const result: string[] = []; + for (const [k, vals] of grouped) { + if ( + k.toLowerCase().includes(q) || + vals.some((v) => v.toLowerCase().includes(q)) + ) { + result.push(k); + } + } + return result; + }, [grouped, search]); + + // 点击外部关闭 + useEffect(() => { + if (!popoverRef.current) return; + const handler = (e: MouseEvent) => { + if (!popoverRef.current?.contains(e.target as Node)) onClose(); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + const allKeys = [...grouped.keys()]; + + const keySelected = (k: string) => + selection[k] !== undefined && selection[k].length > 0; + + const toggleKeyAll = (k: string) => { + const allVals = grouped.get(k) ?? []; + const existing = selection[k] ?? []; + const newSel = { ...selection }; + // 如果已经全选了则取消全选 + if (existing.length === allVals.length) { + delete newSel[k]; + } else { + newSel[k] = allVals; + } + onChange(newSel); + }; + + const toggleValue = (k: string, v: string) => { + const existing = selection[k] ?? []; + const newSel = { ...selection }; + if (existing.includes(v)) { + const rest = existing.filter((x) => x !== v); + if (rest.length === 0) delete newSel[k]; + else newSel[k] = rest; + } else { + newSel[k] = [...existing, v]; + } + onChange(newSel); + }; + + const reset = () => { + onChange({}); + onReset(); + }; + + const toggleSelectAllKeys = () => { + const newSel: Record = {}; + // 如果全部已选则清空,否则全选 + let allSelected = allKeys.length > 0; + for (const k of allKeys) { + const sel = selection[k]; + if (!sel || sel.length !== (grouped.get(k)?.length ?? 0)) { + allSelected = false; + break; + } + } + if (allSelected) { + onChange({}); + } else { + for (const k of allKeys) { + newSel[k] = grouped.get(k) ?? []; + } + onChange(newSel); + } + }; + + // 计算定位位置 + const style: React.CSSProperties = {}; + if (anchorRect) { + const popoverWidth = 440; + const left = Math.min( + Math.max(anchorRect.left, 16), + window.innerWidth - popoverWidth - 16, + ); + style.left = left; + style.top = anchorRect.bottom + 6; + } + + const effectiveHover = hoveredKey ?? filteredKeys.find(keySelected) ?? null; + const selectedValues = useMemo( + () => (effectiveHover ? (grouped.get(effectiveHover) ?? []) : []), + [effectiveHover, grouped], + ); + // 已选中 key 时,搜索同时筛选该 key 下不符合的 value + const matchedValues = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return selectedValues; + return selectedValues.filter((v) => v.toLowerCase().includes(q)); + }, [selectedValues, search]); + const selectedValsForHover = effectiveHover + ? (selection[effectiveHover] ?? []) + : []; + const valuePageSize = 10; + const valuePageCount = Math.max( + 1, + Math.ceil(matchedValues.length / valuePageSize), + ); + const currentValuePage = Math.min(valuePage, valuePageCount); + const pagedValues = matchedValues.slice( + (currentValuePage - 1) * valuePageSize, + currentValuePage * valuePageSize, + ); + + useEffect(() => { + setValuePage(1); + }, [effectiveHover, search]); + + return ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
+
+ {/* 左栏:key 列表 */} +
+
+ +
+ {filteredKeys.length === 0 ? ( +
+ {zh ? "没有匹配的标签" : "No matching tags"} +
+ ) : ( + filteredKeys.map((k) => { + const totalVals = grouped.get(k)?.length ?? 0; + const selVals = selection[k]?.length ?? 0; + return ( + + ); + }) + )} +
+ + {/* 右栏:选中 key 的 values */} +
+
+ + {effectiveHover + ? zh + ? `标签值 — ${effectiveHover}` + : `Values — ${effectiveHover}` + : zh + ? "标签值" + : "Values"} + +
+ {!effectiveHover || selectedValues.length === 0 ? ( +
+ {zh + ? "选择左侧的 key 查看对应的值" + : "Select a key on the left to see its values"} +
+ ) : matchedValues.length === 0 ? ( +
+ {zh ? "没有匹配的值" : "No matching values"} +
+ ) : ( + <> + {pagedValues.map((v) => ( + + ))} + {valuePageCount > 1 && ( +
+ + + {currentValuePage} / {valuePageCount} + + +
+ )} + + )} +
+
+
+ + +
+
+ ); +} diff --git a/apps/rlark-ui/src/components/create.tsx b/apps/rlark-ui/src/components/create.tsx index a1dfd37..7e51252 100644 --- a/apps/rlark-ui/src/components/create.tsx +++ b/apps/rlark-ui/src/components/create.tsx @@ -1,7 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { ChevronDown, X } from "lucide-react"; import type { CRDNodeLite } from "../types"; -import { parseNodeSelectorStr, selectorToStr } from "../utils/job"; +import { + isValidRoleName, + parseNodeSelectorStr, + ROLE_NAME_MAX_LENGTH, + selectorToStr, +} from "../utils/job"; export function NodeSelectorPicker({ value, @@ -254,30 +259,48 @@ export function NodeSelectorPicker({ } export function RoleNameInput({ - role, + id, + value, + zh, onRename, }: { - role: string; - onRename: (old: string, newName: string) => void; + id: string; + value: string; + zh: boolean; + onRename: (id: string, newName: string) => void; }) { - const [draft, setDraft] = useState(role); - useEffect(() => setDraft(role), [role]); + // 受控组件:输入(含粘贴)即时提交,避免依赖失焦时机, + // 否则点击“下一步”时校验可能读到未提交的旧名称。 + const invalid = value.trim().length > 0 && !isValidRoleName(value.trim()); return ( - e.stopPropagation()} - onChange={(e) => setDraft(e.target.value)} - onBlur={() => { - const trimmed = draft.trim(); - if (trimmed && trimmed !== role) onRename(role, trimmed); - else setDraft(role); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - (e.target as HTMLInputElement).blur(); +
e.stopPropagation()}> + + className={invalid ? "input-invalid" : undefined} + onChange={(e) => onRename(id, e.target.value)} + onBlur={() => { + const trimmed = value.trim(); + if (trimmed !== value) onRename(id, trimmed); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement).blur(); + } + }} + /> + {invalid && ( + + {zh + ? "名称格式不正确,仅支持中英文、数字以及-_." + : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed."} + + )} +
); } diff --git a/apps/rlark-ui/src/components/shared.tsx b/apps/rlark-ui/src/components/shared.tsx index bdca027..793676e 100644 --- a/apps/rlark-ui/src/components/shared.tsx +++ b/apps/rlark-ui/src/components/shared.tsx @@ -110,6 +110,58 @@ export function SortButton({ ); } +// 表头筛选触发按钮:列名 + ListFilter 图标,激活态高亮并显示选中数。 +// 与 SortButton 视觉对称,但语义是"筛选"而非"排序"。 +export function ColumnFilterButton({ + label, + selectedCount, + onClick, +}: { + label: string; + selectedCount: number; + onClick: (event: React.MouseEvent) => void; +}) { + const active = selectedCount > 0; + return ( + + ); +} + +// 管理表格内"当前打开哪一列的筛选弹层"和触发位置。 +// 一个表格可能有多列可筛选,但同一时间只开一个弹层。 +export function useColumnFilter() { + const [openKey, setOpenKey] = useState(null); + const [anchorRect, setAnchorRect] = useState(null); + + const openFor = (key: string) => (e: React.MouseEvent) => { + e.stopPropagation(); + if (openKey === key) { + setOpenKey(null); + setAnchorRect(null); + } else { + setAnchorRect(e.currentTarget.getBoundingClientRect()); + setOpenKey(key); + } + }; + + const close = () => { + setOpenKey(null); + setAnchorRect(null); + }; + + return { openKey, anchorRect, openFor, close }; +} + export function Logo({ lang }: { lang: Lang }) { const locale = lang === "zh" ? "zh" : "en"; return ( @@ -120,7 +172,7 @@ export function Logo({ lang }: { lang: Lang }) { className="brand-logo brand-logo-light" /> RLark @@ -136,7 +188,7 @@ export function StatusBadge({ copy: Copy; }) { const Icon = - phase === "Running" || phase === "Stopping" + phase === "Running" || phase === "Stopping" || phase === "Deleting" ? LoaderCircle : phase === "Succeeded" || phase === "Online" ? Check @@ -569,13 +621,32 @@ export function PageToolbar({ ); } -export function ResourceDistribution({ - copy: c, - rows, +export function RefreshOverlay({ + visible, + label, }: { - copy: Copy; - rows: ResourceRow[]; + visible: boolean; + label: string; }) { + if (!visible) return null; + + return ( +
+
+ ); +} + +export function ResourceDistribution({ rows }: { rows: ResourceRow[] }) { const total = rows.reduce((s, r) => s + r.count, 0) || 1; return (
diff --git a/apps/rlark-ui/src/components/terminal.tsx b/apps/rlark-ui/src/components/terminal.tsx index a691c72..08202d7 100644 --- a/apps/rlark-ui/src/components/terminal.tsx +++ b/apps/rlark-ui/src/components/terminal.tsx @@ -12,6 +12,7 @@ import { stripLegacyProxyCloseMessage, } from "../utils/terminalKeyboard"; import "@xterm/xterm/css/xterm.css"; +import { terminalApi } from "../backend"; const workerStatusLabels: Record = { Running: "运行中", @@ -62,9 +63,7 @@ export function TerminalPage({ term.writeln(`Connecting to ${workerName} ...`); - const proto = location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${proto}//${location.host}/api/v1/rlinf.io/v1alpha1/pods/${encodeURIComponent(workerCRName)}/terminal`; - const ws = new WebSocket(wsUrl); + const ws = terminalApi.createSocket(workerCRName); wsRef.current = ws; ws.binaryType = "arraybuffer"; diff --git a/apps/rlark-ui/src/constants.ts b/apps/rlark-ui/src/constants.ts index 1aeca53..cebf2f5 100644 --- a/apps/rlark-ui/src/constants.ts +++ b/apps/rlark-ui/src/constants.ts @@ -56,14 +56,14 @@ export const adminNavItems: AdminNavItem[] = [ }, { id: "jobs", icon: ListChecks, zh: "任务管理", en: "Jobs" }, { id: "domains", icon: Globe2, zh: "网络域", en: "Domains" }, - { id: "api", icon: Braces, zh: "接口参考", en: "API Reference" }, - { id: "config", icon: Settings, zh: "系统配置", en: "Config" }, { id: "storageClass", icon: HardDrive, zh: "存储管理", en: "Storage" }, - { id: "ssh-keys", icon: Terminal, zh: "SSH 公钥", en: "SSH Keys" }, { id: "image-registries", icon: Image, zh: "镜像管理", en: "Image Registries", }, + { id: "ssh-keys", icon: Terminal, zh: "SSH 公钥", en: "SSH Keys" }, + { id: "config", icon: Settings, zh: "系统配置", en: "System Config" }, + { id: "api", icon: Braces, zh: "接口参考", en: "API Reference" }, ]; diff --git a/apps/rlark-ui/src/data.ts b/apps/rlark-ui/src/data.ts index 1c59ac9..75f8730 100644 --- a/apps/rlark-ui/src/data.ts +++ b/apps/rlark-ui/src/data.ts @@ -6,6 +6,9 @@ export type Phase = | "Succeeded" | "Failed" | "Stopped" + | "Stopping" + | "Deleting" + | "Unknown" | "Online" | "Offline"; @@ -82,6 +85,16 @@ export interface Worker { // Task.status.events 聚合,仅在 worker 处于 Pending 时填充,供状态 // 徽标 "i" tooltip 展示。 events?: NodeEventEntry[]; + // Task 名称,用于获取节点 RANK + taskName?: string; +} + +// JobTag 表示任务标签,id 为前端生成的稳定标识(用于 React key), +// key/value 为用户输入的实际标签内容(均不超过 10 字符)。 +export interface JobTag { + id: string; + key: string; + value: string; } export interface Job { @@ -118,6 +131,7 @@ export interface Job { domain?: string; tensorBoardDir?: string; sshPublicKey?: string; + tags?: JobTag[]; resources: Array<{ role: string; cluster: string; @@ -137,8 +151,6 @@ export interface Job { hostPath: string; pvcSizeGb: number; }>; - pvcStorageMap?: Record; - pvcSizeGbMap?: Record; }>; taskStatuses: Array<{ name: string; @@ -160,6 +172,7 @@ export interface PodInfo { node: string; ip: string; message: string; + env?: Array<{ name: string; value: string }>; } export interface Domain { @@ -207,7 +220,6 @@ export interface StorageClassCreateRequest { description: string; } -export const storageClasses: StorageClass[] = []; export const clusters: Cluster[] = [ { id: "cloud-east-a", @@ -283,6 +295,37 @@ export const clusters: Cluster[] = [ }, ]; +export const storageClasses: StorageClass[] = [ + { + id: "training-datasets", + name: "training-datasets", + namespace: "default", + provider: "MinIO", + clusters: clusters.map((cluster) => cluster.id), + endpoint: "https://minio.mock.local", + region: "local", + bucket: "rlark-training", + accessKeyId: "mock-access-key", + pathStyle: true, + description: "用于训练数据集的 Mock 对象存储", + createdAt: "2026-08-01T08:00:00Z", + }, + { + id: "evaluation-results", + name: "evaluation-results", + namespace: "default", + provider: "AWS S3", + clusters: clusters.slice(0, 2).map((cluster) => cluster.id), + endpoint: "https://s3.mock.local", + region: "cn-east-1", + bucket: "rlark-evaluation", + accessKeyId: "mock-access-key", + pathStyle: false, + description: "用于评估产物的 Mock 对象存储", + createdAt: "2026-08-02T08:00:00Z", + }, +]; + export const nodes: NodeItem[] = [ { id: "gpu-cloud-01", diff --git a/apps/rlark-ui/src/i18n.ts b/apps/rlark-ui/src/i18n.ts index 082c895..f32e8c8 100644 --- a/apps/rlark-ui/src/i18n.ts +++ b/apps/rlark-ui/src/i18n.ts @@ -46,11 +46,13 @@ export const copy = { Running: "运行中", Pending: "等待中", Stopping: "停止中", + Deleting: "删除中", Succeeded: "成功", Failed: "失败", Stopped: "已停止", Online: "在线", Offline: "离线", + Unknown: "未知", }, kind: { CloudCompute: "云算力节点", @@ -129,15 +131,10 @@ export const copy = { api: { title: "接口参考", eyebrow: "开发者平台", - desc: "面向集群、节点、Job 和 Worker 的资源 API。", - sections: ["介绍", "认证", "集群", "节点", "任务", "Worker"], - endpointDesc: [ - "查询集群列表", - "查询节点列表", - "创建任务", - "查询 Worker 列表", - "查看 Worker 日志", - ], + desc: "从 Gateway 加载接口分类、路径和响应示例。", + search: "搜索当前分类的接口...", + loading: "正在加载接口信息...", + loadError: "接口信息加载失败,请稍后重试。", example: "响应示例", copy: "复制", }, @@ -239,11 +236,13 @@ export const copy = { Running: "Running", Pending: "Pending", Stopping: "Stopping", + Deleting: "Deleting", Succeeded: "Succeeded", Failed: "Failed", Stopped: "Stopped", Online: "Online", Offline: "Offline", + Unknown: "Unknown", }, kind: { CloudCompute: "Cloud compute", @@ -324,22 +323,10 @@ export const copy = { api: { title: "API Reference", eyebrow: "Developer platform", - desc: "Resource APIs for clusters, nodes, jobs, and workers.", - sections: [ - "Introduction", - "Authentication", - "Clusters", - "Nodes", - "Jobs", - "Workers", - ], - endpointDesc: [ - "List clusters", - "List nodes", - "Create job", - "List workers", - "View worker logs", - ], + desc: "Load endpoint categories, paths, and response examples from Gateway.", + search: "Search this category...", + loading: "Loading API information...", + loadError: "Failed to load API information. Please try again later.", example: "Example response", copy: "Copy", }, diff --git a/apps/rlark-ui/src/mockBackend.ts b/apps/rlark-ui/src/mockBackend.ts index 5699ff1..61f23d4 100644 --- a/apps/rlark-ui/src/mockBackend.ts +++ b/apps/rlark-ui/src/mockBackend.ts @@ -1,5 +1,9 @@ -import { clusters, type StorageClass } from "./data"; -import type { CRDDomain, CRDJob, CRDWorkflow } from "./types"; +import { + clusters, + storageClasses as mockStorageClasses, + type StorageClass, +} from "./data"; +import type { CRDDomain, CRDJob, CRDWorkflow, NodeEventEntry } from "./types"; import { buildMockCRDNodes } from "./utils/nodes"; const nodes = buildMockCRDNodes(); @@ -35,7 +39,6 @@ const makeTask = ( workload: { kind: "Deployment", replicas: 1, - pvcStorageMap: { data: "training-datasets" }, template: { spec: { containers: [ @@ -43,14 +46,29 @@ const makeTask = ( name, image, env: [{ name: "RLARK_TASK_ROLE", value: role }], - volumeMounts: [{ name: "data", mountPath: "/data" }], + volumeMounts: [ + { name: "data", mountPath: "/data" }, + { name: "local-cache", mountPath: "/cache" }, + ], resources: { requests: { cpu: "4", memory: "8Gi", "nvidia.com/gpu": "1" }, }, }, ], volumes: [ - { name: "data", persistentVolumeClaim: { claimName: "data" } }, + { + name: "data", + ephemeral: { + volumeClaimTemplate: { + spec: { + accessModes: ["ReadWriteOnce"], + storageClassName: "training-datasets", + resources: { requests: { storage: "50Gi" } }, + }, + }, + }, + }, + { name: "local-cache", hostPath: { path: "/var/lib/rlark/cache" } }, ], }, }, @@ -58,10 +76,48 @@ const makeTask = ( }, }); +const jobTagPool = [ + { key: "project", values: ["rlark", "console", "vision", "robot"] }, + { key: "environment", values: ["development", "staging", "production"] }, + { key: "team", values: ["platform", "runtime", "frontend"] }, + { key: "priority", values: ["high", "medium", "low"] }, + { key: "wrtest", values: ["collection", "train", "training"] }, + { key: "workload", values: ["training", "evaluation"] }, + { key: "hardware", values: ["gpu-a100", "jetson-orin"] }, + { key: "region", values: ["cn-north", "cn-east", "cn-south"] }, + { key: "owner", values: ["alice", "bob", "carol"] }, + { key: "experiment", values: ["baseline", "ablation", "sweep"] }, + { key: "phase", values: ["prepare", "train", "eval"] }, + { key: "dataset", values: ["images", "pointclouds", "speech"] }, +]; + +// 每个任务固定生成 10 个不同 key 的标签,便于验证多标签展示与筛选 +const makeJobTags = (index: number) => + Array.from({ length: 10 }, (_, offset) => { + const { key, values } = + jobTagPool[(index * 3 + offset) % jobTagPool.length]; + return { key, values: [values[index % values.length]] }; + }); + const jobs: CRDJob[] = [ - ["robot-policy-training", 0, "Running"], - ["warehouse-evaluation", 1, "Succeeded"], - ["vision-data-collection", 2, "Pending"], + // 创建时间最新,desc 排序时排在列表最前面 + ["sim-replay-pipeline", 13, "Running"], + ["scene-bake-rendering", 12, "Succeeded"], + ["nav-policy-distill", 11, "Running"], + ["grasp-dataset-augment", 10, "Succeeded"], + ["lidar-calibration-suite", 9, "Pending"], + ["edge-mapping-benchmark", 8, "Running"], + ["talker-finetune-sft", 7, "Succeeded"], + ["vision-data-collection", 6, "Pending"], + ["slam-bag-replay", 5, "Running"], + ["warehouse-evaluation", 4, "Succeeded"], + ["robot-policy-training", 4, "Running"], + ["dual-arm-transfer", 3, "Pending"], + ["object-6d-pose-estim", 2, "Running"], + ["audio-command-parser", 1, "Succeeded"], + // 创建时间最早(其余任务均晚于它),desc 排序时固定落在列表页底部, + // 且标签数量多,用于验证多标签展开弹层在屏幕底部自动翻转的修复 + ["multi-tag-preview", 0, "Running"], ].map(([name, indexValue, phase]) => { const index = Number(indexValue); const cluster = clusterNames[index % clusterNames.length]; @@ -71,10 +127,11 @@ const jobs: CRDJob[] = [ kind: "Job", metadata: { name: String(name), - creationTimestamp: `2026-08-0${index + 6}T09:00:00Z`, + creationTimestamp: `2026-08-${String(index + 6).padStart(2, "0")}T09:00:00Z`, }, spec: { domain: domains[index % domains.length].metadata.name, + tags: makeJobTags(index), tasks: [ makeTask( taskNames[0], @@ -136,6 +193,64 @@ const pods = jobs.flatMap((job, jobIndex) => }), ); +const pendingWorkerEvents: NodeEventEntry[] = [ + { + type: "Warning", + reason: "FailedScheduling", + message: "Insufficient GPU resources for the requested worker.", + lastTime: "2026-08-08T09:05:00Z", + objectKind: "Pod", + }, + { + type: "Warning", + reason: "ImagePullBackOff", + message: "Back-off pulling the runtime image.", + lastTime: "2026-08-08T09:06:00Z", + objectKind: "Pod", + }, + { + type: "Warning", + reason: "FailedMount", + message: "The training dataset volume is not mounted yet.", + lastTime: "2026-08-08T09:07:00Z", + objectKind: "Pod", + }, + { + type: "Warning", + reason: "NodeNotReady", + message: "The selected node is unavailable because of memory pressure.", + lastTime: "2026-08-08T09:08:00Z", + objectKind: "Node", + }, + { + type: "Warning", + reason: "FailedCreatePodSandBox", + message: "The worker runtime environment is not ready.", + lastTime: "2026-08-08T09:09:00Z", + objectKind: "Pod", + }, +]; + +const pendingWorkerEventMap = Object.fromEntries( + pods + .filter( + (pod) => + pod.metadata.name.startsWith("vision-data-collection-") && + pod.status.phase === "Pending", + ) + .map((pod, index) => [ + pod.metadata.name, + pendingWorkerEvents.map((event) => ({ + ...event, + objectName: + event.objectKind === "Pod" ? pod.spec.podName : pod.status.node, + lastTime: new Date( + Date.parse(event.lastTime ?? "") + index * 60_000, + ).toISOString(), + })), + ]), +); + domains.forEach((domain) => { domain.status = { ipAllocations: pods @@ -176,34 +291,18 @@ const workflows: CRDWorkflow[] = [ }, ]; -const storageClasses: StorageClass[] = [ - { - id: "training-datasets", - name: "training-datasets", - namespace: "default", - provider: "MinIO", - clusters: clusterNames, - endpoint: "https://minio.mock.local", - region: "local", - bucket: "rlark-training", - accessKeyId: "mock-access-key", - pathStyle: true, - description: "Shared datasets for the mock topology", - createdAt: "2026-08-01T08:00:00Z", - }, +const storageClasses: StorageClass[] = mockStorageClasses.map((item) => ({ + ...item, + clusters: item.clusters.length > 0 ? item.clusters : clusterNames, +})); + +const imageRegistries = [ { - id: "evaluation-results", - name: "evaluation-results", - namespace: "default", - provider: "AWS S3", - clusters: clusterNames.slice(0, 2), - endpoint: "https://s3.mock.local", - region: "cn-east-1", - bucket: "rlark-evaluation", - accessKeyId: "mock-access-key", - pathStyle: false, - description: "Evaluation artifacts", - createdAt: "2026-08-02T08:00:00Z", + id: "ir-0123456789abcdef", + name: "Mock Harbor", + registry: "registry.example.com", + username: "robot", + clusterSelection: { mode: "All", clusters: [] as string[] }, }, ]; @@ -282,6 +381,8 @@ export function installMockBackend() { } return json(node); } + if (method === "GET" && path === "/api/v1/rlinf.io/v1alpha1/jobs/tags") + return json({ items: jobTagPool }); if (method === "GET" && path === "/api/v1/rlinf.io/v1alpha1/jobs") return json({ items: jobs }); if ( @@ -379,10 +480,35 @@ export function installMockBackend() { : pods, }); } + if ( + method === "GET" && + path.startsWith("/api/v1/rlinf.io/v1alpha1/pods/") && + path.endsWith("/events") + ) { + const podName = decodeURIComponent(path.split("/").at(-2)!); + return json({ events: pendingWorkerEventMap[podName] ?? [] }); + } if (method === "GET" && path === "/api/v1/rlinf.io/v1alpha1/domains") return json({ items: domains }); if (method === "GET" && path === "/api/v1/rlinf.io/v1alpha1/workflows") return json({ items: workflows }); + if ( + method === "PATCH" && + path.startsWith("/api/v1/rlinf.io/v1alpha1/workflows/") + ) { + const name = decodeURIComponent(path.split("/").pop()!); + const workflow = workflows.find((item) => item.metadata.name === name); + if (!workflow) return json({ error: "not found" }, 404); + const patch = await request.json(); + if (typeof patch.spec?.stopped === "boolean") { + workflow.spec.stopped = patch.spec.stopped; + workflow.status = { + ...workflow.status, + phase: patch.spec.stopped ? "Stopping" : "Running", + }; + } + return json(workflow); + } if (method === "GET" && path === "/api/v1/storage/storageclass") return json({ data: Object.fromEntries(storageClasses.map((item) => [item.id, item])), @@ -430,12 +556,51 @@ export function installMockBackend() { if (index >= 0) storageClasses.splice(index, 1); return json({ success: true }); } + if (method === "GET" && path === "/api/v1/image-registries") + return json(imageRegistries); + if (method === "POST" && path === "/api/v1/image-registries") { + const payload = await request.json(); + const item = { + id: `ir-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, + name: payload.name, + registry: payload.registry, + username: payload.username, + clusterSelection: payload.clusterSelection, + }; + imageRegistries.push(item); + return json(item, 201); + } + if (path.startsWith("/api/v1/image-registries/")) { + const id = decodeURIComponent(path.split("/").pop() || ""); + const index = imageRegistries.findIndex((item) => item.id === id); + if (index < 0) return json({ error: "not found" }, 404); + if (method === "GET") return json(imageRegistries[index]); + if (method === "PUT") { + const payload = await request.json(); + imageRegistries[index] = { + ...imageRegistries[index], + name: payload.name, + registry: payload.registry, + username: payload.username, + clusterSelection: payload.clusterSelection, + }; + return json(imageRegistries[index]); + } + if (method === "DELETE") { + imageRegistries.splice(index, 1); + return json({ ok: true }, 202); + } + } if (method === "GET" && path.includes("/list")) return json({ data: { objects: [] } }); if (method === "GET" && path === "/api/v1/ssh-user-keys") return json(sshUserKeys); if (method === "POST" && path === "/api/v1/ssh-user-keys") { const payload = await request.json(); + if (sshUserKeys.some((item) => item.user === payload.user)) + return json({ error: "public key name already exists" }, 409); + if (sshUserKeys.some((item) => item.public_key === payload.public_key)) + return json({ error: "public key already exists" }, 409); sshUserKeys.push({ index: sshUserKeys.length, user: payload.user, diff --git a/apps/rlark-ui/src/pages/Api.tsx b/apps/rlark-ui/src/pages/Api.tsx index ad14cdc..9342c79 100644 --- a/apps/rlark-ui/src/pages/Api.tsx +++ b/apps/rlark-ui/src/pages/Api.tsx @@ -1,63 +1,353 @@ -import { ChevronRight, Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + Braces, + Check, + Copy as CopyIcon, + KeyRound, + Layers3, + Search, +} from "lucide-react"; +import { + apiReferenceApi, + type ApiReferenceEndpoint, + type ApiReferenceResponse, +} from "../backend"; import type { Copy } from "../i18n"; export function ApiPage({ copy: c }: { copy: Copy }) { - const endpoints = [ - ["GET", "/api/v1/clusters", c.api.endpointDesc[0]], - ["GET", "/api/v1/nodes", c.api.endpointDesc[1]], - ["POST", "/api/v1/jobs", c.api.endpointDesc[2]], - ["GET", "/api/v1/jobs/{id}/workers", c.api.endpointDesc[3]], - ["GET", "/api/v1/workers/{id}/logs", c.api.endpointDesc[4]], - ]; - const example = - '{\\n "kind": "Job",\\n "type": "RL",\\n "workers": [\\n { "role": "Learner", "node": "gpu-cloud-03" },\\n { "role": "Env Worker", "node": "robot-g1-12" }\\n ]\\n}'; + const lang = c.nav.overview === "总览" ? "zh" : "en"; + const zh = lang === "zh"; + const [reference, setReference] = useState(null); + const [loadError, setLoadError] = useState(false); + const [activeSectionID, setActiveSectionID] = useState(""); + const [query, setQuery] = useState(""); + const [selectedKey, setSelectedKey] = useState(""); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let cancelled = false; + apiReferenceApi + .get() + .then((result) => { + if (cancelled) return; + setReference(result); + const firstSection = result.sections[0]; + setActiveSectionID(firstSection?.id ?? ""); + const firstEndpoint = firstSection?.endpoints?.[0]; + setSelectedKey( + firstEndpoint ? `${firstEndpoint.method} ${firstEndpoint.path}` : "", + ); + }) + .catch(() => { + if (!cancelled) setLoadError(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const activeSection = reference?.sections.find( + (section) => section.id === activeSectionID, + ); + const normalizedQuery = query.trim().toLowerCase(); + const filteredEndpoints = (activeSection?.endpoints ?? []).filter( + (endpoint) => + !normalizedQuery || + `${endpoint.method} ${endpoint.path} ${endpoint.description[lang]}` + .toLowerCase() + .includes(normalizedQuery), + ); + const selectedEndpoint = + filteredEndpoints.find( + (endpoint) => `${endpoint.method} ${endpoint.path}` === selectedKey, + ) ?? filteredEndpoints[0]; + const resourceSections = (reference?.sections ?? []).filter( + (section) => section.id !== "overview", + ); + const endpointCount = (reference?.sections ?? []).reduce( + (total, section) => total + (section.endpoints?.length ?? 0), + 0, + ); + + const selectSection = (sectionID: string) => { + setActiveSectionID(sectionID); + setQuery(""); + const firstEndpoint = reference?.sections.find( + (section) => section.id === sectionID, + )?.endpoints?.[0]; + setSelectedKey( + firstEndpoint ? `${firstEndpoint.method} ${firstEndpoint.path}` : "", + ); + }; + + const copyExample = async (endpoint: ApiReferenceEndpoint) => { + await navigator.clipboard.writeText( + JSON.stringify(endpoint.example, null, 2), + ); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }; + + if (!reference) { + return ( +
+
+
+ {c.api.eyebrow} +

{c.api.title}

+

{c.api.desc}

+
+
+
+ {loadError ? c.api.loadError : c.api.loading} +
+
+ ); + } + return ( -
-
+
+
{c.api.eyebrow} -

{c.api.title}

-

{c.api.desc}

+

{reference.title[lang]}

+

{reference.description[lang]}

+
+
+ {zh ? "Gateway 实时数据" : "Live Gateway data"} + {endpointCount} + {zh ? "个已收录接口" : "documented endpoints"}
-
- -
- JOB API -

Jobs & Workers

-

{c.api.desc}

-
- {endpoints.map(([method, path, desc]) => ( -
- - {method} + )} + +
+
+
+ + {activeSection?.id === "overview" + ? zh + ? "快速开始" + : "Quick start" + : zh + ? "接口分类" + : "API category"} + +

{activeSection?.title[lang]}

+

{activeSection?.description[lang]}

+
+ {activeSection?.id !== "overview" && ( + + {filteredEndpoints.length} {zh ? "个接口" : "endpoints"} + + )} +
+ {activeSection?.id === "overview" ? ( +
+
+ + + +
+ + {zh ? "RLark Gateway API" : "RLark Gateway API"} + +

{activeSection.description[lang]}

+
+ /api/v1/rlinf.io/v1alpha1 +
+
+
+ + - {path} -

{desc}

- + {zh ? "认证方式" : "Authentication"} + Bearer JWT +

+ {zh + ? "登录后将令牌放入 Authorization 请求头。" + : "Send the login token in the Authorization header."} +

+
+
+ + + + {zh ? "资源分类" : "Resource groups"} + {resourceSections.length} +

+ {zh + ? "按业务资源组织,可从下方直接进入。" + : "Organized by resource; open one directly below."} +

+
+
+
+
+
+ {zh ? "资源目录" : "Resource catalog"} + + {zh ? "选择分类开始浏览" : "Choose a category"} + +
+ + {resourceSections.reduce( + (total, section) => + total + (section.endpoints?.length ?? 0), + 0, + )}{" "} + {zh ? "个接口" : "endpoints"} + +
+
+ {resourceSections.map((section) => ( + + ))}
- ))} -
-
-
- {c.api.example} -
-
{example}
+
+ {zh ? "调用流程" : "Request flow"} +
    +
  1. + 01 +
    + {zh ? "获取令牌" : "Get a token"} + POST /api/v1/auth/login +
    +
  2. +
  3. + 02 +
    + {zh ? "添加请求头" : "Add the header"} + Authorization: Bearer <token> +
    +
  4. +
  5. + 03 +
    + {zh ? "调用资源接口" : "Call a resource"} + Content-Type: application/json +
    +
  6. +
+
-
-
+ ) : filteredEndpoints.length > 0 ? ( +
+ {filteredEndpoints.map((endpoint) => { + const endpointKey = `${endpoint.method} ${endpoint.path}`; + const expanded = + endpointKey === + `${selectedEndpoint?.method} ${selectedEndpoint?.path}`; + return ( +
+ + {expanded && ( +
+
+
+ {zh ? "请求路径" : "Request path"} + {endpoint.path} +
+ {c.api.example} +
+
+
+ application/json + +
+
{JSON.stringify(endpoint.example, null, 2)}
+
+
+ )} +
+ ); + })} +
+ ) : ( +
+ {normalizedQuery + ? zh + ? "没有匹配的接口" + : "No matching endpoints" + : activeSection?.description[lang]} +
+ )} +
); } diff --git a/apps/rlark-ui/src/pages/ClusterManagement.tsx b/apps/rlark-ui/src/pages/ClusterManagement.tsx index 88a83b7..98ec8f0 100644 --- a/apps/rlark-ui/src/pages/ClusterManagement.tsx +++ b/apps/rlark-ui/src/pages/ClusterManagement.tsx @@ -19,16 +19,28 @@ import { isBusinessWorkerNode, } from "../utils/nodes"; import { - compareSortValues, + ColumnFilterButton, MetricCard, PageToolbar, Pagination, - SortButton, - type SortDirection, + RefreshOverlay, + useColumnFilter, } from "../components/shared"; +import { ColumnFilterPopover } from "../components/ColumnFilterPopover"; import { NodeResourceBrowser } from "../components/NodeResourceBrowser"; -type ClusterPhaseFilter = "All" | "Online" | "Degraded" | "Offline"; +// 集群类型中文化:数据层保留英文枚举,渲染时映射 +function clusterTypeLabel(type: string, zh: boolean): string { + if (!type) return "—"; + const map: Record = { + Cloud: { zh: "云集群", en: "Cloud" }, + Embodied: { zh: "具身集群", en: "Embodied" }, + Hybrid: { zh: "混合集群", en: "Hybrid" }, + }; + const entry = map[type]; + if (!entry) return type; + return zh ? entry.zh : entry.en; +} function clusterIDForNode(node: CRDNode) { return ( @@ -201,56 +213,30 @@ export function ClusterManagementPage({ const [clusters, setClusters] = useState([]); const [detailNodes, setDetailNodes] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [query, setQuery] = useState(""); - const [phaseFilter, setPhaseFilter] = useState("All"); + // 状态列多选筛选;空数组 = 全部 + const [phaseFilterValues, setPhaseFilterValues] = useState([]); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); - const [sort, setSort] = useState<{ - key: - | "name" - | "type" - | "totalNodes" - | "onlineNodes" - | "offlineNodes" - | "rate" - | "phase"; - direction: SortDirection; - }>({ key: "name", direction: "asc" }); - const toggleSort = (key: typeof sort.key) => - setSort((current) => ({ - key, - direction: - current.key === key && current.direction === "asc" ? "desc" : "asc", - })); + const { openKey, anchorRect, openFor, close } = useColumnFilter(); const fetchClusters = async (isInitial = true) => { if (isInitial) setLoading(true); let resolvedNodes: CRDNode[] = []; try { - const nodesURL = new URL( - "/api/v1/rlinf.io/v1alpha1/nodes", - window.location.origin, - ); - if (selectedClusterID) { - nodesURL.searchParams.set( - "labelSelector", - `rlark.io/cluster-id=${selectedClusterID}`, - ); - } - const response = await fetch(nodesURL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const body = await response.json(); - resolvedNodes = body.items ?? []; + resolvedNodes = await nodesApi.list({ + labelSelector: selectedClusterID + ? `rlark.io/cluster-id=${selectedClusterID}` + : undefined, + }); } catch { resolvedNodes = []; } let resolvedClusters: ClusterSummary[] = []; try { - const response = await fetch("/api/v1/clusters"); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const body = await response.json(); - const rawClusters = (body.data ?? []) as ClusterSummary[]; + const rawClusters = await clustersApi.list(); const clusterTypes = new Map( rawClusters.map((cluster) => [ cluster.id || cluster.name, @@ -281,12 +267,10 @@ export function ClusterManagementPage({ if (selectedClusterID) { try { - const response = await fetch( - `/api/v1/clusters/${encodeURIComponent(selectedClusterID)}`, - ); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const body = await response.json(); - const detail = body.data as ClusterSummary & { nodes?: CRDNode[] }; + const detail = await clustersApi.get< + ClusterSummary & { nodes?: CRDNode[] } + >(selectedClusterID); + if (!detail) throw new Error("cluster not found"); const fullNodes = resolvedNodes.filter( (node) => clusterIDForNode(node) === selectedClusterID, ); @@ -331,32 +315,27 @@ export function ClusterManagementPage({ useAutoRefresh(fetchClusters, 10000, [selectedClusterID]); + const handleRefresh = async () => { + if (refreshing) return; + setRefreshing(true); + try { + await fetchClusters(false); + } finally { + setRefreshing(false); + } + }; + const filteredClusters = useMemo(() => { const normalized = query.trim().toLowerCase(); - return clusters - .filter((cluster) => { - const searchable = - `${cluster.name} ${cluster.id} ${cluster.type} ${cluster.region} ${cluster.location}`.toLowerCase(); - return ( - (!normalized || searchable.includes(normalized)) && - (phaseFilter === "All" || cluster.phase === phaseFilter) - ); - }) - .sort((a, b) => { - const value = (cluster: ClusterSummary) => - sort.key === "rate" - ? cluster.totalNodes - ? cluster.onlineNodes / cluster.totalNodes - : 0 - : cluster[sort.key]; - return compareSortValues( - value(a), - value(b), - sort.direction, - zh ? "zh-CN" : "en", - ); - }); - }, [clusters, phaseFilter, query, sort, zh]); + return clusters.filter((cluster) => { + const searchable = + `${cluster.name} ${cluster.id} ${cluster.type} ${cluster.region} ${cluster.location}`.toLowerCase(); + const phaseHit = + phaseFilterValues.length === 0 || + phaseFilterValues.includes(cluster.phase); + return (!normalized || searchable.includes(normalized)) && phaseHit; + }); + }, [clusters, phaseFilterValues, query]); const totalPages = Math.max(1, Math.ceil(filteredClusters.length / pageSize)); const currentPage = Math.min(page, totalPages); @@ -364,7 +343,7 @@ export function ClusterManagementPage({ (currentPage - 1) * pageSize, currentPage * pageSize, ); - useEffect(() => setPage(1), [pageSize, phaseFilter, query]); + useEffect(() => setPage(1), [pageSize, phaseFilterValues, query]); const selectedCluster = selectedClusterID ? clusters.find((cluster) => cluster.id === selectedClusterID) @@ -466,7 +445,8 @@ export function ClusterManagementPage({ fetchClusters(false)} + onRefresh={handleRefresh} + refreshing={refreshing} onSelectNode={onSelectNode} /> @@ -499,59 +479,24 @@ export function ClusterManagementPage({ onChange={setQuery} count={filteredClusters.length} copy={c} - onRefresh={() => fetchClusters(false)} - filterValue={phaseFilter} - onFilterChange={(value) => setPhaseFilter(value as ClusterPhaseFilter)} - filterOptions={[ - { value: "All", label: zh ? "全部状态" : "All statuses" }, - { value: "Online", label: zh ? "在线" : "Online" }, - { value: "Degraded", label: zh ? "部分离线" : "Degraded" }, - { value: "Offline", label: zh ? "离线" : "Offline" }, - ]} + onRefresh={handleRefresh} + refreshing={refreshing} /> -
+
- toggleSort("name")} - /> - toggleSort("type")} - /> - toggleSort("totalNodes")} - /> - toggleSort("onlineNodes")} - /> - toggleSort("offlineNodes")} - /> - toggleSort("rate")} - /> - {zh ? "集群名称" : "Cluster"} + {zh ? "类型" : "Type"} + {zh ? "节点数" : "Nodes"} + {zh ? "在线" : "Online"} + {zh ? "离线" : "Offline"} + {zh ? "在线率" : "Rate"} + toggleSort("phase")} + selectedCount={phaseFilterValues.length} + onClick={openFor("phase")} />
@@ -582,11 +527,10 @@ export function ClusterManagementPage({ {cluster.name} - {cluster.region || cluster.id} - {cluster.type || "—"} + {clusterTypeLabel(cluster.type, zh)} {cluster.totalNodes} {cluster.onlineNodes} @@ -604,7 +548,26 @@ export function ClusterManagementPage({ }) )}
+ + {openKey === "phase" && ( + + )} ); } +import { clustersApi, nodesApi } from "../backend"; diff --git a/apps/rlark-ui/src/pages/Clusters.tsx b/apps/rlark-ui/src/pages/Clusters.tsx index 0ebecaa..2a4463d 100644 --- a/apps/rlark-ui/src/pages/Clusters.tsx +++ b/apps/rlark-ui/src/pages/Clusters.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { Activity, + AlertCircle, Check, ChevronRight, CloudCog, @@ -34,17 +35,20 @@ import { formatResourceQuantity, getGPUResourceKey, getNodeDeviceModel, + getNodeDiskUsage, getNodeCategories, getNodeCategory, getNodeGPUModel, getNodeLocation, getNodeResourceSummary, + getResourceUsagePercent, isBusinessWorkerNode, parseResourceQuantity, } from "../utils/nodes"; import { compareSortValues, MetricCard, + RefreshOverlay, SortButton, StatusBadge, type SortDirection, @@ -115,24 +119,14 @@ export function ClustersPage({ if (isInitial) setLoading(true); setError(""); try { - const [nodesResponse, tasksResponse, podsResponse] = await Promise.all([ - fetch("/api/v1/rlinf.io/v1alpha1/nodes"), - fetch("/api/v1/rlinf.io/v1alpha1/tasks"), - fetch("/api/v1/rlinf.io/v1alpha1/pods"), + const [nodes, tasks, pods] = await Promise.all([ + nodesApi.list(), + tasksApi.list(), + podsApi.list(), ]); - if (!nodesResponse.ok || !tasksResponse.ok || !podsResponse.ok) { - throw new Error( - `HTTP ${nodesResponse.status}/${tasksResponse.status}/${podsResponse.status}`, - ); - } - const [nodesData, tasksData, podsData] = await Promise.all([ - nodesResponse.json(), - tasksResponse.json(), - podsResponse.json(), - ]); - setRealNodes(nodesData.items ?? []); + setRealNodes(nodes); const taskJobs = new Map( - (tasksData.items ?? []).map( + tasks.map( (task: { metadata?: { name?: string; labels?: Record }; }) => [ @@ -145,7 +139,10 @@ export function ClustersPage({ string, { jobs: Set; workers: number } >(); - for (const pod of podsData.items ?? []) { + for (const pod of pods as Array<{ + spec?: { taskName?: string }; + status?: { phase?: string; node?: string }; + }>) { if (pod.status?.phase !== "Running" || !pod.status?.node) continue; const nodeName = pod.status.node as string; const current = workloadMap.get(nodeName) ?? { @@ -290,8 +287,11 @@ export function ClustersPage({ return (
@@ -406,6 +406,10 @@ export function ClustersPage({ /> )} +
); } @@ -566,24 +570,17 @@ export function NodeDetailReal({ const [nodeWorkers, setNodeWorkers] = useState([]); useAutoRefresh( async () => { - const [tasksResponse, podsResponse] = await Promise.all([ - fetch("/api/v1/rlinf.io/v1alpha1/tasks"), - fetch("/api/v1/rlinf.io/v1alpha1/pods"), - ]); - if (!tasksResponse.ok || !podsResponse.ok) { - throw new Error(`HTTP ${tasksResponse.status}/${podsResponse.status}`); - } - const [tasksBody, podsBody] = await Promise.all([ - tasksResponse.json(), - podsResponse.json(), + const [taskItems, podItems] = await Promise.all([ + tasksApi.list(), + podsApi.list(), ]); const tasks = new Map>( - (tasksBody.items ?? []).map((task: Record) => [ + (taskItems as unknown as Record[]).map((task) => [ (task as { metadata?: { name?: string } }).metadata?.name ?? "", task, ]), ); - const workers: NodeWorker[] = (podsBody.items ?? []) + const workers: NodeWorker[] = (podItems as Record[]) .filter( (pod: { status?: { node?: string } }) => pod.status?.node === node.metadata.name, @@ -669,13 +666,13 @@ export function NodeDetailReal({ const pullProgress = node.status?.pullProgress ?? []; const getPercent = (key: string) => { const rawUsed = used[key]; - if (!rawUsed && (capacity[key] ?? allocatable[key])) return 0; + if (!rawUsed && (allocatable[key] ?? capacity[key])) return 0; if (rawUsed?.endsWith("%")) return Math.min(100, Math.max(0, Number.parseFloat(rawUsed))); const usedNumber = parseResourceQuantity(key, rawUsed); const capacityNumber = parseResourceQuantity( key, - capacity[key] ?? allocatable[key], + allocatable[key] ?? capacity[key], ); return usedNumber !== null && capacityNumber !== null && capacityNumber > 0 ? Math.min( @@ -686,13 +683,13 @@ export function NodeDetailReal({ }; const formatUsedResource = (key: string) => { const raw = used[key]; - if (!raw && (capacity[key] ?? allocatable[key])) { + if (!raw && (allocatable[key] ?? capacity[key])) { return formatResourceQuantity(key, "0"); } if (raw?.endsWith("%")) { const capacityValue = parseResourceQuantity( key, - capacity[key] ?? allocatable[key], + allocatable[key] ?? capacity[key], ); const percent = Number.parseFloat(raw); if (capacityValue !== null && Number.isFinite(percent)) { @@ -705,15 +702,16 @@ export function NodeDetailReal({ return formatResourceQuantity(key, raw); }; const formatAvailableResource = (key: string) => { - const available = parseResourceQuantity( - key, - allocatable[key] ?? capacity[key], - ); - const requested = parseResourceQuantity(key, used[key]); - if (available === null) return "—"; + const total = parseResourceQuantity(key, allocatable[key] ?? capacity[key]); + const requested = used[key]?.endsWith("%") + ? total !== null + ? (total * Number.parseFloat(used[key])) / 100 + : null + : parseResourceQuantity(key, used[key]); + if (total === null) return "—"; return formatResourceQuantity( key, - String(Math.max(0, available - (requested ?? 0))), + String(Math.max(0, total - (requested ?? 0))), ); }; const gpuResourceKey = getGPUResourceKey(node); @@ -739,8 +737,18 @@ export function NodeDetailReal({ .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); }; - const hasDiskPressure = node.status?.diskPressure === true; - const diskPressureKnown = node.status?.diskPressure !== undefined; + const diskKey = "ephemeral-storage"; + const diskUsage = getNodeDiskUsage(node); + const diskPercent = + diskUsage?.percent ?? + getResourceUsagePercent( + diskKey, + used[diskKey], + allocatable[diskKey] ?? capacity[diskKey], + ); + const diskWarning = + node.status?.diskPressure === true || + (diskPercent !== null && diskPercent >= 90); const resourceItems = [ { key: "cpu", label: "CPU", icon: Cpu, available: true }, { @@ -754,7 +762,9 @@ export function NodeDetailReal({ label: zh ? "磁盘" : "Storage", icon: HardDrive, available: Boolean( - capacity["ephemeral-storage"] ?? allocatable["ephemeral-storage"], + diskUsage ?? + allocatable["ephemeral-storage"] ?? + capacity["ephemeral-storage"], ), }, { @@ -871,81 +881,92 @@ export function NodeDetailReal({
{resourceItems.map(({ key, label, icon: Icon, available }) => { - if (key === "ephemeral-storage") { - return ( -
-
- - - - {zh ? "磁盘压力" : "Disk pressure"} - - {diskPressureKnown - ? hasDiskPressure - ? zh - ? "存在" - : "Detected" - : zh - ? "正常" - : "Normal" - : zh - ? "未知" - : "Unknown"} - -
-
- -
-
- - {zh ? "状态" : "Status"} - - {diskPressureKnown - ? hasDiskPressure - ? zh - ? "节点存在磁盘压力" - : "Node has disk pressure" - : zh - ? "节点无磁盘压力" - : "No disk pressure" - : zh - ? "未上报" - : "Not reported"} - - - - {zh ? "可分配容量" : "Allocatable"} - - {available - ? formatResourceQuantity( - key, - allocatable[key] ?? capacity[key], - ) - : "—"} - - - - {zh ? "来自节点健康状态" : "From node health"} - -
-
- ); - } - const percent = available ? getPercent(key) : null; + const percent = + key === "ephemeral-storage" + ? diskPercent + : available + ? getPercent(key) + : null; + const usedLabel = + key === "ephemeral-storage" && diskUsage + ? zh + ? "已使用" + : "Used" + : zh + ? "已请求" + : "Requested"; + const usedValue = + key === "ephemeral-storage" && diskUsage + ? formatResourceQuantity(key, String(diskUsage.usedBytes)) + : formatUsedResource(key); + const totalValue = + key === "ephemeral-storage" && diskUsage + ? formatResourceQuantity( + key, + String(diskUsage.capacityBytes), + ) + : formatResourceQuantity( + key, + allocatable[key] ?? capacity[key], + ); + const availableValue = + key === "ephemeral-storage" && diskUsage + ? formatResourceQuantity( + key, + String(diskUsage.availableBytes), + ) + : formatAvailableResource(key); return ( -
+
{label} + {key === "ephemeral-storage" && diskWarning && ( + + + + + {zh + ? "健康与容量告警" + : "Health & capacity alert"} + + + {node.status?.diskPressure + ? zh + ? "节点存在磁盘压力,请及时清理空间" + : "Node disk pressure detected; free up space" + : zh + ? `磁盘使用率已达到 ${diskPercent ?? 90}%,请及时清理空间` + : `Disk usage reached ${diskPercent ?? 90}%; free up space`} + + + + )} +
+
+
+ +
{available ? percent === null @@ -956,41 +977,25 @@ export function NodeDetailReal({ : "None"}
-
- -
- {zh ? "已请求" : "Requested"} + {usedLabel} - {available - ? formatUsedResource(key) - : zh - ? "无" - : "None"} + {available ? usedValue : zh ? "无" : "None"} {zh ? "总量" : "Total"} - {available - ? formatResourceQuantity( - key, - capacity[key] ?? allocatable[key], - ) - : zh - ? "无" - : "None"} + {available ? totalValue : zh ? "无" : "None"} + + + + {zh ? "剩余量" : "Available"} + + {available ? availableValue : zh ? "无" : "None"} - - {zh ? "剩余" : "Available"}{" "} - {available - ? formatAvailableResource(key) - : zh - ? "无" - : "None"} -
); @@ -1176,9 +1181,14 @@ function NodeWorkerTable({ sshJumpPort?: string; }>({}); useEffect(() => { - fetch("/api/v1/system-config") - .then((response) => (response.ok ? response.json() : {})) - .then(setSSHConfig) + systemConfigApi + .get() + .then((config) => + setSSHConfig({ + sshJumpHost: config.ssh?.jumpHost || config.sshJumpHost, + sshJumpPort: config.ssh?.jumpPort || config.sshJumpPort, + }), + ) .catch(() => setSSHConfig({})); }, []); const requestTextFor = useCallback( @@ -1481,3 +1491,4 @@ function NodeWorkerTable({
); } +import { nodesApi, podsApi, systemConfigApi, tasksApi } from "../backend"; diff --git a/apps/rlark-ui/src/pages/CreateJob.tsx b/apps/rlark-ui/src/pages/CreateJob.tsx index 6e51cd1..64584e4 100644 --- a/apps/rlark-ui/src/pages/CreateJob.tsx +++ b/apps/rlark-ui/src/pages/CreateJob.tsx @@ -32,14 +32,25 @@ function formatImageUsage(usedAt: string, useCount: number, zh: boolean) { : `${relative} · used ${useCount} times`; } import { Check, ChevronDown, Plus, Trash2, X } from "lucide-react"; -import { type Cluster, clusters, type Job, type JobType } from "../data"; +import { + storageClasses as mockStorageClasses, + type Cluster, + clusters, + type Job, + type JobType, +} from "../data"; import type { Copy } from "../i18n"; -import type { CRDTask, RoleResource } from "../types"; +import type { RoleResource } from "../types"; +import type { JobTag } from "../data"; import { ROLE_TEMPLATES, automaticNetworkDomain, generateJobCRD, generateJobResourceName, + isValidJobDisplayName, + isValidRoleName, + JOB_DISPLAY_NAME_MAX_LENGTH, + ROLE_NAME_MAX_LENGTH, parseNodeSelectorStr, } from "../utils/job"; import { toYaml } from "../utils/yaml"; @@ -47,12 +58,18 @@ import { useNodeLabels } from "../utils/nodes"; import { imageReferenceHasWhitespace } from "../utils/imageReference"; import { RoleNameInput } from "../components/create"; import { CodeEditorField } from "../components/CodeEditor"; +import { TagEditor } from "../components/TagEditor"; import { ResourcePlacementPicker } from "../components/ResourcePlacementPicker"; import { availableResource, reclaimableResourcesForTasks, type ReclaimableResources, } from "../utils/resourceAvailability"; +import { + groupSSHUserKeys, + splitSSHPublicKeys, + type SSHUserKey, +} from "../utils/sshKeys"; function ClusterSelect({ clusters, @@ -165,6 +182,11 @@ function ClusterSelect({ ); } +// 角色以 id 作为唯一标识,name 为显示名称(允许留空), +// 避免多个未命名角色共用同一标识导致编辑联动。 +type RoleEntry = { id: string; name: string }; +let roleSeq = 0; + export function CreateJobModal({ onClose, onSuccess, @@ -207,23 +229,30 @@ export function CreateJobModal({ return () => document.removeEventListener("keydown", handleEscape); }, [onClose, submitting]); - const [roles, setRoles] = useState( - sourceJob?.defaultRoles ?? ROLE_TEMPLATES[type], + const [roles, setRoles] = useState(() => + (sourceJob?.defaultRoles ?? ROLE_TEMPLATES[type]).map((name) => ({ + id: name, + name, + })), ); const [jobName, setJobName] = useState( sourceJob ? editJob ? sourceJob.displayName : sourceJob.displayName + "-copy" - : "robot-policy-training", + : "", ); const [jobResourceName] = useState(() => editJob ? editJob.name : generateJobResourceName(), ); const [headerRole, setHeaderRole] = useState( - sourceJob?.headerRole ?? roles[0], + sourceJob?.headerRole ?? roles[0]?.id ?? "", ); - const effectiveHeader = roles.includes(headerRole) ? headerRole : roles[0]; + const effectiveHeader = roles.some((entry) => entry.id === headerRole) + ? headerRole + : (roles[0]?.id ?? ""); + const jobNameInvalid = + jobName.trim().length > 0 && !isValidJobDisplayName(jobName.trim()); const [runScript, setRunScript] = useState( sourceJob?.command ?? @@ -233,14 +262,15 @@ export function CreateJobModal({ sourceJob?.tensorBoardDir ?? "", ); const [sshPublicKeys, setSSHPublicKeys] = useState(() => - sourceJob?.sshPublicKey - ? sourceJob.sshPublicKey.split("\n").filter(Boolean) - : [], + splitSSHPublicKeys(sourceJob?.sshPublicKey), ); const sshPublicKey = sshPublicKeys.join("\n"); - const [sshKeys, setSShKeys] = useState< - { index: number; user: string; public_key: string; added_at: string }[] + const [tags, setTags] = useState(sourceJob?.tags ?? []); + const [allJobTags, setAllJobTags] = useState< + Array<{ key: string; values: string[] }> >([]); + const [sshKeys, setSShKeys] = useState([]); + const selectableSSHKeys = groupSSHUserKeys(sshKeys); const [sshKeysLoaded, setSShKeysLoaded] = useState(false); const [domains, setDomains] = useState<{ name: string; cidr: string }[]>([]); const [reclaimableResources, setReclaimableResources] = @@ -265,22 +295,25 @@ export function CreateJobModal({ const inferenceDoneRef = useRef(false); useEffect(() => { - fetch("/api/v1/images") - .then((r) => - r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), - ) - .then((data) => setRecentImages(data.items ?? [])) + imagesApi + .list() + .then(setRecentImages) .catch(() => {}); }, []); useEffect(() => { - fetch("/api/v1/rlinf.io/v1alpha1/domains") - .then((r) => - r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), - ) - .then((data) => + jobsApi + .listTags() + .then(setAllJobTags) + .catch(() => {}); + }, []); + + useEffect(() => { + domainsApi + .list() + .then((items) => setDomains( - (data.items ?? []).map((d: any) => ({ + items.map((d) => ({ name: d.metadata?.name ?? "", cidr: d.spec?.cidr ?? "", })), @@ -291,38 +324,29 @@ export function CreateJobModal({ useEffect(() => { if (!editJob || !restartAfterSave) return; - const selector = encodeURIComponent(`rlinf.io/job=${editJob.name}`); - fetch(`/api/v1/rlinf.io/v1alpha1/tasks?labelSelector=${selector}`) - .then((r) => - r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), - ) - .then((data) => - setReclaimableResources( - reclaimableResourcesForTasks((data.items ?? []) as CRDTask[]), - ), + tasksApi + .list({ labelSelector: `rlinf.io/job=${editJob.name}` }) + .then((items) => + setReclaimableResources(reclaimableResourcesForTasks(items)), ) .catch(() => setReclaimableResources({})); }, [editJob, restartAfterSave]); useEffect(() => { - fetch("/api/v1/ssh-user-keys") - .then((r) => - r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), - ) + sshKeysApi + .list() .then((data) => { - setSShKeys(data ?? []); + setSShKeys(data); setSShKeysLoaded(true); }) .catch(() => setSShKeysLoaded(true)); }, []); useEffect(() => { - fetch("/api/v1/clusters") - .then((r) => - r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), - ) + clustersApi + .list>() .then((data) => { - const list: Cluster[] = (data.data ?? []).map((c: any) => ({ + const list: Cluster[] = data.map((c) => ({ id: c.id ?? c.name ?? "", name: c.name ?? c.id ?? "", type: c.type === "Embodied" ? "Embodied" : "Cloud", @@ -375,18 +399,12 @@ export function CreateJobModal({ setStorageClassLoading(true); setStorageClassFetched(false); try { - const url = new URL( - "/api/v1/storage/storageclass", - window.location.origin, - ); - if (cluster) { - url.searchParams.set("clusters", cluster); - } - const resp = await fetch(url.pathname + url.search); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - const scData = data.data ?? {}; - const storageClassList = Object.values(scData).map((sc: any) => ({ + const data = await storageClassesApi.list<{ + name: string; + description?: string; + bucket?: string; + }>(cluster); + const storageClassList = Object.values(data).map((sc) => ({ name: sc.name, description: sc.description || "", bucket: sc.bucket || "", @@ -394,6 +412,15 @@ export function CreateJobModal({ setStorageClasses(storageClassList); } catch (e) { console.warn("Failed to fetch storage classes:", e); + setStorageClasses( + mockStorageClasses + .filter((sc) => !cluster || sc.clusters.includes(cluster)) + .map(({ name, description, bucket }) => ({ + name, + description, + bucket, + })), + ); } finally { setStorageClassLoading(false); setStorageClassFetched(true); @@ -427,9 +454,9 @@ export function CreateJobModal({ } const defaultRoleResources: Record = {}; if (!sourceJob) { - roles.forEach((role, index) => { - defaultRoleResources[role] = { - role, + roles.forEach((entry, index) => { + defaultRoleResources[entry.id] = { + role: entry.name, cluster: clusterDisplayNames[0] ?? "", nodeSelector: "", replicas: 0, @@ -462,7 +489,9 @@ export function CreateJobModal({ ) : {}, ); - const [activeRoleTab, setActiveRoleTab] = useState(roles[0] ?? ""); + const [activeRoleTab, setActiveRoleTab] = useState( + roles[0]?.id ?? "", + ); const roleConfigTopRef = useRef(null); const selectRole = (role: string, scrollToTop = false) => { @@ -476,6 +505,19 @@ export function CreateJobModal({ } }; + // ray head 只能是单 pod 任务:replicas 为 1(未配置时按 1 处理)。 + const roleReplicas = (role: string) => { + const rr = roleResources[role]; + if (!rr) return 1; + const value = Number(rr.replicas); + return Number.isFinite(value) && value > 0 ? value : 1; + }; + const canBeHeader = (role: string) => roleReplicas(role) === 1; + const selectHeaderRole = (role: string) => { + if (!canBeHeader(role)) return; + setHeaderRole(role); + }; + useEffect(() => { if (availableClusters.length === 0) return; setRoleResources((prev) => { @@ -566,14 +608,17 @@ export function CreateJobModal({ const onTypeChange = (next: JobType) => { setType(next); - const newRoles = ROLE_TEMPLATES[next]; - setRoles(newRoles); - setHeaderRole(newRoles[0] ?? ""); - setActiveRoleTab(newRoles[0] ?? ""); + const newEntries: RoleEntry[] = ROLE_TEMPLATES[next].map((name) => ({ + id: name, + name, + })); + setRoles(newEntries); + setHeaderRole(newEntries[0]?.id ?? ""); + setActiveRoleTab(newEntries[0]?.id ?? ""); const newRR: Record = {}; - newRoles.forEach((role, index) => { - newRR[role] = roleResources[role] ?? { - role, + newEntries.forEach((entry, index) => { + newRR[entry.id] = roleResources[entry.id] ?? { + role: entry.name, cluster: clusterDisplayNames[0] ?? "", nodeSelector: "", replicas: 0, @@ -591,12 +636,13 @@ export function CreateJobModal({ }; const addRole = () => { - const name = zh ? "新角色" : "New Role"; - setRoles((prev) => [...prev, name]); + roleSeq += 1; + const id = `role-${roleSeq}`; + setRoles((prev) => [...prev, { id, name: "" }]); setRoleResources((prev) => ({ ...prev, - [name]: { - role: name, + [id]: { + role: "", cluster: clusterDisplayNames[0] ?? "", nodeSelector: "", replicas: 0, @@ -611,37 +657,38 @@ export function CreateJobModal({ }, })); }; - const removeRole = (role: string) => { + const removeRole = (id: string) => { if (roles.length === 0) return; - setRoles((prev) => prev.filter((r) => r !== role)); + setRoles((prev) => prev.filter((entry) => entry.id !== id)); setRoleResources((prev) => { const next = { ...prev }; - delete next[role]; + delete next[id]; return next; }); - if (headerRole === role) setHeaderRole(roles[0]); + if (headerRole === id) setHeaderRole(roles[0]?.id ?? ""); }; - const renameRole = (oldName: string, newName: string) => { - newName = newName.trim(); - if (!newName || newName.length > 50 || oldName === newName) return; - if (roles.some((role) => role.toLowerCase() === newName.toLowerCase())) - return; - setRoles((prev) => prev.map((r) => (r === oldName ? newName : r))); + // 角色名随输入即时提交;合法性(空/超长/格式/重复)统一由下一步校验拦截, + // 此处不做静默拒绝,避免粘贴或快速点击下一步时丢失修改。 + const renameRole = (id: string, newName: string) => { + setRoles((prev) => + prev.map((entry) => + entry.id === id ? { ...entry, name: newName } : entry, + ), + ); setRoleResources((prev) => { - const rr = prev[oldName]; + const rr = prev[id]; if (!rr) return prev; - const next = { ...prev }; - delete next[oldName]; - next[newName] = { - ...rr, - role: newName, - envs: rr.envs.map((e) => - e.key === "RLARK_TASK_ROLE" ? { ...e, value: newName } : e, - ), + return { + ...prev, + [id]: { + ...rr, + role: newName.trim(), + envs: rr.envs.map((e) => + e.key === "RLARK_TASK_ROLE" ? { ...e, value: newName.trim() } : e, + ), + }, }; - return next; }); - if (headerRole === oldName) setHeaderRole(newName); }; const updateRR = (role: string, field: keyof RoleResource, v: any) => { @@ -723,13 +770,19 @@ export function CreateJobModal({ name: jobResourceName, displayName: jobName.trim(), type, - headerRole: effectiveHeader, - roles, - roleResources, + headerRole: + roles.find((entry) => entry.id === effectiveHeader)?.name.trim() ?? "", + roles: roles.map((entry) => entry.name.trim()), + roleResources: Object.fromEntries( + roles + .filter((entry) => entry.name.trim()) + .map((entry) => [entry.name.trim(), roleResources[entry.id]]), + ), runScript, domain: automaticDomain, tensorBoardDir, sshPublicKey, + tags, }); const yaml = toYaml(crd); const steps = zh @@ -740,28 +793,41 @@ export function CreateJobModal({ if (targetStep === 1) { const trimmedName = jobName.trim(); if (!trimmedName) return zh ? "请输入任务名称。" : "Enter a job name."; - if (trimmedName.length > 50) + if (trimmedName.length > JOB_DISPLAY_NAME_MAX_LENGTH) + return zh + ? `任务名称不能超过 ${JOB_DISPLAY_NAME_MAX_LENGTH} 个字符。` + : `Job name cannot exceed ${JOB_DISPLAY_NAME_MAX_LENGTH} characters.`; + if (!isValidJobDisplayName(trimmedName)) return zh - ? "任务名称不能超过 50 个字符。" - : "Job name cannot exceed 50 characters."; + ? "名称格式不正确,仅支持中英文、数字以及-_." + : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed."; if (roles.length === 0) return zh ? "至少添加一个角色。" : "Add at least one role."; - const normalizedRoles = roles.map((role) => role.trim().toLowerCase()); - if (normalizedRoles.some((role) => !role)) + const names = roles.map((entry) => entry.name.trim()); + const normalizedRoles = names.map((name) => name.toLowerCase()); + if (normalizedRoles.some((name) => !name)) return zh ? "角色名称不能为空。" : "Role names cannot be empty."; - if (roles.some((role) => role.trim().length > 50)) + if (names.some((name) => name.length > ROLE_NAME_MAX_LENGTH)) + return zh + ? `角色名称不能超过 ${ROLE_NAME_MAX_LENGTH} 个字符。` + : `Role names cannot exceed ${ROLE_NAME_MAX_LENGTH} characters.`; + if (names.some((name) => !isValidRoleName(name))) return zh - ? "角色名称不能超过 50 个字符。" - : "Role names cannot exceed 50 characters."; + ? "名称格式不正确,仅支持中英文、数字以及-_." + : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed."; if (new Set(normalizedRoles).size !== normalizedRoles.length) return zh ? "角色名称不能重复。" : "Role names must be unique."; - if (!effectiveHeader || !roles.includes(effectiveHeader)) + if ( + !effectiveHeader || + !roles.some((entry) => entry.id === effectiveHeader) + ) return zh ? "请选择 Header 角色。" : "Select a header role."; } if (targetStep === 2) { - for (const role of roles) { - const resource = roleResources[role]; + for (const entry of roles) { + const resource = roleResources[entry.id]; + const role = entry.name || (zh ? "未命名角色" : "Unnamed role"); if (!resource?.cluster) return zh ? `请为 ${role} 选择集群。` @@ -835,8 +901,20 @@ export function CreateJobModal({ return zh ? `${role} 需要选择对象存储。` : `${role} needs an object storage class.`; + if (mount.type === "storage") { + const size = Number(mount.pvcSizeGb); + if (isNaN(size) || size < 1 || size > 200) { + return zh + ? `${role} 的存储大小必须在 1 到 200 Gi 之间。` + : `${role}'s PVC size must be between 1 and 200 Gi.`; + } + } } } + if (effectiveHeader && !canBeHeader(effectiveHeader)) + return zh + ? `Header 角色 ${effectiveHeader} 只能有一个 Pod(副本数需为 1),请将其副本数改为 1 或改选其他单 Pod 角色作为 Header。` + : `The header role ${effectiveHeader} must have exactly one pod (replicas must be 1). Set its replicas to 1 or choose another single-pod role as the header.`; } if (targetStep === 3 && !runScript.trim()) @@ -876,10 +954,6 @@ export function CreateJobModal({ setSubmitting(true); setError(""); try { - const url = isEdit - ? `/api/v1/rlinf.io/v1alpha1/jobs/${editJob!.name}` - : "/api/v1/rlinf.io/v1alpha1/jobs"; - const method = isEdit ? "PUT" : "POST"; let requestBody = isEdit && restartAfterSave ? { @@ -895,9 +969,7 @@ export function CreateJobModal({ } : crd; if (isEdit) { - const currentResp = await fetch(url); - if (!currentResp.ok) throw new Error(`HTTP ${currentResp.status}`); - const current = await currentResp.json(); + const current = await jobsApi.get(editJob!.name); requestBody = { ...requestBody, metadata: { @@ -909,18 +981,11 @@ export function CreateJobModal({ ...requestBody.metadata.annotations, }, }, - }; + } as typeof requestBody; } - const resp = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - if (!resp.ok) { - const body = await resp.text(); - throw new Error(`HTTP ${resp.status}: ${body}`); - } - const savedJob = await resp.json(); + const savedJob = isEdit + ? await jobsApi.replace(editJob!.name, requestBody) + : await jobsApi.create(requestBody); onSuccess( isEdit ? restartAfterSave @@ -1014,9 +1079,22 @@ export function CreateJobModal({ {zh ? "任务名称" : "Job Name"} setJobName(e.target.value)} /> + + {jobNameInvalid + ? zh + ? "名称格式不正确,仅支持中英文、数字以及-_." + : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed." + : ""} +
- {roles.map((role) => ( -
setHeaderRole(role)} - > - - - - {effectiveHeader === role - ? zh - ? "Header" - : "Header" - : zh - ? "Worker" - : "Worker"} - - -
- ))} + + + + {isHeader + ? "Header" + : !headerAllowed + ? zh + ? "Worker(多 Pod)" + : "Worker (multi-pod)" + : "Worker"} + + +
+ ); + })}
))} @@ -1558,7 +1676,11 @@ export function CreateJobModal({
{zh ? "跨集群网络" : "Cross-cluster Network"}
-
+
{automaticDomain ? zh ? `系统已配置网络域,任务将默认启用跨集群网络(${automaticDomain})。` @@ -1577,14 +1699,17 @@ export function CreateJobModal({
- {sshKeys.map((k) => { - const selected = sshPublicKeys.includes(k.public_key); - const label = `${k.user} #${k.index + 1} (${k.public_key.slice(0, 40)}...)`; + {selectableSSHKeys.map(({ publicKey, owners }) => { + const selected = sshPublicKeys.includes(publicKey); + const ownerLabel = owners + .map(({ user }) => user) + .join(", "); + const label = `${ownerLabel} (${publicKey.slice(0, 40)}...)`; return ( ); })} @@ -1647,6 +1772,17 @@ export function CreateJobModal({ placeholder="/data/tensorboard/train" />
+
+
+ {zh ? "标签 (可选)" : "Tags (optional)"} +
+ +
)} {step === 4 && ( @@ -1678,8 +1814,8 @@ export function CreateJobModal({ )} {step < 4 ? ( (() => { - const currentRole = activeRoleTab || roles[0]; - const isLastRole = currentRole === roles[roles.length - 1]; + const currentRole = activeRoleTab || (roles[0]?.id ?? ""); + const isLastRole = currentRole === roles[roles.length - 1]?.id; const showNextRole = step === 2 && roles.length > 1 && !isLastRole; return ( @@ -1687,8 +1823,10 @@ export function CreateJobModal({ className="primary-button" onClick={() => { if (showNextRole) { - const idx = roles.indexOf(currentRole); - selectRole(roles[idx + 1], true); + const idx = roles.findIndex( + (entry) => entry.id === currentRole, + ); + selectRole(roles[idx + 1]?.id ?? "", true); setError(""); } else { goToStep(step + 1); @@ -1734,3 +1872,12 @@ export function CreateJobModal({
); } +import { + clustersApi, + domainsApi, + imagesApi, + jobsApi, + sshKeysApi, + storageClassesApi, + tasksApi, +} from "../backend"; diff --git a/apps/rlark-ui/src/pages/Domains.tsx b/apps/rlark-ui/src/pages/Domains.tsx index 5c8926d..9f20886 100644 --- a/apps/rlark-ui/src/pages/Domains.tsx +++ b/apps/rlark-ui/src/pages/Domains.tsx @@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react"; import type { Copy } from "../i18n"; import type { CRDDomain } from "../types"; import { useAutoRefresh } from "../hooks"; -import { PageToolbar } from "../components/shared"; +import { PageToolbar, RefreshOverlay } from "../components/shared"; export function DomainsPage({ copy: c, @@ -17,8 +17,10 @@ export function DomainsPage({ const zh = c.nav.overview === "总览"; const [domains, setDomains] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(""); const [showCreate, setShowCreate] = useState(false); + const [query, setQuery] = useState(""); const [newName, setNewName] = useState(""); const [newCidr, setNewCidr] = useState("10.244.0.0/16"); const [creating, setCreating] = useState(false); @@ -27,10 +29,7 @@ export function DomainsPage({ if (isInitial) setLoading(true); setError(""); try { - const resp = await fetch("/api/v1/rlinf.io/v1alpha1/domains"); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setDomains(data.items ?? []); + setDomains(await domainsApi.list()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -40,22 +39,26 @@ export function DomainsPage({ useAutoRefresh(fetchDomains, 10000); + const handleRefresh = async () => { + if (refreshing) return; + setRefreshing(true); + try { + await fetchDomains(false); + } finally { + setRefreshing(false); + } + }; + const handleCreate = async () => { setCreating(true); setError(""); try { - const resp = await fetch("/api/v1/rlinf.io/v1alpha1/domains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - apiVersion: "rlinf.io/v1alpha1", - kind: "Domain", - metadata: { name: newName.trim() }, - spec: { cidr: newCidr.trim() }, - }), + await domainsApi.create({ + apiVersion: "rlinf.io/v1alpha1", + kind: "Domain", + metadata: { name: newName.trim() }, + spec: { cidr: newCidr.trim() }, }); - if (!resp.ok) - throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); setShowCreate(false); setNewName(""); setNewCidr("10.244.0.0/16"); @@ -71,10 +74,7 @@ export function DomainsPage({ if (!confirm(zh ? `确定删除域 "${name}" 吗?` : `Delete domain "${name}"?`)) return; try { - const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/domains/${name}`, { - method: "DELETE", - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await domainsApi.remove(name); setDomains((prev) => prev.filter((d) => d.metadata.name !== name)); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -85,6 +85,12 @@ export function DomainsPage({ selectedName && domains.length > 0 ? (domains.find((d) => d.metadata.name === selectedName) ?? null) : null; + const normalizedQuery = query.trim().toLowerCase(); + const filteredDomains = domains.filter((domain) => + `${domain.metadata.name} ${domain.spec.cidr}` + .toLowerCase() + .includes(normalizedQuery), + ); if (selected) { return ( @@ -117,18 +123,22 @@ export function DomainsPage({
{}} - count={domains.length} + value={query} + onChange={setQuery} + count={filteredDomains.length} copy={c} - onRefresh={() => fetchDomains(false)} + onRefresh={handleRefresh} + refreshing={refreshing} /> {error && (
{error}
)} -
+
@@ -136,18 +146,18 @@ export function DomainsPage({ - - {domains.map((d) => ( + {filteredDomains.map((d) => ( onSelect(d.metadata.name)} > - - ))} - {domains.length === 0 && !loading && ( + {filteredDomains.length === 0 && !loading && (
CIDR {zh ? "IP 分配" : "IP Allocations"} {zh ? "创建时间" : "Created"} + {zh ? "操作" : "Actions"}
- {d.metadata.name} + + {d.metadata.name} {d.spec.cidr} @@ -161,7 +171,7 @@ export function DomainsPage({ {d.metadata.creationTimestamp ?? "—"} +
+
{showCreate && (
-
+
{zh ? "跨集群网络" : "Cross-cluster Network"} -

{domain.metadata.name}

+

+ {domain.metadata.name} +

{zh ? "管理跨集群网络域,为 Pod 分配跨集群可达的 IP 地址。" @@ -528,3 +544,4 @@ export function DomainDetailPage({

); } +import { domainsApi } from "../backend"; diff --git a/apps/rlark-ui/src/pages/ImageRegistries.tsx b/apps/rlark-ui/src/pages/ImageRegistries.tsx index e121d42..73a328a 100644 --- a/apps/rlark-ui/src/pages/ImageRegistries.tsx +++ b/apps/rlark-ui/src/pages/ImageRegistries.tsx @@ -6,33 +6,137 @@ import { Lock, Pencil, Plus, - RefreshCw, Trash2, X, } from "lucide-react"; import type { Copy } from "../i18n"; +import { PageToolbar, RefreshOverlay } from "../components/shared"; -interface ImageRegistryItem { - name: string; - registry: string; - username: string; +type ClusterSelectionMode = "None" | "Selected" | "All"; +type ClusterSelection = { mode: ClusterSelectionMode; clusters: string[] }; +type ClusterOption = { id: string; name: string }; + +function useClusterOptions() { + const [clusters, setClusters] = useState([]); + useEffect(() => { + clustersApi + .list<{ id?: string; name?: string }>() + .then((items) => + setClusters( + items.map((cluster: { id?: string; name?: string }) => ({ + id: (cluster.name || cluster.id || "").replace(/^rlark-/, ""), + name: (cluster.name || cluster.id || "").replace(/^rlark-/, ""), + })), + ), + ) + .catch(() => setClusters([])); + }, []); + return clusters; +} + +function selectionLabel(selection: ClusterSelection, zh: boolean) { + if (selection.mode === "None") return zh ? "仅保存" : "Stored only"; + if (selection.mode === "All") return zh ? "所有集群" : "All clusters"; + return zh + ? `${selection.clusters.length} 个集群` + : `${selection.clusters.length} clusters`; +} + +function ClusterSelectionFields({ + zh, + selection, + clusters, + onChange, +}: { + zh: boolean; + selection: ClusterSelection; + clusters: ClusterOption[]; + onChange: (selection: ClusterSelection) => void; +}) { + const options = Array.from( + new Set([...clusters.map((cluster) => cluster.id), ...selection.clusters]), + ).sort(); + return ( +
+ {zh ? "分发范围" : "Distribution Scope"} +
+ +
+ {selection.mode === "Selected" && ( +
+ {options.length === 0 ? ( +

{zh ? "暂无可选集群" : "No clusters available"}

+ ) : ( + options.map((id) => { + const option = clusters.find((cluster) => cluster.id === id); + return ( + + ); + }) + )} +
+ )} +
+ ); } export function ImageRegistriesPage({ copy: c, - selectedName, + selectedID, onSelect, onCreate, }: { copy: Copy; - selectedName?: string; - onSelect?: (name?: string) => void; + selectedID?: string; + onSelect?: (id?: string) => void; onCreate?: () => void; }) { const zh = c.nav.overview === "总览"; const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); const [editingItem, setEditingItem] = useState( null, ); @@ -41,10 +145,7 @@ export function ImageRegistriesPage({ setLoading(true); setError(""); try { - const resp = await fetch("/api/v1/image-registries"); - if (!resp.ok) throw new Error(await resp.text()); - const data = await resp.json(); - setItems(data || []); + setItems(await imageRegistriesApi.list()); } catch (e) { setError(String(e)); } finally { @@ -56,23 +157,17 @@ export function ImageRegistriesPage({ fetchItems(); }, []); - const handleDelete = async (name: string) => { + const handleDelete = async (item: ImageRegistryItem) => { if ( !confirm( zh - ? `确认删除镜像仓库凭据 "${name}"?` - : `Delete image registry "${name}"?`, + ? `确认删除“${item.name}”(${item.registry})?分发副本将异步清理。` + : `Delete “${item.name}” (${item.registry})? Distributed copies will be removed asynchronously.`, ) ) return; try { - const resp = await fetch( - `/api/v1/image-registries/${encodeURIComponent(name)}`, - { - method: "DELETE", - }, - ); - if (!resp.ok) throw new Error(await resp.text()); + await imageRegistriesApi.remove(item.id); fetchItems(); } catch (e) { setError(String(e)); @@ -82,9 +177,14 @@ export function ImageRegistriesPage({ const providers = Array.from( new Set(items.map((i) => i.registry).filter(Boolean)), ); + const filteredItems = items.filter((item) => + `${item.name} ${item.registry} ${item.username}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); - if (selectedName) { - const item = items.find((i) => i.name === selectedName); + if (selectedID) { + const item = items.find((i) => i.id === selectedID); if (item) { return ( <> @@ -126,7 +226,10 @@ export function ImageRegistriesPage({

- @@ -175,7 +278,24 @@ export function ImageRegistriesPage({
)} -
+ + +
{zh ? "凭据列表" : "Registries"} @@ -185,32 +305,21 @@ export function ImageRegistriesPage({ : "Manage private registry credentials"}
-
- - {zh ? `共 ${items.length} 项` : `${items.length} items`} - - -
+ + {zh + ? `共 ${filteredItems.length} 项` + : `${filteredItems.length} items`} +
- {loading ? ( + {filteredItems.length === 0 ? (

- {zh ? "加载中…" : "Loading…"} -

- ) : items.length === 0 ? ( -

- {zh ? "暂无镜像仓库凭据" : "No image registries"} + {loading + ? zh + ? "加载中…" + : "Loading…" + : zh + ? "暂无镜像仓库凭据" + : "No image registries"}

) : ( @@ -219,15 +328,16 @@ export function ImageRegistriesPage({ - + + - {items.map((item) => ( + {filteredItems.map((item) => ( onSelect?.(item.name)} + onClick={() => onSelect?.(item.id)} > - + ))}
{zh ? "名称" : "Name"} {zh ? "仓库地址" : "Registry"} {zh ? "用户名" : "Username"}{zh ? "分发范围" : "Scope"}{zh ? "操作" : "Actions"}
{item.username} e.stopPropagation()}> - + {selectionLabel(item.clusterSelection, zh)} e.stopPropagation()} + > +
+ +
)} +
+ {createOpen && ( + setCreateOpen(false)} + onCreated={() => { + void fetchItems(); + onCreate?.(); + }} + /> + )}
); } @@ -293,7 +425,7 @@ function ImageRegistryDetailPage({

{zh ? "dockerconfigjson" : "dockerconfigjson"} - {zh ? "自动注入已启用" : "Auto-injection enabled"} + {selectionLabel(item.clusterSelection, zh)}
@@ -323,6 +455,16 @@ function ImageRegistryDetailPage({ {zh ? "名称" : "Name"} {item.name}
+
+ {zh ? "分发范围" : "Distribution"} + {selectionLabel(item.clusterSelection, zh)} +
+
+ + {zh ? "目标命名空间" : "Target Namespace"} + + rlark-system +
{zh ? "仓库地址" : "Registry"} {item.registry} @@ -370,11 +512,13 @@ export function ImageRegistryCreatePage({ const zh = c.nav.overview === "总览"; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const clusters = useClusterOptions(); const [form, setForm] = useState({ name: "", registry: "", username: "", password: "", + clusterSelection: { mode: "All", clusters: [] } as ClusterSelection, }); useEffect(() => { @@ -390,21 +534,13 @@ export function ImageRegistryCreatePage({ setSubmitting(true); setError(""); try { - const resp = await fetch("/api/v1/image-registries", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: form.name.trim(), - registry: form.registry.trim(), - username: form.username.trim(), - password: form.password, - }), + await imageRegistriesApi.create({ + name: form.name.trim(), + registry: form.registry.trim(), + username: form.username.trim(), + password: form.password, + clusterSelection: form.clusterSelection, }); - if (!resp.ok) { - const msg = await resp.text(); - setError(msg || `HTTP ${resp.status}`); - return; - } onCreated?.(); onBack(); } catch (err) { @@ -499,6 +635,14 @@ export function ImageRegistryCreatePage({
+ + setForm({ ...form, clusterSelection }) + } + /> {error && (
{error} @@ -521,7 +665,9 @@ export function ImageRegistryCreatePage({ !form.name.trim() || !form.registry.trim() || !form.username.trim() || - !form.password.trim() + !form.password.trim() || + (form.clusterSelection.mode === "Selected" && + form.clusterSelection.clusters.length === 0) } > {submitting @@ -553,10 +699,13 @@ function ImageRegistryEditModal({ const zh = c.nav.overview === "总览"; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const clusters = useClusterOptions(); const [form, setForm] = useState({ + name: item.name, registry: item.registry, username: item.username, password: "", + clusterSelection: item.clusterSelection, }); useEffect(() => { @@ -572,23 +721,13 @@ function ImageRegistryEditModal({ setSubmitting(true); setError(""); try { - const resp = await fetch( - `/api/v1/image-registries/${encodeURIComponent(item.name)}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - registry: form.registry.trim(), - username: form.username.trim(), - ...(form.password ? { password: form.password } : {}), - }), - }, - ); - if (!resp.ok) { - const msg = await resp.text(); - setError(msg || `HTTP ${resp.status}`); - return; - } + await imageRegistriesApi.update(item.id, { + name: form.name.trim(), + registry: form.registry.trim(), + username: form.username.trim(), + ...(form.password ? { password: form.password } : {}), + clusterSelection: form.clusterSelection, + }); onSaved(); } catch (err) { setError(String(err)); @@ -633,7 +772,13 @@ function ImageRegistryEditModal({
+ + setForm({ ...form, clusterSelection }) + } + /> {error && (
{error} @@ -690,7 +843,12 @@ function ImageRegistryEditModal({ type="submit" className="primary-button" disabled={ - submitting || !form.registry.trim() || !form.username.trim() + submitting || + !form.name.trim() || + !form.registry.trim() || + !form.username.trim() || + (form.clusterSelection.mode === "Selected" && + form.clusterSelection.clusters.length === 0) } > {submitting @@ -707,3 +865,8 @@ function ImageRegistryEditModal({
); } +import { + clustersApi, + imageRegistriesApi, + type ImageRegistryItem, +} from "../backend"; diff --git a/apps/rlark-ui/src/pages/Jobs.tsx b/apps/rlark-ui/src/pages/Jobs.tsx index 8fd7107..891883c 100644 --- a/apps/rlark-ui/src/pages/Jobs.tsx +++ b/apps/rlark-ui/src/pages/Jobs.tsx @@ -1,4 +1,8 @@ -import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; +import type { + CSSProperties, + PointerEvent as ReactPointerEvent, + UIEvent as ReactUIEvent, +} from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -7,10 +11,12 @@ import { ChevronRight, Copy, Download, - ExternalLink, + Filter, Info, KeyRound, LoaderCircle, + Maximize, + Minimize, MoreVertical, Network, Pencil, @@ -22,29 +28,44 @@ import { TerminalSquare, Trash2, Workflow, + X, Zap, } from "lucide-react"; import { type Job, + type JobTag, type Phase, type PodInfo, type PullProgressEntry, type Worker as WorkerItem, } from "../data"; import type { Copy as CopyType } from "../i18n"; -import type { CRDJob, CRDNode, NodeEventEntry } from "../types"; +import type { CRDNode, CRDTask, NodeEventEntry } from "../types"; import { useAutoRefresh } from "../hooks"; import { crdToJob } from "../utils/crd"; import { effectiveJobPhase, type JobDisplayPhase } from "../utils/jobPhase"; import { formatChinaDateTime } from "../utils/time"; +import { resolveSSHKeyOwners, type SSHUserKey } from "../utils/sshKeys"; +import { isDiskUsageWarning } from "../utils/nodeResources"; import { + isValidJobDisplayName, + JOB_DISPLAY_NAME_MAX_LENGTH, +} from "../utils/job"; +import { + ColumnFilterButton, compareSortValues, PageToolbar, Pagination, + RefreshOverlay, SortButton, StatusBadge, + useColumnFilter, type SortDirection, } from "../components/shared"; +import { ColumnFilterPopover } from "../components/ColumnFilterPopover"; +import { JobTagPopover } from "../components/JobTagPopover"; +import { TagFilterPopover } from "../components/TagFilterPopover"; +import { TagEditor } from "../components/TagEditor"; function taskResourceName(jobName: string, taskName: string) { return `${jobName}-${taskName.toLowerCase().replace(/\s+/g, "-")}` @@ -181,11 +202,23 @@ export function JobsPage({ }) { const zh = c.nav.overview === "总览"; const [query, setQuery] = useState(""); - const [phaseFilter, setPhaseFilter] = useState<"All" | Phase>("All"); + // 表头列多选筛选;空数组 = 全部 + const [phaseFilter, setPhaseFilter] = useState([]); + const [typeFilter, setTypeFilter] = useState([]); + const [tagFilter, setTagFilter] = useState>({}); + const [allJobTags, setAllJobTags] = useState< + Array<{ key: string; values: string[] }> + >([]); + const [tagFilterOpen, setTagFilterOpen] = useState(false); + const [tagFilterAnchor, setTagFilterAnchor] = useState(null); const [realJobs, setRealJobs] = useState([]); const [loading, setLoading] = useState(true); const [listRefreshing, setListRefreshing] = useState(false); const [copiedJobId, setCopiedJobId] = useState(""); + const [tagPopover, setTagPopover] = useState<{ + tags: JobTag[]; + anchor: DOMRect; + } | null>(null); const [error, setError] = useState(""); const [actionNotice, setActionNotice] = useState(""); const [jobAction, setJobAction] = useState< @@ -200,16 +233,10 @@ export function JobsPage({ const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [sort, setSort] = useState<{ - key: - | "id" - | "type" - | "phase" - | "workers" - | "roleCount" - | "submittedAt" - | "stoppedAt"; + key: "submittedAt" | "stoppedAt"; direction: SortDirection; }>({ key: "submittedAt", direction: "desc" }); + const columnFilter = useColumnFilter(); const toggleSort = (key: typeof sort.key) => setSort((current) => ({ key, @@ -230,16 +257,23 @@ export function JobsPage({ const [nodeDeviceModelMap, setNodeDeviceModelMap] = useState< Record >({}); + const [nodeDiskWarningMap, setNodeDiskWarningMap] = useState< + Record + >({}); const fetchJobs = async (isInitial = true) => { if (isInitial) setLoading(true); setError(""); try { - const jobsResp = await fetch("/api/v1/rlinf.io/v1alpha1/jobs"); - if (!jobsResp.ok) throw new Error(`HTTP ${jobsResp.status}`); - const data = await jobsResp.json(); - const items: CRDJob[] = data.items ?? []; + const tagSelector = Object.entries(tagFilter) + .flatMap(([key, values]) => values.map((value) => `${key}=${value}`)) + .join(","); + const [items, tags] = await Promise.all([ + jobsApi.list({ tagSelector: tagSelector || undefined }), + jobsApi.listTags(), + ]); setRealJobs(items.map(crdToJob)); + setAllJobTags(tags); const nodeNames = new Set(); for (const job of items) { @@ -254,10 +288,7 @@ export function JobsPage({ // here are non-fatal: the hover tooltip simply won't appear. const nodeResponses = await Promise.all( [...nodeNames].map(async (nodeName) => { - const response = await fetch( - `/api/v1/rlinf.io/v1alpha1/nodes/${encodeURIComponent(nodeName)}`, - ); - return response.ok ? response.json() : null; + return nodesApi.get(nodeName).catch(() => null); }), ); { @@ -270,6 +301,7 @@ export function JobsPage({ string, { gpuModel?: string; deviceModel?: string } > = {}; + const diskWarningMap: Record = {}; for (const n of nodeItems) { const pp = n.status?.pullProgress; if (Array.isArray(pp) && pp.length > 0) { @@ -284,10 +316,14 @@ export function JobsPage({ if (gpuModel || deviceModel) { deviceModelMap[n.metadata.name] = { gpuModel, deviceModel }; } + if (isDiskUsageWarning(n)) { + diskWarningMap[n.metadata.name] = true; + } } setNodePullProgressMap(progressMap); setNodeEventsMap(eventsMap); setNodeDeviceModelMap(deviceModelMap); + setNodeDiskWarningMap(diskWarningMap); } } catch (e) { setRealJobs([]); @@ -319,23 +355,13 @@ export function JobsPage({ setJobAction("delete"); setError(""); try { - const stopResp = await fetch( - `/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ spec: { stopped: true } }), - }, + await jobsApi.remove(job.name); + setRealJobs((prev) => + prev.map((j) => + j.id === job.id ? { ...j, phase: "Deleting" as Phase } : j, + ), ); - if (!stopResp.ok) throw new Error(`HTTP ${stopResp.status}`); - await waitForJobWorkersStopped(job); - - const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, { - method: "DELETE", - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - setRealJobs((prev) => prev.filter((j) => j.id !== job.id)); - setActionNotice(zh ? "任务已删除" : "Job deleted"); + setActionNotice(zh ? "任务正在删除" : "Job deletion started"); return true; } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -345,61 +371,28 @@ export function JobsPage({ } }; - const waitForJobWorkersStopped = async (job: Job) => { - const deadline = Date.now() + 60_000; - const selector = encodeURIComponent(`rlinf.io/job=${job.name}`); - while (Date.now() < deadline) { - const [jobResp, tasksResp] = await Promise.all([ - fetch(`/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`), - fetch(`/api/v1/rlinf.io/v1alpha1/tasks?labelSelector=${selector}`), - ]); - if (!jobResp.ok) throw new Error(`HTTP ${jobResp.status}`); - if (!tasksResp.ok) throw new Error(`HTTP ${tasksResp.status}`); - const current = crdToJob((await jobResp.json()) as CRDJob); - const tasks = (await tasksResp.json()) as { - items?: Array<{ status?: { phase?: string } }>; - }; - const workersStopped = (tasks.items ?? []).every( - (task) => task.status?.phase === "Stopped", - ); - if (current.phase === "Stopped" && workersStopped) { - return current; - } - await new Promise((resolve) => window.setTimeout(resolve, 1000)); - } - throw new Error( - zh ? "等待 Worker 停止超时。" : "Timed out waiting for workers to stop.", - ); - }; - const handleSetStopped = async (job: Job, stopped: boolean) => { setJobAction(stopped ? "stop" : "start"); setError(""); try { - const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ spec: { stopped } }), - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const stoppedJob = stopped ? await waitForJobWorkersStopped(job) : null; + await jobsApi.setStopped(job.name, stopped); setRealJobs((prev) => prev.map((j) => j.id === job.id - ? (stoppedJob ?? { + ? { ...j, stopped, - phase: "Pending" as Phase, - stoppedAt: "—", - }) + phase: stopped ? j.phase : ("Pending" as Phase), + stoppedAt: stopped ? j.stoppedAt : "—", + } : j, ), ); setActionNotice( stopped ? zh - ? "任务已停止,Worker 和 PVC 已清理" - : "Job stopped; workers and PVCs cleaned up" + ? "任务已提交停止" + : "Job stop submitted" : zh ? "任务已提交启动" : "Job start submitted", @@ -417,17 +410,14 @@ export function JobsPage({ setJobAction("restart"); setError(""); try { - const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ - metadata: { - annotations: { "rlark.io/restarted-at": new Date().toISOString() }, + await jobsApi.patch(job.name, { + metadata: { + annotations: { + "rlark.io/restarted-at": new Date().toISOString(), }, - spec: { stopped: false }, - }), + }, + spec: { stopped: false }, }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); setRealJobs((prev) => prev.map((item) => item.id === job.id @@ -448,9 +438,7 @@ export function JobsPage({ const waitForFailedJobCleanup = async (job: Job) => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { - const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const current = crdToJob((await resp.json()) as CRDJob); + const current = crdToJob(await jobsApi.get(job.name)); if (current.phase === "Stopped" && current.runningWorkers === 0) return; await new Promise((resolve) => window.setTimeout(resolve, 1000)); } @@ -465,26 +453,10 @@ export function JobsPage({ setJobAction("restart"); setError(""); try { - const stopResp = await fetch( - `/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ spec: { stopped: true } }), - }, - ); - if (!stopResp.ok) throw new Error(`HTTP ${stopResp.status}`); + await jobsApi.setStopped(job.name, true); await waitForFailedJobCleanup(job); - const startResp = await fetch( - `/api/v1/rlinf.io/v1alpha1/jobs/${job.name}`, - { - method: "PATCH", - headers: { "Content-Type": "application/merge-patch+json" }, - body: JSON.stringify({ spec: { stopped: false } }), - }, - ); - if (!startResp.ok) throw new Error(`HTTP ${startResp.status}`); + await jobsApi.setStopped(job.name, false); setRealJobs((prev) => prev.map((item) => item.id === job.id @@ -522,28 +494,53 @@ export function JobsPage({ }; const allJobs = realJobs; + + // 轻量更新 job(tags 或 displayName),使用 PATCH + const handlePatchJob = async ( + jobName: string, + patchBody: Record, + ) => { + const updated = await jobsApi.patch(jobName, patchBody); + const parsed = crdToJob(updated); + setRealJobs((prev) => prev.map((j) => (j.id === jobName ? parsed : j))); + setAllJobTags((prev) => { + const valuesByKey = new Map( + prev.map((tag) => [tag.key, new Set(tag.values)]), + ); + for (const tag of parsed.tags ?? []) { + if (!valuesByKey.has(tag.key)) valuesByKey.set(tag.key, new Set()); + valuesByKey.get(tag.key)!.add(tag.value); + } + return [...valuesByKey].map(([key, values]) => ({ + key, + values: [...values], + })); + }); + return parsed; + }; + const filtered = allJobs.filter((j) => { - const queryHit = `${j.id} ${j.displayName} ${j.type}` - .toLowerCase() - .includes(query.toLowerCase()); + const queryHit = + `${j.id} ${j.displayName} ${j.type} ${(j.tags ?? []).map((t) => `${t.key}:${t.value}`).join(" ")}` + .toLowerCase() + .includes(query.toLowerCase()); const phaseHit = - phaseFilter === "All" || effectiveJobPhase(j) === phaseFilter; - return queryHit && phaseHit; + phaseFilter.length === 0 || phaseFilter.includes(effectiveJobPhase(j)); + const typeHit = typeFilter.length === 0 || typeFilter.includes(j.type); + return queryHit && phaseHit && typeHit; }); const sortedJobs = useMemo( () => [...filtered].sort((a, b) => { - const value = (job: Job) => { - if (sort.key === "workers") return job.progress; - if (sort.key === "phase") return effectiveJobPhase(job); - return job[sort.key]; - }; - return compareSortValues( - value(a), - value(b), + const comparison = compareSortValues( + a[sort.key], + b[sort.key], sort.direction, zh ? "zh-CN" : "en", ); + return comparison !== 0 + ? comparison + : a.id.localeCompare(b.id, zh ? "zh-CN" : "en"); }), [filtered, sort, zh], ); @@ -554,7 +551,13 @@ export function JobsPage({ currentPage * pageSize, ); - useEffect(() => setPage(1), [query, phaseFilter, pageSize]); + useEffect( + () => setPage(1), + [query, phaseFilter, typeFilter, tagFilter, pageSize], + ); + useEffect(() => { + void fetchJobs(false); + }, [tagFilter]); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); @@ -596,6 +599,9 @@ export function JobsPage({ nodePullProgressMap={nodePullProgressMap} nodeEventsMap={nodeEventsMap} nodeDeviceModelMap={nodeDeviceModelMap} + nodeDiskWarningMap={nodeDiskWarningMap} + allJobTags={allJobTags} + onPatchJob={handlePatchJob} /> {restartTarget && ( setPhaseFilter(value as "All" | Phase)} - filterOptions={[ - { value: "All", label: zh ? "全部状态" : "All statuses" }, - { value: "Running", label: c.status.Running }, - { value: "Pending", label: c.status.Pending }, - { value: "Succeeded", label: c.status.Succeeded }, - { value: "Failed", label: c.status.Failed }, - { value: "Stopped", label: c.status.Stopped }, - ]} /> {error && (
{error}
)} -
+
+ - - - + + - @@ -849,6 +844,38 @@ export function JobsPage({ +
{zh ? "名称/ID" : "Name / ID"} - toggleSort("id")} + - toggleSort("type")} - /> + + - toggleSort("phase")} - /> - - toggleSort("workers")} - /> - - toggleSort("roleCount")} + selectedCount={phaseFilter.length} + onClick={columnFilter.openFor("phase")} /> Worker{zh ? "角色数量" : "Roles"} {filtered.length === 0 && !loading && (
+
@@ -814,10 +806,13 @@ export function JobsPage({ : []; const jobFailedMessage = effectiveJobPhase(job) === "Failed" - ? job.taskStatuses - .filter((ts) => ts.phase === "Failed" && ts.message) - .map((ts) => ts.message) - .join("\n") + ? [ + ...new Set( + job.taskStatuses + .filter((ts) => ts.phase === "Failed") + .map((ts) => jobFailureMessage(ts.message, zh)), + ), + ].join("\n") : undefined; return (
{c.jobType[job.type]} + {(job.tags ?? []).length > 0 ? ( +
+ {(job.tags ?? []).slice(0, 2).map((t) => ( + + {t.key}: {t.value} + + ))} + {(job.tags ?? []).length > 2 && ( + + )} +
+ ) : ( + — + )} +
@@ -914,6 +941,10 @@ export function JobsPage({ })}
+
)} + {tagPopover && + typeof document !== "undefined" && + createPortal( + setTagPopover(null)} + />, + document.body, + )} + {tagFilterOpen && + tagFilterAnchor && + typeof document !== "undefined" && + createPortal( + { + setTagFilterOpen(false); + void fetchJobs(false); + }} + zh={zh} + anchorRect={tagFilterAnchor} + onClose={() => setTagFilterOpen(false)} + />, + document.body, + )} + {columnFilter.openKey === "type" && + typeof document !== "undefined" && + createPortal( + ({ value: v, label: c.jobType[v] ?? v }))} + selected={typeFilter} + onChange={setTypeFilter} + anchorRect={columnFilter.anchorRect} + onClose={columnFilter.close} + zh={zh} + />, + document.body, + )} + {columnFilter.openKey === "phase" && + typeof document !== "undefined" && + createPortal( + , + document.body, + )}
); } @@ -1052,23 +1150,22 @@ function JobActionMenu({ }, [open]); const isStartable = - job.stopped || job.phase === "Stopped" || job.phase === "Failed"; + job.stopped || job.phase === "Stopped" || job.phase === "Succeeded"; const isSucceeded = job.phase === "Succeeded"; + const isFailed = job.phase === "Failed"; + const isDeleting = job.phase === "Deleting"; + const isStopping = job.stopped && job.phase !== "Stopped"; const lifecycleLabel = isSucceeded ? zh ? "已成功完成的任务不能再次启动" : "Succeeded jobs cannot be started again" - : job.phase === "Failed" + : isStartable ? zh - ? "清理残留 Worker 后启动" - : "Clean residual workers, then start" - : isStartable - ? zh - ? "启动任务" - : "Start job" - : zh - ? "停止任务" - : "Stop job"; + ? "启动任务" + : "Start job" + : zh + ? "停止任务" + : "Stop job"; const handleToggle = () => { setOpen((v) => !v); @@ -1076,22 +1173,36 @@ function JobActionMenu({ return (
- - @@ -1141,8 +1252,10 @@ function AdminJobActions({ onRestart: () => void; onDelete: () => void; }) { + const isDeleting = job.phase === "Deleting"; + const isStopping = job.stopped && job.phase !== "Stopped"; const canStop = - !job.stopped && !["Stopped", "Succeeded", "Failed"].includes(job.phase); + !job.stopped && !["Stopped", "Succeeded", "Deleting"].includes(job.phase); return (
@@ -1150,6 +1263,7 @@ function AdminJobActions({ {c.jobs.selected} -

{job.displayName}

+ {nameEditing ? ( +
+ setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void saveNameEdit(); + else if (e.key === "Escape") cancelNameEdit(); + }} + onBlur={() => { + // 失焦自动保存(如果有改动的话) + if (nameDraft.trim() !== job.displayName) void saveNameEdit(); + else cancelNameEdit(); + }} + /> + {nameSaving && ...} + {nameInvalid && ( + + {zh + ? "名称格式不正确,仅支持中英文、数字以及-_." + : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed."} + + )} + {nameError && ( + {nameError} + )} +
+ ) : ( +

+ {job.displayName} + +

+ )}
- ) : !["Succeeded", "Failed"].includes(job.phase) ? ( + ) : !["Succeeded"].includes(job.phase) ? (
- {( - [ - ["name", zh ? "实例名称" : "Worker name"], - ["role", zh ? "角色" : "Role"], - ["cluster", zh ? "集群" : "Cluster"], - ["node", zh ? "节点" : "Node"], - ["kind", zh ? "节点类型" : "Node type"], - ["ip", zh ? "实例 IP" : "Worker IP"], - ["domainIP", zh ? "网络域 IP" : "Domain IP"], - ["gpu", zh ? "申请 GPU" : "GPU"], - ["createdAt", zh ? "创建时间" : "Created"], - ["phase", zh ? "状态" : "Status"], - ] as const - ).map(([key, label]) => ( - - toggleWorkerSort(key)} - /> - - ))} + {zh ? "实例名称" : "Worker name"} + + + + + + + + + + {zh ? "节点" : "Node"} + + + + {zh ? "节点 RANK" : "Node RANK"} + {zh ? "实例 IP" : "Worker IP"} + {zh ? "网络域 IP" : "Domain IP"} + {zh ? "申请 GPU" : "GPU"} + + toggleWorkerSort("createdAt")} + /> + )) ) : ( @@ -2559,6 +3014,10 @@ export function JobDetailPage({ )} +
{visibleWorkers.length > 0 && (
@@ -2603,9 +3062,42 @@ export function JobDetailPage({

{zh ? "Worker 日志流" : "Worker log stream"}

- {logsError ? ( - {logsError} - ) : ( + {logsError && ( +
+ + {logsError} + +
+ )} + {logsLoading && ( +
+ + + +
+ + {zh ? "正在连接 Worker 日志" : "Connecting to worker logs"} + + + {zh + ? "正在汇总各实例的最新输出…" + : "Collecting the latest output from each instance…"} + +
+