diff --git a/README.md b/README.md index 0508eb5..2baaae0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@
+ Documentation + 中文文档 Go Version TypeScript Kubernetes @@ -16,6 +18,8 @@ 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/). + ## What's NEW! - [2026/08] RLark is now open-source. @@ -36,39 +40,36 @@ Manage cross-cluster embodied intelligence workloads through a unified cloud-nat ## Quick Start -See the [Quick Start Guide](apps/rlark/docs/quickstart.md) for a step-by-step guide to set up a local development environment and run your first training job. +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: -```bash -# 1. Build -git clone https://github.com/RLinf/RLark -cd RLark && make build +- **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. -# 2. Start control plane (Docker Compose) -docker compose -f apps/rlark/docs/examples/docker-compose.yml up -d +## Documentation -# 3. Start data plane (kind cluster) -kind create cluster --name rlark-data +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: -# Then follow the quickstart guide to start components and create a job -``` +| 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 | -## Documentation +Repository-specific references remain available alongside the code: -| Document | Description | -|----------|-------------| -| [Architecture](apps/rlark/docs/architecture.md) | RLark core: technical architecture, component interactions, data flows | -| [Core Concepts](apps/rlark/docs/concepts.md) | Domain, Job, Task, Workflow, and other concepts | -| [Quick Start](apps/rlark/docs/quickstart.md) | Local development environment setup and first training job | -| [Deployment Guide](apps/rlark/docs/deployment.md) | Production deployment and configuration | -| [API Reference](apps/rlark/docs/api/reference.md) | Complete REST API reference | -| [API Examples](apps/rlark/docs/api/examples.md) | End-to-end API usage examples | +| Reference | Description | +|-----------|-------------| | [Embodied Runtime](apps/embodied-runtime/README.md) | Robot (ROS) and camera hardware management on edge nodes | | [Web UI](apps/rlark-ui/README.md) | Frontend management console | | [Python SDK](sdks/embodied-runtime-python/README.md) | Python client for robot/camera gRPC services | | [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 | -> [中文文档](apps/rlark/docs/zh/README.md) +> Prefer Chinese? Visit the **[中文 Read the Docs 站点](https://rlark-ci-test.readthedocs.io/zh-cn/latest/)**. ## Tech Stack diff --git a/README.zh-CN.md b/README.zh-CN.md index ac5636c..8017327 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,6 +5,8 @@
+ Documentation + 中文文档 Go Version TypeScript Kubernetes @@ -16,6 +18,8 @@ 以云原生方式统一纳管跨集群具身智能工作负载,覆盖云端 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/)。 + ## 最新动态 - [2026/08] RLark 现已开源。 @@ -36,39 +40,36 @@ ## 快速开始 -请参阅 [快速开始指南](apps/rlark/docs/zh/quickstart.md) 了解如何在本地搭建开发环境并运行第一个训练任务。 +请前往 Read the Docs 的[快速开始指南](https://rlark-ci-test.readthedocs.io/zh-cn/latest/quickstart/),选择一种已经过验证的流程: -```bash -# 1. 编译 -git clone https://github.com/RLinf/RLark -cd RLark && make build +- **一键 CLI 方式**:部署控制面和两个 kind 数据面集群,并验证 Pod 跨集群网络。 +- **UI 交互方式**:在 Web 控制台创建集群和 Domain,部署两个 kind 数据面,将一个 Job 调度到两个集群并验证网络连通性。 -# 2. 启动控制面(Docker Compose) -docker compose -f apps/rlark/docs/examples/docker-compose.yml up -d +## 文档索引 -# 3. 启动数据面(kind 集群) -kind create cluster --name rlark-data +完整、可搜索且支持版本管理的文档发布在 **[Read the Docs 中文站](https://rlark-ci-test.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/) | 组件、交互关系和数据流 | -## 文档索引 +与具体代码配套的说明继续保留仓库内入口: -| 文档 | 说明 | +| 参考 | 说明 | |------|------| -| [架构设计](apps/rlark/docs/zh/architecture.md) | RLark 核心:完整技术架构、组件交互、数据流 | -| [核心概念](apps/rlark/docs/zh/concepts.md) | Domain、Job、Task、Workflow 等概念解释 | -| [快速开始](apps/rlark/docs/zh/quickstart.md) | 本地开发环境搭建与第一个训练任务 | -| [部署指南](apps/rlark/docs/zh/deployment.md) | 生产环境部署、配置说明 | -| [API 参考](apps/rlark/docs/api/reference.md) | 完整的 REST API 参考 | -| [API 示例](apps/rlark/docs/api/examples.md) | 端到端 API 调用示例 | | [Embodied Runtime](apps/embodied-runtime/README.md) | 端侧机器人/摄像头运行时管理 | | [Web UI](apps/rlark-ui/README.md) | 前端管理界面说明 | | [Python SDK](sdks/embodied-runtime-python/README.md) | 机器人/摄像头 gRPC 客户端 | | [Go SDK](sdks/embodied-runtime-go/README.md) | Go 语言 gRPC 客户端 | | [Proto 定义](proto/embodied-runtime/README.md) | gRPC 服务接口定义 | -> [English Documentation](apps/rlark/docs/README.md) +> English documentation is available on the **[English Read the Docs site](https://rlark-ci-test.readthedocs.io/en/latest/)**. ## 技术栈 diff --git a/apps/rlark-ui/Makefile b/apps/rlark-ui/Makefile index 7636b5a..1f24fbd 100644 --- a/apps/rlark-ui/Makefile +++ b/apps/rlark-ui/Makefile @@ -19,7 +19,7 @@ lint: install ## Lint TypeScript/React (eslint) npx eslint src/ fmt: ## Format code (prettier) - npx prettier@3.9.6 --write 'src/' 'index.html' 'package.json' 'vite.config.ts' 'tsconfig*.json' + ./node_modules/.bin/prettier --write 'src/' 'index.html' 'package.json' 'vite.config.ts' 'tsconfig*.json' ##@ Docker diff --git a/apps/rlark-ui/package-lock.json b/apps/rlark-ui/package-lock.json index da8c1a6..6750142 100644 --- a/apps/rlark-ui/package-lock.json +++ b/apps/rlark-ui/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.0", + "prettier": "3.9.6", "terser": "^5.50.0", "typescript": "latest", "typescript-eslint": "^8.0.0" @@ -2645,6 +2646,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", diff --git a/apps/rlark-ui/package.json b/apps/rlark-ui/package.json index 89956b2..3aa6782 100644 --- a/apps/rlark-ui/package.json +++ b/apps/rlark-ui/package.json @@ -15,7 +15,7 @@ "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 --ignoreConfig --outDir dist/test --target ES2022 --module ES2022 --moduleResolution Bundler --lib ES2022,DOM && node --test tests/*.test.mjs" + "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/resourceAvailability.ts --ignoreConfig --outDir dist/test --target ES2022 --module ES2022 --moduleResolution Bundler --lib ES2022,DOM && node --test tests/*.test.mjs" }, "dependencies": { "@vitejs/plugin-react": "^4.7.0", @@ -34,6 +34,7 @@ "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.0", + "prettier": "3.9.6", "terser": "^5.50.0", "typescript": "latest", "typescript-eslint": "^8.0.0" diff --git a/apps/rlark-ui/src/App.tsx b/apps/rlark-ui/src/App.tsx index 833eb69..79e4e81 100644 --- a/apps/rlark-ui/src/App.tsx +++ b/apps/rlark-ui/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { CircleDot, CloudCog } from "lucide-react"; +import { Check, CircleDot, CloudCog } from "lucide-react"; import type { Job } from "./data"; import { copy, type Lang, type Theme } from "./i18n"; import { navItems } from "./constants"; @@ -46,6 +46,7 @@ export default function App() { const [cloneJob, setCloneJob] = useState(null); const [editJob, setEditJob] = useState(null); const [restartAfterEdit, setRestartAfterEdit] = useState(false); + const [jobSubmitNotice, setJobSubmitNotice] = useState(""); const [lang, setLang] = usePersistentState("rlark-language", "zh"); const [theme, setTheme] = usePersistentState("rlark-theme", "light"); const [userLoggedIn, setUserLoggedIn] = useState( @@ -109,6 +110,12 @@ export default function App() { return () => window.removeEventListener("popstate", onPop); }, []); + useEffect(() => { + if (!jobSubmitNotice) return; + const timer = window.setTimeout(() => setJobSubmitNotice(""), 4000); + return () => window.clearTimeout(timer); + }, [jobSubmitNotice]); + if (isAdmin) return ; if (!userLoggedIn) { return ( @@ -251,6 +258,22 @@ export default function App() { setUserLoggedIn(false); }} /> + {jobSubmitNotice && ( +
+ + {jobSubmitNotice} + +
+ )} {page === "overview" && ( )}{" "} @@ -284,6 +307,10 @@ export default function App() { onSelect={(name?: string) => navigate("jobs", name, { replace: !name }) } + onSelectNode={(name: string) => navigate("clusters-nodes", name)} + onSelectCluster={(id: string) => + navigate("clusters-management", id) + } onCreate={() => { setCloneJob(null); setEditJob(null); @@ -346,6 +373,14 @@ export default function App() { setEditJob(null); setRestartAfterEdit(false); }} + onSuccess={(message) => { + setCreateOpen(false); + setCloneJob(null); + setEditJob(null); + setRestartAfterEdit(false); + setJobSubmitNotice(message); + navigate("jobs", undefined, { replace: true }); + }} copy={c} cloneJob={cloneJob} editJob={editJob} diff --git a/apps/rlark-ui/src/admin/AdminApp.tsx b/apps/rlark-ui/src/admin/AdminApp.tsx index 012c9a1..bf857c0 100644 --- a/apps/rlark-ui/src/admin/AdminApp.tsx +++ b/apps/rlark-ui/src/admin/AdminApp.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { + AlertCircle, ArrowRight, CircleDot, CloudCog, - Languages, - Moon, + Eye, + EyeOff, Settings, - Shield, } from "lucide-react"; import { type Copy, type Lang, type Theme, copy } from "../i18n"; import { adminNavItems } from "../constants"; @@ -29,11 +29,8 @@ import { SystemConfigPage } from "../pages/SystemConfig"; import { JobsPage } from "../pages/Jobs"; export function AdminLogin({ - copy: c, lang, - onLangChange, theme, - onThemeChange, onLogin, }: { copy: Copy; @@ -46,9 +43,16 @@ export function AdminLogin({ const zh = lang === "zh"; const [username, setUsername] = useState("admin"); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + useEffect(() => { + if (!error) return; + const timer = window.setTimeout(() => setError(""), 4000); + return () => window.clearTimeout(timer); + }, [error]); + const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (!username.trim() || !password.trim()) { @@ -88,108 +92,109 @@ export function AdminLogin({ return (
-
-
- RLark - RLark -
- RLark - ADMIN -
-
-
-
- - -
-
- +
+
+
+
+ RLark + RLark + ADMIN +
+
+

{zh ? "管理后台登录" : "Admin Login"}

+

+ {zh ? "请输入管理员账号和密码" : "Enter your admin credentials"} +

+
+
+ + { + setUsername(e.target.value); + setError(""); + }} + placeholder="admin" + autoComplete="username" + /> +
+
+ +
+ { + setPassword(e.target.value); + setError(""); + }} + placeholder={zh ? "请输入密码" : "Enter password"} + autoComplete="current-password" + /> + +
+
+ {error && ( +
+ + {error} +
+ )} -
+ + + {zh ? "返回前台" : "Back to Console"} + +
-
-
-
- -
-

{zh ? "管理后台登录" : "Admin Login"}

-

- {zh ? "请输入管理员账号和密码" : "Enter your admin credentials"} -

-
- - setUsername(e.target.value)} - placeholder="admin" - autoComplete="username" - /> -
-
- - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - /> -
- {error && ( -
- {error} -
- )} - - - - {zh ? "返回前台" : "Back to Console"} - -
-
); } diff --git a/apps/rlark-ui/src/admin/AdminPage.tsx b/apps/rlark-ui/src/admin/AdminPage.tsx index 60d6524..762d495 100644 --- a/apps/rlark-ui/src/admin/AdminPage.tsx +++ b/apps/rlark-ui/src/admin/AdminPage.tsx @@ -23,7 +23,12 @@ import { getNodeGPUModel, getNodeLocation, } from "../utils/nodes"; -import { updateNodeModelMetadata } from "../utils/nodeBatchMetadata"; +import { + getRemovedLabelKeys, + NODE_CATEGORY_KEYS, + updateNodeCategoryLabels, + updateNodeModelMetadata, +} from "../utils/nodeBatchMetadata"; import { useAutoRefresh } from "../hooks"; import { MetricCard, StatusBadge } from "../components/shared"; import { NodeResourceBrowser } from "../components/NodeResourceBrowser"; @@ -34,6 +39,10 @@ const NODE_FREE_TEXT_KEYS = [ "rlark.io/gpu-model", "rlark.io/device-model", ] as const; +const NODE_MANAGED_LABEL_KEYS = new Set([ + ...NODE_FREE_TEXT_KEYS, + ...NODE_CATEGORY_KEYS, +]); type BatchMetadataField = "location" | "categories" | "gpu" | "device"; export function ClustersOverviewAdminPage({ copy: c }: { copy: Copy }) { @@ -458,21 +467,84 @@ export function NodeDetailPanel({
{isEditing ? (
-
- rlark.io/city - - ctx.onUpdateLabel("rlark.io/city", event.target.value) - } - placeholder={ - zh ? "所在城市,例如:上海市" : "City, e.g. Shanghai" - } - /> - +
+
+ +
+ {(["cloud", "edge", "robot"] as NodeCategory[]).map( + (category) => { + const key = `rlark.io/node-category-${category}`; + const selected = ctx.labelDraft[key] === "true"; + return ( + + ); + }, + )} +
+
+ {NODE_FREE_TEXT_KEYS.map((key) => ( +
+ + + ctx.onUpdateLabel(key, event.target.value) + } + placeholder={ + key === "rlark.io/city" + ? zh + ? "例如:上海市" + : "e.g. Shanghai" + : zh + ? "留空可移除" + : "Leave blank to remove" + } + /> +
+ ))} +
+
+
+ + {zh ? "扩展调度标签" : "Extended scheduling labels"} + + + {zh + ? "用于自定义亲和性、拓扑与业务调度约束" + : "Custom affinity, topology, and workload constraints"} + +
{Object.entries(ctx.labelDraft) - .filter(([key]) => key !== "rlark.io/city") + .filter(([key]) => !NODE_MANAGED_LABEL_KEYS.has(key)) .map(([key, value]) => (
{key} @@ -484,6 +556,7 @@ export function NodeDetailPanel({ placeholder="value" />
+
+
+ + + + {zh ? "全部节点" : "Total nodes"} + {nodes.length} +
+
+ + + + {zh ? "在线节点" : "Online"} + {nodeOverview.online} +
+
+ + + + {zh ? "允许调度" : "Schedulable"} + {nodeOverview.schedulable} +
+
+ + + + {zh ? "所属集群" : "Clusters"} + {nodeOverview.clusters} +
+
+ {error && (
{error} diff --git a/apps/rlark-ui/src/components/NodeResourceBrowser.tsx b/apps/rlark-ui/src/components/NodeResourceBrowser.tsx index b085749..ae33434 100644 --- a/apps/rlark-ui/src/components/NodeResourceBrowser.tsx +++ b/apps/rlark-ui/src/components/NodeResourceBrowser.tsx @@ -372,16 +372,6 @@ export function NodeResourceBrowser({
onSelectNode(node.metadata.name)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectNode(node.metadata.name); - } - }} - role="button" - tabIndex={0} - aria-label={`${zh ? "查看节点" : "View node"} ${node.metadata.name}`} > {selectable && ( @@ -400,14 +390,19 @@ export function NodeResourceBrowser({ /> )} - + {nodeCategories.map((item) => { const info = categoryLabels[item]; diff --git a/apps/rlark-ui/src/components/ResourcePlacementPicker.tsx b/apps/rlark-ui/src/components/ResourcePlacementPicker.tsx index b5ed00b..c0996ca 100644 --- a/apps/rlark-ui/src/components/ResourcePlacementPicker.tsx +++ b/apps/rlark-ui/src/components/ResourcePlacementPicker.tsx @@ -13,8 +13,13 @@ import { getNodeDeviceModel, getNodeGPUModel, getNodeLocation, + selectDeviceResourceKey, } from "../utils/nodes"; -import { selectorToStr } from "../utils/job"; +import { parseNodeSelectorStr, selectorToStr } from "../utils/job"; +import { + availableResource, + type ReclaimableResources, +} from "../utils/resourceAvailability"; type Mode = "model" | "manual"; type ResourceKind = "gpu" | "device" | "compute"; @@ -26,6 +31,7 @@ type NodeResource = { resourceKey: string; total: number; free: number; + configured: boolean; }; type ResourceOption = { @@ -53,53 +59,68 @@ const requestedResourceAmount = (gpu: string, device?: string) => { return Number.isFinite(parsed) ? Math.max(0, parsed) : 1; }; -function nodeResource(node: CRDNodeLite): NodeResource | null { +function nodeResources( + node: CRDNodeLite, + reclaimableResources: ReclaimableResources, +): NodeResource[] { const fullNode = node as Parameters[0]; const capacity = node.status?.capacity ?? node.status?.allocatable ?? {}; const allocatable = node.status?.allocatable ?? capacity; const used = node.status?.used ?? {}; + const nodeKey = `${node.metadata.namespace ?? ""}/${node.metadata.name}`; + const reclaimable = reclaimableResources[nodeKey] ?? {}; const gpuKey = getGPUResourceKey(fullNode); const gpuTotal = quantity(capacity[gpuKey] ?? allocatable[gpuKey]); + const resources: NodeResource[] = []; if (gpuTotal > 0) { - return { + resources.push({ node, kind: "gpu", model: getNodeGPUModel(fullNode) || "未标注 GPU", resourceKey: gpuKey, total: gpuTotal, - free: Math.max(0, quantity(allocatable[gpuKey]) - quantity(used[gpuKey])), - }; + free: availableResource( + allocatable[gpuKey], + used[gpuKey], + reclaimable[gpuKey], + ), + configured: true, + }); } - const deviceKey = Array.from( - new Set([...Object.keys(capacity), ...Object.keys(allocatable)]), - ).find( - (key) => key === "rlinf.io/device" || key.startsWith("rlinf.io/device-"), - ); - if (!deviceKey) { - return { + const deviceKey = selectDeviceResourceKey(capacity, allocatable); + const deviceTotal = deviceKey + ? quantity(capacity[deviceKey] ?? allocatable[deviceKey]) + : 0; + if (deviceKey && deviceTotal > 0) { + const deviceModel = getNodeDeviceModel(fullNode); + resources.push({ node, - kind: "compute", - model: "CPU", + kind: "device", + model: deviceModel || "__unset_device_model__", + resourceKey: deviceModel ? deviceKey : "", + total: deviceModel ? deviceTotal : 0, + free: deviceModel + ? availableResource( + allocatable[deviceKey], + used[deviceKey], + reclaimable[deviceKey], + ) + : 0, + configured: Boolean(deviceModel), + }); + } else if (resources.length === 0) { + resources.push({ + node, + kind: "device", + model: "__undiscovered_device__", resourceKey: "", - total: 1, - free: - node.status?.phase !== "Offline" && !node.spec?.unschedulable ? 1 : 0, - }; + total: 0, + free: 0, + configured: false, + }); } - const deviceTotal = quantity(capacity[deviceKey] ?? allocatable[deviceKey]); - if (deviceTotal <= 0) return null; - return { - node, - kind: "device", - model: getNodeDeviceModel(fullNode) || deviceKey, - resourceKey: deviceKey, - total: deviceTotal, - free: Math.max( - 0, - quantity(allocatable[deviceKey]) - quantity(used[deviceKey]), - ), - }; + return resources; } export function ResourcePlacementPicker({ @@ -107,15 +128,22 @@ export function ResourcePlacementPicker({ cluster, nodes, loading, + reclaimableResources = {}, + nodeSelector, + placementMode, replicas, gpu, devices, onChange, + onPlacementModeChange, }: { zh: boolean; cluster: string; nodes: CRDNodeLite[]; loading: boolean; + reclaimableResources?: ReclaimableResources; + nodeSelector: string; + placementMode?: Mode; replicas: number; gpu: string; devices: Array<{ name: string; quantity: string }>; @@ -125,14 +153,18 @@ export function ResourcePlacementPicker({ gpu: string; devices: Array<{ name: string; quantity: string }>; }) => void; + onPlacementModeChange?: (mode: Mode) => void; }) { const pickerId = useId(); - const [mode, setMode] = useState("model"); + const [mode, setMode] = useState(placementMode ?? "manual"); const [selectedOptionId, setSelectedOptionId] = useState(""); const [resourceAmount, setResourceAmount] = useState(() => requestedResourceAmount(gpu, devices[0]?.quantity), ); const [selected, setSelected] = useState>(new Set()); + const initializedRef = useRef(false); + const skipInitialCommitRef = useRef(false); + const previousClusterRef = useRef(cluster); const gridRef = useRef(null); const dragStartRef = useRef<{ x: number; @@ -158,9 +190,8 @@ export function ResourcePlacementPicker({ () => nodes .filter((node) => node.metadata.namespace === cluster) - .map(nodeResource) - .filter((item): item is NodeResource => item !== null), - [cluster, nodes], + .flatMap((node) => nodeResources(node, reclaimableResources)), + [cluster, nodes, reclaimableResources], ); const resourceOptions = useMemo(() => { const groups = new Map(); @@ -184,7 +215,9 @@ export function ResourcePlacementPicker({ .reduce((sum, resource) => sum + resource.free, 0), totalNodes: groupedResources.length, availableNodes: groupedResources.filter( - (resource) => isSchedulable(resource) && resource.free > 0, + (resource) => + isSchedulable(resource) && + (!resource.configured || resource.free > 0), ).length, })).sort((left, right) => { const order: Record = { @@ -198,16 +231,97 @@ export function ResourcePlacementPicker({ }, [resources]); useEffect(() => { - if (!resourceOptions.some((option) => option.id === selectedOptionId)) { - setSelectedOptionId(resourceOptions[0]?.id ?? ""); + if (previousClusterRef.current === cluster) return; + previousClusterRef.current = cluster; + initializedRef.current = false; + setSelectedOptionId(""); + setSelected(new Set()); + }, [cluster]); + + useEffect(() => { + if (initializedRef.current || resourceOptions.length === 0) return; + + const selector = parseNodeSelectorStr(nodeSelector); + const selectedNames = new Set( + (selector["kubernetes.io/hostname"] ?? "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ); + const requestedDevice = devices[0]?.name; + const requestedGpu = quantity(gpu) > 0; + const matchingOption = + resourceOptions.find( + (option) => + requestedDevice && + option.kind === "device" && + option.resources.some( + (resource) => resource.resourceKey === requestedDevice, + ), + ) ?? + resourceOptions.find((option) => requestedGpu && option.kind === "gpu") ?? + resourceOptions.find((option) => + option.resources.some((resource) => + selectedNames.has(resource.node.metadata.name), + ), + ) ?? + resourceOptions[0]; + + setSelectedOptionId(matchingOption.id); + if (selectedNames.size > 0) { + const matchingNames = matchingOption.resources + .filter( + (resource) => + isSchedulable(resource) && + (!resource.configured || resource.free >= resourceAmount), + ) + .map((resource) => resource.node.metadata.name); + const restoresAutomaticMode = + matchingNames.length > 0 && + matchingNames.every((name) => selectedNames.has(name)) && + selectedNames.size === matchingNames.length; + const restoredMode = + placementMode ?? (restoresAutomaticMode ? "model" : "manual"); + setMode(restoredMode); + onPlacementModeChange?.(restoredMode); + if (restoredMode === "manual") { + setSelected( + new Set(matchingNames.filter((name) => selectedNames.has(name))), + ); + } } - }, [resourceOptions, selectedOptionId]); + if (matchingOption.resources.every((resource) => !resource.configured)) { + setResourceAmount(0); + } + skipInitialCommitRef.current = true; + initializedRef.current = true; + }, [ + devices, + gpu, + nodeSelector, + placementMode, + resourceAmount, + resourceOptions, + ]); const selectedOption = resourceOptions.find( (option) => option.id === selectedOptionId, ); const kind = selectedOption?.kind ?? "compute"; const model = selectedOption?.model ?? ""; + const displayModel = + model === "__unset_device_model__" + ? zh + ? "未设置设备型号" + : "Device model not set" + : model === "__undiscovered_device__" + ? zh + ? "未发现设备" + : "No device discovered" + : model; + const isUnconfiguredDevice = + kind === "device" && + selectedOption?.resources.every((resource) => !resource.configured); const resourceUnit = kind === "gpu" ? zh @@ -231,14 +345,18 @@ export function ResourcePlacementPicker({ const requested = replicas * resourceAmount; const eligibleNames = candidates .filter( - (resource) => resource.free >= resourceAmount && isSchedulable(resource), + (resource) => + isSchedulable(resource) && + (!resource.configured || resource.free >= resourceAmount), ) .map((resource) => resource.node.metadata.name); const commitModel = (nextReplicas: number, nextPerWorker: number) => { const names = candidates .filter( - (resource) => resource.free >= nextPerWorker && isSchedulable(resource), + (resource) => + isSchedulable(resource) && + (!resource.configured || resource.free >= nextPerWorker), ) .map((resource) => resource.node.metadata.name); onChange({ @@ -248,7 +366,7 @@ export function ResourcePlacementPicker({ replicas: nextReplicas, gpu: kind === "gpu" ? String(nextPerWorker) : "0", devices: - kind === "device" && candidates[0] + kind === "device" && candidates[0]?.resourceKey ? [ { name: candidates[0].resourceKey, @@ -260,7 +378,11 @@ export function ResourcePlacementPicker({ }; useEffect(() => { - if (!model) return; + if (!initializedRef.current || !model) return; + if (skipInitialCommitRef.current) { + skipInitialCommitRef.current = false; + return; + } if (mode === "model") { commitModel(Math.max(1, replicas), resourceAmount); } else { @@ -269,7 +391,7 @@ export function ResourcePlacementPicker({ replicas: 0, gpu: kind === "gpu" ? String(resourceAmount) : "0", devices: - kind === "device" && candidates[0] + kind === "device" && candidates[0]?.resourceKey ? [ { name: candidates[0].resourceKey, @@ -283,11 +405,14 @@ export function ResourcePlacementPicker({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cluster, selectedOptionId]); - useEffect(() => { - setSelected(new Set()); - }, [cluster, selectedOptionId]); - const chooseResourceOption = (optionId: string) => { + const option = resourceOptions.find((item) => item.id === optionId); + const nextAmount = option?.resources.every( + (resource) => !resource.configured, + ) + ? 0 + : Math.max(1, resourceAmount); + setResourceAmount(nextAmount); setSelectedOptionId(optionId); setSelected(new Set()); }; @@ -448,17 +573,31 @@ export function ResourcePlacementPicker({ /> - {option.kind === "gpu" - ? "GPU" - : option.kind === "device" - ? zh - ? "端侧设备" - : "Edge device" - : zh - ? "通用计算" - : "General compute"} + {option.model === "__undiscovered_device__" + ? zh + ? "未分类" + : "Unclassified" + : option.kind === "gpu" + ? "GPU" + : option.kind === "device" + ? zh + ? "端侧设备" + : "Edge device" + : zh + ? "通用计算" + : "General compute"} - {option.model} + + {option.model === "__undiscovered_device__" + ? zh + ? "未发现设备" + : "No device discovered" + : option.model === "__unset_device_model__" + ? zh + ? "未设置设备型号" + : "Device model not set" + : option.model} + {option.freeDevices} @@ -485,6 +624,7 @@ export function ResourcePlacementPicker({ type="number" min={0} value={resourceAmount} + disabled={isUnconfiguredDevice} onChange={(event) => { const nextAmount = Math.max(0, Number(event.target.value)); setResourceAmount(nextAmount); @@ -497,7 +637,7 @@ export function ResourcePlacementPicker({ replicas: 0, gpu: kind === "gpu" ? String(nextAmount) : "0", devices: - kind === "device" && candidates[0] + kind === "device" && candidates[0]?.resourceKey ? [ { name: candidates[0].resourceKey, @@ -527,6 +667,7 @@ export function ResourcePlacementPicker({ checked={mode === "model"} onChange={() => { setMode("model"); + onPlacementModeChange?.("model"); commitModel( Math.max(1, replicas || selected.size), resourceAmount, @@ -542,13 +683,14 @@ export function ResourcePlacementPicker({ checked={mode === "manual"} onChange={() => { setMode("manual"); + onPlacementModeChange?.("manual"); setSelected(new Set()); onChange({ nodeSelector: "", replicas: 0, gpu: kind === "gpu" ? String(resourceAmount) : "0", devices: - kind === "device" && candidates[0] + kind === "device" && candidates[0]?.resourceKey ? [ { name: candidates[0].resourceKey, @@ -613,8 +755,16 @@ export function ResourcePlacementPicker({

{zh - ? `仅显示空闲数量不少于 ${resourceAmount} ${resourceUnit}${kind === "gpu" ? " " : ""}的节点。点击单选,拖拽可批量选择或取消。` - : `Showing nodes with at least ${resourceAmount} ${resourceUnit} available. Click or drag to select and deselect.`} + ? isUnconfiguredDevice + ? model === "__unset_device_model__" + ? "显示有设备资源但未设置设备型号的节点,不申请设备资源。点击单选,拖拽可批量选择或取消。" + : "显示尚未发现 GPU 或端侧设备资源的未分类节点。点击单选,拖拽可批量选择或取消。" + : `仅显示空闲数量不少于 ${resourceAmount} ${resourceUnit}${kind === "gpu" ? " " : ""}的节点。点击单选,拖拽可批量选择或取消。` + : isUnconfiguredDevice + ? model === "__unset_device_model__" + ? "Showing nodes with device resources but no configured device model; no device resource is requested. Click or drag to select." + : "Showing unclassified nodes without discovered GPU or edge-device resources. Click or drag to select." + : `Showing nodes with at least ${resourceAmount} ${resourceUnit} available. Click or drag to select and deselect.`}

)} {candidates - .filter((resource) => resource.free >= resourceAmount) + .filter( + (resource) => + !resource.configured || resource.free >= resourceAmount, + ) .map((resource) => { const name = resource.node.metadata.name; const offline = resource.node.status?.phase === "Offline"; @@ -661,30 +814,40 @@ export function ResourcePlacementPicker({ data-node-name={name} disabled={disabled} className={`placement-node-card${active ? " active" : ""}${disabled ? " unavailable" : ""}`} + title={name} >
{name} - {statusText}
- - {location} - +
+ + {location} + + + {statusText} + {disabled && } + + {active && } + + +
- {resource.model} + + {resource.model === "__unset_device_model__" + ? zh + ? "未设置设备型号" + : "Device model not set" + : resource.configured + ? resource.model + : zh + ? "未分类 · 未发现设备" + : "Unclassified · No device discovered"} + {resource.free} / {resource.total} {zh ? " 空闲" : " free"}
- - {active && } - - {disabled && ( - - )} ); })} @@ -694,28 +857,44 @@ export function ResourcePlacementPicker({
- {kind === "compute" + {isUnconfiguredDevice ? mode === "model" ? zh - ? `已配置 ${Math.max(1, replicas)} 个 Worker` - : `${Math.max(1, replicas)} workers configured` - : zh - ? `已选择 ${selected.size} 个节点,将创建 ${selected.size} 个 Worker` - : `${selected.size} nodes selected, ${selected.size} workers` - : mode === "model" - ? zh - ? `已配置 ${Math.max(1, replicas)} 个 Worker,共申请 ${requested} 个${kind === "gpu" ? " GPU" : "设备"}` - : `${Math.max(1, replicas)} workers configured, ${requested} resources requested` + ? `已配置 ${Math.max(1, replicas)} 个 Worker,调度到${model === "__unset_device_model__" ? "未设置设备型号" : "未发现设备"}的节点` + : `${Math.max(1, replicas)} workers configured for nodes ${model === "__unset_device_model__" ? "without a configured device model" : "without discovered devices"}` : zh - ? `已选择 ${selected.size} 个节点,将创建 ${selected.size} 个 Worker,共申请 ${selected.size * resourceAmount} 个${kind === "gpu" ? " GPU" : "设备"}` - : `${selected.size} nodes selected, ${selected.size} workers, ${selected.size * resourceAmount} resources requested`} + ? `已选择 ${selected.size} 个${model === "__unset_device_model__" ? "未设置设备型号" : "未发现设备"}节点,将创建 ${selected.size} 个 Worker` + : `${selected.size} nodes ${model === "__unset_device_model__" ? "without a configured device model" : "without discovered devices"} selected, ${selected.size} workers` + : kind === "compute" + ? mode === "model" + ? zh + ? `已配置 ${Math.max(1, replicas)} 个 Worker` + : `${Math.max(1, replicas)} workers configured` + : zh + ? `已选择 ${selected.size} 个节点,将创建 ${selected.size} 个 Worker` + : `${selected.size} nodes selected, ${selected.size} workers` + : mode === "model" + ? zh + ? `已配置 ${Math.max(1, replicas)} 个 Worker,共申请 ${requested} 个${kind === "gpu" ? " GPU" : "设备"}` + : `${Math.max(1, replicas)} workers configured, ${requested} resources requested` + : zh + ? `已选择 ${selected.size} 个节点,将创建 ${selected.size} 个 Worker,共申请 ${selected.size * resourceAmount} 个${kind === "gpu" ? " GPU" : "设备"}` + : `${selected.size} nodes selected, ${selected.size} workers, ${selected.size * resourceAmount} resources requested`} - {kind === "compute" + {isUnconfiguredDevice ? zh - ? `通用计算 · ${model}` - : `General compute · ${model}` - : `${zh ? "单 Worker" : "Per worker"}:${resourceAmount} × ${model}`} + ? model === "__unset_device_model__" + ? "仅按未设置设备型号的节点调度,不申请设备资源" + : "仅按未分类节点调度,不申请未发现的设备资源" + : model === "__unset_device_model__" + ? "Schedules nodes without a configured device model and does not request device resources" + : "Schedules unclassified nodes without requesting undiscovered device resources" + : kind === "compute" + ? zh + ? `通用计算 · ${model}` + : `General compute · ${model}` + : `${zh ? "单 Worker" : "Per worker"}:${resourceAmount} × ${displayModel}`}
{mode === "model" ? ( diff --git a/apps/rlark-ui/src/components/shared.tsx b/apps/rlark-ui/src/components/shared.tsx index 7004af4..620a5ec 100644 --- a/apps/rlark-ui/src/components/shared.tsx +++ b/apps/rlark-ui/src/components/shared.tsx @@ -18,8 +18,8 @@ import { LoaderCircle, X, } from "lucide-react"; -import type { Phase } from "../data"; import type { Copy, Lang, Theme } from "../i18n"; +import type { JobDisplayPhase } from "../utils/jobPhase"; import type { ResourceRow } from "../types"; export function PlatformFooter() { @@ -128,9 +128,15 @@ export function Logo({ lang }: { lang: Lang }) { ); } -export function StatusBadge({ phase, copy: c }: { phase: Phase; copy: Copy }) { +export function StatusBadge({ + phase, + copy: c, +}: { + phase: JobDisplayPhase; + copy: Copy; +}) { const Icon = - phase === "Running" + phase === "Running" || phase === "Stopping" ? LoaderCircle : phase === "Succeeded" || phase === "Online" ? Check diff --git a/apps/rlark-ui/src/data.ts b/apps/rlark-ui/src/data.ts index c827dce..c1541c1 100644 --- a/apps/rlark-ui/src/data.ts +++ b/apps/rlark-ui/src/data.ts @@ -66,12 +66,17 @@ export interface Worker { jobId: string; role: string; node: string; + cluster?: string; phase: Phase; cpu: string; memory: string; gpu?: string; webTerminalUrl?: string; logs: string[]; + // 任务状态消息(如 CrashLoopBackOff / ImagePullBackOff 等异常原因), + // 来源于 Task.status.message 或 Pod.status.message,仅在 worker 处于 + // Failed 等异常状态时填充,供状态徽标 "i" tooltip 展示。 + statusMessage?: string; pullProgress?: PullProgressEntry[]; // 节点级 Warning 事件(DiskPressure 等),来源于 Node.status.events 或 // Task.status.events 聚合,仅在 worker 处于 Pending 时填充,供状态 diff --git a/apps/rlark-ui/src/i18n.ts b/apps/rlark-ui/src/i18n.ts index 62a9972..082c895 100644 --- a/apps/rlark-ui/src/i18n.ts +++ b/apps/rlark-ui/src/i18n.ts @@ -45,6 +45,7 @@ export const copy = { status: { Running: "运行中", Pending: "等待中", + Stopping: "停止中", Succeeded: "成功", Failed: "失败", Stopped: "已停止", @@ -237,6 +238,7 @@ export const copy = { status: { Running: "Running", Pending: "Pending", + Stopping: "Stopping", Succeeded: "Succeeded", Failed: "Failed", Stopped: "Stopped", diff --git a/apps/rlark-ui/src/pages/Clusters.tsx b/apps/rlark-ui/src/pages/Clusters.tsx index d189fa8..0db5325 100644 --- a/apps/rlark-ui/src/pages/Clusters.tsx +++ b/apps/rlark-ui/src/pages/Clusters.tsx @@ -59,6 +59,8 @@ type NodeWorker = { job: string; role: string; node: string; + cluster: string; + domain: string; ip: string; phase: string; requests: Record; @@ -234,7 +236,7 @@ export function ClustersPage({ } const selectedNodeObj = - workerNodes.find((n) => n.metadata.name === selectedNodeName) ?? null; + realNodes.find((n) => n.metadata.name === selectedNodeName) ?? null; if (selectedNodeName && selectedNodeObj) { return ( @@ -553,7 +555,7 @@ export function NodeDetailReal({ .map( (pod: { metadata?: { name?: string; namespace?: string }; - spec?: { taskName?: string; podName?: string }; + spec?: { taskName?: string; podName?: string; domain?: string }; status?: { phase?: string; ip?: string }; }) => { const task = tasks.get(pod.spec?.taskName ?? "") as @@ -586,6 +588,8 @@ export function NodeDetailReal({ job, role: task?.spec?.role ?? "—", node: node.metadata.name, + cluster: pod.metadata?.namespace ?? "default", + domain: pod.spec?.domain ?? "", ip: pod.status?.ip ?? "—", phase: pod.status?.phase ?? "Pending", requests: @@ -1203,7 +1207,6 @@ function NodeWorkerTable({
); } - const sshUser = sessionStorage.getItem("rlark-user-name") || ""; return (
{ const requestText = requestTextFor(worker); const sshJump = sshConfig.sshJumpHost - ? `${sshUser}@${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? `:${sshConfig.sshJumpPort}` : ""}` + ? `${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? `:${sshConfig.sshJumpPort}` : ""}` : ""; const sshCommand = sshJump ? `ssh -J ${sshJump} root@${worker.name}` @@ -1368,6 +1371,10 @@ function NodeWorkerTable({ )}
+
+ {zh ? "集群" : "Cluster"} + {worker.cluster} +
{zh ? "角色" : "Role"} {worker.role} @@ -1377,23 +1384,7 @@ function NodeWorkerTable({ {worker.node}
- {zh ? "申请 CPU" : "CPU request"} - - {worker.requests.cpu || - (zh ? "未申请" : "Not requested")} - -
-
- {zh ? "申请内存" : "Memory request"} - - {worker.requests.memory || - (zh ? "未申请" : "Not requested")} - -
-
- - {zh ? "GPU / 端设备" : "GPU / edge devices"} - + {zh ? "申请 GPU" : "GPU request"} {requestText || (zh ? "未使用" : "Not used")} @@ -1406,6 +1397,7 @@ function NodeWorkerTable({ {zh ? "实例名称" : "Worker name"} Job {zh ? "实例 IP" : "Worker IP"} + {zh ? "网络域" : "Domain"} {zh ? "状态" : "Status"} @@ -1418,6 +1410,19 @@ function NodeWorkerTable({ {worker.job} {worker.ip} + + {worker.domain ? ( + <> + + {worker.domain} + + ) : ( + "—" + )} + void; + onSuccess: (message: string) => void; copy: Copy; cloneJob?: Job | null; editJob?: Job | null; @@ -198,6 +205,8 @@ export function CreateJobModal({ >([]); const [sshKeysLoaded, setSShKeysLoaded] = useState(false); const [domains, setDomains] = useState<{ name: string; cidr: string }[]>([]); + const [reclaimableResources, setReclaimableResources] = + useState({}); const { clusterDisplayNames, nodes: allNodes, @@ -231,6 +240,21 @@ export function CreateJobModal({ .catch(() => {}); }, []); + 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[]), + ), + ) + .catch(() => setReclaimableResources({})); + }, [editJob, restartAfterSave]); + useEffect(() => { fetch("/api/v1/ssh-user-keys") .then((r) => @@ -373,6 +397,21 @@ export function CreateJobModal({ const [roleResources, setRoleResources] = useState< Record >(sourceJob ? cloneRR : defaultRoleResources); + const [placementModes, setPlacementModes] = useState< + Record + >(() => + sourceJob + ? Object.fromEntries( + sourceJob.resources + .filter( + (resource) => + Object.keys(parseNodeSelectorStr(resource.nodeSelector)) + .length === 0, + ) + .map((resource) => [resource.role, "model" as const]), + ) + : {}, + ); const [activeRoleTab, setActiveRoleTab] = useState(roles[0] ?? ""); useEffect(() => { @@ -700,8 +739,13 @@ export function CreateJobModal({ const allocatable = Number( node.status?.allocatable?.[resourceRequest.key] ?? 0, ); - const used = Number(node.status?.used?.[resourceRequest.key] ?? 0); - return Math.max(0, allocatable - used); + const used = node.status?.used?.[resourceRequest.key]; + const nodeKey = `${node.metadata.namespace ?? ""}/${node.metadata.name}`; + return availableResource( + String(allocatable), + used, + reclaimableResources[nodeKey]?.[resourceRequest.key], + ); }); const available = freeByNode.reduce((sum, value) => sum + value, 0); const requested = resource.replicas * resourceRequest.amount; @@ -794,7 +838,19 @@ export function CreateJobModal({ const body = await resp.text(); throw new Error(`HTTP ${resp.status}: ${body}`); } - onClose(); + onSuccess( + isEdit + ? restartAfterSave + ? zh + ? "任务已保存并提交重启" + : "Job saved and restart submitted" + : zh + ? "任务修改成功" + : "Job updated successfully" + : zh + ? "任务提交成功" + : "Job submitted successfully", + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setErrorNonce((n) => n + 1); @@ -1032,6 +1088,9 @@ export function CreateJobModal({ cluster={rr.cluster} nodes={allNodes} loading={nodesLoading} + reclaimableResources={reclaimableResources} + nodeSelector={rr.nodeSelector} + placementMode={placementModes[role]} replicas={rr.replicas} gpu={rr.gpu} devices={rr.devices ?? []} @@ -1041,6 +1100,12 @@ export function CreateJobModal({ [role]: { ...previous[role], ...placement }, })) } + onPlacementModeChange={(placementMode) => + setPlacementModes((previous) => ({ + ...previous, + [role]: placementMode, + })) + } />
@@ -1449,20 +1514,34 @@ export function CreateJobModal({ )} {step < 4 ? ( - + (() => { + const currentRole = activeRoleTab || roles[0]; + const isLastRole = currentRole === roles[roles.length - 1]; + const showNextRole = + step === 2 && roles.length > 1 && !isLastRole; + return ( + + ); + })() ) : ( +
+ )}
@@ -624,11 +778,19 @@ export function JobsPage({ job.phase === "Pending" ? aggregateJobEvents(job, nodeEventsMap) : []; + const jobFailedMessage = + effectiveJobPhase(job) === "Failed" + ? job.taskStatuses + .filter((ts) => ts.phase === "Failed" && ts.message) + .map((ts) => ts.message) + .join("\n") + : undefined; return (
); } @@ -751,7 +930,8 @@ function JobActionMenu({ pending, onClone, onDelete, - onToggleStop, + onStart, + onStop, onRestart, }: { job: Job; @@ -759,7 +939,8 @@ function JobActionMenu({ pending: boolean; onClone: () => void; onDelete: () => void; - onToggleStop: () => void; + onStart: () => void; + onStop: () => void; onRestart: () => void; }) { const [open, setOpen] = useState(false); @@ -823,8 +1004,24 @@ function JobActionMenu({ }; }, [open]); - const isStopped = job.stopped || job.phase === "Stopped"; - const isTerminal = ["Succeeded", "Failed"].includes(job.phase); + const isStartable = + job.stopped || job.phase === "Stopped" || job.phase === "Failed"; + const isSucceeded = job.phase === "Succeeded"; + const lifecycleLabel = isSucceeded + ? zh + ? "已成功完成的任务不能再次启动" + : "Succeeded jobs cannot be started again" + : job.phase === "Failed" + ? zh + ? "清理残留 Worker 后启动" + : "Clean residual workers, then start" + : isStartable + ? zh + ? "启动任务" + : "Start job" + : zh + ? "停止任务" + : "Stop job"; const handleToggle = () => { setOpen((v) => !v); @@ -832,68 +1029,41 @@ function JobActionMenu({ return (
- + + + + {open && ( <>
- - +
+
+

{content.description}

+
+ {zh ? "目标任务" : "Target job"} + {job.name} +
+ {error && ( +
+ {error} +
+ )} +
+
+ + +
+ +
+ ); +} + function DeleteJobDialog({ job, zh, @@ -1060,8 +1364,8 @@ function DeleteJobDialog({ )} {pending ? zh - ? "删除中…" - : "Deleting…" + ? "正在停止并清理…" + : "Stopping and cleaning up…" : zh ? "确认删除" : "Delete job"} @@ -1175,6 +1479,8 @@ export function JobDetailPage({ onBack, onClone, lifecycleActions, + onSelectNode, + onSelectCluster, nodePullProgressMap = {}, nodeEventsMap = {}, nodeDeviceModelMap = {}, @@ -1185,6 +1491,8 @@ export function JobDetailPage({ onBack: () => void; onClone?: () => void; lifecycleActions: JobLifecycleActions; + onSelectNode?: (name: string) => void; + onSelectCluster?: (id: string) => void; // Per-node pullProgress cache shared from JobsPage; used by the top // StatusBadge hover to surface image pull progress while the job is Pending. nodePullProgressMap?: Record; @@ -1201,6 +1509,7 @@ export function JobDetailPage({ "workers", ); const [taskNodes, setTaskNodes] = useState>({}); + const [taskClusters, setTaskClusters] = useState>({}); const [tensorBoardProxy, setTensorBoardProxy] = useState(""); const [pullProgressMap, setPullProgressMap] = useState< Record @@ -1229,7 +1538,15 @@ export function JobDetailPage({ const [workerRoleFilter, setWorkerRoleFilter] = useState("All"); const [workerPage, setWorkerPage] = useState(1); const [workerSort, setWorkerSort] = useState<{ - key: "name" | "role" | "node" | "kind" | "ip" | "createdAt" | "phase"; + key: + | "name" + | "role" + | "cluster" + | "node" + | "kind" + | "ip" + | "createdAt" + | "phase"; direction: SortDirection; }>({ key: "name", direction: "asc" }); const workerTableRef = useRef(null); @@ -1266,6 +1583,7 @@ export function JobDetailPage({ const data = await resp.json(); const items = data.items ?? []; const nodeMap: Record = {}; + const clusterMap: Record = {}; const progressMap: Record = {}; const taskEventsMap: Record = {}; let tbProxy = ""; @@ -1273,6 +1591,7 @@ export function JobDetailPage({ const taskName = item.metadata?.name ?? ""; const observedNodes = item.status?.observedNodes ?? []; nodeMap[taskName] = observedNodes.join(", ") || "—"; + clusterMap[taskName] = item.metadata?.namespace ?? "—"; if (item.status?.tensorBoardProxy) { tbProxy = item.status.tensorBoardProxy; } @@ -1291,6 +1610,7 @@ export function JobDetailPage({ } } setTaskNodes(nodeMap); + setTaskClusters(clusterMap); setTensorBoardProxy(tbProxy); setPullProgressMap(progressMap); setTaskEventsMap(taskEventsMap); @@ -1456,6 +1776,7 @@ export function JobDetailPage({ taskNodes[ts.name] ?? ts.observedNodes?.join(", ") ?? "—", + cluster: taskClusters[jobChildName] ?? taskClusters[ts.name] ?? "—", phase: (ts.phase || "Pending") as Phase, cpu: job.resources.find((item) => item.role === ts.name)?.cpu ?? "", memory: @@ -1467,6 +1788,7 @@ export function JobDetailPage({ `${ts.name}: worker state synced`, `${ts.name}: waiting for runtime heartbeat`, ], + statusMessage: ts.message || undefined, pullProgress: pullProgressMap[jobChildName] ?? pullProgressMap[ts.name.toLowerCase()] ?? @@ -1512,6 +1834,7 @@ export function JobDetailPage({ jobId: job.id, role, node: pod.node || "—", + cluster: pod.taskNamespace || pod.namespace || "—", phase: (pod.phase || "Pending") as Phase, cpu: resource?.cpu ?? "", memory: resource?.memory ?? "", @@ -1522,6 +1845,7 @@ export function JobDetailPage({ `${role}: worker state synced`, `${role}: waiting for runtime heartbeat`, ], + statusMessage: pod.message || undefined, pullProgress: nodePullProgress, events: workerEvents, }; @@ -1567,6 +1891,8 @@ export function JobDetailPage({ return worker.name; case "role": return worker.role; + case "cluster": + return worker.cluster ?? ""; case "node": return worker.node; case "kind": @@ -1667,6 +1993,13 @@ export function JobDetailPage({ { id: "logs", label: c.common.logs }, { id: "metrics", label: zh ? "监控" : "Metrics" }, ]; + const jobFailedMessage = + displayPhase === "Failed" + ? job.taskStatuses + .filter((ts) => ts.phase === "Failed" && ts.message) + .map((ts) => ts.message) + .join("\n") + : undefined; return (
{tabs.map((tab) => ( @@ -1749,6 +2083,7 @@ export function JobDetailPage({ [ ["name", zh ? "实例名称" : "Worker name"], ["role", zh ? "角色" : "Role"], + ["cluster", zh ? "集群" : "Cluster"], ["node", zh ? "节点" : "Node"], ["kind", zh ? "节点类型" : "Node type"], ["ip", zh ? "实例 IP" : "Worker IP"], @@ -1782,6 +2117,12 @@ export function JobDetailPage({ worker.name === job.headerWorker } createdAt={formatWorkerCreatedAt(job.startedAt, index)} + onSelectNode={onSelectNode} + onSelectCluster={onSelectCluster} + podEventsMap={podEventsMap} + nodeEventsMap={nodeEventsMap} + nodePullProgressMap={nodePullProgressMap} + taskEventsMap={taskEventsMap} /> ))} @@ -1986,13 +2327,14 @@ function JobPublicOverview({ lifecycleActions, jobPullProgress = [], jobEvents = [], + jobFailedMessage, }: { job: Job; copy: CopyType; onBack: () => void; runningWorkerCount: number; totalWorkers: number; - displayPhase: Phase; + displayPhase: JobDisplayPhase; tensorBoardProxy?: string; onClone?: () => void; lifecycleActions: JobLifecycleActions; @@ -2001,6 +2343,9 @@ function JobPublicOverview({ jobPullProgress?: PullProgressEntry[]; // 节点级 Warning 事件聚合,在 Pending 时与 pullProgress 一并展示。 jobEvents?: NodeEventEntry[]; + // Failed 状态下聚合的异常原因(CrashLoopBackOff / ImagePullBackOff 等), + // 供状态徽标 "i" tooltip 展示。 + jobFailedMessage?: string; }) { const zh = c.nav.overview === "总览"; const baseConfigRows = [ @@ -2029,11 +2374,14 @@ function JobPublicOverview({
- {(jobPullProgress.length > 0 || jobEvents.length > 0) && ( + {(jobPullProgress.length > 0 || + jobEvents.length > 0 || + jobFailedMessage) && ( )}
@@ -2947,11 +3295,13 @@ function PullProgressInfo({ events = [], zh, emptyMessage, + statusMessage, }: { progress: PullProgressEntry[]; events?: NodeEventEntry[]; zh: boolean; emptyMessage?: string; + statusMessage?: string; }) { const wrapperRef = useRef(null); const tooltipRef = useRef(null); @@ -2962,6 +3312,13 @@ function PullProgressInfo({ above: boolean; arrowLeft: number; } | null>(null); + const recentEvents = [...events] + .sort((left, right) => { + const leftTime = Date.parse(left.lastTime ?? "") || 0; + const rightTime = Date.parse(right.lastTime ?? "") || 0; + return rightTime - leftTime; + }) + .slice(0, 4); const measure = () => { const icon = wrapperRef.current; @@ -3023,7 +3380,7 @@ function PullProgressInfo({ }; frame = window.requestAnimationFrame(track); return () => window.cancelAnimationFrame(frame); - }, [open, progress, events, emptyMessage]); + }, [open, progress, events, emptyMessage, statusMessage]); const tooltipStyle: CSSProperties = pos ? { @@ -3066,10 +3423,21 @@ function PullProgressInfo({ aria-hidden="true" style={{ left: pos ? pos.arrowLeft - 6 : 0 }} /> - {progress.length === 0 && events.length === 0 && emptyMessage && ( + {progress.length === 0 && + events.length === 0 && + !statusMessage && + emptyMessage && ( + <> + {zh ? "Worker 等待中" : "Worker pending"} + {emptyMessage} + + )} + {statusMessage && ( <> - {zh ? "Worker 等待中" : "Worker pending"} - {emptyMessage} + {zh ? "异常原因" : "Failure Reason"} + + {statusMessage} + )} {progress.length > 0 && ( @@ -3107,12 +3475,19 @@ function PullProgressInfo({ })} )} - {events.length > 0 && ( + {recentEvents.length > 0 && ( <> {zh ? "Worker 事件" : "Worker Events"} + {events.length > recentEvents.length && ( + + {zh + ? `最近 ${recentEvents.length} 条` + : `Latest ${recentEvents.length}`} + + )} - {events.map((ev, i) => ( + {recentEvents.map((ev, i) => ( ; isHeader?: boolean; createdAt: string; + onSelectNode?: (name: string) => void; + onSelectCluster?: (id: string) => void; + podEventsMap: Record; + nodeEventsMap: Record; + nodePullProgressMap: Record; + taskEventsMap: Record; }) { const zh = c.nav.overview === "总览"; const [copied, setCopied] = useState(false); @@ -3173,9 +3560,8 @@ function WorkerTableRow({ }) .catch(() => {}); }, []); - const sshUser = sessionStorage.getItem("rlark-user-name") || ""; const sshJump = sshConfig?.sshJumpHost - ? `${sshUser}@${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? ":" + sshConfig.sshJumpPort : ""}` + ? `${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? ":" + sshConfig.sshJumpPort : ""}` : ""; const sshCommand = sshJump ? `ssh -J ${sshJump} root@${worker.name}` : ""; const handleCopy = async () => { @@ -3192,8 +3578,10 @@ function WorkerTableRow({ - - {worker.name} + + + {worker.name} + {isHeader ? "Header Worker" @@ -3213,7 +3601,13 @@ function WorkerTableRow({ {worker.role} - {worker.node} + + + + {getNodeKindLabel(worker)} @@ -3229,12 +3623,16 @@ function WorkerTableRow({ {worker.phase !== "Running" && (worker.phase === "Pending" || + worker.phase === "Failed" || (worker.pullProgress && worker.pullProgress.length > 0) || (worker.events && worker.events.length > 0)) && ( -
+ {sshCommand && ( +
+ + {sshCommand} + +
+ )}
+
+ {zh ? "集群" : "Cluster"} + +
{zh ? "角色" : "Role"} {worker.role}
{zh ? "节点" : "Node"} - {worker.node} -
-
- {zh ? "申请 CPU" : "CPU request"} - - {worker.cpu || (zh ? "未申请" : "Not requested")} - -
-
- {zh ? "申请内存" : "Memory request"} - - {worker.memory || (zh ? "未申请" : "Not requested")} - +
{zh ? "申请 GPU" : "GPU request"} @@ -3411,12 +3818,36 @@ function WorkerTableRow({ domainIPMap[ `${pod.namespace}/${pod.podNamespace}/${pod.podName}` ] ?? ""; + const podPhase = (pod.phase || "Pending") as Phase; + // Mirror the worker-row tooltip aggregation so the pod + // subtable surfaces the same failure reason / image-pull + // progress / node events as the top-right status badge. + const podPullProgress = + podPhase === "Pending" && pod.node + ? (nodePullProgressMap[pod.node] ?? []) + : []; + const podEvents = + podPhase === "Pending" + ? (podEventsMap[pod.name] ?? []).length > 0 + ? (podEventsMap[pod.name] ?? []) + : pod.node && + (nodeEventsMap[pod.node] ?? []).length > 0 + ? (nodeEventsMap[pod.node] ?? []) + : (taskEventsMap[ + pod.taskName?.toLowerCase() ?? "" + ] ?? []) + : []; return ( {pod.podName} - {pod.node || "—"} + + + {pod.ip || "—"} {pod.domain ? ( @@ -3440,10 +3871,36 @@ function WorkerTableRow({ )} - +
+ + {podPhase !== "Running" && + (podPhase === "Pending" || + podPhase === "Failed" || + podPullProgress.length > 0 || + podEvents.length > 0) && ( + + )} +
); @@ -3463,3 +3920,55 @@ function WorkerTableRow({ ); } + +function WorkerClusterLink({ + cluster, + onSelectCluster, +}: { + cluster?: string; + onSelectCluster?: (id: string) => void; +}) { + if (!onSelectCluster || !cluster || cluster === "—") { + return ( + + {cluster || "—"} + + ); + } + return ( + + ); +} + +function WorkerNodeLink({ + node, + onSelectNode, +}: { + node: string; + onSelectNode?: (name: string) => void; +}) { + if (!onSelectNode || !node || node === "—" || node.includes(",")) { + return ( + + {node || "—"} + + ); + } + return ( + + ); +} diff --git a/apps/rlark-ui/src/pages/Login.tsx b/apps/rlark-ui/src/pages/Login.tsx index 4d6231c..68eac5c 100644 --- a/apps/rlark-ui/src/pages/Login.tsx +++ b/apps/rlark-ui/src/pages/Login.tsx @@ -1,5 +1,5 @@ -import { useState, type FormEvent } from "react"; -import { ArrowRight, Shield } from "lucide-react"; +import { useEffect, useState, type FormEvent } from "react"; +import { AlertCircle, ArrowRight, Eye, EyeOff } from "lucide-react"; export function UserLogin({ onLogin, @@ -8,9 +8,16 @@ export function UserLogin({ }) { const [username, setUsername] = useState("user"); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + useEffect(() => { + if (!error) return; + const timer = window.setTimeout(() => setError(""), 4000); + return () => window.clearTimeout(timer); + }, [error]); + const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (!username.trim() || !password.trim()) { @@ -46,50 +53,83 @@ export function UserLogin({ return (
+
+
-
-
- -
-

用户登录

-

请输入账号和密码

-
- - setUsername(e.target.value)} - placeholder="user" - autoComplete="username" - /> -
-
- - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - /> -
- {error && ( -
- {error} +
+ +
+ RLark 具身智能云原生纳管平台 +
+
+

用户登录

+

请输入账号和密码

+
+
+ + { + setUsername(e.target.value); + setError(""); + }} + placeholder="user" + autoComplete="username" + /> +
+
+ +
+ { + setPassword(e.target.value); + setError(""); + }} + placeholder="请输入密码" + autoComplete="current-password" + /> + +
- )} - - - - 管理后台 - - + {error && ( +
+ + {error} +
+ )} + + + + 管理后台 + + +
); diff --git a/apps/rlark-ui/src/pages/SSHKeys.tsx b/apps/rlark-ui/src/pages/SSHKeys.tsx index 366ef21..c247f61 100644 --- a/apps/rlark-ui/src/pages/SSHKeys.tsx +++ b/apps/rlark-ui/src/pages/SSHKeys.tsx @@ -133,8 +133,8 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) { .catch(() => {}); }, []); const sshCommand = sshConfig?.sshJumpHost - ? `ssh -J ${newUser}@${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? ":" + sshConfig.sshJumpPort : ""} root@` - : `ssh -J ${newUser}@:2222 root@`; + ? `ssh -J ${sshConfig.sshJumpHost}${sshConfig.sshJumpPort ? ":" + sshConfig.sshJumpPort : ""} root@` + : `ssh -J : root@`; const handleCopy = () => { navigator.clipboard.writeText(sshCommand); @@ -259,7 +259,7 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) { {zh ? "公钥信息" : "Key Info"}