@@ -216,22 +214,17 @@ export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) {
{a.status?.message || "-"}
-
+
setConfigInstalled(a)}
- style={{
- padding: "4px 10px",
- borderRadius: 6,
- border: "1px solid var(--line)",
- background: "transparent",
- cursor: "pointer",
- fontSize: 12,
- color: "var(--blue)",
- }}
>
{zh ? "配置" : "Config"}
{
const label = a.spec?.addonName || a.metadata?.name;
if (
@@ -242,28 +235,11 @@ export function AddonsPage({ copy: c, lang }: { copy: Copy; lang: Lang }) {
)
)
return;
- fetch(
- `/api/v1/clusters/${a.clusterId}/addons/${a.metadata?.name}`,
- {
- method: "DELETE",
- },
- )
- .then((r) => {
- if (!r.ok) throw new Error("Uninstall failed");
- return r.json();
- })
+ addonsApi
+ .remove(a.clusterId, a.metadata?.name)
.then(() => fetchInstalled())
.catch((e) => setError(e.message));
}}
- style={{
- padding: "4px 10px",
- borderRadius: 6,
- border: "1px solid var(--line)",
- background: "transparent",
- cursor: "pointer",
- fontSize: 12,
- color: "#ef4444",
- }}
>
{zh ? "卸载" : "Uninstall"}
@@ -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 (
{
setLoading(true);
setError("");
- fetch(`/api/v1/clusters/${clusterId}/addons/${addonName}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ addonsApi
+ .update(clusterId, addonName, {
addonName: addon.name,
version: addon.version,
values,
- }),
- })
- .then((r) => {
- if (!r.ok) throw new Error("Update failed");
- return r.json();
})
.then(() => {
setLoading(false);
@@ -655,7 +617,7 @@ export function AddonConfigPage({
return (
);
}
+import { addonsApi, clustersApi } from "../backend";
diff --git a/apps/rlark-ui/src/admin/AdminApp.tsx b/apps/rlark-ui/src/admin/AdminApp.tsx
index bf857c0..269c53e 100644
--- a/apps/rlark-ui/src/admin/AdminApp.tsx
+++ b/apps/rlark-ui/src/admin/AdminApp.tsx
@@ -6,14 +6,17 @@ import {
CloudCog,
Eye,
EyeOff,
- Settings,
} from "lucide-react";
import { type Copy, type Lang, type Theme, copy } from "../i18n";
import { adminNavItems } from "../constants";
import { ApiPage } from "../pages/Api";
import { DomainsPage } from "../pages/Domains";
import { ClusterManagementPage } from "../pages/ClusterManagement";
-import { StorageClassesPage, StorageClassCreatePage } from "../pages/Storage";
+import {
+ StorageClassesPage,
+ StorageClassCreatePage,
+ StorageClassFilesPage,
+} from "../pages/Storage";
import { CreateClusterPage } from "./CreateCluster";
import { AddonsPage } from "./Addons";
import { AdminPage } from "./AdminPage";
@@ -21,12 +24,17 @@ import { AdminDashboard } from "./AdminDashboard";
import { Header, Logo, PlatformFooter } from "../components/shared";
import { useBackendMode, usePersistentState } from "../hooks";
import { SSHKeysPage } from "../pages/SSHKeys";
-import {
- ImageRegistriesPage,
- ImageRegistryCreatePage,
-} from "../pages/ImageRegistries";
+import { ImageRegistriesPage } from "../pages/ImageRegistries";
import { SystemConfigPage } from "../pages/SystemConfig";
import { JobsPage } from "../pages/Jobs";
+import { filesPath, parseAdminRoute } from "../utils/route";
+import {
+ clearAuthSession,
+ hasAuthSession,
+ storeAuthSession,
+ UNAUTHORIZED_EVENT,
+} from "../api";
+import { authApi } from "../backend";
export function AdminLogin({
lang,
@@ -61,31 +69,27 @@ export function AdminLogin({
}
setLoading(true);
setError("");
- fetch("/api/v1/auth/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username: username.trim(), password }),
- })
- .then((resp) =>
- resp.ok
- ? resp.json()
- : Promise.reject(
- new Error(
- resp.status === 401
- ? zh
- ? "账号或密码错误"
- : "Invalid credentials"
- : `HTTP ${resp.status}`,
- ),
- ),
- )
- .then(() => {
+ authApi
+ .login(username.trim(), password)
+ .then((result) => {
+ if (!result.token || result.role !== "admin") {
+ throw new Error(
+ zh ? "该账号没有管理员权限" : "Administrator access required",
+ );
+ }
+ storeAuthSession(result.token, result.role);
sessionStorage.setItem("rlark-admin-auth", "1");
sessionStorage.setItem("rlark-admin-user-name", username.trim());
onLogin(username.trim());
})
.catch((err) => {
- setError(err.message);
+ setError(
+ err.status === 401
+ ? zh
+ ? "账号或密码错误"
+ : "Invalid credentials"
+ : err.message,
+ );
setLoading(false);
});
};
@@ -105,7 +109,7 @@ export function AdminLogin({
className="user-login-brand-logo brand-logo-light"
/>
@@ -205,8 +209,7 @@ export function AdminApp() {
const [loggedIn, setLoggedIn] = useState(
() =>
import.meta.env.DEV ||
- (typeof sessionStorage !== "undefined" &&
- sessionStorage.getItem("rlark-admin-auth") === "1"),
+ (typeof sessionStorage !== "undefined" && hasAuthSession("admin")),
);
const [userName, setUserName] = useState(
() => sessionStorage.getItem("rlark-admin-user-name") || "admin",
@@ -217,50 +220,9 @@ export function AdminApp() {
false,
);
const [storageRefreshKey, setStorageRefreshKey] = useState(0);
- const [adminPage, setAdminPage] = useState(() => {
- const p = window.location.pathname
- .replace(/^\/admin\/?/, "")
- .replace(/\/+$/, "");
- const parts = p.split("/").filter(Boolean);
- const valid = [
- "dashboard",
- "clusters-list",
- "create-cluster",
- "clusters-nodes",
- "addons",
- "jobs",
- "domains",
- "api",
- "config",
- "storageClass",
- "image-registries",
- "ssh-keys",
- ];
- if (valid.includes(parts[0])) return parts[0];
- return parts.length > 0 ? "clusters-nodes" : "dashboard";
- });
- const [adminSub, setAdminSub] = useState(() => {
- const p = window.location.pathname
- .replace(/^\/admin\/?/, "")
- .replace(/\/+$/, "");
- const parts = p.split("/").filter(Boolean);
- const explicitPages = [
- "dashboard",
- "clusters-list",
- "create-cluster",
- "clusters-nodes",
- "addons",
- "jobs",
- "domains",
- "api",
- "config",
- "storageClass",
- "image-registries",
- "ssh-keys",
- ];
- const subParts = explicitPages.includes(parts[0]) ? parts.slice(1) : parts;
- return subParts.length > 0 ? decodeURIComponent(subParts.join("/")) : "";
- });
+ const initialRoute = parseAdminRoute();
+ const [adminPage, setAdminPage] = useState(initialRoute.page);
+ const [adminSub, setAdminSub] = useState(initialRoute.sub);
const c = copy[lang];
const zh = lang === "zh";
@@ -281,40 +243,18 @@ export function AdminApp() {
useEffect(() => {
const onPop = () => {
- const p = window.location.pathname
- .replace(/^\/admin\/?/, "")
- .replace(/\/+$/, "");
- const parts = p.split("/").filter(Boolean);
- const valid = [
- "dashboard",
- "clusters-list",
- "create-cluster",
- "clusters-nodes",
- "addons",
- "jobs",
- "domains",
- "api",
- "config",
- "storageClass",
- "image-registries",
- "access-control",
- "ssh-keys",
- ];
- setAdminPage(
- valid.includes(parts[0])
- ? parts[0]
- : parts.length > 0
- ? "clusters-nodes"
- : "dashboard",
- );
- const subParts = valid.includes(parts[0]) ? parts.slice(1) : parts;
- setAdminSub(
- subParts.length > 0 ? decodeURIComponent(subParts.join("/")) : "",
- );
+ const route = parseAdminRoute();
+ setAdminPage(route.page);
+ setAdminSub(route.sub);
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
+ useEffect(() => {
+ const onUnauthorized = () => setLoggedIn(false);
+ window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
+ return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
+ }, []);
if (!loggedIn) {
return (
navigate("create-cluster")}
onLogout={() => {
- sessionStorage.removeItem("rlark-admin-auth");
- sessionStorage.removeItem("rlark-admin-user-name");
+ clearAuthSession();
setLoggedIn(false);
}}
createLabel={zh ? "创建集群" : "Create Cluster"}
@@ -476,10 +415,8 @@ export function AdminApp() {
/>
)}
{adminPage === "api" && }
- {adminPage === "create-cluster" && (
-
- )}
- {adminPage === "addons" && }
+ {adminPage === "create-cluster" && }
+ {adminPage === "addons" && }
{adminPage === "config" && }
{adminPage === "storageClass" && adminSub === "create" && (
navigate("storageClass", name)}
onCreate={() => navigate("storageClass", "create")}
+ onBrowseFiles={(cluster, storageClass) =>
+ window.open(filesPath(cluster, storageClass, true), "_blank")
+ }
refreshKey={storageRefreshKey}
/>
)}
- {adminPage === "image-registries" && adminSub === "create" && (
- navigate("image-registries")}
- onCreated={() => setStorageRefreshKey((key) => key + 1)}
+ sub={adminSub}
+ onBack={() => navigate("storageClass")}
/>
)}
- {adminPage === "image-registries" && adminSub !== "create" && (
+ {adminPage === "image-registries" && (
- navigate("image-registries", name ?? "")
- }
- onCreate={() => navigate("image-registries", "create")}
+ selectedID={adminSub || undefined}
+ onSelect={(id?: string) => navigate("image-registries", id ?? "")}
/>
)}
- {adminPage === "ssh-keys" && }
+ {adminPage === "ssh-keys" && (
+
+ )}
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({
fetchDashboard()}
- disabled={loading}
- aria-busy={loading}
+ onClick={handleRefresh}
+ disabled={loading || refreshing}
+ aria-busy={refreshing}
>
-
- {loading ? (zh ? "刷新中..." : "Refreshing...") : c.common.refresh}
+
+ {refreshing
+ ? zh
+ ? "刷新中..."
+ : "Refreshing..."
+ : c.common.refresh}
@@ -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) => (
onNavigate(item.target, item.name)}
+ onClick={() => onNavigate(item.target, item.resourceName)}
>
{item.type}
{item.name}
@@ -426,6 +447,11 @@ export function AdminDashboard({
)}
+
);
}
+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:
-
-
-
- {zh ? "集群名称" : "Cluster Name"}
- setClusterId(e.target.value)}
- placeholder={
- zh
- ? "输入集群名称,例如 my-cluster-01"
- : "Enter cluster name, e.g. my-cluster-01"
- }
- onKeyDown={(e) => e.key === "Enter" && handleSign()}
- />
-
-
- {loading
- ? zh
- ? "签发中..."
- : "Signing..."
- : zh
- ? "签发证书"
- : "Sign Certificate"}
-
-
+
+
+
+
01
+
+
+ {zh ? "命名并签发身份" : "Name and issue identity"}
+
+
+ {zh
+ ? "名称会成为集群在控制面中的唯一标识"
+ : "The name becomes the cluster identity in the control plane"}
+
+
+
+
+
+ {zh ? "集群名称" : "Cluster Name"}
+ setClusterId(e.target.value)}
+ placeholder={
+ zh
+ ? "输入集群名称,例如 my-cluster-01"
+ : "Enter cluster name, e.g. my-cluster-01"
+ }
+ onKeyDown={(e) => e.key === "Enter" && handleSign()}
+ />
+
+
+ {loading
+ ? zh
+ ? "签发中..."
+ : "Signing..."
+ : zh
+ ? "签发证书"
+ : "Sign Certificate"}
+
+
- {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"}
+
+
{copied ? (zh ? "已复制" : "Copied") : zh ? "复制" : "Copy"}
{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"}
+
+
{
+ event.stopPropagation();
+ handleExpandedCopy();
+ }}
>
+
{expandedCopied
? zh
? "已复制"
@@ -265,17 +283,24 @@ kubernetes:
: "Copy"}
-
{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}
+
+ {allSelected ? (zh ? "清空" : "Clear") : zh ? "全选" : "Select all"}
+
+
+
+ {options.length === 0 ? (
+
+ {zh ? "暂无可选项" : "No options"}
+
+ ) : (
+ options.map((opt) => {
+ const checked = selected.includes(opt.value);
+ return (
+
+ toggleValue(opt.value)}
+ />
+ {opt.label}
+
+ );
+ })
+ )}
+
+
+
+ {zh ? "重置" : "Reset"}
+
+
+ {zh ? "确定" : "OK"}
+
+
+
+ );
+}
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)}
+
= MAX_TAGS}
+ >
+
+ {zh
+ ? `添加标签 (${tagSlotCount(editRows)}/${MAX_TAGS})`
+ : `Add tag (${tagSlotCount(editRows)}/${MAX_TAGS})`}
+
+
+
+ );
+ }
+
+ return (
+
+ {errorMsg &&
{errorMsg}
}
+
{editRows.map(renderRow)}
+
= MAX_TAGS}
+ >
+
+ {zh
+ ? `添加标签 (${tagSlotCount(editRows)}/${MAX_TAGS})`
+ : `Add tag (${tagSlotCount(editRows)}/${MAX_TAGS})`}
+
+
+ );
+}
+
+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}
+ onRemoveValue(value)}
+ aria-label={zh ? `删除 ${value}` : `Remove ${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 列表 */}
+
+
+
+ {zh ? "全选/取消" : "All / None"}
+
+
+ {filteredKeys.length === 0 ? (
+
+ {zh ? "没有匹配的标签" : "No matching tags"}
+
+ ) : (
+ filteredKeys.map((k) => {
+ const totalVals = grouped.get(k)?.length ?? 0;
+ const selVals = selection[k]?.length ?? 0;
+ return (
+
{
+ setHoveredKey(k);
+ setValuePage(1);
+ }}
+ onMouseLeave={() =>
+ setHoveredKey((prev) => (prev === k ? null : prev))
+ }
+ onClick={() => {
+ setHoveredKey(k);
+ setValuePage(1);
+ }}
+ >
+ toggleKeyAll(k)}
+ />
+ {k}
+
+ {selVals > 0 ? `${selVals}/${totalVals}` : `${totalVals}`}
+
+
+ );
+ })
+ )}
+
+
+ {/* 右栏:选中 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) => (
+
+ toggleValue(effectiveHover, v)}
+ />
+ {v}
+
+ ))}
+ {valuePageCount > 1 && (
+
+ setValuePage(currentValuePage - 1)}
+ >
+ {zh ? "上一页" : "Prev"}
+
+
+ {currentValuePage} / {valuePageCount}
+
+ setValuePage(currentValuePage + 1)}
+ >
+ {zh ? "下一页" : "Next"}
+
+
+ )}
+ >
+ )}
+
+
+
+
+ {zh ? "重置" : "Reset"}
+
+
+ {zh ? "确定" : "OK"}
+
+
+
+ );
+}
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 (
+
+ {label}
+
+ {active && {selectedCount} }
+
+ );
+}
+
+// 管理表格内"当前打开哪一列的筛选弹层"和触发位置。
+// 一个表格可能有多列可筛选,但同一时间只开一个弹层。
+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"
/>
@@ -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 (
+
+
+ {label}
+
+ );
+}
+
+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) => (
+
selectSection(section.id)}
+ key={section.id}
+ >
+ {section.title[lang].slice(0, 1)}
+
+ {section.title[lang]}
+
+ {section.endpoints?.length ?? 0}{" "}
+ {zh ? "个接口" : "endpoints"}
+
+
+
+ ))}
- ))}
-
-
-
- {c.api.example}
- {c.api.copy}
-
{example}
+
+
{zh ? "调用流程" : "Request flow"}
+
+
+ 01
+
+ {zh ? "获取令牌" : "Get a token"}
+ POST /api/v1/auth/login
+
+
+
+ 02
+
+ {zh ? "添加请求头" : "Add the header"}
+ Authorization: Bearer <token>
+
+
+
+ 03
+
+ {zh ? "调用资源接口" : "Call a resource"}
+ Content-Type: application/json
+
+
+
+
-
-
+ ) : filteredEndpoints.length > 0 ? (
+
+ {filteredEndpoints.map((endpoint) => {
+ const endpointKey = `${endpoint.method} ${endpoint.path}`;
+ const expanded =
+ endpointKey ===
+ `${selectedEndpoint?.method} ${selectedEndpoint?.path}`;
+ return (
+
+ setSelectedKey(endpointKey)}
+ aria-expanded={expanded}
+ >
+
+ {endpoint.method}
+
+ {endpoint.path}
+ {endpoint.description[lang]}
+
+ {expanded
+ ? zh
+ ? "收起"
+ : "Collapse"
+ : zh
+ ? "查看示例"
+ : "View example"}
+
+
+ {expanded && (
+
+
+
+ {zh ? "请求路径" : "Request path"}
+ {endpoint.path}
+
+
{c.api.example}
+
+
+
+ application/json
+ copyExample(endpoint)}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? (zh ? "已复制" : "Copied") : c.api.copy}
+
+
+
{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."
+ : ""}
+
{zh ? "任务类型" : "Job Type"}
@@ -1038,39 +1116,57 @@ export function CreateJobModal({
{zh ? "角色列表" : "Roles"}
{zh
- ? "点击选择 Header 角色,可编辑角色名称、增删角色。"
- : "Click to select header role. Roles can be renamed, added, or removed."}
+ ? "点击选择 Header 角色(仅限单 Pod 的角色),可编辑角色名称、增删角色。"
+ : "Click to select header role (only single-pod roles are allowed). Roles can be renamed, added, or removed."}
- {roles.map((role) => (
-
setHeaderRole(role)}
- >
-
-
-
- {effectiveHeader === role
- ? zh
- ? "Header"
- : "Header"
- : zh
- ? "Worker"
- : "Worker"}
-
-
{
- e.stopPropagation();
- removeRole(role);
- }}
+ {roles.map((entry) => {
+ const headerAllowed = canBeHeader(entry.id);
+ const isHeader = effectiveHeader === entry.id;
+ return (
+ selectHeaderRole(entry.id)}
+ title={
+ !headerAllowed
+ ? zh
+ ? "Header 角色只能有一个 Pod,请将该角色副本数设为 1。"
+ : "The header role must have exactly one pod. Set its replicas to 1."
+ : undefined
+ }
>
-
-
-
- ))}
+
+
+
+ {isHeader
+ ? "Header"
+ : !headerAllowed
+ ? zh
+ ? "Worker(多 Pod)"
+ : "Worker (multi-pod)"
+ : "Worker"}
+
+ {
+ e.stopPropagation();
+ removeRole(entry.id);
+ }}
+ >
+
+
+
+ );
+ })}
- {roles.map((role) => (
+ {roles.map((entry) => (
selectRole(role, true)}
+ key={entry.id}
+ className={activeRoleTab === entry.id ? "active" : ""}
+ onClick={() => selectRole(entry.id, true)}
>
- {role}
- {effectiveHeader === role && (
+ {entry.name}
+ {effectiveHeader === entry.id && (
{(() => {
- const role = activeRoleTab || roles[0];
+ const role = activeRoleTab || (roles[0]?.id ?? "");
if (!role) return null;
const rr = roleResources[role];
if (!rr) return null;
+ const roleName =
+ roles.find((entry) => entry.id === role)?.name ?? "";
const imageHasWhitespace = imageReferenceHasWhitespace(
rr.image,
);
@@ -1126,7 +1224,7 @@ export function CreateJobModal({
return (
-
{role}
+
{roleName}
{effectiveHeader === role && (
{
+ onClick={() => {
const next =
mount.type === "storage"
? "host"
@@ -1493,21 +1591,41 @@ export function CreateJobModal({
type="number"
min="1"
max="200"
+ step="1"
value={mount.pvcSizeGb}
onChange={(e) =>
updateRRMount(
role,
index,
"pvcSizeGb",
- Math.min(
- 200,
- Math.max(
- 1,
- Number(e.target.value) || 1,
- ),
- ),
+ e.target.value === ""
+ ? ""
+ : Number(e.target.value),
)
}
+ onBlur={(e) => {
+ const value = Number(e.target.value);
+ if (
+ isNaN(value) ||
+ value < 1 ||
+ value > 200
+ ) {
+ // 恢复默认值
+ updateRRMount(
+ role,
+ index,
+ "pvcSizeGb",
+ 10,
+ );
+ // 显示错误提示
+ setError(
+ zh
+ ? "存储大小必须在 1 到 200 Gi 之间"
+ : "PVC size must be between 1 and 200 Gi",
+ );
+ setErrorNonce((n) => n + 1);
+ }
+ }}
/>
)}
@@ -1539,16 +1657,16 @@ export function CreateJobModal({
- {roles.map((role) => (
+ {roles.map((entry) => (
setHeaderRole(role)}
+ key={entry.id}
+ className={effectiveHeader === entry.id ? "active" : ""}
+ onClick={() => setHeaderRole(entry.id)}
>
- {role}
+ {entry.name}
- {effectiveHeader === role ? "Header" : "Worker"}
+ {effectiveHeader === entry.id ? "Header" : "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 (
setSSHPublicKeys((current) =>
selected
- ? current.filter((key) => key !== k.public_key)
- : [...current, k.public_key],
+ ? current.filter((key) => key !== publicKey)
+ : [...current, publicKey],
)
}
/>
- {label}
+ {label}
);
})}
@@ -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({
CIDR
{zh ? "IP 分配" : "IP Allocations"}
{zh ? "创建时间" : "Created"}
-
+ {zh ? "操作" : "Actions"}
- {domains.map((d) => (
+ {filteredDomains.map((d) => (
onSelect(d.metadata.name)}
>
-
- {d.metadata.name}
+
+ {d.metadata.name}
{d.spec.cidr}
@@ -161,7 +171,7 @@ export function DomainsPage({
{d.metadata.creationTimestamp ?? "—"}
-
+
))}
- {domains.length === 0 && !loading && (
+ {filteredDomains.length === 0 && !loading && (
+
{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"}
+
+
+ {zh ? "模式" : "Mode"}
+
+ onChange({
+ mode: event.target.value as ClusterSelectionMode,
+ clusters:
+ event.target.value === "Selected" ? selection.clusters : [],
+ })
+ }
+ >
+ {zh ? "仅保存" : "Stored only"}
+
+ {zh ? "指定集群" : "Selected clusters"}
+
+ {zh ? "所有集群" : "All clusters"}
+
+
+
+ {selection.mode === "Selected" && (
+
+ {options.length === 0 ? (
+
{zh ? "暂无可选集群" : "No clusters available"}
+ ) : (
+ options.map((id) => {
+ const option = clusters.find((cluster) => cluster.id === id);
+ return (
+
+
+ onChange({
+ mode: "Selected",
+ clusters: selection.clusters.includes(id)
+ ? selection.clusters.filter(
+ (cluster) => cluster !== id,
+ )
+ : [...selection.clusters, id].sort(),
+ })
+ }
+ />
+
+ {option?.name || id}
+ {option?.name && option.name !== id && {id} }
+
+
+ );
+ })
+ )}
+
+ )}
+
+ );
}
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({
-
onCreate?.()}>
+ setCreateOpen(true)}
+ >
{zh ? "添加凭据" : "Add Registry"}
@@ -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({
{zh ? "名称" : "Name"}
{zh ? "仓库地址" : "Registry"}
{zh ? "用户名" : "Username"}
-
+ {zh ? "分发范围" : "Scope"}
+ {zh ? "操作" : "Actions"}
- {items.map((item) => (
+ {filteredItems.map((item) => (
onSelect?.(item.name)}
+ onClick={() => onSelect?.(item.id)}
>
{item.username}
- e.stopPropagation()}>
- handleDelete(item.name)}
- >
-
-
+ {selectionLabel(item.clusterSelection, zh)}
+ e.stopPropagation()}
+ >
+
+ handleDelete(item)}
+ >
+
+
+
))}
)}
+
+ {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({
{zh ? "名称" : "Name"}
-
+
+ setForm({ ...form, name: event.target.value })
+ }
+ required
+ />
{zh ? "仓库地址" : "Registry"} *
@@ -672,6 +817,14 @@ 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}
)}
-
+
+ {zh ? "名称/ID" : "Name / ID"}
- toggleSort("id")}
+
-
- toggleSort("type")}
- />
+
+ 0 ? " has-filter" : ""}`}
+ onClick={(e) => {
+ const rect = (
+ e.currentTarget as HTMLElement
+ ).getBoundingClientRect();
+ setTagFilterAnchor(rect);
+ setTagFilterOpen(true);
+ }}
+ >
+ {zh ? "标签" : "Tags"}
+
+
- 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 (
@@ -849,6 +844,38 @@ export function JobsPage({
{c.jobType[job.type]}
+
+ {(job.tags ?? []).length > 0 ? (
+
+ {(job.tags ?? []).slice(0, 2).map((t) => (
+
+ {t.key}: {t.value}
+
+ ))}
+ {(job.tags ?? []).length > 2 && (
+ {
+ setTagPopover({
+ tags: job.tags ?? [],
+ anchor:
+ event.currentTarget.getBoundingClientRect(),
+ });
+ }}
+ >
+ +{(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 (
-
+
{zh ? "复制" : "Clone"}
-
+
{zh ? "重启" : "Restart"}
- {isStartable ? : }
- {isStartable ? (zh ? "启动" : "Start") : zh ? "停止" : "Stop"}
+ {isStartable && !isFailed ? : }
+ {isStartable && !isFailed
+ ? zh
+ ? "启动"
+ : "Start"
+ : zh
+ ? "停止"
+ : "Stop"}
@@ -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({
@@ -1159,6 +1273,7 @@ function AdminJobActions({
@@ -1168,6 +1283,7 @@ function AdminJobActions({
@@ -1531,6 +1647,9 @@ export function JobDetailPage({
nodePullProgressMap = {},
nodeEventsMap = {},
nodeDeviceModelMap = {},
+ nodeDiskWarningMap = {},
+ allJobTags = [],
+ onPatchJob,
}: {
job: Job;
copy: CopyType;
@@ -1550,9 +1669,94 @@ export function JobDetailPage({
string,
{ gpuModel?: string; deviceModel?: string }
>;
+ nodeDiskWarningMap?: Record;
+ allJobTags?: Array<{ key: string; values: string[] }>;
+ onPatchJob?: (
+ jobName: string,
+ patchBody: Record,
+ ) => Promise;
}) {
const zh = c.nav.overview === "总览";
+ // 是否处于编辑/重启中(任务名称编辑需置灰)
+ const isDeleting = job.phase === "Deleting";
+ const isStopping = job.stopped && job.phase !== "Stopped";
+ const isUpdating = lifecycleActions.pending !== null || isDeleting;
const [jobIdCopied, setJobIdCopied] = useState(false);
+
+ // 任务名称内联编辑状态
+ const [nameEditing, setNameEditing] = useState(false);
+ const [nameDraft, setNameDraft] = useState(job.displayName);
+ const [nameSaving, setNameSaving] = useState(false);
+ const [nameError, setNameError] = useState("");
+ // 与创建任务一致:输入过程中立即校验名称格式
+ const nameInvalid =
+ nameDraft.trim().length > 0 && !isValidJobDisplayName(nameDraft.trim());
+
+ // 当 job 切换时重置
+ useEffect(() => {
+ setNameDraft(job.displayName);
+ setNameEditing(false);
+ setNameError("");
+ }, [job.id]);
+
+ const startNameEdit = () => {
+ if (isUpdating) return;
+ setNameDraft(job.displayName);
+ setNameEditing(true);
+ setNameError("");
+ };
+
+ const cancelNameEdit = () => {
+ setNameDraft(job.displayName);
+ setNameEditing(false);
+ setNameError("");
+ };
+
+ const saveNameEdit = async () => {
+ const trimmed = nameDraft.trim();
+ if (!trimmed) {
+ setNameError(zh ? "任务名称不能为空" : "Name cannot be empty");
+ return;
+ }
+ if (trimmed.length > JOB_DISPLAY_NAME_MAX_LENGTH) {
+ setNameError(
+ zh
+ ? `任务名称不能超过 ${JOB_DISPLAY_NAME_MAX_LENGTH} 个字符`
+ : `Name too long (max ${JOB_DISPLAY_NAME_MAX_LENGTH})`,
+ );
+ return;
+ }
+ if (!isValidJobDisplayName(trimmed)) {
+ setNameError(
+ zh
+ ? "名称格式不正确,仅支持中英文、数字以及-_."
+ : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed.",
+ );
+ return;
+ }
+ if (trimmed === job.displayName) {
+ setNameEditing(false);
+ return;
+ }
+ if (!onPatchJob) {
+ setNameError(zh ? "当前环境不支持修改" : "Editing not available");
+ return;
+ }
+ setNameSaving(true);
+ setNameError("");
+ try {
+ await onPatchJob(job.name, {
+ metadata: {
+ annotations: { "rlark.io/display-name": trimmed },
+ },
+ });
+ setNameEditing(false);
+ } catch (e) {
+ setNameError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setNameSaving(false);
+ }
+ };
const handleCopyResourceId = async () => {
if (!(await copyText(job.id))) return;
setJobIdCopied(true);
@@ -1572,6 +1776,11 @@ export function JobDetailPage({
const [taskEventsMap, setTaskEventsMap] = useState<
Record
>({});
+ // Task 列表缓存,用于获取节点 RANK
+ const [tasks, setTasks] = useState([]);
+ const [detailNodeDiskWarningMap, setDetailNodeDiskWarningMap] = useState<
+ Record
+ >({});
const [podEventsMap, setPodEventsMap] = useState<
Record
>({});
@@ -1596,12 +1805,10 @@ export function JobDetailPage({
>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState(null);
+ // 无限滚动:内部游标状态,用户不感知
const [logsHasMore, setLogsHasMore] = useState(false);
const [logsNextCursor, setLogsNextCursor] = useState("");
const [logsLoadingMore, setLogsLoadingMore] = useState(false);
- // 游标历史栈,用于上一页/下一页翻页。首页为空字符串,第一页查完后 push(nextCursor)。
- const [logsCursorHistory, setLogsCursorHistory] = useState([""]);
- const [logsPageIndex, setLogsPageIndex] = useState(0);
// 角色配置区块的角色选择(默认第一个角色)
const [workerRoleFilter, setWorkerRoleFilter] = useState(
job.resources.length > 0 ? job.resources[0].role : "All",
@@ -1610,32 +1817,51 @@ export function JobDetailPage({
const [workerListRoleFilter, setWorkerListRoleFilter] = useState("All");
const [workerPage, setWorkerPage] = useState(1);
const [workerSort, setWorkerSort] = useState<{
- key:
- | "name"
- | "role"
- | "cluster"
- | "node"
- | "kind"
- | "ip"
- | "domainIP"
- | "gpu"
- | "createdAt"
- | "phase";
+ key: "createdAt";
direction: SortDirection;
- }>({ key: "name", direction: "asc" });
+ }>({ key: "createdAt", direction: "desc" });
+ // Worker 表表头多选筛选;空数组 = 全部
+ const [workerRoleFilterValues, setWorkerRoleFilterValues] = useState<
+ string[]
+ >([]);
+ const [workerPhaseFilter, setWorkerPhaseFilter] = useState([]);
+ const [workerClusterFilter, setWorkerClusterFilter] = useState([]);
+ const [workerKindFilter, setWorkerKindFilter] = useState([]);
+ const workerColumnFilter = useColumnFilter();
const workerTableRef = useRef(null);
const workerTableDrag = useRef({ active: false, x: 0, scrollLeft: 0 });
const [workerTableDragging, setWorkerTableDragging] = useState(false);
const [workerRefreshKey, setWorkerRefreshKey] = useState(0);
const [workerRefreshing, setWorkerRefreshing] = useState(false);
- const [logRoleFilter, setLogRoleFilter] = useState("All");
+ // 默认选中第一个角色(不再支持"所有角色")
+ const [logRoleFilter, setLogRoleFilter] = useState(() =>
+ job.resources.length > 0 ? job.resources[0].role : "",
+ );
const [logWorkerFilter, setLogWorkerFilter] = useState("All");
const [logQuery, setLogQuery] = useState("");
+ const [logQueryInput, setLogQueryInput] = useState(""); // 输入框的临时值
+ const logQueryInputRef = useRef(null); // 输入框引用
const [logRange, setLogRange] = useState("1h");
const [logCustomRange, setLogCustomRange] = useState(false);
const [logCustomFrom, setLogCustomFrom] = useState("");
const [logCustomTo, setLogCustomTo] = useState("");
- const [logStreamEnabled, setLogStreamEnabled] = useState(false);
+ const [logOrder, setLogOrder] = useState<"desc" | "asc">("desc");
+ const [logFullscreen, setLogFullscreen] = useState(false);
+ const [logCopied, setLogCopied] = useState(false);
+
+ // 全屏时锁定 body 滚动,防止背景跟着滚
+ useEffect(() => {
+ if (!logFullscreen) return;
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }, [logFullscreen]);
+ // 从后端获取的历史 Worker 列表(用于已停止任务的日志查询)
+ const [logWorkersFromBackend, setLogWorkersFromBackend] = useState(
+ [],
+ );
// Aggregate Node CR pullProgress for the top StatusBadge hover. Uses Node CR
// (not the Task CR-derived pullProgressMap used by WorkerRow) so the tooltip
@@ -1653,21 +1879,22 @@ export function JobDetailPage({
const { refresh: refreshTasks } = useAutoRefresh(
async () => {
const labelSelector = `rlinf.io/job=${job.name}`;
- const resp = await fetch(
- `/api/v1/rlinf.io/v1alpha1/tasks?labelSelector=${encodeURIComponent(labelSelector)}`,
- );
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
- const items = data.items ?? [];
+ const items = await tasksApi.list({ labelSelector });
const nodeMap: Record = {};
const clusterMap: Record = {};
const progressMap: Record = {};
const taskEventsMap: Record = {};
+ const observedNodes = new Map();
let tbProxy = "";
for (const item of items) {
const taskName = item.metadata?.name ?? "";
- const observedNodes = item.status?.observedNodes ?? [];
- nodeMap[taskName] = observedNodes.join(", ") || "—";
+ const taskNamespace = item.metadata?.namespace ?? "";
+ const taskObservedNodes = item.status?.observedNodes ?? [];
+ for (const nodeName of taskObservedNodes) {
+ if (nodeName)
+ observedNodes.set(`${taskNamespace}/${nodeName}`, taskNamespace);
+ }
+ nodeMap[taskName] = taskObservedNodes.join(", ") || "—";
clusterMap[taskName] = item.metadata?.namespace ?? "—";
if (item.status?.tensorBoardProxy) {
tbProxy = item.status.tensorBoardProxy;
@@ -1686,11 +1913,32 @@ export function JobDetailPage({
taskEventsMap[taskName.toLowerCase()] = evs;
}
}
+ const nodeResponses = await Promise.all(
+ [...observedNodes].map(async ([nodeKey, namespace]) => {
+ const nodeName = nodeKey.slice(namespace.length + 1);
+ try {
+ return await nodesApi.get(nodeName, { namespace });
+ } catch {
+ return null;
+ }
+ }),
+ );
+ const diskWarningMap: Record = {};
+ for (const node of nodeResponses) {
+ const nodeName = node?.metadata?.name;
+ if (nodeName) {
+ diskWarningMap[nodeName] = isDiskUsageWarning(node as CRDNode);
+ }
+ }
+
setTaskNodes(nodeMap);
setTaskClusters(clusterMap);
setTensorBoardProxy(tbProxy);
setPullProgressMap(progressMap);
setTaskEventsMap(taskEventsMap);
+ // 保存 Task 列表,用于获取节点 RANK
+ setTasks(items);
+ setDetailNodeDiskWarningMap(diskWarningMap);
},
10000,
[job.name],
@@ -1720,23 +1968,12 @@ export function JobDetailPage({
setWorkerRefreshing(true);
const labelSelector = `rlark.io/task-name in (${workerTaskNamesKey})`;
- const domainsPromise = fetch(`/api/v1/rlinf.io/v1alpha1/domains`).then(
- (resp) =>
- resp.ok
- ? resp.json()
- : Promise.reject(new Error(`HTTP ${resp.status}`)),
- );
-
- const podsPromise = fetch(
- `/api/v1/rlinf.io/v1alpha1/pods?labelSelector=${encodeURIComponent(labelSelector)}`,
- ).then((resp) =>
- resp.ok ? resp.json() : Promise.reject(new Error(`HTTP ${resp.status}`)),
- );
+ const domainsPromise = domainsApi.list();
+ const podsPromise = podsApi.list({ labelSelector });
Promise.all([podsPromise, domainsPromise])
- .then(([podData, domainData]) => {
+ .then(([podItems, domainItems]) => {
if (cancelled) return;
- const podItems = podData.items ?? [];
const uniquePods = new Map();
for (const item of podItems) {
const pod: PodInfo = {
@@ -1760,7 +1997,6 @@ export function JobDetailPage({
const podList = [...uniquePods.values()];
setPods(podList);
- const domainItems = domainData.items ?? [];
const ipMap: Record = {};
for (const d of domainItems) {
const allocs = d.status?.ipAllocations ?? [];
@@ -1802,11 +2038,9 @@ export function JobDetailPage({
const entries = await Promise.all(
pendingPodNames.map(async (podName) => {
try {
- const response = await fetch(
- `/api/v1/rlinf.io/v1alpha1/pods/${encodeURIComponent(podName)}/events`,
+ const data = await podsApi.events<{ events?: NodeEventEntry[] }>(
+ podName,
);
- if (!response.ok) return [podName, []] as const;
- const data = await response.json();
return [
podName,
Array.isArray(data.events) ? data.events : [],
@@ -1822,15 +2056,22 @@ export function JobDetailPage({
[pendingPodNamesKey],
);
- const getLogTimeRange = (): { from: string; to: string } => {
+ // 返回有效的时间范围;如果自定义时间 from >= to,返回 null 表示无效
+ const getLogTimeRange = (): { from: string; to: string } | null => {
if (logCustomRange) {
if (logCustomFrom && logCustomTo) {
- // Convert local datetime-local input (no timezone) to UTC ISO string
- const fromDate = new Date(logCustomFrom);
- const toDate = new Date(logCustomTo);
+ // datetime-local 返回的是本地时间字符串(如 "2026-09-16T14:30"),
+ // 需要手动添加时区偏移,确保被正确解析为本地时间,再转换为 UTC
+ const fromDate = new Date(logCustomFrom + ":00");
+ const toDate = new Date(logCustomTo + ":00");
+ // 校验开始时间必须早于结束时间
+ if (fromDate.getTime() >= toDate.getTime()) {
+ return null;
+ }
return { from: fromDate.toISOString(), to: toDate.toISOString() };
}
- // Fallback to 1h if custom range is incomplete
+ // 自定义时间不完整时返回 null,不发起查询
+ return null;
}
const to = new Date();
const from = new Date();
@@ -1856,20 +2097,33 @@ export function JobDetailPage({
default:
from.setHours(from.getHours() - 1);
}
- // Convert to UTC ISO string (new Date() is already local time)
return { from: from.toISOString(), to: to.toISOString() };
};
+ // 判断当前自定义时间范围是否有效(用于 UI 提示)
+ const isCustomRangeInvalid =
+ logCustomRange &&
+ logCustomFrom &&
+ logCustomTo &&
+ new Date(logCustomFrom).getTime() >= new Date(logCustomTo).getTime();
+
const fetchLogs = async (isInitial = true, cursor = "") => {
if (activeTab !== "logs") return;
+ // 如果时间范围无效(自定义时间 from >= to),直接报错,不发起请求
+ const timeRange = getLogTimeRange();
+ if (!timeRange) {
+ setLogsError(
+ zh ? "开始时间必须早于结束时间" : "Start time must be before end time",
+ );
+ return;
+ }
if (isInitial) setLogsLoading(true);
if (cursor) setLogsLoadingMore(true);
setLogsError(null);
try {
const params = new URLSearchParams();
- const { from, to } = getLogTimeRange();
- params.set("from", from);
- params.set("to", to);
+ params.set("from", timeRange.from);
+ params.set("to", timeRange.to);
// Always send the first task as the base filter (backend requires it)
let taskName = "";
@@ -1902,18 +2156,24 @@ export function JobDetailPage({
params.set("query", logQuery.trim());
}
+ // 传递排序方式
+ params.set("order", logOrder);
+
if (cursor) {
params.set("cursor", cursor);
}
- const resp = await fetch(
- `/api/v1/rlinf.io/v1alpha1/jobs/${encodeURIComponent(job.name)}/logs?${params.toString()}`,
+ const data = await jobsApi.logs(
+ job.name,
+ Object.fromEntries(params.entries()),
);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
if (data.source === "backend" && Array.isArray(data.entries)) {
- // 分页模式:替换当前页数据,而不是追加
- setBackendLogs(data.entries);
+ // 无限滚动:首次替换,追加时拼接
+ if (cursor) {
+ setBackendLogs((prev) => [...prev, ...data.entries]);
+ } else {
+ setBackendLogs(data.entries);
+ }
setPodLogs([]);
setLogsHasMore(Boolean(data.hasMore));
setLogsNextCursor(data.nextCursor || "");
@@ -1935,32 +2195,22 @@ export function JobDetailPage({
}
};
- // 查询条件变化时重置到第一页
- const resetLogsPagination = () => {
- setLogsCursorHistory([""]);
- setLogsPageIndex(0);
- };
-
- const goToNextLogPage = () => {
- if (!logsHasMore || !logsNextCursor || logsLoadingMore) return;
- const nextHistory = [...logsCursorHistory];
- nextHistory[logsPageIndex + 1] = logsNextCursor;
- setLogsCursorHistory(nextHistory);
- setLogsPageIndex(logsPageIndex + 1);
- fetchLogs(false, logsNextCursor);
- };
-
- const goToPrevLogPage = () => {
- if (logsPageIndex === 0 || logsLoadingMore) return;
- const prevIndex = logsPageIndex - 1;
- const prevCursor = logsCursorHistory[prevIndex] || "";
- setLogsPageIndex(prevIndex);
- fetchLogs(false, prevCursor);
+ // 滚动到底部附近时自动加载下一页
+ const handleLogScroll = (e: ReactUIEvent) => {
+ if (!logsHasMore || !logsNextCursor || logsLoadingMore || logsLoading) {
+ return;
+ }
+ const el = e.currentTarget;
+ const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (distanceToBottom < 100) {
+ fetchLogs(false, logsNextCursor);
+ }
};
- // 查询条件变化时重置分页到第一页
+ // 查询条件变化时重新查询
useEffect(() => {
- resetLogsPagination();
+ fetchLogs(true);
+ fetchLogWorkers(); // 时间范围变化时重新获取 Worker 列表
}, [
logRange,
logCustomRange,
@@ -1969,31 +2219,62 @@ export function JobDetailPage({
logRoleFilter,
logWorkerFilter,
logQuery,
+ logOrder,
]);
- useAutoRefresh(fetchLogs, 5000, [
- activeTab,
- job.name,
- logStreamEnabled,
- logRange,
- logCustomRange,
- logCustomFrom,
- logCustomTo,
- logRoleFilter,
- logWorkerFilter,
- logQuery,
- ]);
+ // 首次进入日志页面时自动加载数据
+ useEffect(() => {
+ if (activeTab === "logs") {
+ fetchLogs(true);
+ fetchLogWorkers(); // 同时获取历史 Worker 列表
+ }
+ }, [activeTab]);
+
+ // 从后端获取历史 Worker 列表(用于已停止任务的日志查询)
+ const fetchLogWorkers = async () => {
+ try {
+ const timeRange = getLogTimeRange();
+ if (!timeRange) return;
+ const params = new URLSearchParams();
+ params.set("from", timeRange.from);
+ params.set("to", timeRange.to);
+ params.set("label", "pod");
+ // 如果选择了特定角色,则传递 task 参数
+ if (logRoleFilter !== "All") {
+ const resource = job.resources.find((r) => r.role === logRoleFilter);
+ const taskName = resource
+ ? taskResourceName(job.name, resource.role)
+ : logRoleFilter;
+ params.set("task", taskName);
+ }
+ const data = await jobsApi.logLabelValues<{ values?: string[] }>(
+ job.name,
+ Object.fromEntries(params.entries()),
+ );
+ if (Array.isArray(data.values)) {
+ setLogWorkersFromBackend(data.values);
+ }
+ } catch (e) {
+ // 静默失败,不影响日志查询主流程
+ console.error("failed to fetch log workers:", e);
+ }
+ };
- const schedulingSummary = (
+ const workerStatusSummary = (
message: string | undefined,
events: NodeEventEntry[],
- ) =>
- message === "FailedScheduling" ||
- events.some((event) => event.reason === "FailedScheduling")
- ? zh
+ failed: boolean,
+ ) => {
+ if (
+ message === "FailedScheduling" ||
+ events.some((event) => event.reason === "FailedScheduling")
+ ) {
+ return zh
? "没有合适的节点可调度,资源可能被占用"
- : "No suitable node is available; resources may be occupied"
- : message;
+ : "No suitable node is available; resources may be occupied";
+ }
+ return failed ? jobFailureMessage(message, zh) : message;
+ };
const fallbackWorkers: WorkerItem[] = [];
const resourceForTask = (taskName: string) =>
@@ -2019,7 +2300,7 @@ export function JobDetailPage({
// 缺失时回退到 Task.status.events,让 Pending worker 行 tooltip
// 在调度前就能展示 DiskPressure 等原因。
const workerEvents =
- phase === "Pending"
+ phase === "Pending" || phase === "Failed"
? (podEventsMap[pod.name] ?? []).length > 0
? (podEventsMap[pod.name] ?? [])
: pod.node && (nodeEventsMap[pod.node] ?? []).length > 0
@@ -2043,7 +2324,11 @@ export function JobDetailPage({
`${role}: worker state synced`,
`${role}: waiting for runtime heartbeat`,
],
- statusMessage: schedulingSummary(pod.message, workerEvents),
+ statusMessage: workerStatusSummary(
+ pod.message,
+ workerEvents,
+ phase === "Failed",
+ ),
pullProgress: nodePullProgress,
events: workerEvents,
};
@@ -2079,45 +2364,56 @@ export function JobDetailPage({
}));
setWorkerPage(1);
};
- const sortedWorkers = filteredWorkers
+ // 表头筛选 options(从 jobWorkers 去重)
+ const workerRoleOptions = useMemo(() => {
+ const set = new Set();
+ jobWorkers.forEach((w) => w.role && set.add(w.role));
+ return [...set].sort().map((v) => ({ value: v, label: v }));
+ }, [jobWorkers]);
+ const workerPhaseOptions = useMemo(() => {
+ const set = new Set();
+ jobWorkers.forEach((w) => w.phase && set.add(w.phase));
+ return [...set].sort().map((v) => ({
+ value: v,
+ label: (c.status as Record)[v] ?? v,
+ }));
+ }, [jobWorkers, c]);
+ const workerClusterOptions = useMemo(() => {
+ const set = new Set();
+ jobWorkers.forEach((w) => w.cluster && set.add(w.cluster));
+ return [...set].sort().map((v) => ({ value: v, label: v }));
+ }, [jobWorkers]);
+ const workerKindOptions = useMemo(() => {
+ const set = new Set();
+ jobWorkers.forEach((w) => set.add(getNodeKindLabel(w)));
+ return [...set].sort().map((v) => ({ value: v, label: v }));
+ }, [jobWorkers]);
+ // 在过滤 + 排序前叠加表头多选筛选(角色/状态/集群/节点类型)
+ const columnFilteredWorkers = filteredWorkers.filter((worker) => {
+ const roleHit =
+ workerRoleFilterValues.length === 0 ||
+ workerRoleFilterValues.includes(worker.role);
+ const phaseHit =
+ workerPhaseFilter.length === 0 ||
+ workerPhaseFilter.includes(worker.phase);
+ const clusterHit =
+ workerClusterFilter.length === 0 ||
+ workerClusterFilter.includes(worker.cluster ?? "");
+ const kindHit =
+ workerKindFilter.length === 0 ||
+ workerKindFilter.includes(getNodeKindLabel(worker));
+ return roleHit && phaseHit && clusterHit && kindHit;
+ });
+ const sortedWorkers = columnFilteredWorkers
.map((worker) => ({ worker, index: jobWorkers.indexOf(worker) }))
- .sort((left, right) => {
- const value = ({ worker, index }: typeof left) => {
- const pod = workerPodsByTask.get(worker.id)?.[0];
- switch (workerSort.key) {
- case "name":
- return worker.name;
- case "role":
- return worker.role;
- case "cluster":
- return worker.cluster ?? "";
- case "node":
- return worker.node;
- case "kind":
- return getNodeKindLabel(worker);
- case "ip":
- return pod?.ip ?? "";
- case "domainIP":
- return worker.id
- ? (domainIPMap[
- `${worker.id.split("/")[0]}/${worker.id.split("/")[1]}/${worker.id.split("/")[2]}`
- ] ?? "")
- : "";
- case "gpu":
- return worker.gpu ?? "";
- case "createdAt":
- return formatWorkerCreatedAt(job.startedAt, index);
- case "phase":
- return worker.phase;
- }
- };
- return compareSortValues(
- value(left),
- value(right),
+ .sort((left, right) =>
+ compareSortValues(
+ formatWorkerCreatedAt(job.startedAt, left.index),
+ formatWorkerCreatedAt(job.startedAt, right.index),
workerSort.direction,
zh ? "zh-CN" : "en",
- );
- });
+ ),
+ );
const workersPerPage = 8;
const workerPageCount = Math.max(
1,
@@ -2213,6 +2509,11 @@ export function JobDetailPage({
}, [job.resources, pods]);
const logWorkers = useMemo(() => {
+ // 优先使用从后端获取的历史 Worker 列表(支持已停止任务)
+ if (logWorkersFromBackend.length > 0) {
+ return logWorkersFromBackend;
+ }
+ // 否则从当前运行中的 Pod 获取
return [
...new Set(
pods
@@ -2225,7 +2526,31 @@ export function JobDetailPage({
.map((p) => p.podName),
),
].filter(Boolean);
- }, [pods, logRoleFilter]);
+ }, [pods, logRoleFilter, logWorkersFromBackend]);
+
+ // 任务日志可查的最晚结束时刻(本地 datetime-local 字符串)。
+ // 运行中/Pending 等状态:当前时间;
+ // 已停止/失败/成功:min(stoppedAt, 当前时间),停止之后没有日志。
+ const logMaxEndLocal = useMemo(() => {
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const toLocalInput = (d: Date) =>
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ const now = new Date();
+ const isTerminated =
+ displayPhase === "Stopped" ||
+ displayPhase === "Failed" ||
+ displayPhase === "Succeeded";
+ if (isTerminated && job.stoppedAt && job.stoppedAt !== "—") {
+ const stopped = new Date(job.stoppedAt);
+ if (
+ !Number.isNaN(stopped.getTime()) &&
+ stopped.getTime() < now.getTime()
+ ) {
+ return toLocalInput(stopped);
+ }
+ }
+ return toLocalInput(now);
+ }, [displayPhase, job.stoppedAt]);
const filteredLogEntries = logEntries.filter(
(entry) =>
@@ -2235,6 +2560,35 @@ export function JobDetailPage({
.toLowerCase()
.includes(logQuery.toLowerCase()),
);
+
+ // 复制全部日志到剪贴板:每行 "[时间] [worker] message"
+ const copyAllLogs = async () => {
+ const lines = filteredLogEntries.map((entry) => {
+ const time = entry.timestamp
+ ? new Date(entry.timestamp).toLocaleString(zh ? "zh-CN" : "en-US", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ })
+ : "";
+ const parts: string[] = [];
+ if (time) parts.push(`[${time}]`);
+ if (entry.worker) parts.push(`[${entry.worker}]`);
+ parts.push(entry.message);
+ return parts.join(" ");
+ });
+ try {
+ await navigator.clipboard.writeText(lines.join("\n"));
+ setLogCopied(true);
+ window.setTimeout(() => setLogCopied(false), 2000);
+ } catch {
+ // 剪贴板不可用(非安全上下文等)时静默失败
+ }
+ };
const tabs: Array<{ id: typeof activeTab; label: string }> = [
{ id: "workers", label: zh ? "详情" : "Details" },
{ id: "logs", label: c.common.logs },
@@ -2242,10 +2596,13 @@ export function JobDetailPage({
];
const jobFailedMessage =
displayPhase === "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 (
@@ -2256,7 +2613,71 @@ export function JobDetailPage({
← {zh ? "返回任务列表" : "Back"}
{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}
+ {
+ e.stopPropagation();
+ startNameEdit();
+ }}
+ disabled={isUpdating}
+ title={
+ isUpdating
+ ? zh
+ ? "重启或执行中,无法编辑名称"
+ : "Editing disabled during lifecycle operation"
+ : zh
+ ? "编辑任务名称"
+ : "Edit job name"
+ }
+ aria-label={zh ? "编辑任务名称" : "Edit job name"}
+ >
+
+
+
+ )}
{zh ? "复制" : "Clone"}
@@ -2319,7 +2742,9 @@ export function JobDetailPage({
{lifecycleActions.pending === "start" ? (
@@ -2328,11 +2753,13 @@ export function JobDetailPage({
)}
{zh ? "启动" : "Start"}
- ) : !["Succeeded", "Failed"].includes(job.phase) ? (
+ ) : !["Succeeded"].includes(job.phase) ? (
{lifecycleActions.pending === "stop" ? (
@@ -2345,7 +2772,9 @@ export function JobDetailPage({
{lifecycleActions.pending === "restart" ? (
@@ -2357,7 +2786,9 @@ export function JobDetailPage({
{lifecycleActions.pending === "delete" ? (
@@ -2419,6 +2850,8 @@ export function JobDetailPage({
jobPullProgress={jobPullProgress}
jobEvents={jobEvents}
jobFailedMessage={jobFailedMessage}
+ allJobTags={allJobTags}
+ onPatchJob={onPatchJob}
/>
{/* 第三块:Pod 实例列表 */}
@@ -2485,7 +2918,8 @@ 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"],
- ["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}
+
setLogsError(null)}
+ aria-label={zh ? "关闭" : "Close"}
+ >
+ ×
+
+
+ )}
+ {logsLoading && (
+
+
+
+
+
+
+ {zh ? "正在连接 Worker 日志" : "Connecting to worker logs"}
+
+
+ {zh
+ ? "正在汇总各实例的最新输出…"
+ : "Collecting the latest output from each instance…"}
+
+
+
+
+ )}
+ {!logsLoading && (
<>
@@ -2617,9 +3109,6 @@ export function JobDetailPage({
setLogWorkerFilter("All");
}}
>
-
- {zh ? "全部角色" : "All roles"}
-
{logRoles.map((role) => (
{role}
@@ -2652,10 +3141,17 @@ export function JobDetailPage({
onChange={(event) => {
if (event.target.value === "custom") {
setLogCustomRange(true);
- const now = new Date();
- const past = new Date(now.getTime() - 3600 * 1000);
- setLogCustomTo(now.toISOString().slice(0, 16));
- setLogCustomFrom(past.toISOString().slice(0, 16));
+ // 默认与「最近 1 小时」一致:from = to - 1h。
+ // to 取 logMaxEndLocal(运行中=现在;已停止/失败=任务停止时刻)。
+ const toDate = new Date(logMaxEndLocal);
+ const fromDate = new Date(
+ toDate.getTime() - 3600 * 1000,
+ );
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const toLocalInput = (d: Date) =>
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ setLogCustomTo(toLocalInput(toDate));
+ setLogCustomFrom(toLocalInput(fromDate));
} else {
setLogCustomRange(false);
setLogRange(event.target.value);
@@ -2696,7 +3192,13 @@ export function JobDetailPage({
type="datetime-local"
className="log-custom-datetime"
value={logCustomFrom}
+ max={logMaxEndLocal}
onChange={(e) => setLogCustomFrom(e.target.value)}
+ style={
+ isCustomRangeInvalid
+ ? { borderColor: "var(--error, #ef4444)" }
+ : undefined
+ }
/>
setLogCustomTo(e.target.value)}
+ style={
+ isCustomRangeInvalid
+ ? { borderColor: "var(--error, #ef4444)" }
+ : undefined
+ }
/>
+ {isCustomRangeInvalid && (
+
+ {zh ? "开始时间需早于结束时间" : "Invalid range"}
+
+ )}
)}
+
+ {zh ? "排序" : "Order"}
+
+ setLogOrder(event.target.value as "desc" | "asc")
+ }
+ >
+
+ {zh ? "最新在前" : "Newest first"}
+
+
+ {zh ? "最旧在前" : "Oldest first"}
+
+
+
setLogQuery(event.target.value)}
+ ref={logQueryInputRef}
+ value={logQueryInput}
+ onChange={(event) => setLogQueryInput(event.target.value)}
+ onBlur={() => setLogQuery(logQueryInput)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ setLogQuery(logQueryInput);
+ }
+ }}
placeholder={
zh
? "搜索日志内容(仅支持完整单词/词组)"
@@ -2732,19 +3274,22 @@ export function JobDetailPage({
/>
setLogStreamEnabled((enabled) => !enabled)}
+ className="secondary-button"
+ onClick={() => fetchLogs(true)}
+ disabled={logsLoading}
+ title={zh ? "刷新日志" : "Refresh logs"}
>
-
- {logStreamEnabled
+
+ {logsLoading
? zh
- ? "实时输出中"
- : "Streaming"
+ ? "刷新中…"
+ : "Refreshing…"
: zh
- ? "已暂停"
- : "Paused"}
+ ? "刷新"
+ : "Refresh"}
) : (
-
- {filteredLogEntries.length > 0 ? (
- <>
-
- {zh ? "角色" : "Role"}
- Worker
- {zh ? "日志内容" : "Log message"}
- {zh ? "时间" : "Time"}
-
- {filteredLogEntries.map((entry) => (
-
-
{entry.role}
-
- {entry.worker}
-
-
{entry.message}
-
- {entry.timestamp
- ? new Date(entry.timestamp).toLocaleString(
- zh ? "zh-CN" : "en-US",
- {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- },
- )
- : ""}
-
+ (() => {
+ const terminalNode = (
+
+
+
+
+
+
+ {zh ? "任务日志" : "Job logs"}
- ))}
- {backendLogs.length > 0 && (
-
+
setLogFullscreen(!logFullscreen)}
+ title={
+ logFullscreen
+ ? zh
+ ? "退出全屏"
+ : "Exit fullscreen"
+ : zh
+ ? "全屏"
+ : "Fullscreen"
+ }
>
- {zh ? "上一页" : "Prev"}
+ {logFullscreen ? (
+
+ ) : (
+
+ )}
-
- {zh
- ? `第 ${logsPageIndex + 1} 页`
- : `Page ${logsPageIndex + 1}`}
-
- {logsLoadingMore
- ? zh
- ? "加载中…"
- : "Loading…"
- : zh
- ? "下一页"
- : "Next"}
+ {logCopied ? (
+
+ ) : (
+
+ )}
- )}
- >
- ) : (
-
- {zh ? "未找到匹配的日志。" : "No matching logs found."}
+
+
+ {filteredLogEntries.length > 0 ? (
+ filteredLogEntries.map((entry) => (
+
+
+ {entry.timestamp
+ ? new Date(entry.timestamp).toLocaleString(
+ zh ? "zh-CN" : "en-US",
+ {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ },
+ )
+ : ""}
+
+ {logWorkerFilter === "All" && (
+
+ {entry.worker}
+
+ )}
+
+ {entry.message}
+
+
+ ))
+ ) : (
+
+ {zh
+ ? "未找到匹配的日志。"
+ : "No matching logs found."}
+
+ )}
+ {logsLoadingMore && (
+
+ {zh ? "加载中…" : "Loading…"}
+
+ )}
+
- )}
-
+ );
+ // 全屏时挂到 body 下,避免侧边栏/顶栏的 stacking context
+ // (半透明 + backdrop-filter)与全屏终端叠出脏视觉。
+ return logFullscreen
+ ? createPortal(terminalNode, document.body)
+ : terminalNode;
+ })()
)}
>
)}
@@ -2882,6 +3460,62 @@ export function JobDetailPage({
)}
+ {workerColumnFilter.openKey === "role" &&
+ typeof document !== "undefined" &&
+ createPortal(
+
,
+ document.body,
+ )}
+ {workerColumnFilter.openKey === "phase" &&
+ typeof document !== "undefined" &&
+ createPortal(
+
,
+ document.body,
+ )}
+ {workerColumnFilter.openKey === "cluster" &&
+ typeof document !== "undefined" &&
+ createPortal(
+
,
+ document.body,
+ )}
+ {workerColumnFilter.openKey === "kind" &&
+ typeof document !== "undefined" &&
+ createPortal(
+
,
+ document.body,
+ )}
);
}
@@ -2899,6 +3533,8 @@ function JobPublicOverview({
jobPullProgress = [],
jobEvents = [],
jobFailedMessage,
+ allJobTags = [],
+ onPatchJob,
}: {
job: Job;
copy: CopyType;
@@ -2912,8 +3548,59 @@ function JobPublicOverview({
jobPullProgress?: PullProgressEntry[];
jobEvents?: NodeEventEntry[];
jobFailedMessage?: string;
+ allJobTags?: Array<{ key: string; values: string[] }>;
+ onPatchJob?: (
+ jobName: string,
+ patchBody: Record,
+ ) => Promise;
}) {
const zh = c.nav.overview === "总览";
+ const [sshKeys, setSSHKeys] = useState([]);
+ const [sshKeysLoaded, setSSHKeysLoaded] = useState(false);
+
+ // tags 内联编辑
+ const [tagsEditing, setTagsEditing] = useState(false);
+ const [tagsDraft, setTagsDraft] = useState(job.tags ?? []);
+ const [tagsSaving, setTagsSaving] = useState(false);
+ const [tagsError, setTagsError] = useState("");
+ const [tagPopover, setTagPopover] = useState<{
+ tags: JobTag[];
+ anchor: DOMRect;
+ } | null>(null);
+
+ useEffect(() => {
+ setTagsDraft(job.tags ?? []);
+ setTagsEditing(false);
+ setTagsError("");
+ // 仅在切换任务时重置:列表自动刷新会让 job.tags 变成新引用,不能因此打断编辑
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [job.id]);
+
+ useEffect(() => {
+ if (!job.sshPublicKey) {
+ setSSHKeys([]);
+ setSSHKeysLoaded(true);
+ return;
+ }
+
+ const controller = new AbortController();
+ setSSHKeysLoaded(false);
+ sshKeysApi
+ .list(controller.signal)
+ .then((data) => setSSHKeys(Array.isArray(data) ? data : []))
+ .catch((error) => {
+ if (error instanceof DOMException && error.name === "AbortError")
+ return;
+ setSSHKeys([]);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setSSHKeysLoaded(true);
+ });
+
+ return () => controller.abort();
+ }, [job.sshPublicKey]);
+
+ const resolvedSSHKeys = resolveSSHKeyOwners(job.sshPublicKey, sshKeys);
const baseConfigRows = [
{
label: zh ? "Worker 数量" : "Worker count",
@@ -2930,21 +3617,74 @@ function JobPublicOverview({
{
label: zh ? "网络域" : "Network domain",
value: job.domain || (zh ? "未配置" : "Not configured"),
+ fullValue: job.domain,
+ className: job.domain ? "public-config-truncated-value" : undefined,
},
{
label: "TensorBoard",
value: job.tensorBoardDir || (zh ? "未配置" : "Not configured"),
},
- {
- label: zh ? "SSH 公钥" : "SSH Public Key",
- value: job.sshPublicKey
- ? `${job.sshPublicKey.slice(0, 32)}...`
- : zh
- ? "未配置"
- : "Not configured",
- fullValue: job.sshPublicKey,
- },
];
+
+ const tagGroups = Array.from(
+ (job.tags ?? []).reduce((groups, tag) => {
+ const values = groups.get(tag.key);
+ if (values) {
+ values.push(tag.value);
+ } else {
+ groups.set(tag.key, [tag.value]);
+ }
+ return groups;
+ }, new Map()),
+ );
+ const flatTags = tagGroups.flatMap(([key, values]) =>
+ values.map((value) => ({ key, value })),
+ );
+
+ const saveTags = async () => {
+ if (!onPatchJob) return;
+ setTagsSaving(true);
+ setTagsError("");
+ try {
+ await onPatchJob(job.name, {
+ spec: {
+ tags: [
+ ...new Map(
+ tagsDraft
+ .filter((t) => t.key.trim() && t.value.trim())
+ .map((t) => [
+ t.key.trim(),
+ {
+ key: t.key.trim(),
+ values: tagsDraft
+ .filter((item) => item.key.trim() === t.key.trim())
+ .map((item) => item.value.trim())
+ .filter(
+ (value, index, values) =>
+ value && values.indexOf(value) === index,
+ ),
+ },
+ ]),
+ ).values(),
+ ],
+ },
+ });
+ setTagsEditing(false);
+ } catch (e) {
+ setTagsError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setTagsSaving(false);
+ }
+ };
+
+ const cancelTagsEdit = () => {
+ setTagsDraft(job.tags ?? []);
+ setTagsEditing(false);
+ setTagsError("");
+ };
+
+ const isUpdating =
+ lifecycleActions.pending !== null || job.phase === "Deleting";
return (
@@ -2973,9 +3713,187 @@ function JobPublicOverview({
{baseConfigRows.map((row) => (
{row.label}
- {row.value}
+
+ {row.value}
+
))}
+
+
{zh ? "SSH 公钥" : "SSH Public Keys"}
+ {resolvedSSHKeys.length > 0 ? (
+
+ {resolvedSSHKeys.map(({ publicKey, owners }, index) => {
+ const ownerLabel =
+ owners.length > 0
+ ? owners.map(({ user }) => user).join(", ")
+ : sshKeysLoaded
+ ? zh
+ ? "未知用户"
+ : "Unknown user"
+ : zh
+ ? "加载用户中…"
+ : "Loading user…";
+
+ return (
+
+ {ownerLabel}
+ {publicKey}
+
+ );
+ })}
+
+ ) : (
+
{zh ? "未配置" : "Not configured"}
+ )}
+
+
+
+ {zh ? "标签" : "Tags"}
+
+
+ {flatTags.length > 0 ? (
+
+ {flatTags.slice(0, 2).map(({ key, value }) => (
+
+ {key}: {value}
+
+ ))}
+ {flatTags.length > 2 && (
+ {
+ setTagPopover({
+ tags: job.tags ?? [],
+ anchor:
+ event.currentTarget.getBoundingClientRect(),
+ });
+ }}
+ >
+ +{flatTags.length - 2}
+
+ )}
+
+ ) : (
+
+ {zh ? "未配置" : "Not configured"}
+
+ )}
+
{
+ setTagsDraft(job.tags ?? []);
+ setTagsError("");
+ setTagsEditing(true);
+ }}
+ disabled={isUpdating || !onPatchJob}
+ title={
+ isUpdating
+ ? zh
+ ? "执行中,无法编辑"
+ : "Editing disabled during operation"
+ : zh
+ ? "编辑标签"
+ : "Edit tags"
+ }
+ aria-label={zh ? "编辑标签" : "Edit tags"}
+ >
+
+
+
+
+ {tagPopover &&
+ typeof document !== "undefined" &&
+ createPortal(
+
setTagPopover(null)}
+ />,
+ document.body,
+ )}
+ {tagsEditing &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+ event.target === event.currentTarget &&
+ !tagsSaving &&
+ cancelTagsEdit()
+ }
+ >
+
+
+
+
+ {tagsError && (
+ {tagsError}
+ )}
+
+
+
+ {zh ? "取消" : "Cancel"}
+
+
+ {tagsSaving
+ ? zh
+ ? "保存中…"
+ : "Saving…"
+ : zh
+ ? "确定"
+ : "Confirm"}
+
+
+
+
,
+ document.querySelector(".app-shell") ?? document.body,
+ )}
@@ -2984,54 +3902,6 @@ function JobPublicOverview({
);
}
-function PublicCompactConfigTable({
- title,
- firstHeader,
- secondHeader,
- rows,
- empty,
-}: {
- title: string;
- firstHeader: string;
- secondHeader: string;
- rows: Array<{ key: string; value: string }>;
- empty: string;
-}) {
- return (
-
- {title}
-
-
-
- {firstHeader}
- {secondHeader}
-
-
-
- {rows.length ? (
- rows.map((row, index) => (
-
-
- {row.key || "—"}
-
-
- {row.value || "—"}
-
-
- ))
- ) : (
-
-
- {empty}
-
-
- )}
-
-
-
- );
-}
-
function CommandCodeBlock({
value,
copy: c,
@@ -3099,26 +3969,6 @@ function highlightCommandLine(line: string) {
});
}
-function SummaryMetric({
- label,
- value,
- hint,
- tone,
-}: {
- label: string;
- value: string;
- hint: string;
- tone?: "blue";
-}) {
- return (
-
- {label}
- {value}
- {hint}
-
- );
-}
-
function RoleRuntimeConfig({
job,
copy: c,
@@ -3293,6 +4143,7 @@ function RoleRuntimeConfig({
headers={[
zh ? "挂载类型" : "Mount type",
zh ? "来源" : "Source",
+ zh ? "大小" : "Size",
zh ? "挂载到 Worker" : "Mount in worker",
]}
rows={resource.mounts.map((mount) => [
@@ -3304,6 +4155,7 @@ function RoleRuntimeConfig({
? "主机目录"
: "Host directory",
mount.type === "storage" ? mount.objectStorage : mount.hostPath,
+ mount.type === "storage" ? `${mount.pvcSizeGb} Gi` : "",
mount.mountPath,
])}
empty={zh ? "未配置数据挂载" : "No data mounts configured"}
@@ -3386,12 +4238,13 @@ function exportLogs(
worker: string;
role: string;
message: string;
+ timestamp?: string;
}>,
jobName: string,
) {
- const header = "worker,role,message";
+ const header = "timestamp,worker,role,message";
const rows = entries.map((entry) =>
- [entry.worker, entry.role, entry.message]
+ [entry.timestamp || "", entry.worker, entry.role, entry.message]
.map((value) => `"${value.replaceAll('"', '""')}"`)
.join(","),
);
@@ -3636,6 +4489,128 @@ function formatBytes(bytes: number): string {
return bytes + "B";
}
+const workerEventReasonLabels: Record = {
+ FailedScheduling: { zh: "调度失败", en: "Scheduling failed" },
+ FailedMount: { zh: "存储挂载失败", en: "Storage mount failed" },
+ FailedAttachVolume: { zh: "存储卷连接失败", en: "Volume attachment failed" },
+ FailedBinding: { zh: "存储卷绑定失败", en: "Volume binding failed" },
+ FailedMapVolume: { zh: "存储卷映射失败", en: "Volume mapping failed" },
+ FailedUnMount: { zh: "存储卷卸载失败", en: "Volume unmount failed" },
+ FailedMountOnFilesystemMismatch: {
+ zh: "存储卷文件系统不匹配",
+ en: "Volume filesystem mismatch",
+ },
+ VolumeResizeFailed: { zh: "存储卷扩容失败", en: "Volume resize failed" },
+ FileSystemResizeFailed: {
+ zh: "文件系统扩容失败",
+ en: "Filesystem resize failed",
+ },
+ FailedCreatePodSandBox: {
+ zh: "运行环境创建失败",
+ en: "Pod sandbox creation failed",
+ },
+ FailedCreatePodContainer: {
+ zh: "容器创建失败",
+ en: "Container creation failed",
+ },
+ SandboxChanged: {
+ zh: "运行环境已变更,正在重建",
+ en: "Pod sandbox changed; recreating",
+ },
+ FailedCreate: { zh: "Worker 创建失败", en: "Worker creation failed" },
+ Failed: { zh: "Worker 启动失败", en: "Worker startup failed" },
+ FailedSync: {
+ zh: "Worker 状态同步失败",
+ en: "Worker state synchronization failed",
+ },
+ FailedKillPod: { zh: "Worker 停止失败", en: "Worker termination failed" },
+ FailedPostStartHook: {
+ zh: "容器启动钩子执行失败",
+ en: "Container post-start hook failed",
+ },
+ FailedPreStopHook: {
+ zh: "容器停止钩子执行失败",
+ en: "Container pre-stop hook failed",
+ },
+ ErrImagePull: { zh: "镜像拉取失败", en: "Image pull failed" },
+ ImagePullBackOff: { zh: "镜像拉取重试中", en: "Retrying image pull" },
+ InvalidImageName: { zh: "镜像地址无效", en: "Invalid image name" },
+ FailedToRetrieveImagePullSecret: {
+ zh: "镜像凭据不可用",
+ en: "Image credentials unavailable",
+ },
+ BackOff: { zh: "容器启动重试中", en: "Retrying container startup" },
+ CrashLoopBackOff: {
+ zh: "容器反复启动失败",
+ en: "Container repeatedly failed to start",
+ },
+ OOMKilled: {
+ zh: "容器内存不足被终止",
+ en: "Container terminated due to insufficient memory",
+ },
+ Unhealthy: { zh: "健康检查失败", en: "Health check failed" },
+ Evicted: { zh: "Worker 已被节点驱逐", en: "Worker evicted from node" },
+ Preempted: {
+ zh: "Worker 已被高优先级任务抢占",
+ en: "Worker preempted by a higher-priority workload",
+ },
+ NodeNotReady: { zh: "节点不可用", en: "Node unavailable" },
+ NodeNotReachable: { zh: "节点无法连接", en: "Node unreachable" },
+ NodeNotSchedulable: { zh: "节点不可调度", en: "Node unschedulable" },
+ DiskPressure: { zh: "节点磁盘空间不足", en: "Node disk pressure" },
+ MemoryPressure: { zh: "节点内存压力", en: "Node memory pressure" },
+ PIDPressure: { zh: "节点进程资源不足", en: "Node PID pressure" },
+ OutOfDisk: { zh: "节点磁盘空间耗尽", en: "Node out of disk space" },
+ NetworkUnavailable: { zh: "节点网络不可用", en: "Node network unavailable" },
+ Rebooted: { zh: "节点已重启", en: "Node rebooted" },
+ FreeDiskSpaceFailed: {
+ zh: "镜像清理失败,磁盘空间不足",
+ en: "Image cleanup failed; insufficient disk space",
+ },
+ ContainerGCFailed: { zh: "容器清理失败", en: "Container cleanup failed" },
+ ImageGCFailed: { zh: "镜像清理失败", en: "Image cleanup failed" },
+ FailedNodeAllocatableEnforcement: {
+ zh: "节点资源限制配置失败",
+ en: "Node resource enforcement failed",
+ },
+ Pulling: { zh: "正在拉取镜像", en: "Pulling image" },
+ Pulled: { zh: "镜像已拉取", en: "Image pulled" },
+};
+
+function workerEventReasonLabel(
+ reason: string,
+ zh: boolean,
+ failed = false,
+): string {
+ const label = workerEventReasonLabels[reason];
+ if (label) return zh ? label.zh : label.en;
+ if (failed) return zh ? "Worker 运行失败" : "Worker failed";
+ return zh ? "等待 Worker 启动" : "Waiting for Worker startup";
+}
+
+function jobFailureMessage(message: string | undefined, zh: boolean): string {
+ if (!message) return zh ? "Worker 运行失败" : "Worker failed";
+ if (/oomkilled|out of memory/i.test(message)) {
+ return workerEventReasonLabel("OOMKilled", zh, true);
+ }
+ if (
+ /crashloopbackoff|back-off .*restarting failed container/i.test(message)
+ ) {
+ return workerEventReasonLabel("CrashLoopBackOff", zh, true);
+ }
+ if (/imagepullbackoff/i.test(message)) {
+ return workerEventReasonLabel("ImagePullBackOff", zh, true);
+ }
+ if (/errimagepull/i.test(message)) {
+ return workerEventReasonLabel("ErrImagePull", zh, true);
+ }
+ for (const reason of Object.keys(workerEventReasonLabels)) {
+ if (message.includes(reason))
+ return workerEventReasonLabel(reason, zh, true);
+ }
+ return workerEventReasonLabel("", zh, true);
+}
+
// PullProgressInfo renders an "i" icon at the top-right of a task status badge.
// Hovering (or focusing) it reveals the live image pull progress / speed for
// the task's images while its pods have not yet reached Running, plus any
@@ -3656,12 +4631,20 @@ export function PullProgressInfo({
zh,
emptyMessage,
statusMessage,
+ statusTitle,
+ eventTitle,
+ failed = false,
+ variant = "default",
}: {
progress: PullProgressEntry[];
events?: NodeEventEntry[];
zh: boolean;
emptyMessage?: string;
statusMessage?: string;
+ statusTitle?: string;
+ eventTitle?: string;
+ failed?: boolean;
+ variant?: "default" | "danger";
}) {
const wrapperRef = useRef(null);
const tooltipRef = useRef(null);
@@ -3673,13 +4656,20 @@ export 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 recentEvents = [
+ ...new Map(
+ [...events]
+ .sort((left, right) => {
+ const leftTime = Date.parse(left.lastTime ?? "") || 0;
+ const rightTime = Date.parse(right.lastTime ?? "") || 0;
+ return rightTime - leftTime;
+ })
+ .map((event) => [
+ workerEventReasonLabel(event.reason, zh, failed),
+ event,
+ ]),
+ ).values(),
+ ].slice(0, 4);
const measure = () => {
const icon = wrapperRef.current;
@@ -3763,7 +4753,7 @@ export function PullProgressInfo({
};
frame = window.requestAnimationFrame(track);
return () => window.cancelAnimationFrame(frame);
- }, [open, progress, events, emptyMessage, statusMessage]);
+ }, [open, progress, events, emptyMessage, statusMessage, statusTitle]);
const tooltipStyle: CSSProperties = pos
? {
@@ -3783,7 +4773,7 @@ export function PullProgressInfo({
return (
<>
- {zh ? "异常原因" : "Failure Reason"}
+
+ {statusTitle ?? (zh ? "异常原因" : "Failure Reason")}
+
{statusMessage}
@@ -3863,7 +4855,14 @@ export function PullProgressInfo({
{recentEvents.length > 0 && (
<>
- {zh ? "Worker 事件" : "Worker Events"}
+ {eventTitle ??
+ (failed
+ ? zh
+ ? "失败原因"
+ : "Failure Reasons"
+ : zh
+ ? "等待原因"
+ : "Pending Reasons")}
{events.length > recentEvents.length && (
{zh
@@ -3875,16 +4874,16 @@ export function PullProgressInfo({
{recentEvents.map((ev, i) => (
- {ev.reason || ev.type || "Event"}
+ {workerEventReasonLabel(ev.reason, zh, failed)}
- {ev.objectName && (
- {ev.objectName}
- )}
- {ev.message && (
- {ev.message}
- )}
{ev.lastTime && (
{formatChinaDateTime(ev.lastTime)}
@@ -3911,10 +4910,8 @@ function WorkerTableRow({
createdAt,
onSelectNode,
onSelectCluster,
- podEventsMap,
- nodeEventsMap,
- nodePullProgressMap,
- taskEventsMap,
+ diskWarning,
+ tasks,
}: {
jobName: string;
worker: WorkerItem;
@@ -3925,10 +4922,8 @@ function WorkerTableRow({
createdAt: string;
onSelectNode?: (name: string) => void;
onSelectCluster?: (id: string) => void;
- podEventsMap: Record;
- nodeEventsMap: Record;
- nodePullProgressMap: Record;
- taskEventsMap: Record;
+ diskWarning: boolean;
+ tasks: CRDTask[];
}) {
const zh = c.nav.overview === "总览";
const [copied, setCopied] = useState(false);
@@ -3937,10 +4932,13 @@ function WorkerTableRow({
jumpPort: string;
} | null>(null);
useEffect(() => {
- fetch("/api/v1/system-config")
- .then((r) => (r.ok ? r.json() : null))
+ systemConfigApi
+ .get()
.then((d) => {
- if (d) setSSHConfig(d.ssh);
+ setSSHConfig({
+ jumpHost: d.ssh?.jumpHost || d.sshJumpHost || "",
+ jumpPort: d.ssh?.jumpPort || d.sshJumpPort || "",
+ });
})
.catch(() => {});
}, []);
@@ -3962,6 +4960,39 @@ function WorkerTableRow({
const domainIP =
domainIPMap[`${pod?.namespace}/${pod?.podNamespace}/${pod?.podName}`] ??
"—";
+
+ // 获取节点 RANK:优先从环境变量 RLINF_NODE_RANK 获取,否则从 task 的 ray-node-rank-start annotation 加上 worker index 计算
+ const getNodeRank = (worker: WorkerItem, pods: PodInfo[]) => {
+ const pod = pods[0];
+ if (!pod) return "—";
+
+ // 方式一:从环境变量获取
+ const envRank = pod.env?.find((e) => e.name === "RLINF_NODE_RANK")?.value;
+ if (envRank) return envRank;
+
+ // 方式二:从 task 的 annotation 计算
+ // 从 worker 名称中提取 task 名称(去掉 -0, -1, -2 等后缀)
+ const taskNameMatch = worker.name.match(/^(.+)-(\d+)$/);
+ if (taskNameMatch) {
+ const taskName = taskNameMatch[1];
+ const index = parseInt(taskNameMatch[2], 10);
+
+ // 从 tasks 列表中查找对应的 task
+ const task = tasks.find((t) => t.metadata?.name === taskName);
+ if (task?.metadata?.annotations?.["rlark.io/ray-node-rank-start"]) {
+ const startRank = parseInt(
+ task.metadata.annotations["rlark.io/ray-node-rank-start"],
+ 10,
+ );
+ if (!isNaN(startRank)) {
+ return String(startRank + index);
+ }
+ }
+ }
+
+ return "—";
+ };
+
return (
<>
@@ -3992,36 +5023,6 @@ function WorkerTableRow({
{worker.role}
-
-
-
-
-
-
-
- {getNodeKindLabel(worker)}
-
-
- {pod?.ip || "—"}
-
-
- {domainIP}
-
-
-
- {worker.gpu && worker.gpu !== "0"
- ? `${worker.gpu} GPU`
- : zh
- ? "未申请"
- : "None"}
-
-
-
- {createdAt}
-
@@ -4035,8 +5036,11 @@ function WorkerTableRow({
events={worker.events ?? []}
zh={zh}
statusMessage={
- worker.phase === "Failed" ? worker.statusMessage : undefined
+ worker.phase === "Failed"
+ ? jobFailureMessage(worker.statusMessage, zh)
+ : undefined
}
+ failed={worker.phase === "Failed"}
emptyMessage={
worker.phase === "Pending"
? worker.node && worker.node !== "—"
@@ -4052,6 +5056,58 @@ function WorkerTableRow({
)}
+
+
+
+
+
+
+ {diskWarning && (
+
+ )}
+
+
+
+ {getNodeKindLabel(worker)}
+
+
+ {getNodeRank(worker, pods)}
+
+
+ {pod?.ip || "—"}
+
+
+ {domainIP}
+
+
+
+ {worker.gpu && worker.gpu !== "0"
+ ? `${worker.gpu} GPU`
+ : zh
+ ? "未申请"
+ : "None"}
+
+
+
+ {createdAt}
+
);
}
+import {
+ domainsApi,
+ jobsApi,
+ nodesApi,
+ podsApi,
+ sshKeysApi,
+ systemConfigApi,
+ tasksApi,
+} from "../backend";
diff --git a/apps/rlark-ui/src/pages/Login.tsx b/apps/rlark-ui/src/pages/Login.tsx
index 68eac5c..476abc0 100644
--- a/apps/rlark-ui/src/pages/Login.tsx
+++ b/apps/rlark-ui/src/pages/Login.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState, type FormEvent } from "react";
import { AlertCircle, ArrowRight, Eye, EyeOff } from "lucide-react";
+import { storeAuthSession } from "../api";
+import { authApi } from "../backend";
export function UserLogin({
onLogin,
@@ -26,27 +28,19 @@ export function UserLogin({
}
setLoading(true);
setError("");
- fetch("/api/v1/auth/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username: username.trim(), password }),
- })
- .then((resp) =>
- resp.ok
- ? resp.json()
- : Promise.reject(
- new Error(
- resp.status === 401 ? "账号或密码错误" : `HTTP ${resp.status}`,
- ),
- ),
- )
- .then(() => {
+ authApi
+ .login(username.trim(), password)
+ .then((result) => {
+ if (!result.token || result.role !== "user") {
+ throw new Error("登录响应无效");
+ }
+ storeAuthSession(result.token, result.role);
sessionStorage.setItem("rlark-user-auth", "1");
sessionStorage.setItem("rlark-user-name", username.trim());
onLogin(username.trim());
})
.catch((err) => {
- setError(err.message);
+ setError(err.status === 401 ? "账号或密码错误" : err.message);
setLoading(false);
});
};
diff --git a/apps/rlark-ui/src/pages/Overview.tsx b/apps/rlark-ui/src/pages/Overview.tsx
index de5a661..6c66126 100644
--- a/apps/rlark-ui/src/pages/Overview.tsx
+++ b/apps/rlark-ui/src/pages/Overview.tsx
@@ -10,7 +10,7 @@ import {
} from "lucide-react";
import { activity, type Cluster, type Job, type Phase } from "../data";
import type { Copy } from "../i18n";
-import type { CRDJob, CRDNode, Page, ResourceRow } from "../types";
+import type { CRDNode, Page, ResourceRow } from "../types";
import { useAutoRefresh } from "../hooks";
import { crdToJob } from "../utils/crd";
import {
@@ -21,6 +21,7 @@ import {
} from "../utils/nodes";
import {
MetricCard,
+ RefreshOverlay,
ResourceDistribution,
StatusBadge,
} from "../components/shared";
@@ -46,21 +47,14 @@ export function Overview({
const isZh = c.nav.overview === "总览";
const { refresh } = useAutoRefresh(async () => {
- const [clustersRes, nodesRes, jobsRes] = await Promise.all([
- fetch("/api/v1/clusters")
- .then((r) => (r.ok ? r.json() : Promise.reject()))
- .catch(() => ({ data: [] })),
- fetch("/api/v1/rlinf.io/v1alpha1/nodes")
- .then((r) => (r.ok ? r.json() : Promise.reject()))
- .catch(() => ({ items: [] })),
- fetch("/api/v1/rlinf.io/v1alpha1/jobs")
- .then((r) => (r.ok ? r.json() : Promise.reject()))
- .catch(() => ({ items: [] })),
+ const [clusters, nodes, jobs] = await Promise.all([
+ clustersApi.list().catch(() => []),
+ nodesApi.list().catch(() => []),
+ jobsApi.list().catch(() => []),
]);
- setRealClusters(clustersRes.data ?? []);
- setRealNodes(nodesRes.items ?? []);
- const jobItems: CRDJob[] = jobsRes.items ?? [];
- setRealJobs(jobItems.map(crdToJob));
+ setRealClusters(clusters);
+ setRealNodes(nodes);
+ setRealJobs(jobs.map(crdToJob));
}, 15000);
const handleRefresh = async () => {
@@ -158,13 +152,32 @@ export function Overview({
const robotNodes = displayNodes.filter((n) => hasNodeCategory(n, "robot"));
return (
-
+
{c.overview.eyebrow}
{c.overview.title}
{c.overview.desc}
+
+
+ {refreshing
+ ? isZh
+ ? "刷新中..."
+ : "Refreshing..."
+ : c.common.refresh}
+
-
+
@@ -317,19 +330,6 @@ export function Overview({
{c.overview.recent}
{c.common.production}
-
-
-
{activity.length === 0 ? (
@@ -356,6 +356,11 @@ export function Overview({
+
);
}
+import { clustersApi, jobsApi, nodesApi } from "../backend";
diff --git a/apps/rlark-ui/src/pages/SSHKeys.tsx b/apps/rlark-ui/src/pages/SSHKeys.tsx
index 99e0dc8..7c20c36 100644
--- a/apps/rlark-ui/src/pages/SSHKeys.tsx
+++ b/apps/rlark-ui/src/pages/SSHKeys.tsx
@@ -4,50 +4,65 @@ import {
Trash2,
Terminal,
KeyRound,
- RefreshCw,
Copy as CopyIcon,
Check,
Info,
} from "lucide-react";
import type { Copy } from "../i18n";
import { formatChinaDateTime } from "../utils/time";
+import { findSSHKeyDuplicate } from "../utils/sshKeys";
import {
+ ColumnFilterButton,
compareSortValues,
+ RefreshOverlay,
+ PageToolbar,
SortButton,
+ useColumnFilter,
type SortDirection,
} from "../components/shared";
+import { ColumnFilterPopover } from "../components/ColumnFilterPopover";
-interface SSHKeyItem {
- index: number;
- user: string;
- public_key: string;
- added_at: string;
-}
-
-export function SSHKeysPage({ copy: c }: { copy: Copy }) {
+export function SSHKeysPage({
+ copy: c,
+ userName,
+}: {
+ copy: Copy;
+ userName?: string;
+}) {
const zh = c.nav.overview === "总览";
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showAdd, setShowAdd] = useState(false);
- const [newUser, setNewUser] = useState(
- () => sessionStorage.getItem("rlark-user-name") || "user",
- );
+ const [newUser, setNewUser] = useState(() => userName || "user");
+ const [query, setQuery] = useState("");
const [newKey, setNewKey] = useState("");
const [adding, setAdding] = useState(false);
const [copied, setCopied] = useState(false);
const [copiedKey, setCopiedKey] = useState("");
const [sort, setSort] = useState<{
- key: "user" | "public_key" | "added_at";
+ key: "added_at";
direction: SortDirection;
}>({ key: "added_at", direction: "desc" });
+ // 公钥名称多选筛选;空数组 = 全部
+ const [userFilter, setUserFilter] = useState([]);
+ const columnFilter = useColumnFilter();
const toggleSort = (key: typeof sort.key) =>
setSort((current) => ({
key,
direction:
current.key === key && current.direction === "asc" ? "desc" : "asc",
}));
- const sortedKeys = [...keys].sort((a, b) =>
+ const userOptions = Array.from(new Set(keys.map((k) => k.user)))
+ .sort()
+ .map((v) => ({ value: v, label: v }));
+ const filteredKeys = keys.filter((k) => {
+ const queryHit = `${k.user} ${k.public_key}`
+ .toLowerCase()
+ .includes(query.trim().toLowerCase());
+ return queryHit && (userFilter.length === 0 || userFilter.includes(k.user));
+ });
+ const sortedKeys = [...filteredKeys].sort((a, b) =>
compareSortValues(
a[sort.key],
b[sort.key],
@@ -60,10 +75,7 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
setLoading(true);
setError("");
try {
- const resp = await fetch("/api/v1/ssh-user-keys");
- if (!resp.ok) throw new Error(await resp.text());
- const data = await resp.json();
- setKeys(data || []);
+ setKeys(await sshKeysApi.list());
} catch (e) {
setError(String(e));
} finally {
@@ -77,18 +89,42 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
const handleAdd = async () => {
if (!newUser.trim() || !newKey.trim()) return;
+
+ const duplicate = findSSHKeyDuplicate(newUser, newKey, keys);
+ if (duplicate) {
+ setError(
+ duplicate === "name"
+ ? zh
+ ? "公钥名称已存在,请使用其他名称。"
+ : "The public key name already exists. Use another name."
+ : zh
+ ? "该公钥已上传,请勿重复添加。"
+ : "This public key has already been uploaded.",
+ );
+ return;
+ }
+
setAdding(true);
setError("");
try {
- const resp = await fetch("/api/v1/ssh-user-keys", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- user: newUser.trim(),
- public_key: newKey.trim(),
- }),
- });
- if (!resp.ok) throw new Error(await resp.text());
+ try {
+ await sshKeysApi.create(newUser.trim(), newKey.trim());
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 409) {
+ const data = error.body as { error?: string };
+ setError(
+ data.error === "public key name already exists"
+ ? zh
+ ? "公钥名称已存在,请使用其他名称。"
+ : "The public key name already exists. Use another name."
+ : zh
+ ? "该公钥已上传,请勿重复添加。"
+ : "This public key has already been uploaded.",
+ );
+ return;
+ }
+ throw error;
+ }
setNewKey("");
setShowAdd(false);
fetchKeys();
@@ -109,11 +145,7 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
)
return;
try {
- const resp = await fetch(
- `/api/v1/ssh-user-keys/${index}?user=${encodeURIComponent(user)}`,
- { method: "DELETE" },
- );
- if (!resp.ok) throw new Error(await resp.text());
+ await sshKeysApi.remove(user, index);
fetchKeys();
} catch (e) {
setError(String(e));
@@ -125,10 +157,13 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
sshJumpPort: string;
} | null>(null);
useEffect(() => {
- fetch("/api/v1/system-config")
- .then((r) => (r.ok ? r.json() : null))
+ systemConfigApi
+ .get()
.then((d) => {
- if (d) setSSHConfig(d);
+ setSSHConfig({
+ sshJumpHost: d.ssh?.jumpHost || d.sshJumpHost || "",
+ sshJumpPort: d.ssh?.jumpPort || d.sshJumpPort || "",
+ });
})
.catch(() => {});
}, []);
@@ -158,26 +193,6 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
-
-
- {loading
- ? zh
- ? "刷新中..."
- : "Refreshing..."
- : zh
- ? "刷新"
- : "Refresh"}
-
setShowAdd(!showAdd)}
@@ -252,94 +267,127 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
+
+
{showAdd && (
-
-
-
- {zh ? "添加 SSH 公钥" : "Add SSH Public Key"}
-
- {zh
- ? "粘贴你的公钥内容,支持 ssh-ed25519、ssh-rsa 等格式"
- : "Paste your public key. Supports ssh-ed25519, ssh-rsa, etc."}
-
-
-
- {
+ if (event.target === event.currentTarget && !adding)
+ setShowAdd(false);
+ }}
+ >
+
-
-
{zh ? "公钥信息" : "Key Info"}
-
-
- {zh ? "公钥名称" : "Public Key Name"}
- setNewUser(e.target.value)}
- placeholder="user"
- />
-
-
-
-
- {zh ? "公钥内容" : "Public Key"}
-
+
+
+ {zh ? "添加 SSH 公钥" : "Add SSH Public Key"}
+
+ {zh
+ ? "粘贴你的公钥内容,支持 ssh-ed25519、ssh-rsa 等格式"
+ : "Paste your public key. Supports ssh-ed25519, ssh-rsa, etc."}
+
- {error && (
-
- {error}
-
- )}
-
{
- setShowAdd(false);
- setNewKey("");
- setError("");
+
+
{zh ? "公钥信息" : "Key Info"}
+
+
+ {zh ? "公钥名称" : "Public Key Name"}
+ {
+ setNewUser(e.target.value);
+ setError("");
+ }}
+ placeholder="user"
+ />
+
+
+
+
+ {zh ? "公钥内容" : "Public Key"}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
- {zh ? "取消" : "Cancel"}
-
-
-
- {adding
- ? zh
- ? "添加中…"
- : "Adding…"
- : zh
- ? "确认添加"
- : "Add"}
-
+
{
+ setShowAdd(false);
+ setNewKey("");
+ setError("");
+ }}
+ >
+ {zh ? "取消" : "Cancel"}
+
+
+
+ {adding
+ ? zh
+ ? "添加中…"
+ : "Adding…"
+ : zh
+ ? "确认添加"
+ : "Add"}
+
+
-
-
+
+
)}
)}
-
+
{zh ? "公钥列表" : "SSH Keys"}
@@ -382,36 +433,34 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
: "Manage public keys for SSH access"}
-
{zh ? `共 ${keys.length} 项` : `${keys.length} items`}
+
+ {zh
+ ? `共 ${filteredKeys.length} 项`
+ : `${filteredKeys.length} items`}
+
- {loading ? (
-
- {zh ? "加载中…" : "Loading…"}
-
- ) : keys.length === 0 ? (
+ {filteredKeys.length === 0 ? (
- {zh ? "暂无公钥" : "No SSH keys found"}
+ {loading
+ ? zh
+ ? "加载中…"
+ : "Loading…"
+ : zh
+ ? "暂无公钥"
+ : "No SSH keys found"}
) : (
- toggleSort("user")}
- />
-
-
- toggleSort("public_key")}
+ selectedCount={userFilter.length}
+ onClick={columnFilter.openFor("user")}
/>
+ {zh ? "公钥" : "Public Key"}
toggleSort("added_at")}
/>
-
+ {zh ? "操作" : "Actions"}
@@ -471,14 +520,18 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
{formatChinaDateTime(k.added_at)}
-
- handleDelete(k.user, k.index)}
- >
-
-
+
+
+ handleDelete(k.user, k.index)}
+ >
+
+
+
);
@@ -486,7 +539,24 @@ export function SSHKeysPage({ copy: c }: { copy: Copy }) {
)}
+
+ {columnFilter.openKey === "user" && (
+
+ )}
);
}
+import { ApiError } from "../api";
+import { sshKeysApi, systemConfigApi, type SSHKeyItem } from "../backend";
diff --git a/apps/rlark-ui/src/pages/Storage.tsx b/apps/rlark-ui/src/pages/Storage.tsx
index a67e3db..8eb7fc1 100644
--- a/apps/rlark-ui/src/pages/Storage.tsx
+++ b/apps/rlark-ui/src/pages/Storage.tsx
@@ -28,12 +28,13 @@ import {
} from "../data";
import { copy, type Copy } from "../i18n";
import {
- compareSortValues,
+ ColumnFilterButton,
PageToolbar,
Pagination,
- SortButton,
- type SortDirection,
+ RefreshOverlay,
+ useColumnFilter,
} from "../components/shared";
+import { ColumnFilterPopover } from "../components/ColumnFilterPopover";
import { formatChinaDateTime } from "../utils/time";
type StorageClassFormState = {
@@ -104,12 +105,14 @@ export function StorageClassesPage({
selectedName,
onSelect,
onCreate,
+ onBrowseFiles,
refreshKey = 0,
}: {
copy: Copy;
selectedName?: string;
onSelect: (name?: string) => void;
onCreate: () => void;
+ onBrowseFiles: (cluster: string, storageClass: string) => void;
refreshKey?: number;
}) {
const zh = c === copy.zh;
@@ -117,19 +120,12 @@ export function StorageClassesPage({
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [search, setSearch] = useState("");
- const [providerFilter, setProviderFilter] = useState("All");
+ // 表头列多选筛选;空数组 = 全部
+ const [providerFilter, setProviderFilter] = useState([]);
+ const [clusterFilter, setClusterFilter] = useState([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
- const [sort, setSort] = useState<{
- key: "name" | "provider" | "bucket" | "clusters" | "description";
- 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 columnFilter = useColumnFilter();
const [editingClass, setEditingClass] = useState(null);
const selected = useMemo(
@@ -141,16 +137,11 @@ export function StorageClassesPage({
if (loading) return;
setLoading(true);
try {
- const resp = await fetch("/api/v1/storage/storageclass");
- if (resp.ok) {
- const data = await resp.json();
- const list: StorageClass[] = Object.values(data.data || {}).map(
- normalizeStorageClass,
- );
- setRealClasses(list);
- } else {
- setRealClasses([]);
- }
+ const data = await storageClassesApi.list();
+ const list: StorageClass[] = Object.values(data).map(
+ normalizeStorageClass,
+ );
+ setRealClasses(list);
} catch {
setRealClasses([]);
} finally {
@@ -167,7 +158,10 @@ export function StorageClassesPage({
setFetched(false);
}, [refreshKey]);
- useEffect(() => setPage(1), [search, providerFilter, pageSize]);
+ useEffect(
+ () => setPage(1),
+ [search, providerFilter, clusterFilter, pageSize],
+ );
const handleDelete = async (id: string, name: string) => {
if (
@@ -177,13 +171,8 @@ export function StorageClassesPage({
)
return;
try {
- const resp = await fetch(
- `/api/v1/storage/storageclass/${encodeURIComponent(id || name)}`,
- {
- method: "DELETE",
- },
- );
- if (resp.ok) fetchClasses();
+ await storageClassesApi.remove(id || name);
+ fetchClasses();
} catch (e) {
console.error("Failed to delete storage class:", e);
}
@@ -206,25 +195,19 @@ export function StorageClassesPage({
sc.provider.toLowerCase().includes(query) ||
sc.bucket.toLowerCase().includes(query) ||
sc.clusters.some((cluster) => cluster.toLowerCase().includes(query));
- return (
- matchesSearch &&
- (providerFilter === "All" || sc.provider === providerFilter)
- );
+ const providerHit =
+ providerFilter.length === 0 || providerFilter.includes(sc.provider);
+ const clusterHit =
+ clusterFilter.length === 0 ||
+ sc.clusters.some((cluster) => clusterFilter.includes(cluster));
+ return matchesSearch && providerHit && clusterHit;
});
const providers = Array.from(new Set(realClasses.map((sc) => sc.provider)));
const associatedClusters = new Set(realClasses.flatMap((sc) => sc.clusters));
- const sortedClasses = [...filtered].sort((a, b) => {
- const value = (storageClass: StorageClass) =>
- sort.key === "clusters"
- ? storageClass.clusters.length
- : storageClass[sort.key];
- return compareSortValues(
- value(a),
- value(b),
- sort.direction,
- zh ? "zh-CN" : "en",
- );
- });
+ // 列表页列不再支持排序,按名称做稳定 tiebreak 即可
+ const sortedClasses = [...filtered].sort((a, b) =>
+ a.name.localeCompare(b.name, zh ? "zh-CN" : "en", { numeric: true }),
+ );
const totalPages = Math.max(1, Math.ceil(sortedClasses.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedClasses = sortedClasses.slice(
@@ -293,17 +276,11 @@ export function StorageClassesPage({
copy={c}
onRefresh={fetchClasses}
refreshing={loading}
- filterValue={providerFilter}
- onFilterChange={setProviderFilter}
- filterOptions={[
- { value: "All", label: zh ? "全部提供商" : "All providers" },
- ...providers.map((provider) => ({
- value: provider,
- label: provider,
- })),
- ]}
/>
-
+
{zh ? "存储资源" : "Storage resources"}
@@ -320,47 +297,24 @@ export function StorageClassesPage({
+ {c.storageClass.name}
- toggleSort("name")}
- />
-
-
- toggleSort("provider")}
+ selectedCount={providerFilter.length}
+ onClick={columnFilter.openFor("provider")}
/>
+ {c.storageClass.bucket}
- toggleSort("bucket")}
- />
-
-
- toggleSort("clusters")}
- />
-
-
- toggleSort("description")}
+ selectedCount={clusterFilter.length}
+ onClick={columnFilter.openFor("cluster")}
/>
-
+ {c.storageClass.description}
+ {zh ? "操作" : "Actions"}
@@ -388,7 +342,6 @@ export function StorageClassesPage({
{sc.name}
- {sc.namespace}
@@ -412,7 +365,7 @@ export function StorageClassesPage({
{sc.description || "—"}
-
+
@@ -459,6 +411,10 @@ export function StorageClassesPage({
))}
+
+ {columnFilter.openKey === "provider" && (
+
({ value: p, label: p }))}
+ selected={providerFilter}
+ onChange={setProviderFilter}
+ anchorRect={columnFilter.anchorRect}
+ onClose={columnFilter.close}
+ zh={zh}
+ />
+ )}
+ {columnFilter.openKey === "cluster" && (
+ ({ value: cl, label: cl }))}
+ selected={clusterFilter}
+ onChange={setClusterFilter}
+ anchorRect={columnFilter.anchorRect}
+ onClose={columnFilter.close}
+ zh={zh}
+ />
+ )}
{editingClass && (
+
+ ← {zh ? "返回存储列表" : "Back to storage"}
+
{c.storageClass.eyebrow}
@@ -518,15 +505,6 @@ export function StorageClassDetailPage({
-
-
- {zh ? "返回" : "Back"}
-
@@ -632,14 +610,17 @@ export function StorageClassCreatePage({
useEffect(() => {
let cancelled = false;
- fetch("/api/v1/clusters")
- .then((r) =>
- r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
- )
+ clustersApi
+ .list<{
+ id?: string;
+ name?: string;
+ region?: string;
+ location?: string;
+ }>()
.then((data) => {
if (cancelled) return;
- const options: ClusterOption[] = (data.data ?? [])
- .map((cluster: any) => ({
+ const options: ClusterOption[] = data
+ .map((cluster) => ({
id: cluster.id ?? cluster.name ?? "",
name: cluster.name ?? cluster.id ?? "",
description: cluster.region ?? cluster.location ?? "",
@@ -678,32 +659,11 @@ export function StorageClassCreatePage({
setSubmitting(true);
setError("");
try {
- const resp = await fetch(
- isEdit
- ? `/api/v1/storage/storageclass/${encodeURIComponent(form.name)}`
- : "/api/v1/storage/storageclass",
- {
- method: isEdit ? "PUT" : "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(form),
- },
- );
- if (!resp.ok) {
- const msg = await resp.text();
- setError(
- (isEdit
- ? zh
- ? "更新存储类失败"
- : "Failed to update storage class"
- : c.storageClass.createFailed) +
- ": " +
- msg,
- );
- return;
- }
+ if (isEdit) await storageClassesApi.update(form.name, form);
+ else await storageClassesApi.create(form);
onCreated?.();
onBack();
- } catch (err) {
+ } catch {
setError(c.storageClass.createFailed);
} finally {
setSubmitting(false);
@@ -1097,14 +1057,10 @@ export function StorageClassFilesPage({
setLoading(true);
setError("");
try {
- const params = new URLSearchParams();
- params.set("prefix", prefix);
- params.set("maxKeys", "100");
- const resp = await fetch(
- `/api/v1/storage/storageclass/${encodeURIComponent(name)}/${encodeURIComponent(cluster)}/list?${params}`,
- );
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
+ const data = await storageObjectsApi.list<{
+ objects?: any[];
+ common_prefixes?: string[];
+ }>(name, cluster, prefix);
setObjects(data.objects || []);
setCommonPrefixes(data.common_prefixes || []);
} catch (e: any) {
@@ -1133,14 +1089,7 @@ export function StorageClassFilesPage({
setUploadProgress(
`${zh ? "上传中" : "Uploading"}: ${file.name} (${i + 1}/${files.length})`,
);
- const resp = await fetch(
- `/api/v1/storage/storageclass/${encodeURIComponent(name)}/${encodeURIComponent(cluster)}/upload`,
- { method: "POST", body: formData },
- );
- if (!resp.ok) {
- const err = await resp.json().catch(() => ({}));
- throw new Error(err.error || `Upload failed: ${resp.status}`);
- }
+ await storageObjectsApi.upload(name, cluster, formData);
}
setUploadProgress("");
fetchFiles();
@@ -1153,11 +1102,11 @@ export function StorageClassFilesPage({
const handleDownload = async (key: string) => {
try {
- const resp = await fetch(
- `/api/v1/storage/storageclass/${encodeURIComponent(name)}/${encodeURIComponent(cluster)}/object/${encodeURIComponent(key)}?expire=3600`,
+ const data = await storageObjectsApi.download<{ url?: string }>(
+ name,
+ cluster,
+ key,
);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
if (data.url) {
window.open(data.url, "_blank");
}
@@ -1172,14 +1121,7 @@ export function StorageClassFilesPage({
if (!confirm(zh ? `确定删除文件 "${key}" 吗?` : `Delete file "${key}"?`))
return;
try {
- const resp = await fetch(
- `/api/v1/storage/storageclass/${encodeURIComponent(name)}/${encodeURIComponent(cluster)}/object/${encodeURIComponent(key)}`,
- { method: "DELETE" },
- );
- if (!resp.ok) {
- const data = await resp.json().catch(() => ({}));
- throw new Error(data.error || `Delete failed: ${resp.status}`);
- }
+ await storageObjectsApi.remove(name, cluster, key);
fetchFiles();
} catch (e: any) {
alert(e?.message || (zh ? "删除失败" : "Delete failed"));
@@ -1373,7 +1315,10 @@ export function StorageClassFilesPage({
)}
-
+
{zh ? "目录内容" : "Directory contents"}
@@ -1407,99 +1352,81 @@ export function StorageClassFilesPage({
)}
- {loading && (
-
-
-
-
-
+ {filteredPrefixes.map((folder) => {
+ const folderName = folder.endsWith("/")
+ ? folder.slice(0, -1)
+ : folder;
+ const displayName = prefix
+ ? folderName.replace(prefix, "")
+ : folderName;
+ return (
+ navigateFolder(folder)}
+ >
+
+
+
+
+
+
+ {displayName}
+ {zh ? "文件夹" : "Folder"}
+
-
- {zh
- ? "正在加载目录内容..."
- : "Loading directory contents..."}
-
-
-
-
- )}
- {!loading &&
- filteredPrefixes.map((folder) => {
- const folderName = folder.endsWith("/")
- ? folder.slice(0, -1)
- : folder;
- const displayName = prefix
- ? folderName.replace(prefix, "")
- : folderName;
- return (
- navigateFolder(folder)}
- >
-
-
-
-
-
-
- {displayName}
- {zh ? "文件夹" : "Folder"}
-
+
+ —
+ —
+
+
+
+
+
+
+ );
+ })}
+ {pagedObjects.map((obj) => {
+ const fileName = obj.key.split("/").pop() || obj.key;
+ return (
+
+
+
+
+
+
+
+ {fileName}
+ {zh ? "对象文件" : "Object"}
-
- —
- —
-
+
+
+ {formatSize(obj.size || 0)}
+ {formatDate(obj.last_modified)}
+
+
handleDownload(obj.key)}
>
-
+
-
-
- );
- })}
- {!loading &&
- pagedObjects.map((obj) => {
- const fileName = obj.key.split("/").pop() || obj.key;
- return (
-
-
-
-
-
-
-
- {fileName}
- {zh ? "对象文件" : "Object"}
-
-
-
- {formatSize(obj.size || 0)}
- {formatDate(obj.last_modified)}
-
-
- handleDownload(obj.key)}
- >
-
-
- handleDelete(obj.key)}
- >
-
-
-
-
-
- );
- })}
+ handleDelete(obj.key)}
+ >
+
+
+
+
+
+ );
+ })}
{!loading &&
filteredObjects.length === 0 &&
filteredPrefixes.length === 0 &&
@@ -1522,6 +1449,10 @@ export function StorageClassFilesPage({
)}
+
{!loading && filteredObjects.length > 0 && (
);
}
+import { clustersApi, storageClassesApi, storageObjectsApi } from "../backend";
diff --git a/apps/rlark-ui/src/pages/SystemConfig.tsx b/apps/rlark-ui/src/pages/SystemConfig.tsx
index ce141a4..c98ede1 100644
--- a/apps/rlark-ui/src/pages/SystemConfig.tsx
+++ b/apps/rlark-ui/src/pages/SystemConfig.tsx
@@ -5,8 +5,18 @@ import {
Check,
Copy as CopyIcon,
RefreshCw,
+ ServerCog,
+ ScrollText,
+ ShieldCheck,
} from "lucide-react";
import type { Copy } from "../i18n";
+import { RefreshOverlay } from "../components/shared";
+import {
+ systemConfigApi,
+ type DeploymentConfig,
+ type SystemConfigResponse,
+} from "../backend";
+import { resolveDeploymentConfig } from "../utils/deployYaml";
interface LogBackendConfig {
endpoint: string;
@@ -26,10 +36,26 @@ interface SystemConfig {
sshJumpPort: string;
log: LogConfig;
isAccessKeySecretSet: boolean; // 标记 accessKeySecret 是否已设置(用于区分掩码和用户输入)
+ deployment: {
+ controlPlaneAddress: string;
+ sshAddress: string;
+ insecureSkipTlsVerify: boolean;
+ kubernetes: {
+ kubeconfig: string;
+ agentImage: string;
+ image: string;
+ imagePullPolicy: "" | "Always" | "IfNotPresent" | "Never";
+ imagePullSecrets: string;
+ containerdSocket: string;
+ };
+ };
}
export function SystemConfigPage({ copy: c }: { copy: Copy }) {
const zh = c.nav.overview === "总览";
+ const [activeCategory, setActiveCategory] = useState<
+ "ssh" | "deployment" | "log"
+ >("ssh");
const [config, setConfig] = useState({
sshJumpHost: "",
sshJumpPort: "",
@@ -44,6 +70,19 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
},
},
isAccessKeySecretSet: false,
+ deployment: {
+ controlPlaneAddress: "",
+ sshAddress: "",
+ insecureSkipTlsVerify: false,
+ kubernetes: {
+ kubeconfig: "",
+ agentImage: "rlark:latest",
+ image: "rlark:latest",
+ imagePullPolicy: "",
+ imagePullSecrets: "",
+ containerdSocket: "",
+ },
+ },
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -53,6 +92,41 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
// 记录日志配置是否被修改过,用于决定是否在保存时发送 log 字段
const [isLogConfigDirty, setIsLogConfigDirty] = useState(false);
+ const applyConfig = (data: SystemConfigResponse) => {
+ const secret = data.log?.config?.accessKeySecret || "";
+ const deployment = resolveDeploymentConfig(data.deployment);
+ setConfig({
+ sshJumpHost: data.ssh?.jumpHost || data.sshJumpHost || "",
+ sshJumpPort: data.ssh?.jumpPort || data.sshJumpPort || "",
+ log: {
+ backend: data.log?.backend || "none",
+ config: {
+ endpoint: data.log?.config?.endpoint || "",
+ project: data.log?.config?.project || "",
+ logstore: data.log?.config?.logstore || "",
+ accessKeyId: data.log?.config?.accessKeyId || "",
+ accessKeySecret: secret,
+ },
+ },
+ isAccessKeySecretSet: Boolean(secret),
+ deployment: {
+ controlPlaneAddress: deployment.controlPlaneAddress || "",
+ sshAddress: deployment.sshAddress || "",
+ insecureSkipTlsVerify: deployment.insecureSkipTlsVerify || false,
+ kubernetes: {
+ kubeconfig: deployment.kubernetes?.kubeconfig || "",
+ agentImage: deployment.kubernetes?.agentImage || "",
+ image: deployment.kubernetes?.image || "",
+ imagePullPolicy: deployment.kubernetes?.imagePullPolicy || "",
+ imagePullSecrets:
+ deployment.kubernetes?.imagePullSecrets?.join(", ") || "",
+ containerdSocket: deployment.kubernetes?.containerdSocket || "",
+ },
+ },
+ });
+ setIsLogConfigDirty(false);
+ };
+
useEffect(() => {
fetchConfig();
}, []);
@@ -61,28 +135,7 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
setLoading(true);
setError("");
try {
- const resp = await fetch("/api/v1/system-config");
- if (!resp.ok) throw new Error(await resp.text());
- const data = await resp.json();
- const secret = data.log?.config?.accessKeySecret || "";
- setConfig({
- sshJumpHost: data.ssh?.jumpHost || data.sshJumpHost || "",
- sshJumpPort: data.ssh?.jumpPort || data.sshJumpPort || "",
- log: {
- backend: data.log?.backend || "sls",
- config: {
- endpoint: data.log?.config?.endpoint || "",
- project: data.log?.config?.project || "",
- logstore: data.log?.config?.logstore || "",
- accessKeyId: data.log?.config?.accessKeyId || "",
- // 后端返回掩码时展示掩码;用于让用户知道已设置
- accessKeySecret: secret,
- },
- },
- isAccessKeySecretSet: Boolean(secret),
- });
- // 加载完成后,重置脏标记
- setIsLogConfigDirty(false);
+ applyConfig(await systemConfigApi.get({ refresh: true }));
} catch (e) {
setError(String(e));
} finally {
@@ -95,24 +148,72 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
setError("");
setSaved(false);
try {
+ const host = config.sshJumpHost.trim();
+ const port = config.sshJumpPort.trim();
+ if (activeCategory === "ssh" && !host && port) {
+ throw new Error(
+ zh
+ ? "设置端口时必须填写跳板地址"
+ : "Jump host is required when port is set",
+ );
+ }
+ if (
+ activeCategory === "ssh" &&
+ (/\s|\/|@/.test(host) || host.includes(":"))
+ ) {
+ throw new Error(
+ zh
+ ? "跳板地址必须是不含协议、用户、端口或路径的主机名/IP"
+ : "Jump host must be a hostname/IP without scheme, user, port, or path",
+ );
+ }
+ if (
+ activeCategory === "ssh" &&
+ port &&
+ (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)
+ ) {
+ throw new Error(
+ zh
+ ? "端口必须是 1 到 65535 之间的整数"
+ : "Port must be an integer between 1 and 65535",
+ );
+ }
// 构建请求体:如果日志配置没有被修改,则不包含 log 字段
- const requestBody: any = {
- ssh: {
- jumpHost: config.sshJumpHost,
- jumpPort: config.sshJumpPort,
- },
- };
+ const requestBody: {
+ ssh?: { jumpHost: string; jumpPort: string };
+ log?: LogConfig;
+ deployment?: DeploymentConfig;
+ } = {};
+
+ if (activeCategory === "ssh") {
+ requestBody.ssh = {
+ jumpHost: host,
+ jumpPort: port,
+ };
+ }
+ if (activeCategory === "deployment") {
+ requestBody.deployment = {
+ apiVersion: "rlark.io/v1alpha1",
+ kind: "DeployConfig",
+ plane: "data",
+ controlPlaneAddress: config.deployment.controlPlaneAddress,
+ sshAddress: config.deployment.sshAddress,
+ insecureSkipTlsVerify: config.deployment.insecureSkipTlsVerify,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ imagePullSecrets: config.deployment.kubernetes.imagePullSecrets
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+ },
+ };
+ }
- if (isLogConfigDirty) {
+ if (activeCategory === "log") {
requestBody.log = config.log;
}
- const resp = await fetch("/api/v1/system-config", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(requestBody),
- });
- if (!resp.ok) throw new Error(await resp.text());
+ applyConfig(await systemConfigApi.update(requestBody));
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
@@ -133,8 +234,11 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
};
return (
-
-
+
+
@@ -149,6 +253,7 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
{saved ? : }
{saving
@@ -188,240 +295,552 @@ export function SystemConfigPage({ copy: c }: { copy: Copy }) {
+
+
+
+
+
+ {zh ? "接入入口" : "Access entry"}
+
+ {config.sshJumpHost || (zh ? "尚未配置" : "Not configured")}
+
+
+
+
+
+
+ {zh ? "Agent 部署" : "Agent deployment"}
+
+ {config.deployment.kubernetes.agentImage ||
+ (zh ? "使用默认镜像" : "Default image")}
+
+
+
+
+
+
+ {zh ? "历史日志" : "Historical logs"}
+
+ {config.log.backend === "none"
+ ? zh
+ ? "未开启"
+ : "Disabled"
+ : config.log.backend.toUpperCase()}
+
+
+
+
{error && (
{error}
)}
-
-
-
- {zh ? "SSH 跳板配置" : "SSH Jump Host"}
-
- {zh
- ? "用户通过 SSH 代理连接到任务 Pod 时使用的跳板地址和端口"
- : "Jump host address and port for SSH proxy access to task pods"}
-
-
+
+
+ setActiveCategory("ssh")}
+ aria-pressed={activeCategory === "ssh"}
+ >
+ {zh ? "SSH 跳板配置" : "SSH Jump Host"}
+
+ setActiveCategory("deployment")}
+ aria-pressed={activeCategory === "deployment"}
+ >
+ {zh ? "部署配置" : "Deployment"}
+
+ setActiveCategory("log")}
+ aria-pressed={activeCategory === "log"}
+ >
+ {zh ? "日志后端配置" : "Log Backend"}
+
-
-
+
+
+ {activeCategory === "ssh" && (
+ <>
+
+
+
+ {zh ? "SSH 跳板配置" : "SSH Jump Host"}
+
+ {zh
+ ? "用户通过 SSH 代理连接到任务 Pod 时使用的跳板地址和端口"
+ : "Jump host address and port for SSH proxy access to task pods"}
+
+
+
-
-
-
+
+
+ {sshCommand && (
+
+
+
+
+ {zh ? "预览 SSH 命令" : "SSH Command Preview"}
+
+
+ {zh
+ ? "用户在任务页面看到的 SSH 连接命令"
+ : "The SSH command users will see on the job page"}
+
+
+
+
+
+ {sshCommand}
+
+ {copied ? : }
+ {copied ? (zh ? "已复制" : "Copied") : zh ? "复制" : "Copy"}
+
+
+
+
+ )}
+ >
+ )}
- {sshCommand && (
-
+ {activeCategory === "deployment" && (
+
- {zh ? "预览 SSH 命令" : "SSH Command Preview"}
+ {zh ? "部署配置默认值" : "Deployment Defaults"}
{zh
- ? "用户在任务页面看到的 SSH 连接命令"
- : "The SSH command users will see on the job page"}
+ ? "用于生成签发集群页面中的部署配置 YAML"
+ : "Defaults used to generate deployment YAML on the cluster signing page"}
-
-
-
{sshCommand}
-
+
+
- {copied ? : }
- {copied ? (zh ? "已复制" : "Copied") : zh ? "复制" : "Copy"}
-
+
+ {zh ? "控制面地址" : "Control Plane Address"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ controlPlaneAddress: event.target.value,
+ },
+ })
+ }
+ placeholder="https://rlark.example.com:8443"
+ />
+
+
+ {zh
+ ? "控制面 SSH 地址(可选)"
+ : "Control Plane SSH Address (optional)"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ sshAddress: event.target.value,
+ },
+ })
+ }
+ placeholder="client@rlark.example.com:2222"
+ />
+
+
+ {zh ? "Kubeconfig 路径" : "Kubeconfig Path"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ kubeconfig: event.target.value,
+ },
+ },
+ })
+ }
+ placeholder="~/.kube/config"
+ />
+
+
+ {zh ? "Agent 镜像" : "Agent Image"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ agentImage: event.target.value,
+ },
+ },
+ })
+ }
+ placeholder="rlark:latest"
+ />
+
+
+ {zh
+ ? "共享 RLark 镜像(可选)"
+ : "Shared RLark Image (optional)"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ image: event.target.value,
+ },
+ },
+ })
+ }
+ placeholder="rlark:latest"
+ />
+
+
+ {zh ? "镜像拉取策略" : "Image Pull Policy"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ imagePullPolicy: event.target.value as
+ "" | "Always" | "IfNotPresent" | "Never",
+ },
+ },
+ })
+ }
+ >
+
+ {zh ? "使用默认值" : "Use default"}
+
+ Always
+ IfNotPresent
+ Never
+
+
+
+ {zh
+ ? "镜像拉取 Secret(逗号分隔)"
+ : "Image Pull Secrets (comma-separated)"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ imagePullSecrets: event.target.value,
+ },
+ },
+ })
+ }
+ placeholder="registry-secret"
+ />
+
+
+ {zh
+ ? "Containerd Socket(可选)"
+ : "Containerd Socket (optional)"}
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ kubernetes: {
+ ...config.deployment.kubernetes,
+ containerdSocket: event.target.value,
+ },
+ },
+ })
+ }
+ placeholder="/run/containerd/containerd.sock"
+ />
+
+
+ {zh ? "TLS 验证" : "TLS Verification"}
+
+
+ setConfig({
+ ...config,
+ deployment: {
+ ...config.deployment,
+ insecureSkipTlsVerify: event.target.checked,
+ },
+ })
+ }
+ />
+ {zh
+ ? "跳过控制面 TLS 证书验证(不推荐)"
+ : "Skip control-plane TLS certificate verification (not recommended)"}
+
+
+
)}
-
-
-
-
{zh ? "日志后端配置" : "Log Backend Configuration"}
-
- {zh
- ? "配置日志后端的连接参数,用于查询历史日志"
- : "Configure log backend connection parameters for querying historical logs"}
-
+ {activeCategory === "log" && (
+
+
+
+
+ {zh ? "日志后端配置" : "Log Backend Configuration"}
+
+
+ {zh
+ ? "配置日志后端的连接参数,用于查询历史日志"
+ : "Configure log backend connection parameters for querying historical logs"}
+
+
-
-
-
-
-
- {zh ? "后端类型" : "Backend Type"}
- {
- setConfig({
- ...config,
- log: { ...config.log, backend: e.target.value },
- });
- setIsLogConfigDirty(true);
- }}
- >
- 阿里云 SLS
-
- Loki (待支持)
-
-
- Elasticsearch (待支持)
-
-
-
-
- {zh ? "接入地址 (Endpoint)" : "Endpoint"}
- {
- setConfig({
- ...config,
- log: {
- ...config.log,
- config: {
- ...config.log.config,
- endpoint: e.target.value,
- },
- },
- });
- setIsLogConfigDirty(true);
- }}
- placeholder="rlark.cn-beijing.log.aliyuncs.com:10012"
- />
-
-
- {zh ? "项目/组织名 (Project)" : "Project"}
- {
- setConfig({
- ...config,
- log: {
- ...config.log,
- config: {
- ...config.log.config,
- project: e.target.value,
- },
- },
- });
- setIsLogConfigDirty(true);
- }}
- placeholder="rlark"
- />
-
-
- {zh ? "日志库/索引名 (Logstore)" : "Logstore"}
- {
- setConfig({
- ...config,
- log: {
- ...config.log,
- config: {
- ...config.log.config,
- logstore: e.target.value,
- },
- },
- });
- setIsLogConfigDirty(true);
- }}
- placeholder="rlark"
- />
-
-
- {zh ? "认证 ID (Access Key ID)" : "Access Key ID"}
- {
- setConfig({
- ...config,
- log: {
- ...config.log,
- config: {
- ...config.log.config,
- accessKeyId: e.target.value,
- },
- },
- });
- setIsLogConfigDirty(true);
- }}
- placeholder="xxxxx"
- />
-
-
- {zh ? "认证 Secret (Access Key Secret)" : "Access Key Secret"}
- {
- setConfig({
- ...config,
- log: {
- ...config.log,
- config: {
- ...config.log.config,
- accessKeySecret: e.target.value,
- },
- },
- isAccessKeySecretSet: false,
- });
- setIsLogConfigDirty(true);
- }}
- placeholder={
- config.isAccessKeySecretSet
- ? zh
- ? "已设置,输入以更新"
- : "Configured. Type to update"
- : "xxxxx"
- }
- />
-
+
+
+
+
+ {zh ? "后端类型" : "Backend Type"}
+ {
+ setConfig({
+ ...config,
+ log: { ...config.log, backend: e.target.value },
+ });
+ setIsLogConfigDirty(true);
+ }}
+ >
+ {zh ? "不开启" : "Disabled"}
+ 阿里云 SLS
+
+ Loki (待支持)
+
+
+ Elasticsearch (待支持)
+
+
+
+ {config.log.backend === "sls" && (
+
+ {zh ? "接入地址 (Endpoint)" : "Endpoint"}
+ {
+ setConfig({
+ ...config,
+ log: {
+ ...config.log,
+ config: {
+ ...config.log.config,
+ endpoint: e.target.value,
+ },
+ },
+ });
+ setIsLogConfigDirty(true);
+ }}
+ placeholder="rlark.cn-beijing.log.aliyuncs.com:10012"
+ />
+
+ )}
+ {config.log.backend === "sls" && (
+
+ {zh ? "项目/组织名 (Project)" : "Project"}
+ {
+ setConfig({
+ ...config,
+ log: {
+ ...config.log,
+ config: {
+ ...config.log.config,
+ project: e.target.value,
+ },
+ },
+ });
+ setIsLogConfigDirty(true);
+ }}
+ placeholder="rlark"
+ />
+
+ )}
+ {config.log.backend === "sls" && (
+
+ {zh ? "日志库/索引名 (Logstore)" : "Logstore"}
+ {
+ setConfig({
+ ...config,
+ log: {
+ ...config.log,
+ config: {
+ ...config.log.config,
+ logstore: e.target.value,
+ },
+ },
+ });
+ setIsLogConfigDirty(true);
+ }}
+ placeholder="rlark"
+ />
+
+ )}
+ {config.log.backend === "sls" && (
+
+ {zh ? "认证 ID (Access Key ID)" : "Access Key ID"}
+ {
+ setConfig({
+ ...config,
+ log: {
+ ...config.log,
+ config: {
+ ...config.log.config,
+ accessKeyId: e.target.value,
+ },
+ },
+ });
+ setIsLogConfigDirty(true);
+ }}
+ placeholder="xxxxx"
+ />
+
+ )}
+ {config.log.backend === "sls" && (
+
+ {zh
+ ? "认证 Secret (Access Key Secret)"
+ : "Access Key Secret"}
+ {
+ setConfig({
+ ...config,
+ log: {
+ ...config.log,
+ config: {
+ ...config.log.config,
+ accessKeySecret: e.target.value,
+ },
+ },
+ isAccessKeySecretSet: false,
+ });
+ setIsLogConfigDirty(true);
+ }}
+ placeholder={
+ config.isAccessKeySecretSet
+ ? zh
+ ? "已设置,输入以更新"
+ : "Configured. Type to update"
+ : "xxxxx"
+ }
+ />
+
+ )}
+
-
-
+
+ )}
+
);
}
diff --git a/apps/rlark-ui/src/pages/Workflows.tsx b/apps/rlark-ui/src/pages/Workflows.tsx
index 61261e3..a635113 100644
--- a/apps/rlark-ui/src/pages/Workflows.tsx
+++ b/apps/rlark-ui/src/pages/Workflows.tsx
@@ -6,8 +6,21 @@ import {
useState,
type MouseEvent,
} from "react";
-import { Check, ChevronLeft, Plus, Trash2, X } from "lucide-react";
-import { clusters, type Cluster, type JobType } from "../data";
+import {
+ Check,
+ ChevronLeft,
+ Play,
+ Plus,
+ Square,
+ Trash2,
+ X,
+} from "lucide-react";
+import {
+ storageClasses as mockStorageClasses,
+ clusters,
+ type Cluster,
+ type JobType,
+} from "../data";
import type { Copy } from "../i18n";
import type {
CRDWorkflow,
@@ -21,8 +34,9 @@ import { crdToWorkflow } from "../utils/crd";
import { hasCycle, makeDefaultRoleResources } from "../utils/dag";
import {
automaticNetworkDomain,
- computePvcStorageMap,
generateJobCRD,
+ isValidRoleName,
+ ROLE_NAME_MAX_LENGTH,
ROLE_TEMPLATES,
} from "../utils/job";
import { toYaml } from "../utils/yaml";
@@ -31,26 +45,32 @@ import { useNodeLabels } from "../utils/nodes";
import { NodeSelectorPicker, RoleNameInput } from "../components/create";
import { CodeEditorField } from "../components/CodeEditor";
import {
+ ColumnFilterButton,
compareSortValues,
PageToolbar,
Pagination,
+ RefreshOverlay,
SortButton,
StatusBadge,
+ useColumnFilter,
type SortDirection,
} from "../components/shared";
+import { ColumnFilterPopover } from "../components/ColumnFilterPopover";
export function WorkflowDetailPage({
wf,
- crd,
copy: c,
onBack,
onJobClick,
+ onSetStopped,
+ actionPending,
}: {
wf: ReturnType
;
- crd: CRDWorkflow;
copy: Copy;
onBack: () => void;
onJobClick: (jobName: string) => void;
+ onSetStopped: (name: string, stopped: boolean) => void;
+ actionPending: boolean;
}) {
const zh = c.nav.overview === "总览";
const templates = wf.templates;
@@ -208,6 +228,24 @@ export function WorkflowDetailPage({
+ {(wf.phase === "Running" ||
+ wf.phase === "Pending" ||
+ wf.phase === "Stopped") && (
+
onSetStopped(wf.name, wf.phase !== "Stopped")}
+ >
+ {wf.phase === "Stopped" ? : }
+ {wf.phase === "Stopped"
+ ? zh
+ ? "恢复"
+ : "Resume"
+ : zh
+ ? "停止"
+ : "Stop"}
+
+ )}
@@ -373,14 +411,19 @@ export function WorkflowsPage({
const zh = c.nav.overview === "总览";
const [workflows, setWorkflows] = useState
([]);
const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
+ const [actionPending, setActionPending] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [sort, setSort] = useState<{
- key: "name" | "phase" | "jobCount" | "created";
+ key: "created";
direction: SortDirection;
}>({ key: "created", direction: "desc" });
+ // 状态列多选筛选;空数组 = 全部
+ const [phaseFilter, setPhaseFilter] = useState([]);
+ const columnFilter = useColumnFilter();
const toggleSort = (key: typeof sort.key) =>
setSort((current) => ({
key,
@@ -392,10 +435,7 @@ export function WorkflowsPage({
if (isInitial) setLoading(true);
setError("");
try {
- const resp = await fetch("/api/v1/rlinf.io/v1alpha1/workflows");
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
- setWorkflows(data.items ?? []);
+ setWorkflows(await workflowsApi.list());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -405,6 +445,16 @@ export function WorkflowsPage({
useAutoRefresh(fetchWorkflows, 10000);
+ const handleRefresh = async () => {
+ if (refreshing) return;
+ setRefreshing(true);
+ try {
+ await fetchWorkflows(false);
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
const handleDelete = async (name: string) => {
if (
!confirm(
@@ -413,30 +463,68 @@ export function WorkflowsPage({
)
return;
try {
- const resp = await fetch(`/api/v1/rlinf.io/v1alpha1/workflows/${name}`, {
- method: "DELETE",
- });
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- setWorkflows((prev) => prev.filter((w) => w.metadata.name !== name));
+ await workflowsApi.remove(name);
+ setWorkflows((prev) =>
+ prev.map((w) =>
+ w.metadata.name === name
+ ? {
+ ...w,
+ metadata: {
+ ...w.metadata,
+ deletionTimestamp: new Date().toISOString(),
+ },
+ }
+ : w,
+ ),
+ );
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
+ const handleSetStopped = async (name: string, stopped: boolean) => {
+ setActionPending(name);
+ setError("");
+ try {
+ await workflowsApi.setStopped(name, stopped);
+ setWorkflows((prev) =>
+ prev.map((workflow) =>
+ workflow.metadata.name === name
+ ? { ...workflow, spec: { ...workflow.spec, stopped } }
+ : workflow,
+ ),
+ );
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setActionPending("");
+ }
+ };
+
const items = workflows.map(crdToWorkflow);
- const filteredItems = items.filter((workflow) =>
- `${workflow.name} ${workflow.phase}`
+ const filteredItems = items.filter((workflow) => {
+ const queryHit = `${workflow.name} ${workflow.phase}`
.toLowerCase()
- .includes(query.trim().toLowerCase()),
- );
+ .includes(query.trim().toLowerCase());
+ const phaseHit =
+ phaseFilter.length === 0 || phaseFilter.includes(workflow.phase);
+ return queryHit && phaseHit;
+ });
const sortedItems = [...filteredItems].sort((a, b) =>
compareSortValues(
- a[sort.key],
- b[sort.key],
+ a.created,
+ b.created,
sort.direction,
zh ? "zh-CN" : "en",
),
);
+ // 状态选项:从当前工作流去重
+ const phaseOptions = Array.from(new Set(items.map((w) => w.phase)))
+ .sort()
+ .map((v) => ({
+ value: v,
+ label: (c.status as Record)[v] ?? v,
+ }));
const totalPages = Math.max(1, Math.ceil(sortedItems.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedItems = sortedItems.slice(
@@ -444,7 +532,7 @@ export function WorkflowsPage({
currentPage * pageSize,
);
- useEffect(() => setPage(1), [query, pageSize]);
+ useEffect(() => setPage(1), [query, phaseFilter, pageSize]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
@@ -458,10 +546,11 @@ export function WorkflowsPage({
return (
w.metadata.name === selectedName)!}
copy={c}
onBack={() => onSelect(undefined)}
onJobClick={onJobClick}
+ onSetStopped={handleSetStopped}
+ actionPending={actionPending === selected.name}
/>
);
}
@@ -485,41 +574,30 @@ export function WorkflowsPage({
onChange={setQuery}
count={filteredItems.length}
copy={c}
- onRefresh={() => fetchWorkflows(false)}
+ onRefresh={handleRefresh}
+ refreshing={refreshing}
/>
{error && (
{error}
)}
-
+
+ {zh ? "工作流名称" : "Name"}
- toggleSort("name")}
- />
-
-
- toggleSort("phase")}
- />
-
-
- toggleSort("jobCount")}
+ selectedCount={phaseFilter.length}
+ onClick={columnFilter.openFor("phase")}
/>
+ {zh ? "任务数" : "Jobs"}
toggleSort("created")}
/>
-
+ {zh ? "操作" : "Actions"}
@@ -562,14 +640,42 @@ export function WorkflowsPage({
{formatChinaDateTime(wf.created)}
-
+
+ {(wf.phase === "Running" ||
+ wf.phase === "Pending" ||
+ wf.phase === "Stopped") && (
+
{
+ event.stopPropagation();
+ handleSetStopped(wf.name, wf.phase !== "Stopped");
+ }}
+ disabled={actionPending === wf.name}
+ title={
+ wf.phase === "Stopped"
+ ? zh
+ ? "恢复"
+ : "Resume"
+ : zh
+ ? "停止"
+ : "Stop"
+ }
+ >
+ {wf.phase === "Stopped" ? (
+
+ ) : (
+
+ )}
+
+ )}
{
event.stopPropagation();
handleDelete(wf.name);
}}
+ disabled={wf.phase === "Deleting"}
title={zh ? "删除" : "Delete"}
>
@@ -598,6 +704,10 @@ export function WorkflowsPage({
)}
+
+ {columnFilter.openKey === "phase" && (
+
+ )}
);
}
@@ -649,7 +770,7 @@ export function CreateWorkflowModal({
>([]);
const [storageClassLoading, setStorageClassLoading] = useState(false);
const [storageClassFetched, setStorageClassFetched] = useState(false);
- const [clustersLoaded, setClustersLoaded] = useState(false);
+ const [, setClustersLoaded] = useState(false);
const lastFetchedStorageClusterRef = useRef("");
const [dragNode, setDragNode] = useState<{
@@ -690,13 +811,11 @@ export function CreateWorkflowModal({
]);
useEffect(() => {
- fetch("/api/v1/rlinf.io/v1alpha1/domains")
- .then((r) =>
- r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
- )
- .then((data) =>
+ domainsApi
+ .list()
+ .then((items) =>
setDomains(
- (data.items ?? []).map((d: any) => ({
+ items.map((d) => ({
name: d.metadata?.name ?? "",
cidr: d.spec?.cidr ?? "",
})),
@@ -706,12 +825,10 @@ export function CreateWorkflowModal({
}, []);
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",
@@ -771,30 +888,32 @@ export function CreateWorkflowModal({
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 result = await resp.json();
+ const data = await storageClassesApi.list<{
+ name?: string;
+ description?: string;
+ bucket?: string;
+ }>(cluster);
const list: Array<{ name: string; description: string; bucket: string }> =
[];
- const data = result.data ?? {};
for (const [key, value] of Object.entries(data)) {
- const item = value as any;
list.push({
- name: item.name ?? key,
- description: item.description ?? "",
- bucket: item.bucket ?? "",
+ name: value.name ?? key,
+ description: value.description ?? "",
+ bucket: value.bucket ?? "",
});
}
setStorageClasses(list);
} 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);
@@ -838,12 +957,7 @@ export function CreateWorkflowModal({
type: "RL",
roles,
headerRole: roles[0],
- roleResources: makeDefaultRoleResources(
- "RL",
- roles,
- clusterDisplayNames,
- name,
- ),
+ roleResources: makeDefaultRoleResources("RL", roles, clusterDisplayNames),
runScript:
"python train.py --config /mnt/config/train.yaml --dataset /mnt/dataset --output /mnt/checkpoints",
domain: "",
@@ -1030,11 +1144,19 @@ export function CreateWorkflowModal({
setError(zh ? "角色名称不能为空。" : "Role names cannot be empty.");
return;
}
- if (job.roles.some((role) => role.trim().length > 50)) {
+ if (job.roles.some((role) => role.trim().length > ROLE_NAME_MAX_LENGTH)) {
+ setError(
+ zh
+ ? `角色名称不能超过 ${ROLE_NAME_MAX_LENGTH} 个字符。`
+ : `Role names cannot exceed ${ROLE_NAME_MAX_LENGTH} characters.`,
+ );
+ return;
+ }
+ if (job.roles.some((role) => !isValidRoleName(role.trim()))) {
setError(
zh
- ? "角色名称不能超过 50 个字符。"
- : "Role names cannot exceed 50 characters.",
+ ? "名称格式不正确,仅支持中英文、数字以及-_."
+ : "Invalid name format. Only Chinese/English letters, digits, -, _ and . are allowed.",
);
return;
}
@@ -1042,18 +1164,27 @@ export function CreateWorkflowModal({
setError(zh ? "角色名称不能重复。" : "Role names must be unique.");
return;
}
+ // ray head 只能是单 pod 任务:header 角色的副本数必须为 1。
+ const header =
+ job.roles.includes(job.headerRole) && job.headerRole
+ ? job.headerRole
+ : (job.roles[0] ?? "");
+ if (header) {
+ const raw = Number(job.roleResources[header]?.replicas);
+ const headerReplicas = Number.isFinite(raw) && raw > 0 ? raw : 1;
+ if (headerReplicas !== 1) {
+ setError(
+ zh
+ ? `Job ${job.name} 的 Header 角色 ${header} 只能有一个 Pod(副本数需为 1)。`
+ : `The header role ${header} of job ${job.name} must have exactly one pod (replicas must be 1).`,
+ );
+ return;
+ }
+ }
}
setSubmitting(true);
try {
- const resp = await fetch("/api/v1/rlinf.io/v1alpha1/workflows", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(crd),
- });
- if (!resp.ok) {
- const body = await resp.text();
- throw new Error(`HTTP ${resp.status}: ${body}`);
- }
+ await workflowsApi.create(crd);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -1067,6 +1198,18 @@ export function CreateWorkflowModal({
activeJob && activeJob.roles.includes(activeJob.headerRole)
? activeJob.headerRole
: (activeJob?.roles[0] ?? "");
+ // ray head 只能是单 pod 任务:replicas 为 1(未配置时按 1 处理)。
+ const roleReplicas = (role: string) => {
+ const rr = activeJob?.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 (!activeJob || !canBeHeader(role)) return;
+ updateJob(activeJob.id, { headerRole: role });
+ };
const steps = zh
? ["DAG 编排", "Job 详情", "YAML 预览"]
: ["DAG Editor", "Job Details", "YAML Preview"];
@@ -1171,11 +1314,10 @@ export function CreateWorkflowModal({
const mounts = rr.mounts.map((m, i) =>
i === index ? { ...m, [field]: value } : m,
);
- const pvcStorageMap = computePvcStorageMap(role, mounts, activeJob.name);
updateJob(activeJob.id, {
roleResources: {
...activeJob.roleResources,
- [role]: { ...rr, mounts, pvcStorageMap },
+ [role]: { ...rr, mounts },
},
});
};
@@ -1194,11 +1336,10 @@ export function CreateWorkflowModal({
pvcSizeGb: 10,
},
];
- const pvcStorageMap = computePvcStorageMap(role, newMounts, activeJob.name);
updateJob(activeJob.id, {
roleResources: {
...activeJob.roleResources,
- [role]: { ...rr, mounts: newMounts, pvcStorageMap },
+ [role]: { ...rr, mounts: newMounts },
},
});
};
@@ -1208,11 +1349,10 @@ export function CreateWorkflowModal({
const rr = activeJob.roleResources[role];
if (!rr) return;
const newMounts = rr.mounts.filter((_, i) => i !== index);
- const pvcStorageMap = computePvcStorageMap(role, newMounts, activeJob.name);
updateJob(activeJob.id, {
roleResources: {
...activeJob.roleResources,
- [role]: { ...rr, mounts: newMounts, pvcStorageMap },
+ [role]: { ...rr, mounts: newMounts },
},
});
};
@@ -1220,12 +1360,7 @@ export function CreateWorkflowModal({
const onJobTypeChange = (next: JobType) => {
if (!activeJob) return;
const newRoles = ROLE_TEMPLATES[next];
- const newRR = makeDefaultRoleResources(
- next,
- newRoles,
- clusterDisplayNames,
- activeJob.name,
- );
+ const newRR = makeDefaultRoleResources(next, newRoles, clusterDisplayNames);
updateJob(activeJob.id, {
type: next,
roles: newRoles,
@@ -1280,7 +1415,9 @@ export function CreateWorkflowModal({
const renameRole = (old: string, newName: string) => {
if (!activeJob) return;
newName = newName.trim();
- if (!newName || newName.length > 50 || old === newName) return;
+ // 允许清空/超长名称即时写入,空与格式问题由提交校验统一拦截;
+ // 此处仅拦截重复,避免按名称索引的 roleResources 相互覆盖。
+ if (old === newName) return;
if (
activeJob.roles.some(
(role) => role !== old && role.toLowerCase() === newName.toLowerCase(),
@@ -1551,32 +1688,54 @@ export function CreateWorkflowModal({
- {activeJob.roles.map((role) => (
-
- updateJob(activeJob.id, { headerRole: role })
- }
- >
-
-
-
- {effectiveHeader === role ? "Header" : "Worker"}
-
- {activeJob.roles.length > 1 && (
- {
- e.stopPropagation();
- removeRole(role);
- }}
- >
-
-
- )}
-
- ))}
+ {activeJob.roles.map((role) => {
+ const headerAllowed = canBeHeader(role);
+ const isHeader = effectiveHeader === role;
+ return (
+
selectHeaderRole(role)}
+ title={
+ !headerAllowed
+ ? zh
+ ? "Header 角色只能有一个 Pod,请将该角色副本数设为 1。"
+ : "The header role must have exactly one pod. Set its replicas to 1."
+ : undefined
+ }
+ >
+
+
+
+ {isHeader
+ ? "Header"
+ : !headerAllowed
+ ? zh
+ ? "Worker(多 Pod)"
+ : "Worker (multi-pod)"
+ : "Worker"}
+
+ {activeJob.roles.length > 1 && (
+ {
+ e.stopPropagation();
+ removeRole(role);
+ }}
+ >
+
+
+ )}
+
+ );
+ })}
{activeJob.roles.length > 0 && (
@@ -1982,13 +2141,9 @@ export function CreateWorkflowModal({
role,
index,
"pvcSizeGb",
- Math.min(
- 200,
- Math.max(
- 1,
- Number(e.target.value) || 1,
- ),
- ),
+ e.target.value === ""
+ ? ""
+ : Number(e.target.value),
)
}
/>
@@ -2014,21 +2169,39 @@ export function CreateWorkflowModal({
{zh ? "选择 Head 节点" : "Select Head"}
- {activeJob.roles.map((role) => (
-
- updateJob(activeJob.id, { headerRole: role })
- }
- >
-
- {role}
-
- {effectiveHeader === role ? "Header" : "Worker"}
-
-
- ))}
+ {activeJob.roles.map((role) => {
+ const headerAllowed = canBeHeader(role);
+ const isHeader = effectiveHeader === role;
+ return (
+ selectHeaderRole(role)}
+ title={
+ !headerAllowed
+ ? zh
+ ? "Header 角色只能有一个 Pod,请将该角色副本数设为 1。"
+ : "The header role must have exactly one pod. Set its replicas to 1."
+ : undefined
+ }
+ >
+
+ {role}
+
+ {isHeader
+ ? "Header"
+ : !headerAllowed
+ ? zh
+ ? "Worker(多 Pod)"
+ : "Worker (multi-pod)"
+ : "Worker"}
+
+
+ );
+ })}
@@ -2155,3 +2328,9 @@ export function CreateWorkflowModal({
);
}
+import {
+ clustersApi,
+ domainsApi,
+ storageClassesApi,
+ workflowsApi,
+} from "../backend";
diff --git a/apps/rlark-ui/src/styles.css b/apps/rlark-ui/src/styles.css
index 81da577..89071e5 100644
--- a/apps/rlark-ui/src/styles.css
+++ b/apps/rlark-ui/src/styles.css
@@ -1,17892 +1,54 @@
-@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap");
-
-:root {
- font-family:
- Inter,
- ui-sans-serif,
- system-ui,
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- sans-serif;
- color: #171b25;
- background: #eef1f5;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
-
- --ink: #171b25;
- --muted: #7c8492;
- --soft: #a9b0bd;
- --line: #e7ebf0;
- --line-strong: #dce2ea;
- --panel: #ffffff;
- --canvas: #f4f6f9;
- --blue: #7c3aed;
- --blue-2: #a78bfa;
- --green: #36c98f;
- --green-soft: #e7f8f1;
- --red: #ef5a7a;
- --orange: #f59e35;
- --shadow: 0 18px 50px rgba(28, 39, 58, 0.07);
- --shadow-soft: 0 8px 24px rgba(28, 39, 58, 0.055);
- --app-bg: #f4f6f9;
- --hover: #f5f0fe;
-}
-
-* {
- box-sizing: border-box;
-}
-
-html {
- background: #e9edf3;
-}
-
-html,
-body,
-#root {
- width: 100%;
- height: 100%;
- overflow: hidden;
-}
-
-body {
- margin: 0;
- min-width: 0;
- min-height: 100%;
- background:
- radial-gradient(
- circle at 82% 8%,
- rgba(124, 58, 237, 0.08),
- transparent 26%
- ),
- linear-gradient(180deg, #eef1f5 0%, #f6f8fb 100%);
-}
-
-button,
-input,
-textarea,
-select {
- font: inherit;
-}
-
-button {
- cursor: pointer;
-}
-
-.app-shell {
- width: 100%;
- height: 100vh;
- min-height: 0;
- margin: 0;
- display: grid;
- grid-template-columns: 256px minmax(0, 1fr);
- overflow: hidden;
- background: var(--canvas);
- transition: grid-template-columns 0.25s ease;
-}
-
-.sidebar {
- position: sticky;
- top: 0;
- align-self: start;
- z-index: 5;
- height: 100vh;
- min-height: 0;
- padding: 24px 16px 22px;
- background: rgba(255, 255, 255, 0.88);
- border-right: 1px solid var(--line);
- color: var(--ink);
- display: flex;
- flex-direction: column;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
-}
-
-.brand {
- height: 70px;
- display: flex;
- align-items: center;
- padding: 0 8px;
- margin-bottom: 24px;
-}
-
-.brand-logo {
- width: 198px;
- height: 58px;
- object-fit: contain;
- object-position: left center;
- flex-shrink: 0;
- display: block;
- background: transparent;
-}
-
-.brand-logo-light {
- width: 198px;
-}
-
-.brand-logo-dark {
- display: none;
-}
-
-.brand-mark {
- width: 36px;
- height: 36px;
- position: relative;
- border-radius: 11px;
- background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
- box-shadow: 0 9px 18px rgba(124, 58, 237, 0.25);
-}
-
-.brand-mark span {
- position: absolute;
- display: block;
- width: 8px;
- height: 8px;
- border-radius: 3px;
- background: rgba(255, 255, 255, 0.95);
-}
-
-.brand-mark span:nth-child(1) {
- left: 6px;
- top: 6px;
- opacity: 0.9;
-}
-
-.brand-mark span:nth-child(2) {
- right: 6px;
- top: 6px;
- opacity: 0.65;
-}
-
-.brand-mark span:nth-child(3) {
- left: 10px;
- bottom: 6px;
- opacity: 0.8;
-}
-
-.brand strong {
- display: block;
- color: #131722;
- font-size: 20px;
- line-height: 20px;
- letter-spacing: -0.8px;
-}
-
-.brand small {
- color: #8f98a8;
- font-size: 8px;
- letter-spacing: 1.6px;
- font-weight: 800;
-}
-
-.sidebar nav {
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.nav-label {
- font-size: 10px;
- letter-spacing: 1.2px;
- color: #a5adbb;
- font-weight: 800;
- padding: 18px 12px 7px;
-}
-
-.sidebar nav button,
-.sidebar-bottom > button {
- width: 100%;
- border: 0;
- color: #4e5768;
- background: transparent;
- border-radius: 12px;
- height: 44px;
- padding: 0 12px;
- display: flex;
- align-items: center;
- gap: 12px;
- font-size: 13px;
- font-weight: 650;
- text-align: left;
- position: relative;
- transition: 0.18s ease;
- white-space: nowrap;
-}
-
-.sidebar nav button:hover {
- background: #f3f6fb;
- color: #1f2530;
-}
-
-.sidebar nav button.active {
- color: var(--blue);
- background: #f3eefe;
-}
-
-.sidebar nav button.active::before {
- content: "";
- position: absolute;
- left: -16px;
- width: 4px;
- height: 28px;
- background: var(--blue);
- border-radius: 0 8px 8px 0;
-}
-
-.sidebar nav button em {
- margin-left: auto;
- min-width: 24px;
- height: 20px;
- display: grid;
- place-items: center;
- font-size: 10px;
- color: #14a771;
- background: #dff8ed;
- border-radius: 999px;
- font-style: normal;
-}
-
-.nav-children {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding-left: 16px;
-}
-
-.nav-children button {
- height: 38px;
- font-size: 12px;
- font-weight: 550;
-}
-
-.nav-children button.active::before {
- height: 22px;
-}
-
-.sidebar nav button.nav-parent-expanded {
- color: #4e5768;
- background: transparent;
- font-weight: 650;
-}
-
-.sidebar nav button.nav-parent-expanded:hover {
- background: #f3f6fb;
-}
-
-.sidebar-bottom {
- margin-top: auto;
- display: grid;
- gap: 12px;
-}
-
-.environment-card {
- min-height: 118px;
- background:
- radial-gradient(
- circle at 80% 25%,
- rgba(255, 255, 255, 0.18),
- transparent 28%
- ),
- linear-gradient(145deg, #6d28d9 0%, #5b21b6 100%);
- border: 0;
- border-radius: 18px;
- padding: 16px;
- display: grid;
- grid-template-columns: 38px 1fr 8px;
- gap: 11px;
- align-items: start;
- box-shadow: 0 14px 28px rgba(124, 58, 237, 0.24);
- color: white;
-}
-
-.environment-card > span {
- width: 38px;
- height: 38px;
- background: rgba(255, 255, 255, 0.16);
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: 12px;
- color: #fff;
- display: grid;
- place-items: center;
-}
-
-.environment-card small {
- display: block;
- font-size: 9px;
- letter-spacing: 1px;
- color: rgba(255, 255, 255, 0.66);
- margin-top: 2px;
-}
-
-.environment-card strong {
- display: block;
- color: #fff;
- font-size: 13px;
- margin-top: 5px;
-}
-
-.env-meta {
- display: block;
- color: rgba(255, 255, 255, 0.72);
- font-size: 10px;
- font-weight: 500;
- margin-top: 10px;
-}
-
-.environment-card > i {
- width: 8px;
- height: 8px;
- background: #73ffbf;
- border-radius: 50%;
- box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.14);
- margin-top: 5px;
-}
-
-.sidebar-bottom > button {
- color: #828b9a;
- justify-content: flex-start;
-}
-
-.main-area {
- min-width: 0;
- width: 100%;
- height: 100vh;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- background: #f6f8fb;
- height: 100vh;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.platform-footer {
- flex: 0 0 auto;
- padding: 10px 24px 11px;
- border-top: 1px solid var(--line);
- color: var(--muted);
- background: var(--canvas);
- text-align: center;
- font-size: 11px;
- letter-spacing: 0.15px;
-}
-
-.platform-footer-links {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 11px;
-}
-
-.platform-footer-links strong {
- color: var(--ink);
- font-size: 12px;
-}
-
-.platform-footer-links > span {
- color: var(--line-strong);
-}
-
-.platform-footer-links .platform-footer-maintainer {
- color: var(--muted);
-}
-
-.platform-footer a {
- color: var(--ink);
- font-weight: 700;
- text-decoration: none;
-}
-
-.platform-footer a:hover {
- color: var(--blue);
- text-decoration: underline;
-}
-
-.topbar {
- height: 72px;
- flex-shrink: 0;
- background: rgba(255, 255, 255, 0.86);
- backdrop-filter: blur(18px);
- border-bottom: 1px solid var(--line);
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0 30px 0 28px;
- position: sticky;
- top: 0;
- z-index: 4;
-}
-
-.breadcrumbs {
- display: none;
-}
-
-.topbar-context {
- min-width: 150px;
- display: grid;
- gap: 2px;
-}
-
-.topbar-context > span {
- color: #9aa4b2;
- font-size: 9px;
- font-weight: 800;
- letter-spacing: 0.7px;
- text-transform: uppercase;
-}
-
-.topbar-context h1 {
- margin: 0;
- color: var(--ink);
- font-size: 15px;
- line-height: 1.2;
- letter-spacing: -0.25px;
-}
-
-.topbar-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: nowrap;
- min-width: 0;
-}
-
-.segmented-control {
- height: 38px;
- display: flex;
- align-items: center;
- gap: 3px;
- padding: 4px;
- border: 1px solid var(--line);
- border-radius: 999px;
- background: #fff;
- box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
- flex: none;
-}
-
-.segmented-control button {
- height: 28px;
- min-width: 34px;
- border: 0;
- border-radius: 999px;
- padding: 0 10px;
- background: transparent;
- color: #7a8494;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- font-size: 11px;
- font-weight: 850;
- white-space: nowrap;
-}
-
-.segmented-control button.active {
- background: #f3eefe;
- color: var(--blue);
- box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.08);
-}
-
-.theme-control button {
- min-width: 46px;
-}
-
-.cluster-picker,
-.secondary-button {
- height: 38px;
- border: 1px solid var(--line);
- background: #fff;
- color: #525b6a;
- border-radius: 12px;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 0 12px;
- font-size: 12px;
- font-weight: 700;
- box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
- white-space: nowrap;
- flex: none;
-}
-
-.online-pulse {
- width: 8px;
- height: 8px;
- background: #32c98d;
- border-radius: 50%;
- box-shadow: 0 0 0 4px #def7ec;
-}
-
-.icon-button {
- width: 38px;
- height: 38px;
- border: 1px solid var(--line);
- color: #5f6978;
- background: #fff;
- border-radius: 50%;
- display: grid;
- place-items: center;
- position: relative;
- padding: 0;
- box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
-}
-
-.icon-button em {
- position: absolute;
- right: -2px;
- top: -3px;
- width: 15px;
- height: 15px;
- border-radius: 50%;
- background: #ff6b70;
- color: #fff;
- font-style: normal;
- font-size: 8px;
- display: grid;
- place-items: center;
- border: 2px solid #fff;
-}
-
-.icon-button.small {
- width: 30px;
- height: 30px;
- border-radius: 10px;
-}
-
-.primary-button {
- height: 40px;
- padding: 0 14px;
- border: 0;
- background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%);
- color: #fff;
- border-radius: 13px;
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- font-weight: 800;
- box-shadow: 0 10px 22px rgba(124, 58, 237, 0.26);
- white-space: nowrap;
- flex: none;
-}
-
-.avatar {
- width: 38px;
- height: 38px;
- display: grid;
- place-items: center;
- border-radius: 50%;
- background: linear-gradient(145deg, #ffe0b6, #ffd1c8);
- color: #68412f;
- font-size: 11px;
- font-weight: 800;
- margin-left: 2px;
- border: 3px solid #fff;
- box-shadow: 0 0 0 1px #e1e6ed;
-}
-
-.environment-status {
- cursor: default;
-}
-
-.topbar-menu {
- position: relative;
- flex: none;
-}
-
-.topbar-popover {
- position: absolute;
- z-index: 20;
- top: calc(100% + 10px);
- right: 0;
- width: 230px;
- padding: 14px;
- display: grid;
- gap: 8px;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: var(--panel);
- box-shadow: var(--shadow);
- color: var(--ink);
-}
-
-.topbar-popover strong {
- font-size: 12px;
-}
-
-.topbar-popover span {
- color: var(--muted);
- font-size: 11px;
-}
-
-.topbar-popover button {
- height: 32px;
- margin-top: 4px;
- border: 1px solid var(--line);
- border-radius: 9px;
- background: transparent;
- color: var(--ink);
- font-size: 11px;
- font-weight: 700;
-}
-
-.page-content {
- padding: 28px 28px 38px;
- flex: 1;
- overflow-y: auto;
- min-height: 0;
-}
-
-.hero-strip {
- min-height: 74px;
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- margin-bottom: 18px;
-}
-
-.eyebrow {
- color: #8f98a8;
- font-size: 10px;
- letter-spacing: 0.8px;
- font-weight: 800;
- display: flex;
- align-items: center;
- gap: 6px;
- text-transform: uppercase;
-}
-
-.hero-strip h2,
-.section-heading h2 {
- margin: 7px 0 7px;
- font-size: 28px;
- line-height: 1.05;
- letter-spacing: -1.1px;
- color: #151922;
-}
-
-.hero-strip p,
-.section-heading p {
- margin: 0;
- color: #7f8897;
- font-size: 13px;
-}
-
-.hero-health {
- min-width: 210px;
- padding: 14px 16px;
- border: 1px solid var(--line);
- border-radius: 18px;
- display: grid;
- gap: 4px;
- background: #fff;
- box-shadow: var(--shadow-soft);
-}
-
-.hero-health > span {
- color: #8993a3;
- font-size: 11px;
-}
-
-.hero-health strong {
- font-size: 14px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.hero-health strong i {
- width: 9px;
- height: 9px;
- border-radius: 50%;
- background: var(--green);
- box-shadow: 0 0 0 5px #e0f8ee;
-}
-
-.hero-health small {
- color: #a6afbc;
- font-size: 10px;
-}
-
-.metric-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 18px;
- margin: 14px 0 18px;
-}
-
-.metric-card,
-.panel,
-.table-panel,
-.master-detail,
-.node-layout,
-.api-layout {
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 22px;
- box-shadow: var(--shadow-soft);
-}
-
-.metric-card {
- padding: 20px 20px 18px;
- min-height: 138px;
- position: relative;
- overflow: hidden;
-}
-
-.metric-card-action {
- width: 100%;
- border: 1px solid var(--line);
- color: inherit;
- font: inherit;
- text-align: left;
- cursor: pointer;
- transition:
- transform 0.18s ease,
- border-color 0.18s ease,
- box-shadow 0.18s ease;
-}
-
-.metric-card-action:hover {
- transform: translateY(-2px);
- border-color: rgba(124, 58, 237, 0.32);
- box-shadow: 0 20px 54px rgba(124, 58, 237, 0.12);
-}
-
-.metric-card-action:focus-visible {
- outline: 3px solid rgba(124, 58, 237, 0.22);
- outline-offset: 3px;
-}
-
-.metric-card::after {
- content: "";
- position: absolute;
- right: -40px;
- top: -42px;
- width: 112px;
- height: 112px;
- border-radius: 50%;
- background: rgba(124, 58, 237, 0.055);
-}
-
-.metric-head {
- display: flex;
- justify-content: space-between;
- color: #a2abb8;
- margin-bottom: 18px;
-}
-
-.metric-icon {
- width: 30px;
- height: 30px;
- display: grid;
- place-items: center;
- border-radius: 10px;
-}
-
-.tone-mint .metric-icon {
- background: #e4f8f0;
- color: #20a875;
-}
-
-.tone-blue .metric-icon {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.tone-violet .metric-icon {
- background: #f2edff;
- color: #7b61ff;
-}
-
-.tone-orange .metric-icon {
- background: #fff2df;
- color: #e58a20;
-}
-
-.metric-label {
- color: #4a5260;
- font-size: 13px;
- font-weight: 700;
- white-space: nowrap;
-}
-
-.metric-value-row {
- display: flex;
- align-items: center;
- gap: 9px;
- margin: 8px 0 4px;
-}
-
-.metric-value-row strong {
- font-size: 30px;
- line-height: 1;
- letter-spacing: -1.4px;
-}
-
-.delta {
- font-size: 11px;
- font-weight: 800;
- background: #dcf8eb;
- color: #17a672;
- border-radius: 999px;
- padding: 4px 8px;
-}
-
-.metric-card > small {
- font-size: 11px;
- color: #98a1af;
- display: block;
- line-height: 1.45;
- max-width: 100%;
-}
-
-.dashboard-grid {
- display: grid;
- grid-template-columns: 1.55fr 0.95fr;
- gap: 18px;
- margin-bottom: 18px;
-}
-
-.bottom-grid {
- display: grid;
- grid-template-columns: 1.45fr 1fr;
- gap: 18px;
-}
-
-.panel {
- padding: 20px;
-}
-
-.panel-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.panel-title > div {
- min-width: 0;
-}
-
-.panel-title .plain-button,
-.panel-title > .icon-button {
- flex: 0 0 auto;
-}
-
-.panel-title span {
- color: #8e97a6;
- font-size: 11px;
-}
-
-.panel-title h3 {
- margin: 4px 0 0;
- font-size: 16px;
- letter-spacing: -0.35px;
-}
-
-.legend {
- font-size: 11px;
- color: #8c96a5;
- display: flex;
- align-items: center;
- gap: 7px;
-}
-
-.legend i {
- width: 8px;
- height: 8px;
- background: var(--blue);
- border-radius: 50%;
-}
-
-.legend strong {
- color: #222a36;
- margin-left: 5px;
-}
-
-.plain-button {
- border: 0;
- background: transparent;
- color: #596576;
- font-size: 12px;
- font-weight: 800;
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.resource-chart {
- height: 194px;
- margin: 15px 0 10px;
- position: relative;
-}
-
-.resource-chart svg {
- width: 100%;
- height: 166px;
- overflow: visible;
-}
-
-.grid-lines line {
- stroke: #edf1f6;
- stroke-width: 1;
-}
-
-.resource-chart .area {
- fill: url(#area);
-}
-
-.resource-chart .line {
- fill: none;
- stroke: var(--blue);
- stroke-width: 2.8;
- vector-effect: non-scaling-stroke;
- filter: drop-shadow(0 5px 8px rgba(124, 58, 237, 0.2));
-}
-
-.chart-labels {
- display: flex;
- justify-content: space-between;
- color: #a2abb8;
- font-size: 10px;
-}
-
-.resource-summary {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- border-top: 1px solid var(--line);
- padding-top: 14px;
- gap: 18px;
-}
-
-.resource-summary > div {
- position: relative;
- padding-bottom: 10px;
-}
-
-.resource-summary span {
- display: block;
- color: #8a94a4;
- font-size: 11px;
-}
-
-.resource-summary strong {
- display: block;
- font-size: 14px;
- margin-top: 5px;
-}
-
-.resource-summary small {
- font-size: 10px;
- color: #98a1af;
- font-weight: 500;
-}
-
-.resource-summary b {
- height: 4px;
- background: linear-gradient(90deg, var(--blue), #c4b5fd);
- position: absolute;
- left: 0;
- bottom: 0;
- border-radius: 999px;
-}
-
-.cluster-resource-line {
- display: block;
- white-space: nowrap;
-}
-
-.mini-bars {
- height: 178px;
- display: flex;
- gap: 12px;
- align-items: flex-end;
- padding: 22px 8px 14px;
- border-bottom: 1px solid var(--line);
-}
-
-.mini-bars i {
- flex: 1;
- background: #e4e9ef;
- border-radius: 12px;
- min-width: 12px;
-}
-
-.mini-bars i.active {
- background: linear-gradient(180deg, #7c3aed, #a78bfa);
- box-shadow: 0 8px 20px rgba(124, 58, 237, 0.18);
-}
-
-.phase-summary {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 9px;
- padding-top: 14px;
-}
-
-.phase-summary > div {
- display: grid;
- grid-template-columns: 8px 1fr auto;
- align-items: center;
- gap: 7px;
-}
-
-.phase-summary i {
- width: 8px;
- height: 8px;
- border-radius: 50%;
-}
-
-.dot-running {
- background: var(--green);
-}
-
-.dot-pending {
- background: var(--orange);
-}
-
-.dot-failed {
- background: var(--red);
-}
-
-.phase-summary span {
- font-size: 11px;
- color: #7e8796;
-}
-
-.phase-summary strong {
- font-size: 13px;
-}
-
-.workflow-list {
- margin-top: 10px;
-}
-
-.workflow-list > button {
- width: 100%;
- height: 66px;
- display: grid;
- grid-template-columns: 40px 1fr auto 118px 18px;
- align-items: center;
- gap: 12px;
- border: 0;
- border-top: 1px solid var(--line);
- background: transparent;
- text-align: left;
- color: #6f7988;
-}
-
-.workflow-list > button:hover {
- background: #f8faff;
-}
-
-.workflow-symbol {
- width: 36px;
- height: 36px;
- border-radius: 12px;
- display: grid;
- place-items: center;
-}
-
-.workflow-symbol.running {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.workflow-symbol.succeeded {
- background: #e4f8f0;
- color: #1f9c70;
-}
-
-.workflow-symbol.failed {
- background: #ffe9ee;
- color: #d94767;
-}
-
-.workflow-symbol.pending {
- background: #fff2df;
- color: #d48220;
-}
-
-.workflow-info strong,
-.master-list strong {
- display: block;
- color: #272d38;
- font-size: 13px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.workflow-info small,
-.master-list small {
- display: block;
- color: #98a1af;
- font-size: 11px;
- margin-top: 3px;
-}
-
-.status {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- font-weight: 800;
- border-radius: 999px;
- padding: 5px 9px;
- width: max-content;
- white-space: nowrap;
- flex: none;
-}
-
-.status i {
- width: 6px;
- height: 6px;
- border-radius: 50%;
-}
-
-.status-icon {
- width: 12px;
- height: 12px;
- stroke-width: 2.5;
-}
-
-.status-running .status-icon,
-.status-stopping .status-icon {
- animation: status-spin 1.25s linear infinite;
-}
-
-@keyframes status-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.status-running,
-.status-online {
- background: #e7f8f1;
- color: #168a61;
-}
-
-.status-running i,
-.status-online i {
- background: var(--green);
-}
-
-.status-succeeded {
- background: #e9f1ff;
- color: #2563c9;
-}
-
-.status-succeeded i {
- background: #3b82f6;
-}
-
-.status-failed,
-.status-offline {
- background: #ffe9ee;
- color: #cf3f61;
-}
-
-.status-failed i,
-.status-offline i {
- background: var(--red);
-}
-
-.status-pending {
- background: #fff3df;
- color: #bd7424;
-}
-
-.status-pending i {
- background: var(--orange);
-}
-
-.status-stopping,
-.status-stopped {
- background: #f0f0f5;
- color: #6b6b80;
-}
-
-.status-stopped i {
- background: #9a9ab0;
-}
-
-.status-with-info {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-
-.status-info {
- position: relative;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- width: 20px;
- height: 20px;
- box-sizing: border-box;
- border: 1px solid #d9e0ea;
- border-radius: 50%;
- background: #f6f8fb;
- cursor: help;
- color: #7d8999;
- transition:
- border-color 0.15s ease,
- background 0.15s ease,
- color 0.15s ease;
-}
-
-.status-info:hover,
-.status-info:focus {
- border-color: #b7a5ed;
- outline: none;
- background: #f2effc;
- color: #7051c7;
-}
-
-.status-info-tooltip {
- position: absolute;
- bottom: calc(100% + 10px);
- left: 50%;
- width: min(292px, calc(100vw - 24px));
- box-sizing: border-box;
- padding: 12px 13px;
- border: 1px solid #dfe4ec;
- border-radius: 10px;
- background: #ffffff;
- color: #273142;
- font-size: 11px;
- font-weight: 500;
- line-height: 1.55;
- white-space: normal;
- box-shadow: 0 12px 30px rgba(31, 45, 65, 0.13);
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.12s;
- z-index: 999;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.status-info-tooltip-arrow {
- position: absolute;
- bottom: -5px;
- width: 10px;
- height: 10px;
- box-sizing: border-box;
- border-right: 1px solid #dfe4ec;
- border-bottom: 1px solid #dfe4ec;
- background: #ffffff;
- transform: rotate(45deg);
-}
-
-/* Flipped-below variant: the arrow is moved to the top edge of the tooltip.
- Position itself is handled by inline style (position: fixed) set from JS
- in PullProgressInfo, so only the arrow needs to be flipped here. */
-.status-info-tooltip.status-info-tooltip-below .status-info-tooltip-arrow {
- top: -5px;
- bottom: auto;
- border: 0;
- border-top: 1px solid #dfe4ec;
- border-left: 1px solid #dfe4ec;
-}
-
-.status-info-tooltip.status-info-tooltip-open {
- opacity: 1;
- pointer-events: auto;
-}
-
-.status-info-tooltip .pull-entry {
- display: flex;
- flex-direction: column;
- gap: 2px;
- border-top: 1px solid #edf0f5;
- padding-top: 6px;
-}
-
-.status-info-tooltip .pull-entry:first-of-type {
- border-top: none;
- padding-top: 0;
-}
-
-.status-info-tooltip .pending-empty-message {
- color: #667286;
- line-height: 1.55;
-}
-
-.status-info-tooltip .status-message-entry {
- color: #c0392b;
- line-height: 1.55;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.status-info-tooltip .pull-entry code {
- font-size: 10px;
- color: #5f49aa;
- background: #f4f1fc;
- padding: 2px 5px;
- border-radius: 5px;
- word-break: break-all;
-}
-
-.status-info-tooltip .pull-detail {
- color: #7a8698;
- font-size: 10px;
-}
-
-.status-info-tooltip .pull-status {
- color: #9b661d;
-}
-
-/* Section heading for the "Node Events" block when both pull progress and
- events are rendered. Mirrors .pull-entry's top border so the events
- section reads as a sibling group. */
-.status-info-tooltip .status-info-tooltip-section {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- border-top: 1px solid #edf0f5;
- padding-top: 6px;
- margin-top: 2px;
-}
-
-.status-info-tooltip .status-info-tooltip-section small {
- color: #8a95a6;
- font-size: 9px;
- font-weight: 500;
-}
-
-/* First section heading (Image Pull Progress) has no preceding border. */
-.status-info-tooltip > strong:first-child {
- border-top: none;
- padding-top: 0;
- margin-top: 0;
-}
-
-.status-info-tooltip .event-entry .event-chip {
- display: inline-block;
- align-self: flex-start;
- padding: 1px 6px;
- border-radius: 3px;
- font-size: 10px;
- font-weight: 600;
- background: #edf1f6;
- color: #667286;
-}
-
-.status-info-tooltip .event-entry .event-chip.event-warning {
- background: #fff0f1;
- color: #c6485e;
-}
-
-.status-info-tooltip .event-entry .event-chip.event-normal {
- background: #edf8f2;
- color: #2f8a60;
-}
-
-.status-info-tooltip .event-entry .event-object {
- font-size: 10px;
- color: #5f49aa;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.status-info-tooltip .event-entry .event-message {
- color: #667286;
- display: -webkit-box;
- font-size: 10px;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.theme-dark .status-info {
- border-color: #344156;
- background: #1b2637;
- color: #9aa7ba;
-}
-
-.theme-dark .status-info:hover,
-.theme-dark .status-info:focus {
- border-color: #7562b8;
- background: #292442;
- color: #c4b5fd;
-}
-
-.theme-dark .status-info-tooltip {
- border-color: #334056;
- background: #182231;
- color: #edf1f7;
- box-shadow: 0 14px 34px rgba(0, 0, 0, 0.32);
-}
-
-.theme-dark .status-info-tooltip-arrow {
- border-color: #334056;
- background: #182231;
-}
-
-.theme-dark
- .status-info-tooltip.status-info-tooltip-below
- .status-info-tooltip-arrow {
- border-color: #334056;
-}
-
-.theme-dark .status-info-tooltip .pending-empty-message,
-.theme-dark .status-info-tooltip .event-entry .event-message {
- color: #aeb9c9;
-}
-
-.theme-dark .status-info-tooltip .pull-entry,
-.theme-dark .status-info-tooltip .status-info-tooltip-section {
- border-color: #2d394c;
-}
-
-.theme-dark .status-info-tooltip .status-message-entry {
- color: #ff6b6b;
-}
-
-.progress-cell {
- display: grid;
- grid-template-columns: 1fr 32px;
- align-items: center;
- gap: 9px;
-}
-
-.progress-cell > i,
-.inline-progress > i {
- display: block;
- width: 100%;
- min-width: 40px;
- height: 5px;
- background: #e8edf4;
- border-radius: 999px;
- overflow: hidden;
-}
-
-.progress-cell b,
-.inline-progress b {
- display: block;
- height: 100%;
- background: linear-gradient(90deg, var(--blue), #c4b5fd);
- border-radius: 999px;
-}
-
-.progress-cell small {
- font-size: 10px;
- color: #7f8897;
-}
-
-.activity-list {
- margin-top: 12px;
-}
-
-.activity-list > div {
- height: 54px;
- border-top: 1px solid var(--line);
- display: grid;
- grid-template-columns: 28px 1fr auto;
- align-items: center;
-}
-
-.activity-dot {
- width: 21px;
- height: 21px;
- display: grid;
- place-items: center;
- border-radius: 50%;
- background: #eef2f7;
-}
-
-.activity-dot i {
- width: 7px;
- height: 7px;
- border-radius: 50%;
-}
-
-.activity-dot.success i,
-.activity-dot.running i {
- background: var(--green);
-}
-
-.activity-dot.warning i {
- background: var(--orange);
-}
-
-.activity-dot.error i {
- background: var(--red);
-}
-
-.activity-list strong {
- display: block;
- font-size: 12px;
-}
-
-.activity-list small {
- font-size: 10px;
- color: #98a1af;
-}
-
-.activity-list time {
- font-size: 10px;
- color: #a2abb8;
-}
-
-.resource-page {
- padding-top: 28px;
-}
-
-.node-detail-page {
- display: flex;
- flex-direction: column;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
- scrollbar-color: #aeb8c8 #e9edf3;
- scrollbar-width: auto;
-}
-
-.node-detail-page > * {
- flex-shrink: 0;
-}
-
-.node-detail-page::-webkit-scrollbar {
- width: 10px;
-}
-
-.node-detail-page::-webkit-scrollbar-track {
- background: #e9edf3;
-}
-
-.node-detail-page::-webkit-scrollbar-thumb {
- border: 2px solid #e9edf3;
- border-radius: 999px;
- background: #aeb8c8;
-}
-
-.overview-page {
- display: flex;
- flex-direction: column;
- overflow-x: hidden;
- overflow-y: auto;
- gap: 18px;
-}
-
-.overview-china-panel {
- flex: 0 0 auto;
- gap: 14px;
- padding: 16px;
- overflow: hidden;
-}
-
-.overview-china-heading {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
-}
-
-.overview-china-heading > div:first-child {
- min-width: 0;
-}
-
-.overview-china-heading h3 {
- margin: 5px 0 3px;
- font-size: 17px;
-}
-
-.overview-china-heading p {
- margin: 0;
- color: var(--muted);
- font-size: 11px;
-}
-
-.overview-demo-badge {
- display: inline-flex;
- align-items: center;
- height: 22px;
- padding: 0 8px;
- border-radius: 999px;
- color: #7357e8;
- background: rgba(115, 87, 232, 0.1);
- font-size: 10px;
- font-weight: 750;
-}
-
-.overview-china-legend {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 7px;
- flex-wrap: wrap;
-}
-
-.overview-china-legend span {
- height: 30px;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 0 10px;
- border: 1px solid currentColor;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 700;
-}
-
-.overview-china-legend .cloud {
- color: #367ce8;
- background: rgba(54, 124, 232, 0.07);
-}
-
-.overview-china-legend .edge {
- color: #21a976;
- background: rgba(33, 169, 118, 0.07);
-}
-
-.overview-china-legend .robot {
- color: #7357e8;
- background: rgba(115, 87, 232, 0.07);
-}
-
-.overview-china-layout {
- min-height: 360px;
- display: grid;
- grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.75fr);
- gap: 14px;
-}
-
-.overview-china-map {
- position: relative;
- min-width: 0;
- overflow: hidden;
- cursor: grab;
- touch-action: none;
- user-select: none;
- border: 1px solid var(--line);
- border-radius: 16px;
- background:
- radial-gradient(
- circle at 72% 68%,
- rgba(54, 201, 143, 0.13),
- transparent 31%
- ),
- radial-gradient(
- circle at 28% 32%,
- rgba(123, 97, 255, 0.14),
- transparent 33%
- ),
- linear-gradient(145deg, #f7f9ff, #f2fbf7);
-}
-
-.overview-china-map.is-dragging {
- cursor: grabbing;
-}
-
-.overview-china-map > svg {
- position: absolute;
- inset: 8px 16px 5px;
- width: calc(100% - 32px);
- height: calc(100% - 13px);
- overflow: visible;
-}
-
-.overview-map-controls {
- position: absolute;
- z-index: 4;
- top: 12px;
- right: 12px;
- display: flex;
- align-items: center;
- padding: 4px;
- border: 1px solid rgba(115, 87, 232, 0.16);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.9);
- box-shadow: 0 6px 18px rgba(31, 43, 65, 0.08);
- backdrop-filter: blur(8px);
- cursor: default;
-}
-
-.overview-map-controls button {
- width: 28px;
- height: 28px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: 0;
- border-radius: 7px;
- color: var(--muted);
- background: transparent;
- cursor: pointer;
-}
-
-.overview-map-controls button:hover:not(:disabled) {
- color: #7357e8;
- background: rgba(115, 87, 232, 0.1);
-}
-
-.overview-map-controls button:disabled {
- opacity: 0.35;
- cursor: default;
-}
-
-.overview-map-controls span {
- min-width: 38px;
- color: var(--muted);
- font-size: 9px;
- font-weight: 700;
- text-align: center;
-}
-
-.china-provinces path {
- fill: rgba(235, 242, 252, 0.9);
- fill-rule: evenodd;
- stroke: rgba(138, 159, 190, 0.48);
- stroke-width: 0.8;
- vector-effect: non-scaling-stroke;
- transition: fill 0.18s ease;
-}
-
-.china-provinces path:hover {
- fill: rgba(220, 231, 251, 0.98);
-}
-
-.china-city-links .city-link-line {
- fill: none;
- stroke: rgba(115, 87, 232, 0.15);
- stroke-width: 0.85;
- stroke-dasharray: 3 6;
- vector-effect: non-scaling-stroke;
-}
-
-.china-city-links .link-particle {
- fill: #7357e8;
- filter: drop-shadow(0 0 3px rgba(115, 87, 232, 0.75));
-}
-
-.china-city-links .link-particle.particle-1 {
- fill: #367ce8;
- filter: drop-shadow(0 0 3px rgba(54, 124, 232, 0.75));
-}
-
-.china-city-links .link-particle.particle-2 {
- fill: #21a976;
- filter: drop-shadow(0 0 3px rgba(33, 169, 118, 0.75));
-}
-
-.china-city-pin {
- cursor: pointer;
- outline: none;
-}
-
-.china-city-pin .pin-halo {
- fill: rgba(115, 87, 232, 0.14);
- stroke: rgba(115, 87, 232, 0.18);
- stroke-width: 5;
-}
-
-.china-city-pin .pin-core {
- fill: #7357e8;
- stroke: white;
- stroke-width: 2;
-}
-
-.china-city-pin.pin-cloud .pin-core {
- fill: #367ce8;
-}
-
-.china-city-pin.pin-edge .pin-core {
- fill: #21a976;
-}
-
-.china-city-pin.pin-robot .pin-core {
- fill: #8b5cf6;
-}
-
-.china-city-pin .pin-badge {
- fill: #14a86c;
- stroke: white;
- stroke-width: 1.5;
-}
-
-.china-city-pin .pin-value {
- fill: white;
- font-size: 8px;
- font-weight: 800;
- pointer-events: none;
-}
-
-.china-city-pin .pin-label {
- fill: var(--ink);
- font-size: 9px;
- font-weight: 750;
- paint-order: stroke;
- stroke: rgba(255, 255, 255, 0.9);
- stroke-width: 3px;
- stroke-linejoin: round;
- pointer-events: none;
-}
-
-.china-city-pin:hover .pin-halo,
-.china-city-pin:focus-visible .pin-halo {
- fill: rgba(115, 87, 232, 0.24);
- stroke: rgba(115, 87, 232, 0.28);
-}
-
-.china-city-tooltip {
- display: none;
-}
-
-.china-city-pin:hover .china-city-tooltip,
-.china-city-pin:focus-visible .china-city-tooltip {
- display: block;
-}
-
-.china-city-tooltip rect {
- fill: rgba(255, 255, 255, 0.96);
- stroke: rgba(115, 87, 232, 0.22);
- stroke-width: 1;
- filter: drop-shadow(0 5px 10px rgba(31, 43, 65, 0.18));
-}
-
-.china-city-tooltip .tooltip-city {
- fill: var(--ink);
- font-size: 11px;
- font-weight: 800;
-}
-
-.china-city-tooltip .tooltip-detail {
- fill: var(--muted);
- font-size: 9px;
- font-weight: 650;
-}
-
-.overview-map-loading {
- position: absolute;
- inset: 0;
- display: grid;
- place-items: center;
- color: var(--muted);
- font-size: 11px;
-}
-
-.overview-map-caption {
- position: absolute;
- left: 14px;
- bottom: 14px;
- display: flex;
- align-items: center;
- gap: 9px;
- padding: 9px 11px;
- border: 1px solid rgba(115, 87, 232, 0.16);
- border-radius: 11px;
- color: #7357e8;
- background: rgba(255, 255, 255, 0.9);
- box-shadow: 0 8px 20px rgba(31, 43, 65, 0.09);
- backdrop-filter: blur(8px);
- z-index: 3;
-}
-
-.overview-map-caption span,
-.overview-map-caption strong,
-.overview-map-caption small {
- display: block;
-}
-
-.overview-map-caption strong {
- color: var(--ink);
- font-size: 10px;
-}
-
-.overview-map-caption small {
- margin-top: 2px;
- color: var(--muted);
- font-size: 9px;
-}
-
-.overview-china-aside {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.overview-map-stats {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
-}
-
-.overview-map-stats button {
- min-width: 0;
- min-height: 70px;
- padding: 10px;
- text-align: left;
- border: 1px solid var(--line);
- border-radius: 11px;
- color: var(--ink);
- background: var(--panel);
- cursor: pointer;
- transition: 0.16s ease;
-}
-
-.overview-map-stats button:hover {
- border-color: rgba(115, 87, 232, 0.25);
- transform: translateY(-1px);
- box-shadow: var(--shadow-soft);
-}
-
-.overview-map-stats small,
-.overview-map-stats strong {
- display: block;
-}
-
-.overview-map-stats small {
- overflow: hidden;
- color: var(--muted);
- font-size: 9px;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.overview-map-stats strong {
- margin-top: 7px;
- font-size: 20px;
- line-height: 1;
-}
-
-.overview-map-stats .cloud strong {
- color: #367ce8;
-}
-.overview-map-stats .edge strong {
- color: #21a976;
-}
-.overview-map-stats .robot strong {
- color: #7357e8;
-}
-
-.overview-cross-region {
- min-height: 82px;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 5px;
- padding: 12px;
- border: 1px dashed rgba(115, 87, 232, 0.25);
- border-radius: 12px;
- color: var(--ink);
- background: rgba(115, 87, 232, 0.045);
- cursor: pointer;
-}
-
-.overview-cross-region span {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- color: #7357e8;
- font-size: 10px;
- font-weight: 750;
-}
-
-.overview-cross-region strong {
- font-size: 11px;
-}
-
-.overview-cross-region small {
- color: var(--muted);
- font-size: 9px;
-}
-
-.overview-city-summary {
- min-height: 0;
- display: grid;
- gap: 7px;
-}
-
-.overview-city-summary button {
- min-width: 0;
- display: grid;
- grid-template-columns: 22px minmax(0, 1fr) auto;
- align-items: center;
- gap: 7px;
- padding: 9px 10px;
- border: 1px solid var(--line);
- border-radius: 10px;
- color: var(--muted);
- background: var(--panel);
- text-align: left;
- cursor: pointer;
-}
-
-.overview-city-summary button:hover {
- color: #7357e8;
- border-color: rgba(115, 87, 232, 0.24);
-}
-
-.overview-city-summary span,
-.overview-city-summary strong,
-.overview-city-summary small {
- min-width: 0;
- display: block;
-}
-
-.overview-city-summary strong {
- overflow: hidden;
- color: var(--ink);
- font-size: 10px;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.overview-city-summary small {
- margin-top: 2px;
- color: var(--muted);
- font-size: 9px;
-}
-
-.overview-city-summary b {
- color: var(--ink);
- font-size: 13px;
-}
-
-.theme-dark .overview-china-map {
- border-color: #2a3950;
- background:
- radial-gradient(
- circle at 72% 68%,
- rgba(54, 201, 143, 0.12),
- transparent 31%
- ),
- radial-gradient(
- circle at 28% 32%,
- rgba(123, 97, 255, 0.16),
- transparent 33%
- ),
- linear-gradient(145deg, #101a2a, #101d25);
-}
-
-.theme-dark .china-provinces path {
- fill: rgba(29, 45, 66, 0.92);
- stroke: rgba(112, 137, 171, 0.48);
-}
-
-.theme-dark .china-provinces path:hover {
- fill: rgba(39, 58, 84, 0.98);
-}
-
-.theme-dark .china-city-pin .pin-core,
-.theme-dark .china-city-pin .pin-badge {
- stroke: #111b2a;
-}
-
-.theme-dark .china-city-pin .pin-label {
- stroke: rgba(16, 25, 40, 0.95);
-}
-
-.theme-dark .overview-map-caption {
- border-color: rgba(151, 123, 255, 0.25);
- background: rgba(19, 30, 47, 0.9);
-}
-
-.theme-dark .overview-map-controls {
- border-color: rgba(151, 123, 255, 0.25);
- background: rgba(19, 30, 47, 0.9);
-}
-
-.theme-dark .china-city-tooltip rect {
- fill: rgba(19, 30, 47, 0.97);
- stroke: rgba(151, 123, 255, 0.34);
-}
-
-.theme-dark .overview-map-stats button,
-.theme-dark .overview-city-summary button {
- border-color: #2b3950;
- background: #151f2f;
-}
-
-.theme-dark .overview-cross-region {
- border-color: rgba(151, 123, 255, 0.3);
- background: rgba(123, 97, 255, 0.09);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .china-city-links .link-particle {
- display: none;
- }
-}
-
-.overview-page .hero-strip {
- flex-shrink: 0;
- margin-bottom: 0;
-}
-
-.overview-page .metric-grid {
- flex-shrink: 0;
- margin: 0;
-}
-.overview-page .metric-grid .metric-card {
- min-height: 0;
- padding: 10px 14px 9px;
-}
-.overview-page .metric-card .metric-head {
- margin-bottom: 8px;
-}
-.overview-page .metric-card .metric-value-row strong {
- font-size: 22px;
-}
-.overview-page .metric-card > small {
- font-size: 10px;
-}
-
-.overview-page .dashboard-grid {
- flex: 0 0 auto;
- min-height: auto;
- margin-bottom: 0;
-}
-
-.overview-page .bottom-grid {
- flex: 0 0 auto;
- min-height: auto;
-}
-
-.overview-page .panel {
- display: flex;
- flex-direction: column;
- min-height: 0;
-}
-
-.overview-page .panel-title {
- flex-shrink: 0;
-}
-
-.overview-page .robot-state-list {
- overflow-y: auto;
- flex: 0 0 auto;
- min-height: auto;
-}
-
-.overview-page .workflow-list {
- overflow-y: auto;
- flex: 0 0 auto;
- min-height: auto;
-}
-
-.overview-page .activity-list {
- overflow-y: auto;
- flex: 0 0 auto;
- min-height: auto;
-}
-
-.overview-page .resource-chart {
- flex-shrink: 0;
-}
-
-.overview-page .resource-summary {
- flex-shrink: 0;
-}
-
-.overview-page .mini-bars {
- flex-shrink: 0;
-}
-
-.overview-page .phase-summary {
- flex-shrink: 0;
-}
-
-.overview-page .resource-split {
- margin: 0;
- flex: 0 0 auto;
- min-height: auto;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding-top: 4px;
-}
-.overview-page .resource-split .resource-row {
- flex: 0 0 auto;
- min-height: 62px;
- grid-template-rows: auto;
- align-items: center;
- padding: 12px 20px;
-}
-.overview-page .resource-row > span {
- height: 12px;
-}
-
-.overview-page .chart-panel {
- gap: 12px;
-}
-
-.overview-page .workload-panel {
- gap: 12px;
-}
-
-.cluster-overview-page {
- display: flex;
- flex-direction: column;
- overflow: hidden;
- gap: 18px;
-}
-.cluster-overview-page .section-heading {
- flex-shrink: 0;
- margin-bottom: 0;
-}
-.cluster-overview-page .cluster-overview-grid {
- flex-shrink: 0;
- margin-bottom: 0;
-}
-.cluster-overview-page .cluster-overview-grid .metric-card {
- min-height: 0;
- padding: 10px 14px 9px;
-}
-.cluster-overview-page .cluster-overview-grid .metric-card .metric-head {
- margin-bottom: 8px;
-}
-.cluster-overview-page
- .cluster-overview-grid
- .metric-card
- .metric-value-row
- strong {
- font-size: 22px;
-}
-.cluster-overview-page .cluster-overview-grid .metric-card > small {
- font-size: 10px;
-}
-.cluster-overview-page .cluster-topology-grid {
- flex-shrink: 0;
- margin-bottom: 0;
- height: 320px;
-}
-.cluster-overview-page .cluster-topology-grid > * {
- min-height: 0;
- max-height: 100%;
-}
-.cluster-overview-page .cluster-map-card {
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-.cluster-overview-page .cluster-map-card .cluster-map {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
-}
-.cluster-overview-page .selected-cluster-panel {
- display: flex;
- flex-direction: column;
- overflow: hidden;
- min-height: 0;
-}
-.cluster-overview-page .selected-cluster-panel .cluster-detail-header {
- flex-shrink: 0;
-}
-.cluster-overview-page .selected-cluster-panel .cluster-node-table-wrap {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
-}
-.cluster-overview-page .cluster-list-panel {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-.cluster-overview-page .cluster-list-panel .panel-title {
- flex-shrink: 0;
-}
-.cluster-overview-page .cluster-list-panel .cluster-list-scroll {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- margin-top: 12px;
-}
-
-.cluster-list-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-.cluster-list-table thead th {
- position: sticky;
- top: 0;
- z-index: 1;
- background: #f7f8fa;
- text-align: left;
- padding: 9px 14px;
- font-size: 11px;
- font-weight: 600;
- color: #8d97a6;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- border-bottom: 1px solid var(--line);
-}
-.cluster-list-table tbody tr {
- cursor: pointer;
- border-bottom: 1px solid #f0f1f4;
- transition: background 0.15s;
-}
-.cluster-list-table tbody tr:hover {
- background: #f7f8ff;
-}
-.cluster-list-table tbody tr.selected {
- background: #f0ecff;
-}
-.cluster-list-table tbody tr.selected td {
- color: var(--blue);
-}
-.cluster-list-table td {
- padding: 10px 14px;
- color: #4a5568;
- vertical-align: middle;
-}
-.cluster-list-name {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.cluster-list-name strong {
- font-size: 13px;
- color: #202733;
-}
-.cluster-list-rate {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.cluster-list-rate i {
- display: block;
- width: 50px;
- height: 5px;
- border-radius: 999px;
- background: #e8edf4;
- overflow: hidden;
-}
-.cluster-list-rate b {
- display: block;
- height: 100%;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--blue), #c4b5fd);
-}
-.cluster-list-rate small {
- font-size: 11px;
- color: #8d97a6;
- min-width: 32px;
-}
-
-.cluster-detail-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 18px;
- border-bottom: 1px solid var(--line);
- flex-shrink: 0;
-}
-.cluster-detail-title {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.cluster-detail-title strong {
- font-size: 15px;
- color: #202733;
-}
-.cluster-detail-meta {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- color: #7f8998;
-}
-.cluster-detail-meta .dot {
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: #c4c9d4;
- display: inline-block;
-}
-
-.cluster-node-table-wrap {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
-}
-.cluster-node-table {
- width: 100%;
- min-width: 940px;
- border-collapse: collapse;
- font-size: 13px;
-}
-.cluster-node-table thead th {
- position: sticky;
- top: 0;
- z-index: 1;
- background: #f7f8fa;
- text-align: left;
- padding: 9px 14px;
- font-size: 11px;
- font-weight: 600;
- color: #8d97a6;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- border-bottom: 1px solid var(--line);
-}
-.cluster-node-table td {
- padding: 9px 14px;
- color: #4a5568;
- border-bottom: 1px solid #f0f1f4;
- vertical-align: middle;
-}
-.cluster-node-table td strong {
- color: #202733;
- font-size: 13px;
-}
-.cluster-node-table td small {
- color: #8d97a6;
- font-size: 12px;
-}
-
-.table-sort-button {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- max-width: 100%;
- padding: 0;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- font-weight: inherit;
- letter-spacing: inherit;
- text-transform: inherit;
- cursor: pointer;
-}
-
-.table-sort-button:hover,
-.table-sort-button.active {
- color: var(--blue);
-}
-
-.table-sort-placeholder {
- font-size: 11px;
- line-height: 1;
- opacity: 0.45;
-}
-
-.section-heading {
- display: flex;
- justify-content: space-between;
- align-items: end;
- margin-bottom: 20px;
-}
-
-.section-heading h2 {
- margin-top: 7px;
-}
-
-.view-switch {
- display: flex;
- padding: 4px;
- border: 1px solid var(--line);
- background: #fff;
- border-radius: 13px;
-}
-
-.view-switch button {
- border: 0;
- width: 34px;
- height: 32px;
- border-radius: 10px;
- display: grid;
- place-items: center;
- background: transparent;
- color: #8c96a5;
-}
-
-.view-switch button.active {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.page-toolbar {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 10px;
- margin-bottom: 14px;
-}
-
-.page-toolbar > small {
- margin-left: auto;
- font-size: 11px;
- color: #8d96a5;
- white-space: nowrap;
-}
-
-.search-field {
- height: 40px;
- min-width: min(320px, 100%);
- flex: 1 1 320px;
- display: flex;
- align-items: center;
- gap: 9px;
- background: #fff;
- border: 1px solid var(--line);
- border-radius: 999px;
- padding: 0 14px;
- color: #8d97a6;
-}
-
-.toolbar-filter {
- height: 40px;
- min-width: 132px;
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 0 10px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--panel);
- color: var(--muted);
- box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
-}
-
-.toolbar-filter select {
- min-width: 0;
- flex: 1;
- border: 0;
- outline: 0;
- background: transparent;
- color: var(--ink);
- font-size: 12px;
- font-weight: 700;
- cursor: pointer;
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
-.search-field input {
- border: 0;
- outline: 0;
- flex: 1;
- min-width: 0;
- font-size: 12px;
- background: transparent;
- color: #29313e;
-}
-
-.search-field kbd {
- font-size: 10px;
- border: 1px solid #dfe5ed;
- background: #f6f8fb;
- padding: 2px 6px;
- border-radius: 8px;
- color: #9aa4b2;
-}
-
-.secondary-button span {
- background: #e7f8f1;
- color: #179467;
- padding: 2px 6px;
- border-radius: 999px;
- font-size: 10px;
-}
-
-.master-detail {
- min-height: 590px;
- display: grid;
- grid-template-columns: 305px 1fr;
- overflow: hidden;
-}
-
-.master-list {
- border-right: 1px solid var(--line);
- padding: 12px;
- background: #fbfcfe;
-}
-
-.master-list button {
- width: 100%;
- min-height: 76px;
- display: grid;
- grid-template-columns: 40px 1fr auto 14px;
- gap: 11px;
- align-items: center;
- border: 1px solid transparent;
- background: transparent;
- border-radius: 16px;
- text-align: left;
- padding: 11px;
- color: #7f8998;
-}
-
-.master-list button:hover {
- background: #fff;
-}
-
-.master-list button.selected {
- background: #fff;
- border-color: var(--line);
- box-shadow: var(--shadow-soft);
-}
-
-.detail-area {
- padding: 24px;
- min-width: 0;
-}
-
-.detail-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
-}
-
-.detail-header h3 {
- font-size: 21px;
- margin: 6px 0 4px;
- letter-spacing: -0.55px;
-}
-
-.detail-header p {
- margin: 0;
- color: #8e98a6;
- font-size: 12px;
-}
-
-.detail-header > div:last-child {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.detail-stats {
- display: grid;
- grid-template-columns: 1.2fr repeat(3, 1fr);
- margin: 24px 0 16px;
- border: 1px solid var(--line);
- border-radius: 16px;
- overflow: hidden;
- background: #fff;
-}
-
-.detail-stats > div {
- padding: 16px;
- border-right: 1px solid var(--line);
- position: relative;
-}
-
-.detail-stats > div:last-child {
- border: 0;
-}
-
-.detail-stats span {
- display: block;
- color: #8792a1;
- font-size: 11px;
-}
-
-.detail-stats strong {
- display: block;
- font-size: 18px;
- margin-top: 5px;
-}
-
-.detail-stats small {
- display: block;
- color: #9aa4b2;
- font-size: 10px;
- margin-top: 3px;
-}
-
-.detail-stats i {
- height: 4px;
- position: absolute;
- bottom: 0;
- left: 16px;
- right: 16px;
- background: #e8edf4;
- border-radius: 999px 999px 0 0;
-}
-
-.detail-stats i b {
- display: block;
- height: 100%;
- background: linear-gradient(90deg, var(--blue), #c4b5fd);
-}
-
-.sub-tabs {
- height: 46px;
- display: flex;
- gap: 5px;
- align-items: center;
- padding: 4px;
- border: 0;
- border-radius: 12px;
- background: #f0f3f8;
-}
-
-.sub-tabs button {
- flex: 0 0 auto;
- height: 38px;
- border: 0;
- border-radius: 9px;
- background: transparent;
- padding: 0 16px;
- font-size: 12px;
- color: #8a94a3;
- font-weight: 800;
-}
-
-.sub-tabs button.active {
- color: var(--blue);
- background: #fff;
- box-shadow: 0 2px 8px rgba(42, 52, 73, 0.09);
-}
-
-/* Job 详情页:Tab 栏与下方内容面板融合为一个整体卡片 */
-.job-tab-panel {
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fff;
- box-shadow: var(--shadow-soft);
- overflow: hidden;
-}
-
-.job-tab-panel > .sub-tabs {
- border-radius: 0;
- border-bottom: 1px solid var(--line);
- margin: 0;
- padding: 6px 12px;
-}
-
-/* 融合卡片内的子面板去掉自身边框/圆角/阴影,由外层统一承载 */
-.job-tab-panel .worker-primary-panel,
-.job-tab-panel .job-observe-panel,
-.job-tab-panel .job-detail-summary-card,
-.job-detail-page .job-tab-panel .role-runtime-config.job-detail-summary-card {
- border: 0;
- border-radius: 0;
- box-shadow: none;
- margin: 0;
-}
-
-/* 融合卡片内的三个区块之间用浅色背景带分隔,替代生硬的分界线 */
-.job-tab-panel .role-runtime-config.job-detail-summary-card,
-.job-tab-panel > .job-detail-summary-card {
- padding-bottom: 20px;
- margin-bottom: 4px;
-}
-
-.job-tab-panel .worker-primary-panel {
- padding-top: 4px;
-}
-
-/* 每个区块之间用浅色背景条做视觉缓冲 */
-.job-tab-panel > section + section,
-.job-tab-panel > .worker-primary-panel {
- position: relative;
-}
-
-.job-tab-panel > section + section::before,
-.job-tab-panel > .worker-primary-panel::before {
- content: "";
- display: block;
- height: 6px;
- background: #f7f8fb;
- margin: 0 -16px;
- border-top: 1px solid #eef0f4;
- border-bottom: 1px solid #eef0f4;
-}
-
-.dag-canvas {
- height: 365px;
- margin-top: 18px;
- border: 1px solid var(--line);
- border-radius: 20px;
- position: relative;
- overflow: hidden;
- background: #fbfcff;
-}
-
-.canvas-grid {
- position: absolute;
- inset: 0;
- opacity: 0.52;
- background-image: radial-gradient(#cdd5df 1px, transparent 1px);
- background-size: 20px 20px;
-}
-
-.dag-lines,
-.node-map > svg {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
-}
-
-.dag-lines path,
-.node-map > svg path {
- fill: none;
- stroke: #b8c4d2;
- stroke-width: 1.8;
- stroke-dasharray: 5 4;
- vector-effect: non-scaling-stroke;
-}
-
-.dag-node {
- position: absolute;
- width: 184px;
- min-height: 70px;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.96);
- border-radius: 16px;
- padding: 12px;
- display: flex;
- gap: 10px;
- box-shadow: var(--shadow-soft);
-}
-
-.dag-node small {
- display: block;
- color: #9ba5b3;
- font-size: 9px;
- letter-spacing: 0.7px;
-}
-
-.dag-node strong {
- display: block;
- font-size: 12px;
- margin: 3px 0;
-}
-
-.dag-node em {
- display: block;
- font-style: normal;
- color: #8792a1;
- font-size: 10px;
-}
-
-.node-icon {
- width: 31px;
- height: 31px;
- border-radius: 10px;
- display: grid;
- place-items: center;
- flex: none;
-}
-
-.node-icon.success {
- background: #e5f8f0;
- color: #18a170;
-}
-
-.node-icon.running {
- background: #e9f1ff;
- color: var(--blue);
-}
-
-.node-icon.pending {
- background: #fff2df;
- color: #cf7c20;
-}
-
-.node-start {
- left: 5%;
- top: 42%;
-}
-
-.node-top {
- left: 38%;
- top: 18%;
-}
-
-.node-bottom {
- left: 38%;
- top: 64%;
-}
-
-.node-end {
- right: 5%;
- top: 42%;
-}
-
-.dag-actions {
- position: absolute;
- z-index: 2;
- right: 14px;
- top: 14px;
- display: flex;
- background: #fff;
- border: 1px solid var(--line);
- border-radius: 12px;
- overflow: hidden;
- box-shadow: var(--shadow-soft);
-}
-
-.dag-actions button {
- border: 0;
- border-right: 1px solid var(--line);
- background: #fff;
- width: 34px;
- height: 32px;
- display: grid;
- place-items: center;
- color: #6f7a89;
-}
-
-.dag-actions button:last-child {
- border: 0;
-}
-
-.live-chip,
-.map-live {
- position: absolute;
- bottom: 14px;
- left: 14px;
- padding: 8px 11px;
- background: #fff;
- border: 1px solid var(--line);
- box-shadow: var(--shadow-soft);
- border-radius: 999px;
- font-size: 11px;
- color: #677282;
- display: flex;
- align-items: center;
- gap: 7px;
-}
-
-.live-chip span,
-.map-live i {
- width: 7px;
- height: 7px;
- background: var(--green);
- border-radius: 50%;
-}
-
-.table-panel {
- overflow-x: auto;
- overflow-y: hidden;
-}
-
-.jobs-table-panel table {
- min-width: 920px;
-}
-
-.selected-cluster-panel .table-panel table {
- min-width: 560px;
-}
-
-.table-empty-state {
- min-height: 220px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 28px;
- text-align: center;
-}
-
-.table-empty-state > span {
- width: 46px;
- height: 46px;
- display: grid;
- place-items: center;
- border-radius: 14px;
- background: #f3eefe;
- color: var(--blue);
-}
-
-.table-empty-state strong {
- color: var(--ink);
- font-size: 14px;
-}
-
-.table-empty-state small {
- max-width: 420px;
- color: var(--muted);
- font-size: 11px;
-}
-
-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 12px;
-}
-
-th {
- height: 48px;
- background: #fbfcfe;
- color: #8993a3;
- text-align: left;
- font-size: 11px;
- letter-spacing: 0.35px;
- font-weight: 800;
- padding: 0 18px;
- border-bottom: 1px solid var(--line);
- white-space: nowrap;
-}
-
-td {
- height: 66px;
- padding: 0 18px;
- color: #667182;
- border-bottom: 1px dashed var(--line);
-}
-
-tbody tr:last-child td {
- border: 0;
-}
-
-tbody tr:hover {
- background: #fbfdff;
-}
-
-.table-primary {
- display: flex;
- align-items: center;
- gap: 11px;
-}
-
-.table-primary strong {
- color: #262d39;
- font-size: 12px;
-}
-
-.worker-name {
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.table-primary small {
- display: block;
- color: #98a1af;
- font-size: 10px;
- margin-top: 3px;
-}
-
-.row-icon {
- width: 34px;
- height: 34px;
- display: grid;
- place-items: center;
- background: #f3eefe;
- color: var(--blue);
- border-radius: 11px;
-}
-
-.inline-progress {
- display: grid;
- grid-template-columns: 82px auto;
- align-items: center;
- gap: 10px;
- font-size: 11px;
-}
-
-.role-chip {
- background: #f0f3f7;
- color: #667182;
- padding: 6px 9px;
- border-radius: 999px;
- font-size: 11px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.node-layout {
- min-height: 560px;
- display: grid;
- grid-template-columns: 305px 1fr;
- overflow: hidden;
-}
-
-.node-list {
- border-right: 1px solid var(--line);
- background: #fbfcfe;
- padding: 12px;
-}
-
-.node-list button {
- width: 100%;
- height: 76px;
- display: grid;
- grid-template-columns: 40px 1fr auto 14px;
- align-items: center;
- gap: 11px;
- border: 1px solid transparent;
- border-radius: 16px;
- background: transparent;
- text-align: left;
- color: #7f8998;
- padding: 11px;
-}
-
-.node-list button.selected {
- background: #fff;
- border-color: var(--line);
- box-shadow: var(--shadow-soft);
-}
-
-.node-list strong {
- display: block;
- font-size: 13px;
- color: #272d38;
-}
-
-.node-list small {
- display: block;
- font-size: 10px;
- color: #98a1af;
- margin-top: 4px;
-}
-
-.node-status-ring {
- width: 36px;
- height: 36px;
- display: grid;
- place-items: center;
- border-radius: 12px;
- background: #e7f8f1;
- color: #1f9c70;
-}
-
-.node-status-ring.offline {
- background: #ffe9ee;
- color: #d94767;
-}
-
-.node-detail {
- padding: 24px;
-}
-
-.node-health {
- display: grid;
- grid-template-columns: 1fr 1fr 1.25fr 1.25fr;
- gap: 12px;
- margin: 24px 0 16px;
-}
-
-.gauge,
-.gpu-card {
- min-height: 112px;
- border: 1px solid var(--line);
- border-radius: 18px;
- padding: 15px;
- background: #fff;
-}
-
-.gauge {
- display: grid;
- grid-template-columns: 62px minmax(0, 1fr);
- align-items: center;
- justify-content: center;
- gap: 14px;
-}
-
-.gauge > div {
- width: 62px;
- height: 62px;
- min-width: 62px;
- aspect-ratio: 1 / 1;
- flex: 0 0 62px;
- border-radius: 50%;
- display: grid;
- place-items: center;
- position: relative;
- overflow: hidden;
-}
-
-.gauge > div::after {
- content: "";
- position: absolute;
- inset: 7px;
- background: white;
- border-radius: 50%;
-}
-
-.gauge span {
- position: relative;
- z-index: 1;
- font-size: 12px;
- font-weight: 900;
-}
-
-.gauge small {
- color: #778292;
- font-size: 11px;
- font-weight: 700;
- min-width: 0;
- line-height: 1.35;
-}
-
-.gpu-card span {
- width: 34px;
- height: 34px;
- display: grid;
- place-items: center;
- border-radius: 11px;
- background: #f3eefe;
- color: var(--blue);
-}
-
-.gpu-card strong {
- display: block;
- font-size: 21px;
- margin-top: 12px;
-}
-
-.gpu-card small {
- display: block;
- color: #8d96a5;
- font-size: 11px;
-}
-
-.node-map {
- height: 305px;
- border: 1px solid var(--line);
- border-radius: 20px;
- position: relative;
- overflow: hidden;
- background: #fbfcff;
-}
-
-.machine {
- position: absolute;
- z-index: 2;
- width: 178px;
- min-height: 84px;
- border: 1px solid var(--line);
- background: #fff;
- border-radius: 18px;
- padding: 14px;
- display: grid;
- grid-template-columns: 38px 1fr;
- column-gap: 11px;
- box-shadow: var(--shadow-soft);
-}
-
-.machine span {
- grid-row: 1 / 3;
- width: 38px;
- height: 38px;
- display: grid;
- place-items: center;
- border-radius: 12px;
- background: #f3eefe;
- color: var(--blue);
-}
-
-.machine strong {
- font-size: 12px;
- align-self: end;
-}
-
-.machine small {
- font-size: 10px;
- color: #8d96a5;
-}
-
-.machine-main {
- left: 10%;
- top: 37%;
-}
-
-.machine-gpu {
- right: 12%;
- top: 16%;
-}
-
-.machine-storage {
- right: 12%;
- bottom: 16%;
-}
-
-.api-layout {
- display: grid;
- grid-template-columns: 250px 1fr;
- min-height: 580px;
- overflow: hidden;
-}
-
-.api-layout aside {
- padding: 18px 14px;
- background: #fbfcfe;
- border-right: 1px solid var(--line);
-}
-
-.api-layout aside .search-field {
- min-width: 0;
- width: 100%;
- margin-bottom: 16px;
-}
-
-.api-layout aside > button {
- width: 100%;
- height: 42px;
- padding: 0 12px;
- border: 0;
- background: transparent;
- border-radius: 12px;
- color: #727d8d;
- font-size: 12px;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.api-layout aside > button.active {
- background: #f3eefe;
- color: var(--blue);
- font-weight: 800;
-}
-
-.api-layout main {
- padding: 32px 38px;
- max-width: 900px;
-}
-
-.api-layout main h2 {
- margin: 8px 0 6px;
- font-size: 26px;
-}
-
-.api-layout main > p {
- color: #7d8797;
- font-size: 13px;
-}
-
-.endpoint-list {
- border: 1px solid var(--line);
- border-radius: 18px;
- margin-top: 24px;
- overflow: hidden;
-}
-
-.endpoint-list > div {
- height: 58px;
- display: grid;
- grid-template-columns: 58px minmax(260px, 1.5fr) 1fr 18px;
- align-items: center;
- gap: 12px;
- padding: 0 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.endpoint-list > div:last-child {
- border: 0;
-}
-
-.method {
- width: 46px;
- padding: 5px 0;
- text-align: center;
- border-radius: 9px;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- font-weight: 800;
-}
-
-.method.get {
- background: #e7f8f1;
- color: #168a61;
-}
-
-.method.post {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.method.patch {
- background: #fff2df;
- color: #bd7424;
-}
-
-.endpoint-list code {
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- color: #29313e;
-}
-
-.endpoint-list p {
- font-size: 12px;
- color: #7f8998;
-}
-
-.code-block {
- margin-top: 18px;
- border-radius: 18px;
- overflow: hidden;
- background: #101827;
- color: #c8d3e4;
-}
-
-.code-block > div {
- height: 42px;
- border-bottom: 1px solid #243048;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 16px;
- font-size: 12px;
-}
-
-.code-block button {
- border: 0;
- background: transparent;
- color: #8ea7d6;
- font-size: 11px;
-}
-
-.code-block pre {
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.8;
- margin: 0;
- padding: 18px;
- color: #d7e4f6;
-}
-
-.modal-backdrop {
- position: fixed;
- inset: 0;
- /* 必须高于 .action-dropdown(50),否则打开弹窗时下拉菜单会浮在遮罩之上 */
- z-index: 60;
- display: grid;
- place-items: center;
- background: rgba(16, 22, 32, 0.46);
- backdrop-filter: blur(4px);
-}
-
-.modal {
- width: 580px;
- max-height: 86vh;
- background: #fff;
- border-radius: 24px;
- box-shadow: 0 28px 90px rgba(13, 22, 38, 0.28);
- overflow: hidden;
-}
-
-.modal-head {
- padding: 24px 24px 16px;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- border-bottom: 1px solid var(--line);
-}
-
-.modal-head h2 {
- margin: 6px 0 0;
- font-size: 22px;
-}
-
-.stepper {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- padding: 16px 24px;
- background: #fbfcfe;
- border-bottom: 1px solid var(--line);
-}
-
-.stepper > div {
- display: flex;
- align-items: center;
- gap: 8px;
- position: relative;
-}
-
-.stepper > div:not(:last-child)::after {
- content: "";
- position: absolute;
- left: 72%;
- right: 5%;
- height: 1px;
- background: #dce4ee;
-}
-
-.stepper span {
- width: 25px;
- height: 25px;
- display: grid;
- place-items: center;
- border-radius: 50%;
- background: #e8edf4;
- color: #8692a3;
- font-size: 11px;
- font-weight: 800;
-}
-
-.stepper .active span {
- background: var(--blue);
- color: #fff;
-}
-
-.stepper small {
- font-size: 11px;
- color: #727d8d;
- font-weight: 700;
-}
-
-.modal-body {
- min-height: 340px;
- padding: 22px 24px;
-}
-
-.modal-body label {
- display: grid;
- gap: 7px;
- margin-bottom: 14px;
- color: #596374;
- font-size: 12px;
- font-weight: 800;
-}
-
-.modal-body input,
-.modal-body textarea,
-.modal-body select {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 13px;
- padding: 11px 12px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
-}
-
-.modal-body input:focus,
-.modal-body textarea:focus,
-.modal-body select:focus {
- border-color: #c4b5fd;
- box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.1);
-}
-
-.modal-body textarea {
- height: 78px;
- resize: none;
-}
-
-.form-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
-}
-
-.job-builder {
- height: 290px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.job-builder > div {
- width: 188px;
- min-height: 98px;
- border: 1px solid var(--line);
- border-radius: 18px;
- padding: 16px;
- box-shadow: var(--shadow-soft);
-}
-
-.job-builder strong,
-.job-builder small {
- display: block;
- margin-top: 8px;
- font-size: 12px;
-}
-
-.job-builder small {
- color: #8792a1;
- font-size: 10px;
-}
-
-.builder-line {
- width: 48px;
- height: 1px;
- border-top: 1px dashed #aeb9c8;
-}
-
-.review-state {
- height: 290px;
- display: grid;
- place-content: center;
- text-align: center;
- justify-items: center;
-}
-
-.review-state > span {
- width: 66px;
- height: 66px;
- display: grid;
- place-items: center;
- border-radius: 50%;
- background: #e5f8f0;
- color: #19a371;
-}
-
-.review-state h3 {
- margin: 16px 0 6px;
-}
-
-.review-state p {
- color: #7e8998;
- font-size: 12px;
-}
-
-.modal-footer {
- height: 72px;
- display: flex;
- justify-content: flex-end;
- align-items: center;
- gap: 10px;
- padding: 0 24px;
- border-top: 1px solid var(--line);
- background: #fbfcfe;
-}
-
-.sidebar-collapsed {
- grid-template-columns: 72px minmax(0, 1fr);
-}
-
-.sidebar-collapsed .brand > div:last-child,
-.sidebar-collapsed nav button span,
-.sidebar-collapsed nav button em,
-.sidebar-collapsed .nav-label,
-.sidebar-collapsed .environment-card div,
-.sidebar-collapsed .environment-card i,
-.sidebar-collapsed .sidebar-bottom > button span {
- display: none;
-}
-
-.sidebar-collapsed .sidebar {
- padding-left: 12px;
- padding-right: 12px;
-}
-
-.sidebar-collapsed .brand {
- height: 44px;
- padding: 0 6px;
- margin-bottom: 22px;
-}
-
-.sidebar-collapsed .brand-logo {
- width: 40px;
- height: 40px;
- object-fit: contain;
-}
-
-.sidebar-collapsed nav button,
-.sidebar-collapsed .sidebar-bottom > button {
- justify-content: center;
- padding: 0;
-}
-
-.sidebar-collapsed .environment-card {
- grid-template-columns: 1fr;
- min-height: 56px;
- padding: 9px;
-}
-
-.sidebar-collapsed .environment-card > span {
- margin: auto;
-}
-
-.theme-dark {
- --ink: #f5f7fb;
- --muted: #9ca8ba;
- --soft: #748196;
- --line: #273348;
- --line-strong: #33425b;
- --panel: #151d2b;
- --canvas: #0f1623;
- --blue: #a78bfa;
- --blue-2: #c4b5fd;
- --green: #48d8a2;
- --green-soft: #12352d;
- --shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
- --shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.22);
- --app-bg: #0c111b;
- --hover: rgba(167, 139, 250, 0.08);
- color: #f5f7fb;
- background:
- radial-gradient(
- circle at 82% 8%,
- rgba(167, 139, 250, 0.14),
- transparent 28%
- ),
- linear-gradient(180deg, #101827 0%, #0c111b 100%);
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.theme-dark .brand-logo-light {
- display: none;
-}
-
-.theme-dark .brand-logo-dark {
- display: block;
-}
-
-.theme-dark .main-area,
-.theme-dark .master-list,
-.theme-dark .node-list,
-.theme-dark .api-layout aside,
-.theme-dark .stepper,
-.theme-dark .modal-footer,
-.theme-dark th {
- background: #101827;
-}
-
-.theme-dark .sidebar,
-.theme-dark .topbar {
- background: rgba(20, 29, 44, 0.9);
- border-color: var(--line);
-}
-
-.theme-dark .brand strong,
-.theme-dark .topbar h1,
-.theme-dark .hero-strip h2,
-.theme-dark .section-heading h2,
-.theme-dark .panel-title h3,
-.theme-dark .metric-value-row strong,
-.theme-dark .workflow-info strong,
-.theme-dark .master-list strong,
-.theme-dark .node-list strong,
-.theme-dark .detail-header h3,
-.theme-dark .detail-stats strong,
-.theme-dark .table-primary strong,
-.theme-dark .dag-node strong,
-.theme-dark .machine strong,
-.theme-dark .gpu-card strong,
-.theme-dark .api-layout main h2,
-.theme-dark .activity-list strong,
-.theme-dark .modal-head h2,
-.theme-dark .review-state h3 {
- color: #f5f7fb;
-}
-
-.theme-dark .brand small,
-.theme-dark .nav-label,
-.theme-dark .hero-strip p,
-.theme-dark .section-heading p,
-.theme-dark .metric-card > small,
-.theme-dark .workflow-info small,
-.theme-dark .master-list small,
-.theme-dark .node-list small,
-.theme-dark .detail-header p,
-.theme-dark .detail-stats span,
-.theme-dark .detail-stats small,
-.theme-dark .activity-list small,
-.theme-dark .activity-list time,
-.theme-dark .api-layout main > p,
-.theme-dark .endpoint-list p,
-.theme-dark .chart-labels,
-.theme-dark .gauge small,
-.theme-dark .gpu-card small,
-.theme-dark .machine small,
-.theme-dark .dag-node em,
-.theme-dark .modal-body label,
-.theme-dark .review-state p {
- color: #9ca8ba;
-}
-
-.theme-dark .sidebar nav button,
-.theme-dark .sidebar-bottom > button {
- color: #a7b2c5;
-}
-
-.theme-dark .sidebar nav button:hover {
- background: #1b2637;
- color: #fff;
-}
-
-.theme-dark .sidebar nav button.active,
-.theme-dark .view-switch button.active,
-.theme-dark .api-layout aside > button.active,
-.theme-dark .segmented-control button.active {
- background: rgba(167, 139, 250, 0.16);
- color: #c4b5fd;
-}
-
-.theme-dark .metric-card,
-.theme-dark .panel,
-.theme-dark .table-panel,
-.theme-dark .master-detail,
-.theme-dark .node-layout,
-.theme-dark .api-layout,
-.theme-dark .hero-health,
-.theme-dark .modal,
-.theme-dark .gauge,
-.theme-dark .gpu-card,
-.theme-dark .node-map,
-.theme-dark .dag-canvas,
-.theme-dark .detail-stats,
-.theme-dark .endpoint-list,
-.theme-dark .dag-node,
-.theme-dark .machine {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .topbar h1::before,
-.theme-dark .cluster-picker,
-.theme-dark .secondary-button,
-.theme-dark .icon-button,
-.theme-dark .segmented-control,
-.theme-dark .search-field,
-.theme-dark .view-switch,
-.theme-dark .dag-actions,
-.theme-dark .dag-actions button,
-.theme-dark .live-chip,
-.theme-dark .map-live,
-.theme-dark .modal-body input,
-.theme-dark .modal-body textarea,
-.theme-dark .modal-body select {
- background: #111a29;
- border-color: var(--line);
- color: #d8e0ee;
-}
-
-.theme-dark .topbar > div:first-child:has(h1)::before {
- border-color: #687895;
-}
-
-.theme-dark .topbar > div:first-child:has(h1)::after,
-.theme-dark .search-field kbd {
- color: #8796ad;
- border-color: #2e3a51;
- background: #101827;
-}
-
-.theme-dark .toolbar-filter,
-.theme-dark .toolbar-filter select {
- background: #151d2b;
- color: #d8e0ee;
- border-color: var(--line);
-}
-
-.theme-dark .metric-card::after {
- background: rgba(167, 139, 250, 0.08);
-}
-
-.theme-dark .grid-lines line {
- stroke: #263248;
-}
-
-.theme-dark .resource-summary,
-.theme-dark .mini-bars,
-.theme-dark .workflow-list > button,
-.theme-dark .activity-list > div,
-.theme-dark .sub-tabs,
-.theme-dark .modal-head,
-.theme-dark .modal-footer,
-.theme-dark .stepper,
-.theme-dark td,
-.theme-dark th,
-.theme-dark .endpoint-list > div {
- border-color: var(--line);
-}
-
-/* Cluster overview tables need dedicated dark surfaces because their light
- styles intentionally use opaque header and selected-row backgrounds. */
-.theme-dark .selected-cluster-panel .cluster-detail-header {
- background: #151d2b;
- border-color: #2b3850;
-}
-
-.theme-dark .selected-cluster-panel .cluster-detail-title {
- color: #dbe4f2;
-}
-
-.theme-dark .selected-cluster-panel .cluster-detail-title strong,
-.theme-dark .cluster-list-table .cluster-list-name strong {
- color: #f5f7fb;
-}
-
-.theme-dark .selected-cluster-panel .cluster-detail-meta {
- color: #9eacc1;
-}
-
-.theme-dark .selected-cluster-panel .cluster-detail-meta .dot {
- background: #53627a;
-}
-
-.theme-dark .cluster-node-table thead th,
-.theme-dark .cluster-list-table thead th {
- background: #1b2637;
- color: #aeb9cc;
- border-color: #33425b;
-}
-
-.theme-dark .cluster-node-table tbody tr,
-.theme-dark .cluster-list-table tbody tr {
- background: transparent;
- border-color: #273348;
-}
-
-.theme-dark .cluster-node-table tbody tr:hover,
-.theme-dark .cluster-list-table tbody tr:hover {
- background: rgba(167, 139, 250, 0.07);
-}
-
-.theme-dark .cluster-node-table td,
-.theme-dark .cluster-list-table td {
- color: #c5d0e0;
- border-color: #273348;
-}
-
-.theme-dark .cluster-node-table td strong {
- color: #eef3fa;
-}
-
-.theme-dark .cluster-node-table td small,
-.theme-dark .cluster-list-rate small {
- color: #91a0b6;
-}
-
-.theme-dark .cluster-list-table tbody tr.selected {
- background: rgba(167, 139, 250, 0.14);
-}
-
-.theme-dark .cluster-list-table tbody tr.selected td {
- color: #d9ccff;
-}
-
-.theme-dark .cluster-list-table tbody tr.selected .cluster-list-name strong {
- color: #ffffff;
-}
-
-.theme-dark .cluster-list-rate i {
- background: #2b3850;
-}
-
-.theme-dark .cluster-node-table-wrap,
-.theme-dark .cluster-list-scroll {
- color-scheme: dark;
- scrollbar-color: #53627a #151d2b;
-}
-
-.theme-dark .mini-bars i,
-.theme-dark .progress-cell > i,
-.theme-dark .inline-progress > i,
-.theme-dark .detail-stats i,
-.theme-dark .resource-row > span {
- background: #273348;
-}
-
-.theme-dark .workflow-list > button:hover,
-.theme-dark tbody tr:hover,
-.theme-dark .master-list button:hover {
- background: rgba(255, 255, 255, 0.035);
-}
-
-.theme-dark .master-list button.selected,
-.theme-dark .node-list button.selected {
- background: #182235;
- border-color: #33425b;
-}
-
-.theme-dark .canvas-grid {
- background-image: radial-gradient(#33425b 1px, transparent 1px);
-}
-
-.theme-dark .gauge > div::after {
- background: #151d2b;
-}
-
-.theme-dark .code-block {
- background: #070b12;
- border: 1px solid #263248;
-}
-
-.theme-dark .modal-backdrop {
- background: rgba(2, 6, 14, 0.62);
-}
-
-.theme-dark .status-running,
-.theme-dark .status-online {
- background: rgba(72, 216, 162, 0.13);
- color: #70e0b5;
-}
-
-.theme-dark .status-succeeded {
- background: rgba(59, 130, 246, 0.16);
- color: #8bb9ff;
-}
-
-.theme-dark .status-failed,
-.theme-dark .status-offline {
- background: rgba(239, 90, 122, 0.14);
- color: #ff8ca4;
-}
-
-.theme-dark .status-pending {
- background: rgba(245, 158, 53, 0.14);
- color: #ffbd6b;
-}
-
-.theme-dark .row-icon,
-.theme-dark .gpu-card span,
-.theme-dark .machine span,
-.theme-dark .node-status-ring,
-.theme-dark .workflow-symbol.running {
- background: rgba(167, 139, 250, 0.15);
- color: #c4b5fd;
-}
-
-.theme-dark .role-chip {
- background: #202b3d;
- color: #b1bdd0;
-}
-
-.platform-metrics {
- grid-template-columns: 1fr 1.15fr 1.35fr 1fr;
-}
-
-.resource-split {
- display: grid;
- gap: 16px;
- margin: 22px 0;
-}
-
-.resource-row {
- display: grid;
- grid-template-columns: 230px 1fr 48px;
- align-items: center;
- gap: 16px;
- padding: 16px;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fbfcff;
-}
-
-.resource-row strong {
- display: block;
- color: #252c38;
- font-size: 14px;
-}
-
-.resource-row small {
- display: block;
- color: #8d97a6;
- font-size: 11px;
- margin-top: 4px;
-}
-
-.resource-row > span {
- height: 9px;
- border-radius: 999px;
- background: #e8edf4;
- overflow: hidden;
-}
-
-.resource-row > span i {
- display: block;
- height: 100%;
- border-radius: 999px;
-}
-
-.resource-row.blue > span i {
- background: linear-gradient(90deg, #7c3aed, #c4b5fd);
-}
-
-.resource-row.green > span i {
- background: linear-gradient(90deg, #26b985, #67ddb2);
-}
-
-.resource-row.orange > span i {
- background: linear-gradient(90deg, #f59e35, #ffc36f);
-}
-
-.resource-row b {
- justify-self: end;
- color: #27303d;
- font-size: 14px;
-}
-
-.cluster-models {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- border-top: 1px solid var(--line);
- padding-top: 16px;
-}
-
-.cluster-models > div {
- padding: 14px;
- border-radius: 16px;
- background: #f7f9fc;
-}
-
-.cluster-models span {
- display: block;
- color: #8d97a6;
- font-size: 11px;
-}
-
-.cluster-models strong {
- display: block;
- margin-top: 5px;
- color: #202733;
- font-size: 13px;
- line-height: 1.4;
-}
-
-.robot-state-list {
- display: grid;
- gap: 12px;
- margin-top: 16px;
-}
-
-.robot-state-list > div {
- display: grid;
- grid-template-columns: 38px 1fr auto;
- align-items: center;
- gap: 11px;
- padding: 13px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: #fbfcff;
-}
-
-.robot-state-list strong {
- display: block;
- color: #252c38;
- font-size: 13px;
-}
-
-.robot-state-list small {
- display: block;
- color: #8d97a6;
- font-size: 11px;
- margin-top: 3px;
-}
-
-.side-section-title {
- color: #8d97a6;
- font-size: 11px;
- font-weight: 900;
- letter-spacing: 0.8px;
- text-transform: uppercase;
- padding: 10px 10px 8px;
-}
-
-.cluster-pill {
- display: grid;
- grid-template-columns: 38px 1fr auto;
- gap: 10px;
- align-items: center;
- padding: 11px;
- margin-bottom: 8px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: #fff;
-}
-
-.cluster-pill > span {
- width: 36px;
- height: 36px;
- display: grid;
- place-items: center;
- border-radius: 12px;
-}
-
-.cluster-pill > span.cloud {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.cluster-pill > span.embodied {
- background: #e7f8f1;
- color: #1f9c70;
-}
-
-.cluster-pill strong {
- display: block;
- color: #272d38;
- font-size: 12px;
-}
-
-.cluster-pill small {
- display: block;
- color: #98a1af;
- font-size: 10px;
- margin-top: 4px;
-}
-
-.platform-node-layout {
- grid-template-columns: 360px 1fr;
-}
-
-.platform-map {
- height: 360px;
-}
-
-.job-detail-layout {
- grid-template-columns: 330px 1fr;
-}
-
-.job-detail-heading {
- align-items: flex-start;
- gap: 20px;
-}
-
-.job-detail-page {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.job-detail-page > * {
- flex: 0 0 auto;
-}
-
-.job-worker-overview {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr);
- gap: 16px;
- margin: 18px 0 14px;
-}
-
-.job-worker-main,
-.job-worker-side {
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fff;
- box-shadow: var(--shadow-soft);
-}
-
-.job-worker-main {
- padding: 20px;
-}
-
-.job-worker-main h3 {
- margin: 7px 0 8px;
- color: var(--text);
- font-size: 24px;
- line-height: 1.2;
- letter-spacing: 0;
-}
-
-.job-worker-main p {
- margin: 0;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.6;
-}
-
-.job-worker-progress {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 12px;
- align-items: center;
- margin-top: 18px;
-}
-
-.job-worker-progress i,
-.worker-metric-card label i {
- display: block;
- height: 8px;
- border-radius: 999px;
- background: #e8edf5;
- overflow: hidden;
-}
-
-.job-worker-progress b,
-.worker-metric-card label b {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(90deg, var(--blue), #8b5cf6);
-}
-
-.job-worker-progress span {
- color: var(--blue);
- font-size: 13px;
- font-weight: 900;
-}
-
-.job-worker-side {
- display: grid;
- grid-template-columns: 1fr;
- overflow: hidden;
-}
-
-.job-worker-side > div {
- padding: 16px;
- min-width: 0;
-}
-
-.job-worker-side > div + div {
- border-top: 1px solid var(--line);
-}
-
-.job-worker-side span,
-.worker-role-strip span,
-.worker-detail-grid span {
- display: block;
- color: var(--muted);
- font-size: 11px;
- font-weight: 850;
- margin-bottom: 6px;
-}
-
-.job-worker-side strong,
-.worker-role-strip strong,
-.worker-detail-grid strong {
- display: block;
- color: var(--text);
- font-size: 15px;
- line-height: 1.35;
-}
-
-.job-worker-side small,
-.worker-role-strip small {
- display: block;
- margin-top: 4px;
- color: var(--muted);
- font-size: 12px;
-}
-
-.job-worker-side code {
- display: block;
- max-width: 100%;
- color: #26313f;
- background: #f0f4fa;
- border-radius: 10px;
- padding: 7px 8px;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.45;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.worker-role-strip {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
- gap: 10px;
- margin: 0 0 16px;
-}
-
-.worker-role-strip > div {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fbfcff;
- padding: 12px;
-}
-
-.worker-primary-panel,
-.job-observe-panel {
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fff;
- box-shadow: var(--shadow-soft);
- overflow: hidden;
- position: relative;
- z-index: 1;
-}
-
-.worker-panel-head,
-.observe-panel-head {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- align-items: flex-start;
- padding: 16px 18px;
- border-bottom: 1px solid var(--line);
-}
-
-.worker-panel-head {
- flex-wrap: wrap;
-}
-
-.worker-list-role-tabs {
- width: auto;
- flex: 0 1 auto;
-}
-
-.worker-panel-head h3,
-.observe-panel-head h3 {
- margin: 4px 0 0;
- color: var(--text);
- font-size: 14px;
- font-weight: 600;
- line-height: 1.3;
- letter-spacing: 0;
-}
-
-.worker-panel-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.worker-refresh-button {
- min-height: 30px;
- padding: 6px 10px;
- border-radius: 9px;
- font-size: 11px;
-}
-
-.worker-panel-head small {
- max-width: 280px;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.5;
- text-align: right;
-}
-
-.worker-and-channel {
- display: grid;
- grid-template-columns: 1.3fr 0.9fr;
- gap: 16px;
- margin-top: 18px;
- align-items: start;
-}
-
-.worker-and-channel > * {
- min-width: 0;
-}
-
-.worker-table {
- border: 1px solid var(--line);
- border-radius: 18px;
- /* overflow: hidden removed so the PullProgressInfo tooltip can extend
- above the table when a Pending worker sits in the first row. Corner
- rounding is preserved via per-cell border-radius below. */
-}
-
-.worker-primary-panel .worker-table {
- border-width: 1px 0 0;
- border-radius: 0;
-}
-
-.worker-table table {
- table-layout: fixed;
- width: 100%;
-}
-
-.worker-table table td {
- word-break: break-word;
-}
-
-.worker-table th:first-child,
-.worker-table td:first-child {
- width: 220px;
-}
-
-.worker-name-block {
- min-width: 0;
-}
-
-.worker-name {
- display: block;
- min-width: 0;
- max-width: 280px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.worker-node-cell strong {
- display: block;
- color: var(--text);
- font-size: 13px;
- line-height: 1.35;
-}
-
-.worker-node-cell small {
- display: block;
- color: var(--muted);
- font-size: 11px;
- margin-top: 3px;
-}
-
-.worker-resource-cell {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.worker-resource-cell span {
- padding: 5px 8px;
- border-radius: 999px;
- background: #f1f5fb;
- color: #596579;
- font-size: 11px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-/* Round the corner cells to keep the rounded border look without overflow:
- hidden, which would clip the worker-row pull-progress tooltip. */
-.worker-table thead th:first-child {
- border-top-left-radius: 18px;
-}
-.worker-table thead th:last-child {
- border-top-right-radius: 18px;
-}
-.worker-table tbody tr:last-child td:first-child {
- border-bottom-left-radius: 18px;
-}
-.worker-table tbody tr:last-child td:last-child {
- border-bottom-right-radius: 18px;
-}
-
-/* Move the hover background from to so the per-cell border-radius
- clips the hover fill at the corners. Excludes .pod-detail-row to preserve
- its surface-2 background. */
-.worker-table tbody tr:hover {
- background: transparent;
-}
-.worker-table tbody tr:not(.pod-detail-row):hover > td {
- background: #fbfdff;
-}
-.theme-dark .worker-table tbody tr:not(.pod-detail-row):hover > td {
- background: rgba(255, 255, 255, 0.035);
-}
-
-.pod-detail-row td {
- padding: 0 !important;
- background: var(--surface-2);
-}
-
-.worker-detail-drawer {
- padding: 14px 18px 16px;
- border-top: 1px solid var(--line);
-}
-
-.worker-detail-grid {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 10px;
- margin-bottom: 12px;
-}
-
-.worker-detail-grid > div {
- min-width: 0;
- padding: 12px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
-}
-
-.pod-subtable {
- padding: 0;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #f8fafc;
- overflow: hidden;
-}
-
-.pod-subtable table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0;
- table-layout: auto;
-}
-
-.pod-subtable th {
- background: #f3f6fb;
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(--text-2);
- padding: 6px 10px;
- text-align: left;
-}
-
-.pod-subtable td {
- padding: 10px;
- border-top: 1px solid var(--line);
- background: #fbfdff;
- font-size: 13px;
-}
-
-.pod-subtable tbody tr:nth-child(even) td {
- background: #f6f9fd;
-}
-
-.pod-subtable tbody tr:hover td {
- background: #f1f6ff;
-}
-
-.pod-subtable .inline-code {
- background: #eef3fb;
-}
-
-.empty-inline {
- padding: 12px;
- border: 1px dashed var(--line);
- border-radius: 12px;
- color: var(--muted);
- background: #fff;
- font-size: 13px;
-}
-
-.job-observe-panel {
- padding: 0;
-}
-
-.job-observe-panel > .log-toolbar,
-.job-observe-panel > .log-stream,
-.job-observe-panel > code,
-.job-observe-panel > .job-config-summary,
-.job-observe-panel > .worker-metrics-grid,
-.job-observe-panel > .embodied-channel {
- margin: 16px 18px;
-}
-
-.log-loading-state {
- position: relative;
- min-height: 116px;
- margin: 18px;
- padding: 24px;
- border: 1px solid #e5eaf3;
- border-radius: 14px;
- overflow: hidden;
- display: flex;
- align-items: center;
- gap: 14px;
- background: linear-gradient(135deg, #fafbff 0%, #f5f2ff 100%);
-}
-
-.log-loading-icon {
- width: 42px;
- height: 42px;
- border-radius: 12px;
- display: grid;
- place-items: center;
- color: #7c3aed;
- background: #ede9fe;
-}
-
-.log-loading-icon svg {
- animation: status-spin 1.1s linear infinite;
-}
-
-.log-loading-state div {
- display: grid;
- gap: 5px;
-}
-
-.log-loading-state strong {
- color: var(--text);
- font-size: 14px;
-}
-
-.log-loading-state small {
- color: var(--muted);
- font-size: 12px;
-}
-
-.log-loading-shimmer {
- position: absolute;
- inset: 0;
- transform: translateX(-100%);
- background: linear-gradient(
- 90deg,
- transparent,
- rgba(255, 255, 255, 0.65),
- transparent
- );
- animation: log-loading-shimmer 1.8s ease-in-out infinite;
-}
-
-@keyframes log-loading-shimmer {
- to {
- transform: translateX(100%);
- }
-}
-
-.theme-dark .log-loading-state {
- border-color: #273348;
- background: linear-gradient(135deg, #111a29 0%, #171429 100%);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .status-icon,
- .log-loading-icon svg,
- .log-loading-shimmer {
- animation: none;
- }
-}
-
-.worker-metrics-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
- gap: 12px;
-}
-
-.worker-metric-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fbfcff;
- padding: 14px;
-}
-
-.worker-metric-card > div {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
-}
-
-.worker-metric-card > div strong {
- color: var(--text);
- font-size: 13px;
- margin-right: auto;
-}
-
-.worker-metric-card label {
- display: grid;
- grid-template-columns: 64px minmax(0, 1fr) 42px;
- gap: 8px;
- align-items: center;
- margin-top: 9px;
- color: var(--muted);
- font-size: 12px;
- font-weight: 800;
-}
-
-.worker-metric-card label span {
- color: var(--text);
- text-align: right;
-}
-
-.worker-metric-card small {
- display: block;
- margin-top: 12px;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.45;
-}
-
-.embodied-channel {
- display: grid;
- gap: 12px;
-}
-
-.channel-screen {
- min-height: 210px;
- border: 1px solid var(--line);
- border-radius: 20px;
- background:
- radial-gradient(
- circle at 50% 38%,
- rgba(124, 58, 237, 0.18),
- transparent 32%
- ),
- linear-gradient(145deg, #101827, #17243a);
- color: white;
- display: grid;
- place-content: center;
- justify-items: center;
- text-align: center;
- padding: 24px;
-}
-
-.channel-screen strong {
- margin-top: 12px;
- font-size: 15px;
-}
-
-.channel-screen span {
- margin-top: 8px;
- color: rgba(255, 255, 255, 0.68);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.log-stream {
- min-height: 132px;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #101827;
- padding: 14px;
- display: grid;
- align-content: start;
- gap: 8px;
-}
-
-.log-stream code {
- color: #c8d3e4;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
-}
-
-.log-stream-head {
- display: flex;
- align-items: center;
- gap: 8px;
- padding-bottom: 8px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- margin-bottom: 8px;
-}
-
-.log-stream-head strong {
- color: #e0e7f0;
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
-}
-
-.log-stream-head small {
- color: #6b7891;
- font-size: 11px;
- margin-left: auto;
-}
-
-.log-content {
- color: #c8d3e4;
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- line-height: 1.6;
- white-space: pre-wrap;
- word-break: break-all;
- margin: 0;
- max-height: 400px;
- overflow-y: auto;
-}
-
-.log-error {
- color: #ef4444 !important;
-}
-
-.log-toolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- flex-wrap: wrap;
- background: #0d1520;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- padding: 10px 12px;
-}
-
-.log-role-tabs {
- display: flex;
- gap: 4px;
- flex-wrap: wrap;
-}
-
-.log-role-tab {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: rgba(255, 255, 255, 0.03);
- color: #8b95a7;
- border-radius: 8px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.log-role-tab small {
- font-size: 10px;
- opacity: 0.6;
-}
-
-.log-role-tab:hover {
- border-color: rgba(255, 255, 255, 0.2);
- color: #c8d3e4;
- background: rgba(255, 255, 255, 0.06);
-}
-
-.log-role-tab.active {
- background: rgba(99, 102, 241, 0.15);
- border-color: rgba(99, 102, 241, 0.5);
- color: #c7d2fe;
-}
-
-.log-role-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #4b5563;
- flex-shrink: 0;
-}
-
-.log-role-dot.running {
- background: #34d399;
- box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
-}
-
-.log-pod-picker {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- margin-left: auto;
-}
-
-.log-pod-label {
- font-size: 11px;
- color: #6b7891;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- flex-shrink: 0;
-}
-
-.log-pod-select {
- padding: 5px 10px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: rgba(255, 255, 255, 0.04);
- color: #c8d3e4;
- border-radius: 8px;
- font-size: 12px;
- font-family: "JetBrains Mono", monospace;
- cursor: pointer;
- max-width: 320px;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.log-pod-select:hover,
-.log-pod-select:focus {
- border-color: rgba(255, 255, 255, 0.2);
-}
-
-.role-template {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 14px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: #fbfcfe;
-}
-
-.role-template span {
- padding: 7px 10px;
- border-radius: 999px;
- background: #f3eefe;
- color: var(--blue);
- font-size: 12px;
- font-weight: 800;
-}
-
-.subpage-tabs {
- display: flex;
- gap: 8px;
- margin: -4px 0 18px;
-}
-
-.subpage-tabs button {
- height: 40px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- gap: 7px;
- border: 1px solid var(--line);
- border-radius: 999px;
- background: #fff;
- color: #667182;
- font-size: 12px;
- font-weight: 850;
-}
-
-.subpage-tabs button.active {
- background: #f3eefe;
- border-color: #ddd0fe;
- color: var(--blue);
-}
-
-.job-config-summary {
- display: grid;
- grid-template-columns: 1fr 1.2fr;
- gap: 10px;
- margin: 0 0 16px;
-}
-
-.job-config-summary > div {
- border: 1px solid var(--line);
- border-radius: 16px;
- padding: 12px;
- background: #fbfcff;
- min-width: 0;
-}
-
-.job-config-summary span {
- display: block;
- color: #8d97a6;
- font-size: 11px;
- font-weight: 800;
- margin-bottom: 7px;
-}
-
-.job-config-summary code,
-.job-config-summary pre {
- display: block;
- margin: 5px 0 0;
- color: #26313f;
- background: #f0f4fa;
- border-radius: 10px;
- padding: 7px 8px;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- line-height: 1.45;
- white-space: pre-wrap;
- word-break: normal;
-}
-
-.job-config-summary strong {
- display: block;
- color: #202733;
- font-size: 13px;
-}
-
-.job-detail-summary-card {
- display: grid;
- gap: 10px;
- margin: 0 0 2px;
- padding: 16px;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fff;
- box-shadow: var(--shadow-soft);
-}
-
-.job-detail-summary-head {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 20px;
- align-items: start;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--line);
-}
-
-.job-detail-summary-head h2 {
- margin: 6px 0;
- color: var(--text);
- font-size: 26px;
- line-height: 1.15;
- letter-spacing: 0;
-}
-
-.job-detail-summary-head p {
- margin: 0;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.job-detail-summary-status {
- min-width: 126px;
- display: grid;
- justify-items: end;
- gap: 8px;
-}
-
-.job-detail-summary-status small {
- color: var(--muted);
- font-size: 11px;
- font-weight: 750;
-}
-
-.job-detail-summary-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px;
-}
-
-.task-summary-metric {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fbfcff;
- padding: 10px 12px;
-}
-
-.task-summary-metric.tone-blue {
- border-color: #cfdcff;
- background: #f7f9ff;
-}
-
-.task-summary-metric span,
-.task-summary-metric small {
- display: block;
- color: var(--muted);
- font-size: 11px;
- line-height: 1.4;
-}
-
-.task-summary-metric strong {
- display: block;
- margin: 4px 0 2px;
- color: var(--text);
- font-size: 16px;
- letter-spacing: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.task-summary-metric.tone-blue strong {
- color: var(--blue);
-}
-
-.job-detail-summary-columns {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 10px 12px;
- align-items: stretch;
-}
-
-.job-detail-summary-section {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fbfcff;
- padding: 12px;
-}
-
-.public-runtime-card {
- grid-column: 1;
- grid-row: auto;
-}
-
-.header-worker-card-legacy {
- display: none;
-}
-
-.header-summary-metric {
- position: relative;
-}
-
-.header-summary-metric a {
- position: absolute;
- top: 10px;
- right: 10px;
- display: grid;
- place-items: center;
- color: var(--blue);
-}
-
-.job-detail-clone-button {
- min-height: 32px;
- height: 32px;
- gap: 6px;
-}
-
-.public-config-table {
- width: 100%;
- margin-top: 10px;
- border-collapse: separate;
- border-spacing: 0;
- border: 1px solid var(--line);
- border-radius: 10px;
- overflow: hidden;
-}
-
-.public-config-table th,
-.public-config-table td {
- padding: 9px 11px;
- border-bottom: 1px solid var(--line);
- text-align: left;
- font-size: 11px;
-}
-
-.public-config-table th {
- height: 34px;
-}
-
-.public-config-table td {
- height: 40px;
-}
-
-.public-config-table th {
- color: var(--muted);
- background: #f5f7fb;
- font-weight: 800;
-}
-
-.public-config-table tr:last-child td {
- border-bottom: 0;
-}
-
-.public-config-table td:first-child {
- width: 150px;
- color: var(--muted);
- font-weight: 750;
-}
-
-.public-config-table code {
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- overflow-wrap: anywhere;
-}
-
-.public-config-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: #fff;
- padding: 15px;
- box-shadow: var(--shadow-soft);
-}
-
-.public-card-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 12px;
-}
-
-.public-card-head h3 {
- margin: 4px 0 0;
- color: var(--text);
- font-size: 15px;
-}
-
-.public-runtime-topology {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin-top: 0;
-}
-
-.public-command-card,
-.public-basic-config-card,
-.public-compact-config-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
-}
-
-.public-command-card,
-.public-basic-config-card {
- padding: 10px;
- display: flex;
- flex-direction: column;
- height: 100%;
-}
-
-.public-command-card .command-code-block {
- flex: 1;
-}
-
-.public-config-title {
- display: block;
- margin-bottom: 8px;
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.public-command-card .copyable-code-block {
- margin-top: 0;
-}
-
-.command-code-block {
- position: relative;
- min-height: 66px;
- height: 100%;
- border: 1px solid #1f2a3d;
- border-radius: 10px;
- background: #0f172a;
- overflow: hidden;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
-}
-
-.command-code-block .icon-button {
- position: absolute;
- top: 8px;
- right: 8px;
- z-index: 1;
- width: 26px;
- height: 26px;
- background: #172235;
- border-color: #2c3a52;
- color: #d7e4f6;
-}
-
-.command-code-block pre {
- margin: 0;
- padding: 12px 44px 12px 0;
- overflow-x: auto;
-}
-
-.command-code-block code {
- display: grid;
- grid-template-columns: 34px minmax(0, 1fr);
- min-height: 21px;
- color: #d7e4f6;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.65;
-}
-
-.command-line-number {
- padding-right: 10px;
- border-right: 1px solid #26344d;
- color: #64748b;
- text-align: right;
- user-select: none;
-}
-
-.command-line-content {
- min-width: 0;
- padding-left: 12px;
- overflow-wrap: anywhere;
- white-space: pre-wrap;
-}
-
-.command-token-keyword {
- color: #c4b5fd;
- font-weight: 850;
-}
-
-.command-token-flag {
- color: #93c5fd;
-}
-
-.command-token-value {
- color: #6ee7b7;
-}
-
-.public-basic-config-list {
- display: grid;
- gap: 0;
- border: 1px solid var(--line);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.public-basic-config-list div {
- display: grid;
- grid-template-columns: 96px minmax(0, 1fr);
- align-items: stretch;
- gap: 0;
- min-height: 28px;
- border-bottom: 1px solid var(--line);
- border-radius: 0;
- background: transparent;
- padding: 0;
-}
-
-.public-basic-config-list div:last-child {
- border-bottom: 0;
-}
-
-.public-basic-config-list span {
- display: flex;
- align-items: center;
- padding: 0 10px;
- background: #f3f6fb;
- color: var(--muted);
- font-size: 10px;
- font-weight: 800;
-}
-
-.public-basic-config-list code {
- min-width: 0;
- display: block;
- width: auto;
- max-width: 100%;
- border-radius: 0;
- border-left: 1px solid var(--line);
- background: transparent;
- padding: 7px 10px;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- overflow-wrap: anywhere;
-}
-
-.public-compact-config-table code {
- min-width: 0;
- display: inline-flex;
- width: fit-content;
- max-width: 100%;
- border-radius: 6px;
- background: #f1f5fb;
- padding: 5px 8px;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- overflow-wrap: anywhere;
-}
-
-.public-runtime-tables {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin-top: 10px;
-}
-
-.public-runtime-tables > .public-compact-config-card:only-child {
- grid-column: 1 / -1;
-}
-
-.public-compact-config-card {
- overflow: hidden;
-}
-
-.public-compact-config-card .public-config-title {
- margin: 0;
- padding: 9px 11px 7px;
-}
-
-.public-compact-config-table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.public-compact-config-table th,
-.public-compact-config-table td {
- height: 34px;
- padding: 7px 11px;
- border-top: 1px solid var(--line);
- text-align: left;
- font-size: 11px;
-}
-
-.public-compact-config-table th {
- color: var(--muted);
- background: #f5f7fb;
- font-weight: 800;
-}
-
-.public-compact-config-table td:first-child {
- width: 44%;
-}
-
-.public-compact-config-table small {
- color: var(--muted);
-}
-
-.public-card-head a {
- display: grid;
- place-items: center;
- width: 30px;
- height: 30px;
- border-radius: 9px;
- background: #f1f4ff;
- color: var(--blue);
-}
-
-.header-worker-identity {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 10px;
-}
-
-.header-worker-identity strong {
- min-width: 0;
- color: var(--text);
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.copyable-code-block {
- margin-top: 9px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #f7f9fc;
- overflow: hidden;
-}
-
-.copyable-code-block > div {
- min-height: 34px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 5px 7px 5px 10px;
- border-bottom: 1px solid var(--line);
-}
-
-.copyable-code-block span {
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.copyable-code-block .icon-button {
- width: 25px;
- height: 25px;
- flex: 0 0 auto;
-}
-
-.copyable-code-block code {
- display: block;
- padding: 8px 10px;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.45;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-.worker-console-panel {
- margin-bottom: 16px;
-}
-
-.role-runtime-config {
- border-bottom: 1px solid var(--line);
- background: #fbfcff;
- padding: 14px 18px;
-}
-
-.job-detail-page .role-runtime-config.job-detail-summary-card {
- border: 1px solid var(--line);
- border-radius: 18px;
- background: #fff;
- box-shadow: var(--shadow-soft);
- padding: 16px;
- position: relative;
- z-index: 1;
-}
-
-.role-runtime-heading {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 11px;
-}
-
-.role-runtime-heading > div:first-child {
- min-width: 0;
-}
-
-.role-runtime-heading strong {
- display: block;
- margin-top: 3px;
- color: var(--text);
- font-size: 14px;
- font-weight: 600;
-}
-
-.role-runtime-tabs {
- min-width: 0;
- width: 100%;
- display: flex;
- justify-content: flex-start;
- gap: 5px;
- overflow-x: auto;
- padding-bottom: 1px;
-}
-
-.role-runtime-tabs button {
- height: 30px;
- display: inline-flex;
- align-items: center;
- gap: 5px;
- flex: 0 0 auto;
- border: 1px solid var(--line);
- border-radius: 7px;
- background: #fff;
- color: var(--muted);
- padding: 0 9px;
- font-size: 11px;
- font-weight: 800;
-}
-
-.role-runtime-tabs button span {
- border-radius: 4px;
- background: #ede9fe;
- color: var(--blue);
- padding: 2px 4px;
- font-size: 9px;
-}
-
-.role-runtime-tabs button.active {
- border-color: #cfc4ff;
- background: #f4f0ff;
- color: var(--blue);
-}
-
-.role-runtime-summary {
- display: grid;
- grid-template-columns: minmax(0, 1.2fr) minmax(190px, 0.8fr) auto auto;
- align-items: center;
- gap: 12px;
- margin-top: 12px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #f8fafc;
- padding: 9px 10px;
-}
-
-.role-runtime-image {
- min-width: 0;
- flex: 1 1 auto;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.role-runtime-image span {
- flex: 0 0 auto;
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.role-runtime-image code {
- min-width: 0;
- overflow: hidden;
- border: 1px solid #e5ebf3;
- border-radius: 7px;
- background: #fff;
- padding: 5px 7px;
- color: #455166;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.role-runtime-resource-summary {
- min-width: 0;
-}
-
-.role-runtime-resource-summary span,
-.role-runtime-resource-summary strong {
- display: block;
-}
-
-.role-runtime-resource-summary span {
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.role-runtime-resource-summary strong {
- margin-top: 3px;
- display: inline-flex;
- max-width: 100%;
- border: 1px solid #e5ebf3;
- border-radius: 7px;
- background: #fff;
- padding: 5px 7px;
- color: var(--text);
- font-size: 11px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.role-runtime-image .icon-button {
- width: 26px;
- height: 26px;
- flex: 0 0 auto;
-}
-
-.role-runtime-meta {
- display: flex;
- gap: 6px;
- flex: 0 0 auto;
-}
-
-.role-runtime-meta span {
- border-radius: 6px;
- background: #eef2f7;
- color: #637086;
- padding: 5px 7px;
- font-size: 10px;
- font-weight: 750;
-}
-
-.role-runtime-toggle {
- flex: 0 0 auto;
- gap: 5px;
-}
-
-.role-runtime-toggle svg {
- transition: transform 0.15s ease;
-}
-
-.role-runtime-details {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 8px;
- margin-top: 10px;
- align-items: stretch;
-}
-
-.role-runtime-all-summary {
- margin: 12px 0 0;
- padding-top: 11px;
- border-top: 1px dashed var(--line);
- color: var(--muted);
- font-size: 11px;
-}
-
-.role-runtime-details .copyable-code-block,
-.role-runtime-details .config-value-list.compact {
- margin-top: 0;
-}
-
-.role-runtime-facts {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
- padding: 10px;
-}
-
-.role-runtime-facts div {
- min-width: 0;
-}
-
-.role-runtime-facts span,
-.role-runtime-facts strong,
-.role-runtime-facts code {
- display: block;
-}
-
-.role-runtime-facts span {
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.role-runtime-facts strong,
-.role-runtime-facts code {
- margin-top: 4px;
- color: var(--text);
- font-size: 10px;
- line-height: 1.4;
- overflow-wrap: anywhere;
-}
-
-.role-runtime-facts code {
- font-family: "JetBrains Mono", monospace;
-}
-
-.worker-total-count {
- align-self: center;
- border-radius: 999px;
- background: #eef3ff;
- color: var(--blue);
- padding: 6px 10px;
- font-size: 11px;
- font-weight: 850;
-}
-
-.restart-choice-backdrop {
- z-index: 1200;
-}
-
-.delete-job-backdrop {
- z-index: 1200;
-}
-
-.job-lifecycle-backdrop {
- z-index: 1200;
-}
-
-.job-lifecycle-modal {
- width: min(500px, calc(100vw - 32px));
- border-radius: 18px;
-}
-
-.job-lifecycle-head {
- display: grid;
- grid-template-columns: 42px minmax(0, 1fr) 34px;
- gap: 12px;
- align-items: start;
- padding: 22px 22px 18px;
- border-bottom: 1px solid var(--line);
-}
-
-.job-lifecycle-icon {
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- border-radius: 12px;
- background: #eef8f4;
- color: #16875d;
-}
-
-.job-lifecycle-modal.stop .job-lifecycle-icon {
- background: #fff7e8;
- color: #d97706;
-}
-
-.job-lifecycle-head h2 {
- margin: 3px 0 0;
- color: var(--text);
- font-size: 20px;
-}
-
-.job-lifecycle-body {
- padding: 18px 22px 16px;
-}
-
-.job-lifecycle-body > p {
- margin: 0 0 14px;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.65;
-}
-
-.job-lifecycle-actions {
- display: flex;
- justify-content: flex-end;
- gap: 9px;
- padding: 14px 22px 20px;
- border-top: 1px solid var(--line);
-}
-
-.job-lifecycle-actions button {
- min-height: 36px;
- padding: 0 15px;
- border-radius: 10px;
-}
-
-.job-lifecycle-actions .primary-button {
- display: inline-flex;
- align-items: center;
- gap: 7px;
-}
-
-.delete-job-modal {
- width: min(500px, calc(100vw - 32px));
- border-radius: 18px;
-}
-
-.delete-job-head {
- display: grid;
- grid-template-columns: 42px minmax(0, 1fr) 34px;
- gap: 12px;
- align-items: start;
- padding: 22px 22px 18px;
- border-bottom: 1px solid var(--line);
-}
-
-.delete-job-icon {
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- border-radius: 12px;
- background: #fff0f1;
- color: #dc3545;
-}
-
-.delete-job-head h2 {
- margin: 3px 0 0;
- color: var(--text);
- font-size: 20px;
-}
-
-.delete-job-body {
- padding: 18px 22px 16px;
-}
-
-.delete-job-body > p {
- margin: 0 0 14px;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.65;
-}
-
-.delete-job-target {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 18px;
- min-height: 48px;
- padding: 10px 13px;
- border: 1px solid #e2e6ee;
- border-radius: 11px;
- background: #fafbfc;
-}
-
-.delete-job-target span {
- flex: none;
- color: var(--muted);
- font-size: 11px;
-}
-
-.delete-job-target strong {
- min-width: 0;
- overflow: hidden;
- color: var(--text);
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.delete-job-warning {
- display: flex;
- gap: 8px;
- align-items: flex-start;
- margin-top: 12px;
- color: #a16207;
- font-size: 11px;
- line-height: 1.55;
-}
-
-.delete-job-warning svg {
- flex: none;
- margin-top: 1px;
-}
-
-.delete-job-error {
- margin-top: 12px;
- padding: 9px 11px;
- border-radius: 9px;
- background: #fff0f1;
- color: #b42332;
- font-size: 11px;
- line-height: 1.5;
-}
-
-.delete-job-actions {
- display: flex;
- justify-content: flex-end;
- gap: 9px;
- padding: 14px 22px 20px;
- border-top: 1px solid var(--line);
-}
-
-.delete-job-actions .secondary-button,
-.delete-job-confirm {
- min-height: 36px;
- padding: 0 15px;
- border-radius: 10px;
- font-size: 12px;
- font-weight: 750;
-}
-
-.delete-job-confirm {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 7px;
- border: 1px solid #dc3545;
- background: #dc3545;
- color: #fff;
- box-shadow: 0 7px 16px rgba(220, 53, 69, 0.18);
-}
-
-.delete-job-confirm:hover:not(:disabled) {
- border-color: #c62d3c;
- background: #c62d3c;
-}
-
-.delete-job-confirm:disabled {
- cursor: not-allowed;
- opacity: 0.65;
-}
-
-.restart-choice-modal {
- width: min(520px, calc(100vw - 32px));
- border-radius: 18px;
-}
-
-.restart-choice-head {
- display: grid;
- grid-template-columns: 42px minmax(0, 1fr) 34px;
- gap: 12px;
- align-items: start;
- padding: 22px 22px 18px;
- border-bottom: 1px solid var(--line);
-}
-
-.restart-choice-icon {
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- border-radius: 12px;
- background: #f1edff;
- color: #6d45d8;
-}
-
-.restart-choice-head h2 {
- margin: 3px 0 2px;
- color: var(--text);
- font-size: 20px;
-}
-
-.restart-choice-head p {
- margin: 0;
- overflow: hidden;
- color: var(--muted);
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.restart-choice-options {
- display: grid;
- gap: 10px;
- padding: 18px 22px 12px;
-}
-
-.restart-choice-option {
- display: grid;
- grid-template-columns: 36px minmax(0, 1fr) 18px;
- grid-template-rows: auto auto;
- column-gap: 12px;
- align-items: center;
- width: 100%;
- padding: 13px 14px;
- border: 1px solid #e2e6ee;
- border-radius: 12px;
- background: #fff;
- color: var(--text);
- text-align: left;
- transition:
- border-color 0.15s ease,
- box-shadow 0.15s ease;
-}
-
-.restart-choice-option:hover {
- border-color: #bfaef0;
- box-shadow: 0 8px 22px rgba(83, 63, 148, 0.1);
-}
-
-.restart-choice-option > span {
- grid-row: 1 / span 2;
- display: grid;
- place-items: center;
- width: 36px;
- height: 36px;
- border-radius: 10px;
- background: #f4f2fa;
- color: #67558f;
-}
-
-.restart-choice-option.primary > span {
- background: #ede8ff;
- color: #6d45d8;
-}
-
-.restart-choice-option strong {
- font-size: 13px;
-}
-
-.restart-choice-option small {
- color: var(--muted);
- font-size: 11px;
- line-height: 1.45;
-}
-
-.restart-choice-option > svg {
- grid-column: 3;
- grid-row: 1 / span 2;
- color: #9aa3b2;
-}
-
-.restart-choice-note {
- margin: 0 22px 20px;
- color: #8a6a3f;
- font-size: 11px;
- line-height: 1.5;
-}
-
-.theme-dark .restart-choice-modal,
-.theme-dark .restart-choice-option {
- background: #171b25;
-}
-
-.theme-dark .delete-job-modal {
- background: #171b25;
-}
-
-.theme-dark .job-lifecycle-modal {
- background: #121a28;
- border-color: #2a3547;
-}
-
-.theme-dark .job-lifecycle-icon {
- background: rgba(22, 135, 93, 0.16);
-}
-
-.theme-dark .delete-job-icon,
-.theme-dark .delete-job-error {
- background: rgba(248, 113, 113, 0.12);
- color: #fca5a5;
-}
-
-.theme-dark .delete-job-target {
- border-color: #303744;
- background: #1d222d;
-}
-
-.theme-dark .delete-job-warning {
- color: #fcd34d;
-}
-
-.theme-dark .restart-choice-option {
- border-color: #303744;
-}
-
-.theme-dark .restart-choice-icon,
-.theme-dark .restart-choice-option.primary > span {
- background: rgba(139, 92, 246, 0.18);
- color: #c4b5fd;
-}
-
-.theme-dark .restart-choice-option > span {
- background: #252a36;
- color: #c7ced9;
-}
-
-.worker-filter-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 18px;
- border: 0;
- background: #fff;
-}
-
-.worker-filter-bar label,
-.log-console-toolbar label {
- display: grid;
- gap: 5px;
-}
-
-.worker-filter-bar label > span,
-.log-console-toolbar label > span {
- display: none;
-}
-
-.worker-filter-bar select,
-.log-console-toolbar select,
-.worker-role-filter-select {
- height: 34px;
- border: 1px solid var(--line);
- border-radius: 9px;
- background: #fff;
- color: var(--text);
- padding: 0 28px 0 9px;
- font-size: 12px;
- outline: 0;
-}
-
-.worker-filter-bar::before {
- content: "筛选";
- color: var(--muted);
- font-size: 11px;
- font-weight: 850;
- margin-right: 2px;
-}
-
-.worker-console-table {
- border: 0;
- border-radius: 0;
-}
-
-.worker-table-scroll {
- overflow-x: auto;
- cursor: grab;
- overscroll-behavior-x: contain;
- scrollbar-gutter: stable;
- touch-action: pan-y;
-}
-
-.worker-table-scroll.dragging {
- cursor: grabbing;
- user-select: none;
-}
-
-.worker-table-scroll .table-sort-button,
-.worker-table-scroll button,
-.worker-table-scroll a {
- cursor: pointer;
-}
-
-.worker-console-table table {
- min-width: 930px;
- /* Auto layout lets each column size to its content so long cluster/node
- names widen the table (scrollable via .worker-table-scroll) instead of
- overflowing into neighbouring cells and overlapping their content. */
- table-layout: auto;
-}
-
-.worker-console-table th,
-.worker-console-table td {
- padding: 12px 13px;
- white-space: nowrap;
-}
-
-.worker-console-table th {
- color: #8994a6;
- font-size: 10px;
- letter-spacing: 0.025em;
- text-transform: uppercase;
-}
-
-/* 集群/节点 chip:与 .role-chip / .node-kind-chip 同一风格体系,
- 可点击变体通过 hover 淡紫底提示可跳转。 */
-.worker-chip {
- display: inline-flex;
- align-items: center;
- max-width: 260px;
- border-radius: 999px;
- background: #f0f3f7;
- color: #667182;
- padding: 6px 9px;
- font-size: 11px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.worker-chip-link {
- border: 0;
- cursor: pointer;
- transition:
- background 0.12s ease,
- color 0.12s ease;
-}
-
-.worker-chip-link:hover {
- background: rgba(124, 58, 237, 0.08);
- color: var(--blue);
-}
-
-.worker-link-label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.theme-dark .worker-chip {
- background: #202b3d;
- color: #b1bdd0;
-}
-
-.theme-dark .worker-chip-link:hover {
- background: rgba(167, 139, 250, 0.15);
- color: #c4b5fd;
-}
-
-.job-action-notice {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 14px;
- border: 1px solid color-mix(in srgb, #23a36d 35%, var(--line));
- border-radius: 10px;
- background: color-mix(in srgb, #23a36d 8%, var(--surface));
- color: var(--text);
- padding: 10px 12px;
- font-size: 13px;
- font-weight: 650;
-}
-
-.job-action-notice > svg {
- color: #1c9b64;
-}
-
-.job-action-notice > span {
- flex: 1;
-}
-
-.job-action-notice > button {
- border: 0;
- background: transparent;
- color: var(--muted);
- font-size: 18px;
- cursor: pointer;
-}
-
-.app-job-submit-notice {
- margin: 18px 24px 0;
-}
-
-.node-kind-chip {
- display: inline-flex;
- border-radius: 999px;
- background: #f1f4f8;
- color: #667489;
- padding: 4px 7px;
- font-size: 10px;
- font-weight: 800;
-}
-
-.table-date {
- color: var(--muted);
- font-size: 11px;
-}
-
-.worker-table-actions {
- display: flex;
- align-items: center;
- gap: 5px;
- justify-content: flex-end;
-}
-
-.worker-sticky-header-col,
-.worker-sticky-actions {
- position: sticky;
- right: 0;
- background: #fff;
- z-index: 10;
- box-shadow: -6px 0 8px -6px rgba(15, 23, 42, 0.1);
- text-align: right;
-}
-
-.worker-sticky-header-col {
- padding-right: 24px;
-}
-
-.theme-dark .worker-sticky-header-col,
-.theme-dark .worker-sticky-actions {
- background: #151d2b;
- box-shadow: -6px 0 8px -6px rgba(0, 0, 0, 0.5);
-}
-
-.action-tooltip {
- position: relative;
- display: inline-flex;
-}
-
-.action-tooltip::after {
- content: attr(data-tooltip);
- position: absolute;
- right: 0;
- bottom: calc(100% + 7px);
- z-index: 999;
- width: max-content;
- max-width: 180px;
- padding: 6px 8px;
- border-radius: 6px;
- background: #202733;
- color: #fff;
- font-size: 11px;
- font-weight: 700;
- line-height: 1.3;
- white-space: nowrap;
- box-shadow: 0 5px 14px rgba(28, 36, 50, 0.2);
- opacity: 0;
- pointer-events: none;
- transform: translateY(3px);
- transition:
- opacity 0.12s ease,
- transform 0.12s ease;
-}
-
-.action-tooltip:hover::after,
-.action-tooltip:focus-within::after {
- opacity: 1;
- transform: translateY(0);
-}
-
-.worker-table-actions .icon-button {
- width: 31px;
- height: 31px;
- border: 1px solid var(--line);
- border-radius: 9px;
- background: #fff;
- color: #667489;
-}
-
-.worker-table-actions .worker-terminal-icon {
- color: var(--blue);
- background: #f1f5ff;
- border-color: #d9e2ff;
-}
-
-.worker-expanded-row td {
- padding: 0 !important;
- background: #f8faff;
-}
-
-.worker-expanded-row .worker-detail-drawer {
- padding: 14px 18px 16px;
-}
-
-.worker-detail-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 11px;
-}
-
-.worker-detail-head > div:first-child {
- min-width: 0;
- flex: 0 1 auto;
-}
-
-.worker-detail-head strong {
- display: block;
- margin-top: 4px;
- color: var(--text);
- font-size: 13px;
-}
-
-.worker-ssh-button {
- height: 32px;
- gap: 6px;
-}
-
-.worker-ssh-button svg:last-child {
- transition: transform 0.15s ease;
-}
-
-.role-runtime-command-row {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
-}
-
-.role-runtime-command-card,
-.role-runtime-selector-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
- overflow: hidden;
-}
-
-.role-runtime-command-card {
- display: flex;
- flex-direction: column;
- height: 100%;
-}
-
-.role-runtime-command-card .command-code-block {
- flex: 1;
-}
-
-.role-runtime-command-card > span,
-.role-runtime-selector-card > span {
- display: grid;
- place-items: center;
- min-height: 34px;
- padding: 9px 10px;
- border-bottom: 1px solid var(--line);
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.role-runtime-command-card .command-code-block {
- border: 0;
- border-radius: 0;
-}
-
-.role-runtime-selector-card > div {
- display: grid;
- gap: 0;
-}
-
-.role-runtime-selector-card p {
- min-width: 0;
- display: grid;
- grid-template-columns: minmax(0, 0.9fr) auto minmax(0, 1.1fr);
- gap: 8px;
- align-items: center;
- margin: 0;
- padding: 9px 10px;
- border-bottom: 1px solid var(--line);
- background: #fff;
-}
-
-.role-runtime-selector-card p:last-child {
- border-bottom: 0;
-}
-
-.role-runtime-selector-card code {
- min-width: 0;
- display: block;
- width: auto;
- max-width: 100%;
- border: 1px solid #e6ebf2;
- border-radius: 7px;
- background: #f8fafc;
- padding: 5px 7px;
- color: #475569;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- overflow-wrap: anywhere;
-}
-
-.role-runtime-selector-card b {
- display: grid;
- place-items: center;
- width: 20px;
- height: 20px;
- border-radius: 999px;
- background: #f1f4f8;
- color: #7b8797;
- font-size: 11px;
-}
-
-.role-runtime-selector-card small {
- display: block;
- padding: 10px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.role-runtime-table-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
- overflow: hidden;
-}
-
-.role-runtime-table-card > span {
- display: grid;
- place-items: center;
- min-height: 34px;
- padding: 9px 11px;
- border-bottom: 1px solid var(--line);
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.role-runtime-table-card table {
- width: 100%;
- border-collapse: collapse;
- table-layout: fixed;
-}
-
-.role-runtime-table-card th,
-.role-runtime-table-card td {
- height: 42px;
- padding: 8px 11px;
- border-bottom: 1px solid var(--line);
- text-align: center;
- vertical-align: middle;
- font-size: 10px;
-}
-
-.role-runtime-table-card th {
- color: var(--muted);
- background: #eef3f9;
- font-weight: 800;
-}
-
-.role-runtime-table-card td {
- background: #fbfdff;
-}
-
-.role-runtime-table-card tbody tr:nth-child(even) td {
- background: #f6f9fd;
-}
-
-.role-runtime-table-card tr:last-child td {
- border-bottom: 0;
-}
-
-.role-runtime-table-card code {
- display: inline-flex;
- max-width: 100%;
- border-radius: 6px;
- background: #eef3fb;
- padding: 4px 7px;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-.role-runtime-table-card .empty-cell {
- color: var(--muted);
- text-align: center;
-}
-
-.worker-console-table .empty-cell {
- height: 120px;
- color: var(--muted);
- text-align: center;
- font-size: 13px;
-}
-
-.role-runtime-config-tables {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
-}
-
-.role-runtime-env-table th:first-child,
-.role-runtime-env-table td:first-child {
- width: 32%;
-}
-
-.role-runtime-mount-table th:first-child,
-.role-runtime-mount-table td:first-child {
- width: 100px;
-}
-
-.role-runtime-mount-table th:nth-child(2),
-.role-runtime-mount-table td:nth-child(2) {
- width: 38%;
-}
-
-.worker-ssh-inline {
- min-width: 0;
- max-width: min(60%, 520px);
- flex: 1 1 320px;
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 5px 7px 5px 10px;
- border: 1px solid var(--line);
- border-radius: 9px;
- background: #fff;
-}
-
-.worker-ssh-inline code {
- min-width: 0;
- flex: 1 1 auto;
- display: block;
- overflow: hidden;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.worker-ssh-inline > svg {
- flex: 0 0 auto;
-}
-
-.worker-ssh-inline .icon-button {
- width: 26px;
- height: 26px;
- flex: 0 0 auto;
-}
-
-.city-label-row > svg {
- flex: 0 0 auto;
- color: var(--blue);
-}
-
-.worker-ssh-access {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 11px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
- padding: 10px 12px;
-}
-
-.worker-ssh-access > div {
- min-width: 0;
- flex: 1 1 auto;
-}
-
-.worker-ssh-access span {
- display: block;
- margin-bottom: 5px;
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-
-.worker-ssh-access code {
- display: block;
- color: #39475a;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.45;
- overflow-wrap: anywhere;
-}
-
-.worker-ssh-access .secondary-button {
- flex: 0 0 auto;
- gap: 6px;
-}
-
-.worker-pagination {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- padding: 12px 18px;
- border-top: 1px solid var(--line);
- color: var(--muted);
- font-size: 11px;
-}
-
-.worker-pagination > div {
- display: flex;
- gap: 6px;
-}
-
-.worker-pagination .secondary-button {
- height: 30px;
- padding: 0 9px;
- font-size: 11px;
-}
-
-.log-console-toolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- flex-wrap: wrap;
- margin: 16px 18px;
- padding: 12px;
- background: var(--bg-secondary, #f8f9fa);
- border-radius: 8px;
- border: 1px solid var(--line);
-}
-
-.log-search-field {
- display: flex !important;
- align-items: center;
- gap: 7px;
- flex: 1;
- min-width: 300px;
- height: 36px;
- border: 1px solid var(--line);
- border-radius: 6px;
- background: #fff;
- padding: 0 12px;
- color: #8793a6;
-}
-
-.log-search-field input {
- width: 100%;
- min-width: 0;
- border: 0;
- outline: 0;
- background: transparent;
- color: var(--text);
- font-size: 13px;
-}
-
-.log-custom-datetime {
- height: 36px;
- border: 1px solid var(--line);
- border-radius: 6px;
- background: #fff;
- padding: 0 12px;
- font-size: 13px;
- color: var(--text);
- font-family: inherit;
-}
-
-.log-custom-datetime:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
-}
-
-.stream-toggle {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 36px;
- border: 1px solid var(--line);
- border-radius: 6px;
- background: #fff;
- color: #788598;
- padding: 0 12px;
- font-size: 12px;
- font-weight: 600;
-}
-
-.stream-toggle i {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: #a2acba;
-}
-
-.stream-toggle.active {
- border-color: #bcead9;
- background: #f0fbf6;
- color: #16966d;
-}
-
-.stream-toggle.active i {
- background: #24c994;
- box-shadow: 0 0 0 3px rgba(36, 201, 148, 0.14);
-}
-
-.log-export-button {
- height: 36px;
- padding: 0 12px;
- font-size: 12px;
-}
-
-.log-list {
- margin: 0 18px 18px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
- overflow: hidden;
-}
-
-.log-list-head,
-.log-list-row {
- display: grid;
- grid-template-columns: 100px minmax(150px, 0.9fr) minmax(260px, 2.4fr) 140px;
- gap: 12px;
-}
-
-.log-list-head {
- padding: 8px 12px;
- border-bottom: 1px solid var(--line);
- background: #f6f7fa;
- color: var(--muted);
- font-size: 10px;
- font-weight: 750;
-}
-
-.log-list-row {
- align-items: baseline;
- padding: 10px 12px;
- border-bottom: 1px solid #edf0f5;
- font-size: 12px;
-}
-
-.log-list-row:last-child {
- border-bottom: 0;
-}
-
-.log-role-name {
- color: var(--muted);
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
-}
-.log-worker-name {
- color: var(--text);
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- font-weight: 750;
-}
-.log-list-row p {
- margin: 0;
- color: #4b5768;
- line-height: 1.45;
-}
-
-.log-timestamp {
- color: var(--muted);
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- font-weight: 500;
- text-align: right;
-}
-
-.log-load-more {
- display: flex;
- justify-content: center;
- padding: 10px 12px;
- border-bottom: 0;
- background: #fafbfc;
-}
-
-.log-load-more .secondary-button {
- height: 32px;
- padding: 0 14px;
- font-size: 12px;
-}
-
-.log-pagination {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 10px;
- padding: 10px 12px;
- background: #fafbfc;
-}
-
-.log-pagination .secondary-button {
- height: 32px;
- padding: 0 14px;
- font-size: 12px;
-}
-
-.log-page-indicator {
- font-size: 12px;
- color: var(--muted);
- font-family: "JetBrains Mono", monospace;
-}
-
-.metrics-dashboard {
- margin: 16px 18px 18px;
-}
-
-.metrics-integration-state {
- display: grid;
- place-items: center;
- min-height: 280px;
- padding: 48px 24px;
- border: 1px dashed var(--line);
- border-radius: 12px;
- background: #fafbfe;
- text-align: center;
-}
-
-.metrics-integration-icon {
- display: grid;
- place-items: center;
- width: 44px;
- height: 44px;
- margin-bottom: 12px;
- border-radius: 10px;
- background: #eef1f6;
- color: var(--muted);
-}
-
-.metrics-integration-state strong {
- color: var(--text);
- font-size: 16px;
-}
-
-.metrics-integration-state p {
- margin: 7px 0 0;
- color: var(--muted);
- font-size: 12px;
-}
-
-.metrics-filter-bar {
- display: flex;
- align-items: center;
- gap: 9px;
- flex-wrap: wrap;
- margin-bottom: 13px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--line);
-}
-
-.metrics-filter-bar label {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.metrics-filter-bar label > span {
- color: var(--muted);
- font-size: 10px;
- font-weight: 850;
-}
-.metrics-filter-bar select {
- height: 32px;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: #fff;
- color: var(--text);
- padding: 0 24px 0 9px;
- font-size: 11px;
-}
-.metrics-filter-bar select:disabled {
- background: #f3f5f8;
- color: #9aa4b2;
- cursor: not-allowed;
-}
-.metrics-scope-toggle {
- display: inline-flex;
- padding: 3px;
- border-radius: 9px;
- background: #f0f3f8;
-}
-.metrics-scope-toggle button {
- height: 28px;
- border: 0;
- border-radius: 7px;
- background: transparent;
- color: var(--muted);
- padding: 0 10px;
- font-size: 11px;
- font-weight: 850;
-}
-.metrics-scope-toggle button.active {
- background: #fff;
- color: var(--blue);
- box-shadow: 0 1px 4px rgba(42, 52, 73, 0.1);
-}
-.metrics-source-label {
- margin-left: auto;
- color: var(--muted);
- font-size: 10px;
-}
-
-.metrics-overview-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 12px;
- color: var(--muted);
- font-size: 11px;
-}
-
-.metrics-overview-row strong {
- color: #209a73;
- font-size: 11px;
-}
-
-.time-series-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.time-series-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fff;
- padding: 13px;
-}
-
-.time-series-head {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
-}
-.time-series-head span {
- display: block;
- color: var(--muted);
- font-size: 11px;
- font-weight: 800;
-}
-.time-series-head strong {
- display: block;
- margin-top: 5px;
- color: var(--text);
- font-size: 20px;
-}
-.time-series-head strong small {
- color: var(--muted);
- font-size: 11px;
- font-weight: 700;
-}
-.time-series-head i {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-top: 4px;
-}
-.time-series-card svg {
- display: block;
- width: 100%;
- height: 118px;
- margin-top: 8px;
- overflow: visible;
-}
-.time-series-card line {
- stroke: #edf1f7;
- stroke-width: 0.7;
- vector-effect: non-scaling-stroke;
-}
-.time-series-card polyline {
- fill: none;
- stroke-width: 2.2;
- vector-effect: non-scaling-stroke;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-.time-series-foot {
- display: flex;
- justify-content: space-between;
- color: #a0aabd;
- font-size: 10px;
-}
-
-.role-worker-panel {
- margin-bottom: 16px;
-}
-
-.role-worker-groups {
- display: grid;
- gap: 14px;
- padding: 16px 18px 18px;
-}
-
-.role-worker-group {
- border: 1px solid var(--line);
- border-radius: 16px;
- overflow: hidden;
- background: #fbfcff;
-}
-
-.role-worker-group-head {
- display: flex;
- justify-content: space-between;
- gap: 18px;
- align-items: flex-start;
- padding: 14px 16px;
- border-bottom: 1px solid var(--line);
- background: #fff;
-}
-
-.role-worker-title {
- display: flex;
- align-items: center;
- gap: 7px;
- margin-bottom: 9px;
-}
-
-.role-worker-group-head > div > strong,
-.role-worker-resource-summary strong {
- display: block;
- color: var(--text);
- font-size: 14px;
- line-height: 1.45;
-}
-
-.role-worker-group-head small,
-.role-worker-resource-summary span,
-.role-worker-resource-summary small {
- display: block;
- margin-top: 4px;
- color: var(--muted);
- font-size: 11px;
- line-height: 1.45;
-}
-
-.role-worker-resource-summary {
- min-width: 190px;
- text-align: right;
-}
-
-.worker-access-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
- gap: 10px;
- padding: 12px;
-}
-
-.worker-access-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fff;
- padding: 13px;
-}
-
-.worker-access-head {
- display: flex;
- align-items: flex-start;
- gap: 8px;
-}
-
-.worker-access-head > div {
- min-width: 0;
- margin-right: auto;
-}
-
-.worker-access-head strong {
- display: block;
- color: var(--text);
- font-size: 13px;
- line-height: 1.35;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.worker-access-head small {
- display: block;
- margin-top: 3px;
- color: var(--muted);
- font-size: 11px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.header-tag {
- display: inline-flex;
- align-items: center;
- width: fit-content;
- border-radius: 999px;
- padding: 3px 7px;
- background: #ede9fe;
- color: #7041d8;
- font-size: 10px;
- font-weight: 900;
- line-height: 1.3;
-}
-
-.worker-access-meta {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 6px;
- margin-top: 12px;
-}
-
-.worker-access-meta span {
- min-width: 0;
- padding: 7px;
- border-radius: 9px;
- background: #f4f7fb;
- color: var(--muted);
- font-size: 10px;
- font-weight: 750;
- line-height: 1.35;
-}
-
-.worker-access-meta b {
- display: block;
- margin-top: 2px;
- color: var(--text);
- font-size: 11px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.worker-access-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 12px;
-}
-
-.worker-access-actions .terminal-button {
- flex: 1 1 auto;
- justify-content: center;
-}
-
-.worker-access-card .worker-detail-drawer {
- margin-top: 12px;
- padding: 12px 0 0;
-}
-
-.worker-access-card .pod-subtable {
- overflow-x: auto;
-}
-
-.worker-access-card .pod-subtable table {
- min-width: 580px;
-}
-
-.job-config-summary {
- display: grid;
- grid-template-columns: 1fr;
- gap: 14px;
- margin: 16px 18px 18px;
-}
-
-.config-section {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: #fbfcff;
- overflow: hidden;
-}
-
-.config-section-head {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- align-items: flex-start;
- padding: 15px 16px;
- border-bottom: 1px solid var(--line);
- background: #fff;
-}
-
-.config-section-head h3 {
- margin: 4px 0 0;
- color: var(--text);
- font-size: 16px;
- line-height: 1.35;
-}
-
-.config-section-head small {
- max-width: 280px;
- color: var(--muted);
- font-size: 11px;
- line-height: 1.5;
- text-align: right;
-}
-
-.config-access-grid,
-.config-shared-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 10px;
- padding: 12px;
-}
-
-.config-kv-card {
- position: relative;
- min-width: 0;
- min-height: 84px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
- padding: 12px;
-}
-
-.config-kv-card > span,
-.config-command > span,
-.config-value-list > span {
- display: block;
- color: var(--muted);
- font-size: 11px;
- font-weight: 850;
-}
-
-.config-kv-card strong {
- margin-top: 6px;
- color: var(--text);
- font-size: 14px;
-}
-
-.config-kv-card small {
- display: block;
- margin-top: 3px;
- color: var(--muted);
- font-size: 11px;
-}
-
-.config-ssh-card code {
- display: block;
- margin-top: 7px;
- color: #334155;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- line-height: 1.45;
- word-break: break-all;
-}
-
-.config-link-card {
- text-decoration: none;
- transition:
- border-color 0.15s,
- transform 0.15s;
-}
-
-.config-link-card:hover {
- border-color: #b7a2f4;
- transform: translateY(-1px);
-}
-
-.config-link-card svg {
- position: absolute;
- right: 12px;
- bottom: 12px;
- color: var(--blue);
-}
-
-.config-command {
- padding: 12px;
-}
-
-.config-command > span {
- margin-bottom: 8px;
-}
-
-.config-value-list {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
- padding: 12px;
-}
-
-.config-value-list > div {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 9px;
-}
-
-.config-value-list code {
- display: inline-block;
- max-width: 100%;
- border-radius: 7px;
- background: #f1f5fb;
- color: #455166;
- padding: 5px 7px;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
- overflow-wrap: anywhere;
-}
-
-.config-value-list small {
- display: block;
- margin-top: 9px;
- color: var(--muted);
- font-size: 11px;
-}
-
-.config-value-list.compact {
- margin-top: 10px;
- padding: 10px;
-}
-
-.role-config-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
- gap: 12px;
- padding: 12px;
-}
-
-.role-config-card {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fff;
- padding: 13px;
-}
-
-.role-config-head {
- display: flex;
- justify-content: space-between;
- gap: 10px;
- align-items: center;
-}
-
-.role-config-head > div {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.role-config-head strong {
- color: var(--text);
- font-size: 12px;
-}
-
-.role-config-facts {
- display: grid;
- gap: 9px;
- margin: 13px 0 0;
-}
-
-.role-config-facts div {
- display: grid;
- gap: 3px;
-}
-
-.role-config-facts dt {
- color: var(--muted);
- font-size: 10px;
- font-weight: 800;
-}
-
-.role-config-facts dd {
- min-width: 0;
- margin: 0;
- color: var(--text);
- font-size: 11px;
- line-height: 1.45;
- overflow-wrap: anywhere;
-}
-
-.role-config-facts code {
- color: #536176;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
-}
-
-.role-prepare-script {
- margin-top: 10px;
- border-top: 1px dashed var(--line);
- padding-top: 10px;
-}
-
-.role-prepare-script summary {
- cursor: pointer;
- color: var(--blue);
- font-size: 11px;
- font-weight: 850;
-}
-
-.role-prepare-script .code-editor-viewer {
- margin-top: 10px;
-}
-
-.terminal-button {
- height: 32px;
- padding: 0 10px;
- border-radius: 10px;
-}
-
-.worker-table .row-actions {
- display: flex;
- gap: 6px;
-}
-
-.ssh-modal {
- max-width: 560px;
-}
-
-.ssh-modal .modal-body {
- padding: 20px 24px;
-}
-
-.terminal-page {
- width: 100vw;
- height: 100vh;
- padding: 12px;
- box-sizing: border-box;
- background: #101020;
-}
-
-.terminal-page-panel {
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- border: 1px solid #30334d;
- border-radius: 12px;
- background: #1a1a2e;
- box-shadow: 0 12px 36px rgb(0 0 0 / 25%);
-}
-
-.terminal-toolbar {
- flex: 0 0 auto;
- min-height: 58px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 8px 12px;
- border-bottom: 1px solid #30334d;
- background: #17182a;
-}
-
-.terminal-identity,
-.terminal-actions,
-.terminal-title span {
- display: flex;
- align-items: center;
-}
-
-.terminal-identity {
- min-width: 0;
- gap: 10px;
-}
-
-.terminal-app-icon {
- display: grid;
- width: 34px;
- height: 34px;
- flex: 0 0 auto;
- place-items: center;
- border: 1px solid #3c4263;
- border-radius: 9px;
- color: #9dacff;
- background: #22243b;
-}
-
-.terminal-title {
- min-width: 0;
-}
-
-.terminal-title strong {
- display: block;
- overflow: hidden;
- color: #f7f8ff;
- font:
- 650 14px/1.35 Menlo,
- Monaco,
- "Courier New",
- monospace;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.terminal-title small {
- display: block;
- overflow: hidden;
- margin-bottom: 1px;
- color: #8f96b3;
- font-size: 10px;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.terminal-title span {
- gap: 5px;
- margin-top: 2px;
- color: #9da3bd;
- font-size: 11px;
-}
-
-.terminal-worker-status {
- border-radius: 4px;
- padding: 1px 5px;
- color: #d9dcf0;
- background: #30334a;
- font-style: normal;
- line-height: 1.4;
-}
-
-.terminal-worker-status.running,
-.terminal-worker-status.online,
-.terminal-worker-status.succeeded {
- color: #72e4af;
- background: rgb(61 220 151 / 12%);
-}
-
-.terminal-worker-status.failed,
-.terminal-worker-status.offline {
- color: #ff8797;
- background: rgb(255 102 122 / 12%);
-}
-
-.terminal-status-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #f3bd45;
-}
-
-.terminal-status-dot.connected {
- background: #3ddc97;
-}
-
-.terminal-status-dot.disconnected {
- background: #ff667a;
-}
-
-.terminal-actions {
- flex: 0 0 auto;
- gap: 6px;
-}
-
-.terminal-action-button,
-.terminal-download-submit {
- display: inline-flex;
- height: 34px;
- align-items: center;
- justify-content: center;
- gap: 6px;
- border: 1px solid #3c4263;
- border-radius: 8px;
- color: #e7e9f5;
- background: #22243b;
- cursor: pointer;
-}
-
-.terminal-action-button {
- padding: 0 11px;
- font-size: 12px;
-}
-
-.terminal-action-button:hover,
-.terminal-action-button.active {
- border-color: #6574dc;
- color: #fff;
- background: #30365d;
-}
-
-.terminal-download-bar {
- display: flex;
- flex: 0 0 auto;
- gap: 8px;
- padding: 8px 12px;
- border-bottom: 1px solid #30334d;
- background: #151627;
-}
-
-.terminal-download-bar input {
- min-width: 0;
- height: 34px;
- flex: 1;
- box-sizing: border-box;
- border: 1px solid #3c4263;
- border-radius: 8px;
- outline: none;
- padding: 0 11px;
- color: #f1f2f8;
- background: #1d1f34;
- font:
- 12px Menlo,
- Monaco,
- "Courier New",
- monospace;
-}
-
-.terminal-download-bar input:focus {
- border-color: #7081f4;
- box-shadow: 0 0 0 2px rgb(112 129 244 / 18%);
-}
-
-.terminal-download-submit {
- min-width: 62px;
- padding: 0 14px;
-}
-
-.terminal-download-submit:disabled {
- cursor: not-allowed;
- opacity: 0.45;
-}
-
-.terminal-transfer-status {
- flex: 0 0 auto;
- padding: 5px 12px;
- border-bottom: 1px solid #30334d;
- color: #8ed0ff;
- background: #181a2d;
- font-size: 11px;
-}
-
-.terminal-body {
- flex: 1;
- min-height: 0;
- padding: 0;
- overflow: hidden;
- background: #1a1a2e;
- border-radius: 0 0 10px 10px;
-}
-
-.terminal-page-error {
- display: grid;
- min-height: 100vh;
- place-items: center;
- color: #f0f0fa;
- background: #101020;
-}
-
-.terminal-container {
- width: 100%;
- height: 100%;
- padding: 8px;
- box-sizing: border-box;
-}
-
-.terminal-container .xterm {
- height: 100%;
-}
-
-.terminal-container .xterm-viewport {
- background-color: #1a1a2e !important;
-}
-
-.ssh-desc {
- margin: 0 0 14px;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.ssh-command-box {
- display: flex;
- align-items: center;
- gap: 10px;
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 12px 14px;
-}
-
-.ssh-command-box code {
- flex: 1;
- font-family: var(--mono);
- font-size: 14px;
- color: var(--ink);
- word-break: break-all;
-}
-
-.theme-dark .ssh-command-box {
- background: rgba(255, 255, 255, 0.04);
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.theme-dark .ssh-command-box code {
- color: #e2e8f0;
-}
-
-.system-config-ssh-preview {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- border: 1px solid var(--line);
- border-radius: 8px;
- padding: 12px 16px;
- color: #24324a;
- background: #f4f7fb;
-}
-
-.system-config-ssh-preview code {
- min-width: 0;
- color: inherit;
- background: transparent;
- font-family: var(--font-mono, monospace);
- font-size: 13px;
- line-height: 1.55;
- word-break: break-all;
-}
-
-.system-config-copy-button {
- flex: none;
- display: inline-flex;
- align-items: center;
- gap: 5px;
- border: 1px solid rgba(79, 70, 229, 0.22);
- border-radius: 7px;
- padding: 6px 9px;
- color: #4f46e5;
- background: rgba(79, 70, 229, 0.07);
- cursor: pointer;
- font-size: 11px;
- font-weight: 700;
-}
-
-.system-config-copy-button:hover {
- border-color: rgba(79, 70, 229, 0.4);
- background: rgba(79, 70, 229, 0.13);
-}
-
-.theme-dark .system-config-ssh-preview {
- border-color: #33445e;
- color: #dbeafe;
- background: #0b1320;
- box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.04);
-}
-
-.theme-dark .system-config-copy-button {
- border-color: rgba(147, 197, 253, 0.25);
- color: #bfdbfe;
- background: rgba(59, 130, 246, 0.12);
-}
-
-@media (max-width: 620px) {
- .system-config-ssh-preview {
- align-items: stretch;
- flex-direction: column;
- }
-
- .system-config-copy-button {
- align-self: flex-end;
- }
-}
-
-.create-job-modal {
- width: min(1120px, calc(100vw - 72px));
- max-height: 90vh;
-}
-
-.create-job-body {
- max-height: calc(90vh - 148px);
- overflow: auto;
-}
-
-.form-section {
- border: 1px solid var(--line);
- border-radius: 18px;
- padding: 14px;
- margin-bottom: 14px;
- background: #fbfcfe;
-}
-
-.form-section-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.form-section-head strong {
- color: #202733;
- font-size: 13px;
-}
-
-.form-section-head small {
- color: #7f8998;
- font-size: 11px;
-}
-
-.ssh-key-select-list {
- max-height: 216px;
- overflow-y: auto;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
-}
-
-.modal-body .ssh-key-select-option {
- display: flex;
- grid-template-columns: none;
- align-items: center;
- gap: 10px;
- min-height: 42px;
- margin: 0;
- padding: 9px 12px;
- cursor: pointer;
- color: #4f5b6b;
-}
-
-.modal-body .ssh-key-select-option input[type="checkbox"] {
- width: 17px;
- height: 17px;
- padding: 0;
- flex: 0 0 17px;
-}
-
-.ssh-key-select-option + .ssh-key-select-option {
- border-top: 1px solid var(--line);
-}
-
-.ssh-key-select-option.selected {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.ssh-key-select-option input {
- flex: 0 0 auto;
- margin: 0;
-}
-
-.ssh-key-select-option span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.role-template.selectable {
- display: flex;
- flex-wrap: wrap;
- gap: 9px;
- padding: 0;
- border: 0;
- background: transparent;
-}
-
-.role-template.selectable button {
- min-height: 42px;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: #fff;
- color: #4f5b6b;
- padding: 8px 11px;
- display: flex;
- align-items: center;
- gap: 7px;
- font-size: 12px;
- font-weight: 800;
-}
-
-.role-template.selectable button.active {
- border-color: var(--blue);
- background: #f3eefe;
- color: var(--blue);
-}
-
-.role-template.selectable small {
- color: #8d97a6;
- font-size: 10px;
-}
-
-.role-edit-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.role-edit-row {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
- cursor: pointer;
- transition: border-color 0.15s;
-}
-
-.role-edit-row:hover {
- border-color: #c5cee0;
-}
-
-.role-edit-row.active {
- border-color: var(--blue);
- background: #f3eefe;
-}
-
-.role-edit-row input {
- flex: 1;
- border: 1px solid transparent;
- background: transparent;
- font-size: 13px;
- font-weight: 600;
- color: #202733;
- padding: 4px 6px;
- border-radius: 6px;
- outline: none;
-}
-
-.role-edit-row input:focus {
- border-color: var(--blue);
- background: #fff;
-}
-
-.role-edit-row small {
- font-size: 10px;
- color: #8d97a6;
- white-space: nowrap;
-}
-
-.role-edit-row.active small {
- color: var(--blue);
-}
-
-.theme-dark .role-edit-row {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .role-edit-row.active {
- background: rgba(167, 139, 250, 0.16);
- border-color: #5b21b6;
-}
-
-.theme-dark .role-edit-row input {
- color: #f5f7fb;
-}
-
-.role-resource-grid {
- display: grid;
- grid-template-columns: 1fr;
- gap: 14px;
- margin-bottom: 14px;
-}
-
-.role-config-tabs {
- display: flex;
- gap: 4px;
- border-bottom: 1px solid var(--line);
- margin-bottom: 16px;
- flex-wrap: wrap;
-}
-
-.role-config-tabs button {
- padding: 8px 16px;
- border: none;
- border-bottom: 2px solid transparent;
- background: none;
- color: #6b7684;
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 2px;
- transition:
- color 0.15s,
- border-color 0.15s;
-}
-
-.role-config-tabs button:hover {
- color: #27303d;
-}
-
-.role-config-tabs button.active {
- color: var(--blue);
- border-bottom-color: var(--blue);
-}
-
-.empty-state-hint {
- padding: 40px 16px;
- text-align: center;
- color: #6b7684;
- font-size: 14px;
-}
-
-.input-hint {
- display: block;
- margin-top: 4px;
- font-size: 12px;
- color: #9ca3af;
-}
-
-.label-with-hint .label-text {
- display: inline-flex;
- align-items: baseline;
- gap: 4px;
- flex-wrap: wrap;
-}
-
-.input-hint-inline {
- font-size: 12px;
- color: #9ca3af;
- font-weight: 400;
-}
-
-.theme-dark .input-hint-inline {
- color: #9ca8ba;
-}
-
-.step-actions {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding-top: 16px;
- border-top: 1px solid var(--line);
- margin-top: 16px;
-}
-
-.theme-dark .role-config-tabs {
- border-color: var(--line);
-}
-
-.theme-dark .role-config-tabs button {
- color: #8b95a5;
-}
-
-.theme-dark .role-config-tabs button:hover {
- color: #d8e0ee;
-}
-
-.theme-dark .role-config-tabs button.active {
- color: #c4b5fd;
- border-bottom-color: #c4b5fd;
-}
-
-.theme-dark .step-actions {
- border-color: var(--line);
-}
-
-.role-resource-card {
- border: 0;
- border-radius: 0;
- padding: 4px 0 0;
- background: transparent;
-}
-
-.role-resource-card .form-section-head strong {
- color: #202733;
-}
-
-.worker-config-section {
- margin-top: 18px;
- padding: 0;
- border: 0;
- border-radius: 0;
-}
-
-.worker-placement-section {
- background: transparent;
-}
-
-.worker-runtime-section {
- padding-top: 20px;
- border-top: 1px solid var(--line);
- background: transparent;
-}
-
-.worker-config-section-head {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding-bottom: 12px;
- border-bottom: 0;
-}
-
-.worker-config-section-index {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: none;
- width: 22px;
- height: 22px;
- border-radius: 7px;
- background: #7650df;
- color: #fff;
- font-size: 11px;
- font-weight: 800;
-}
-
-.worker-config-section-head > div {
- display: grid;
- gap: 4px;
-}
-
-.worker-config-section-head strong {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- color: #202733;
- font-size: 13px;
-}
-
-.worker-config-section-head small {
- color: var(--muted);
- font-size: 11px;
-}
-
-.worker-cluster-field {
- display: grid;
- gap: 7px;
- width: min(100%, 620px);
- margin: 8px 0 0 32px !important;
-}
-
-.worker-cluster-field > span {
- color: var(--muted);
- font-size: 11px;
- font-weight: 700;
-}
-
-.worker-cluster-select {
- position: relative;
-}
-
-.worker-cluster-trigger {
- display: grid;
- grid-template-columns: minmax(150px, 1fr) auto auto 18px;
- align-items: center;
- gap: 9px;
- width: 100%;
- height: 42px;
- box-sizing: border-box;
- padding: 0 12px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--panel);
- color: var(--text);
- text-align: left;
- cursor: pointer;
-}
-
-.worker-cluster-trigger:hover,
-.worker-cluster-trigger[aria-expanded="true"] {
- border-color: rgba(118, 80, 223, 0.58);
- box-shadow: 0 0 0 3px rgba(118, 80, 223, 0.09);
-}
-
-.worker-cluster-trigger strong,
-.worker-cluster-options strong {
- overflow: hidden;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.worker-cluster-trigger > svg {
- color: var(--muted);
- transition: transform 0.18s ease;
-}
-
-.worker-cluster-trigger > svg.open {
- transform: rotate(180deg);
-}
-
-.worker-cluster-placeholder {
- grid-column: 1 / 4;
- color: var(--muted);
- font-size: 12px;
-}
-
-.worker-cluster-type,
-.worker-cluster-state {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- min-height: 22px;
- box-sizing: border-box;
- padding: 3px 8px;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 700;
- line-height: 1;
- white-space: nowrap;
-}
-
-.worker-cluster-type {
- background: rgba(118, 80, 223, 0.12);
- color: #6842ca;
-}
-
-.worker-cluster-state {
- background: rgba(37, 134, 90, 0.11);
- color: #25865a;
-}
-
-.worker-cluster-state i {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
-}
-
-.worker-cluster-state.degraded,
-.worker-cluster-state.pending {
- background: rgba(197, 135, 28, 0.13);
- color: #a76d0f;
-}
-
-.worker-cluster-state.offline,
-.worker-cluster-state.failed,
-.worker-cluster-state.stopped {
- background: rgba(214, 77, 104, 0.12);
- color: #cf3f5d;
-}
-
-.worker-cluster-options {
- position: absolute;
- z-index: 30;
- top: calc(100% + 6px);
- right: 0;
- left: 0;
- overflow: hidden;
- padding: 5px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--panel);
- box-shadow: 0 16px 36px rgba(24, 31, 43, 0.16);
-}
-
-.worker-cluster-options > button {
- display: grid;
- grid-template-columns: minmax(150px, 1fr) auto auto 18px;
- align-items: center;
- gap: 9px;
- width: 100%;
- min-height: 42px;
- padding: 7px 9px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: var(--text);
- text-align: left;
- cursor: pointer;
-}
-
-.worker-cluster-options > button:hover {
- background: var(--soft);
-}
-
-.worker-cluster-options > button.active {
- background: rgba(118, 80, 223, 0.08);
-}
-
-.worker-cluster-check {
- color: var(--blue);
-}
-
-.theme-dark .worker-cluster-trigger,
-.theme-dark .worker-cluster-options {
- background: #141d2c;
-}
-
-.theme-dark .worker-cluster-options {
- box-shadow: 0 18px 42px rgba(0, 0, 0, 0.34);
-}
-
-.theme-dark .worker-cluster-type {
- background: rgba(167, 139, 250, 0.16);
- color: #c4b5fd;
-}
-
-.theme-dark .worker-cluster-state {
- background: rgba(52, 211, 153, 0.14);
- color: #6ee7b7;
-}
-
-.theme-dark .worker-cluster-state.degraded,
-.theme-dark .worker-cluster-state.pending {
- background: rgba(251, 191, 36, 0.14);
- color: #fcd34d;
-}
-
-.theme-dark .worker-cluster-state.offline,
-.theme-dark .worker-cluster-state.failed,
-.theme-dark .worker-cluster-state.stopped {
- background: rgba(248, 113, 113, 0.14);
- color: #fca5a5;
-}
-
-.worker-placement-section .placement-picker {
- margin: 16px 0 0 32px;
-}
-
-.worker-runtime-section > .form-section {
- padding: 0;
- border: 0;
- background: transparent;
-}
-
-.theme-dark .worker-placement-section,
-.theme-dark .worker-runtime-section {
- background: transparent;
-}
-
-.theme-dark .worker-config-section-head strong {
- color: #eef0f6;
-}
-
-.placement-picker {
- margin-top: 10px;
- padding: 14px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: transparent;
-}
-
-.placement-section-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- margin-top: 18px;
- padding-top: 2px;
-}
-
-.placement-section-heading > span {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- color: #4d5667;
- font-size: 12px;
- font-weight: 800;
-}
-
-.placement-section-heading > small {
- color: var(--muted);
- font-size: 11px;
-}
-
-.placement-resource-config {
- margin-top: 16px;
-}
-
-.placement-resource-options {
- min-width: 0;
- margin: 0;
- padding: 0;
- border: 0;
-}
-
-.placement-resource-options legend {
- margin-bottom: 8px;
- color: #4d5667;
- font-size: 12px;
- font-weight: 800;
-}
-
-.placement-resource-option-head,
-.placement-resource-options > label {
- display: grid;
- grid-template-columns: minmax(220px, 1fr) 160px 150px;
- align-items: center;
- column-gap: 16px;
-}
-
-.placement-resource-option-head {
- min-height: 28px;
- padding: 0 12px 0 42px;
- border-bottom: 1px solid #dfe4eb;
- color: #8993a3;
- font-size: 9px;
- font-weight: 700;
-}
-
-.placement-resource-options > label {
- position: relative;
- min-height: 48px;
- margin: 0 !important;
- padding: 0 12px 0 42px;
- border-bottom: 1px solid #e6e9ee;
- cursor: pointer;
- transition: background-color 120ms ease;
-}
-
-.placement-resource-options > label:hover {
- background: #f7f7fa;
-}
-
-.placement-resource-options > label.active {
- background: #f3f0fb;
-}
-
-.modal-body .placement-resource-options input[type="radio"] {
- position: absolute;
- left: 13px;
- top: 50%;
- width: 16px;
- min-width: 16px;
- height: 16px;
- min-height: 16px;
- margin: -8px 0 0;
- padding: 0;
- appearance: none;
- border: 1.5px solid #9ca6b5;
- border-radius: 50%;
- background: #fff;
-}
-
-.modal-body .placement-resource-options input[type="radio"]:checked {
- border: 5px solid #7650df;
- box-shadow: none;
-}
-
-.placement-resource-option-name {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.placement-resource-option-name small {
- flex: none;
- min-width: 48px;
- color: #7650df;
- font-size: 10px;
- font-weight: 800;
-}
-
-.placement-resource-option-name strong {
- overflow: hidden;
- color: #202733;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.placement-resource-option-stat {
- display: inline-flex;
- align-items: baseline;
- gap: 4px;
- color: #202733;
-}
-
-.placement-resource-option-stat strong {
- font-size: 13px;
-}
-
-.placement-resource-option-stat small {
- color: var(--muted);
- font-size: 11px;
- font-weight: 600;
-}
-
-.placement-plan-controls {
- display: grid;
- grid-template-columns: minmax(160px, 0.8fr) minmax(250px, 1.25fr) minmax(
- 160px,
- 0.8fr
- );
- align-items: start;
- gap: 16px;
- margin-top: 14px;
- padding: 14px;
- border-radius: 10px;
- background: #f5f6f8;
-}
-
-.placement-field {
- display: grid;
- gap: 8px;
- min-width: 0;
- margin: 0 !important;
- color: var(--muted);
- font-size: 12px;
- font-weight: 700;
-}
-
-.placement-field > small,
-.placement-scheduling-choice > small,
-.placement-derived-workers > small {
- color: var(--muted);
- font-size: 10px;
- font-weight: 500;
- line-height: 1.4;
-}
-
-.placement-field-label {
- display: flex;
- align-items: center;
- height: 16px;
- line-height: 16px;
- white-space: nowrap;
-}
-
-.placement-field-label i {
- display: inline-flex;
- align-items: center;
- gap: 3px;
- margin-left: auto;
- color: #8993a3;
- font-size: 9px;
- font-style: normal;
- font-weight: 600;
-}
-
-.placement-field select,
-.placement-field input,
-.placement-readonly-control {
- width: 100%;
- height: 44px;
- min-height: 44px;
- box-sizing: border-box;
- border: 1px solid var(--line);
- border-radius: 12px;
- font-size: 13px;
-}
-
-.placement-field select,
-.placement-field input {
- padding: 0 14px;
- line-height: 42px;
-}
-
-.placement-field select {
- padding-right: 42px !important;
- border-color: rgba(118, 80, 223, 0.28);
- background-color: rgba(118, 80, 223, 0.055);
- background-position: right 14px center;
- color: #5631bd;
- font-weight: 700;
-}
-
-.placement-field input {
- border-color: #b9c2cf;
- background: #fff;
- color: #202733;
- font-weight: 700;
-}
-
-.placement-readonly-control {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 0 14px;
- border-color: #dfe4eb;
- background: #eef1f5;
- font-weight: 500;
-}
-
-.placement-readonly-control strong {
- color: #202733;
- font-size: 15px;
- line-height: 1;
- white-space: nowrap;
-}
-
-.placement-readonly-control small {
- overflow: hidden;
- color: var(--muted);
- font-size: 11px;
- font-weight: 500;
- line-height: 1.25;
- text-align: right;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.placement-scheduling-choice {
- min-width: 0;
- margin: 0;
- padding: 0;
- border: 0;
-}
-
-.placement-scheduling-choice legend {
- height: 16px;
- margin-bottom: 8px;
- color: var(--muted);
- font-size: 12px;
- font-weight: 700;
- line-height: 16px;
-}
-
-.placement-scheduling-choice > div {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- height: 44px;
- padding: 3px;
- box-sizing: border-box;
- border: 1px solid #cfd5de;
- border-radius: 12px;
- background: #e8ebf0;
-}
-
-.placement-scheduling-choice label {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 0;
- margin: 0 !important;
- border-radius: 9px;
- color: #657083;
- cursor: pointer;
-}
-
-.placement-scheduling-choice label.active {
- background: #fff;
- box-shadow: 0 1px 3px rgba(20, 27, 38, 0.12);
- color: #5631bd;
-}
-
-.modal-body .placement-scheduling-choice input[type="radio"] {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: 0;
- opacity: 0;
- pointer-events: none;
-}
-
-.placement-scheduling-choice label span {
- overflow: hidden;
- font-size: 11px;
- font-weight: 750;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.placement-scheduling-choice > small {
- display: block;
- margin-top: 8px;
-}
-
-.placement-derived-workers {
- display: grid;
- gap: 8px;
- min-width: 0;
- color: var(--muted);
- font-size: 12px;
- font-weight: 700;
-}
-
-.placement-derived-workers > div {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- height: 44px;
- padding: 0 14px;
- box-sizing: border-box;
- border: 1px solid #d8dde5;
- border-radius: 12px;
- background: #eceff3;
-}
-
-.placement-derived-workers strong {
- color: #202733;
- font-size: 15px;
-}
-
-.placement-derived-workers div small {
- color: #7c8695;
- font-size: 10px;
- font-weight: 600;
-}
-.placement-empty,
-.placement-manual-hint {
- margin-top: 14px;
- color: var(--muted);
- font-size: 12px;
-}
-
-.placement-manual-hint {
- margin-bottom: 0;
- padding: 0 2px;
- line-height: 1.45;
-}
-
-.placement-node-grid {
- position: relative;
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
- gap: 10px;
- margin-top: 10px;
- user-select: none;
- touch-action: none;
-}
-
-.placement-selection-box {
- position: absolute;
- z-index: 10;
- border: 1px solid #7650df;
- border-radius: 5px;
- background: rgba(118, 80, 223, 0.14);
- box-shadow: 0 0 0 1px rgba(118, 80, 223, 0.08);
- pointer-events: none;
-}
-
-.placement-node-card {
- position: relative;
- display: grid;
- gap: 8px;
- min-width: 0;
- min-height: 122px;
- padding: 14px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: #fff;
- color: #202733;
- text-align: left;
- cursor: pointer;
-}
-
-.placement-node-head {
- min-width: 0;
-}
-
-.placement-node-head strong {
- display: block;
- width: 100%;
- font-size: 14px;
- line-height: 1.3;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-
-.placement-node-head em {
- flex: none;
- padding: 3px 7px;
- border-radius: 999px;
- background: rgba(37, 134, 90, 0.1);
- color: #25865a;
- font-size: 10px;
- font-style: normal;
- font-weight: 700;
-}
-
-.placement-node-card.unavailable .placement-node-head em {
- background: rgba(214, 77, 104, 0.12);
- color: #d64d68;
-}
-
-.placement-node-location {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.placement-node-meta,
-.placement-node-state {
- display: flex;
- align-items: center;
-}
-
-.placement-node-meta {
- justify-content: space-between;
- gap: 8px;
-}
-
-.placement-node-state {
- flex: none;
- justify-content: flex-end;
- gap: 6px;
-}
-
-.placement-node-specs {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding-top: 8px;
- border-top: 1px solid var(--line);
-}
-
-.placement-node-card .placement-node-specs > span {
- display: flex;
- min-width: 0;
- overflow: hidden;
- font-size: 11px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.placement-node-card .placement-node-specs > span:last-child {
- flex: none;
-}
-
-.placement-node-specs b {
- color: inherit;
- font-size: 12px;
-}
-
-.placement-node-card:hover {
- border-color: #a996ec;
-}
-.placement-node-card.active {
- border-color: #7650df;
- box-shadow: inset 0 0 0 1px #7650df;
- background: #f5f2ff;
-}
-.placement-node-card:disabled {
- opacity: 1;
- cursor: not-allowed;
-}
-.placement-node-card.unavailable {
- border-color: rgba(214, 77, 104, 0.3);
- background: rgba(214, 77, 104, 0.06);
-}
-.placement-node-card small {
- overflow: hidden;
- color: var(--muted);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.placement-node-card span {
- display: flex;
- align-items: center;
- gap: 5px;
- color: var(--muted);
- font-size: 11px;
-}
-.placement-node-card em {
- color: #25865a;
- font-size: 11px;
- font-style: normal;
-}
-.placement-node-card.unavailable em {
- color: #d64d68;
- font-weight: 700;
-}
-.placement-node-state > svg {
- color: #d64d68;
-}
-.placement-node-check {
- justify-content: center;
- width: 18px;
- height: 18px;
- border: 1px solid var(--line);
- border-radius: 5px;
-}
-.placement-node-card.active .placement-node-check {
- border-color: #7650df;
- background: #7650df;
- color: #fff;
-}
-.placement-selection-summary {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid var(--line);
-}
-.placement-selection-summary > div {
- display: grid;
- gap: 4px;
-}
-.placement-selection-summary small {
- color: var(--muted);
- font-size: 11px;
-}
-.placement-selection-summary button {
- border: 0;
- background: transparent;
- color: #7650df;
- cursor: pointer;
-}
-
-.placement-summary-validation {
- display: grid;
- justify-items: end;
- gap: 3px;
- text-align: right;
-}
-
-.placement-summary-validation strong {
- color: #25865a;
- font-size: 12px;
-}
-
-.placement-summary-validation small {
- color: var(--muted);
- font-size: 10px;
-}
-
-.placement-summary-validation.invalid strong {
- color: #d64d68;
-}
-
-.theme-dark .placement-picker {
- border-color: #303a49;
- background: transparent;
-}
-.theme-dark .placement-mode-tabs,
-.theme-dark .placement-readonly-control,
-.theme-dark .placement-node-card {
- background: #20242f;
-}
-.theme-dark .placement-mode-tabs {
- background: transparent;
-}
-.theme-dark .placement-section-heading > span {
- color: #d8e0ee;
-}
-.theme-dark .placement-resource-options legend,
-.theme-dark .placement-resource-option-name strong,
-.theme-dark .placement-resource-option-stat {
- color: #d8e0ee;
-}
-.theme-dark .placement-resource-option-head,
-.theme-dark .placement-resource-options > label {
- border-color: #303a49;
-}
-.theme-dark .placement-resource-options > label:hover {
- background: rgba(255, 255, 255, 0.035);
-}
-.theme-dark .placement-resource-options > label.active {
- background: rgba(118, 80, 223, 0.14);
-}
-.theme-dark .placement-plan-controls {
- background: #171d27;
-}
-.theme-dark .placement-scheduling-choice > div {
- border-color: #3b475a;
- background: #111722;
-}
-.theme-dark .placement-scheduling-choice label.active {
- background: #29243d;
- color: #c1aef8;
- box-shadow: none;
-}
-.theme-dark .placement-derived-workers > div {
- border-color: #303a49;
- background: #252b36;
-}
-.theme-dark .placement-derived-workers strong {
- color: #eef0f6;
-}
-.theme-dark .placement-field select {
- border-color: rgba(155, 126, 238, 0.38);
- background-color: rgba(118, 80, 223, 0.14);
- color: #c1aef8;
-}
-.theme-dark .placement-field input {
- border-color: #3b475a;
- background: #111a29;
- color: #d8e0ee;
-}
-.theme-dark .placement-readonly-control {
- border-color: #303a49;
- background: #252b36;
-}
-.theme-dark .placement-node-card.unavailable {
- background: rgba(214, 77, 104, 0.1);
-}
-.theme-dark .placement-node-card,
-.theme-dark .placement-readonly-control strong {
- color: #eef0f6;
-}
-.theme-dark .placement-node-card.active {
- background: #29243d;
-}
-
-@media (max-width: 900px) {
- .placement-plan-controls {
- grid-template-columns: 1fr;
- }
-
- .placement-resource-option-head,
- .placement-resource-options > label {
- grid-template-columns: minmax(160px, 1fr) 120px 110px;
- column-gap: 10px;
- }
-}
-
-.resource-input-row {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
-}
-
-.form-error-banner {
- margin-bottom: 14px;
- padding: 11px 13px;
- border: 1px solid rgba(239, 90, 122, 0.28);
- border-radius: 12px;
- background: rgba(239, 90, 122, 0.08);
- color: #d84062;
- font-size: 12px;
- font-weight: 650;
-}
-
-.image-picker {
- position: relative;
-}
-
-.image-picker-list {
- position: absolute;
- z-index: 20;
- top: calc(100% + 4px);
- left: 0;
- right: 0;
- max-height: 224px;
- overflow-y: auto;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: #fff;
- box-shadow: 0 10px 24px rgba(31, 45, 65, 0.14);
-}
-
-.image-picker-option {
- display: flex;
- width: 100%;
- flex-direction: column;
- align-items: flex-start;
- gap: 3px;
- padding: 9px 12px;
- border: 0;
- border-bottom: 1px solid var(--line);
- background: transparent;
- cursor: pointer;
- text-align: left;
-}
-
-.image-picker-option:last-child {
- border-bottom: 0;
-}
-
-.image-picker-option:hover,
-.image-picker-option:focus-visible {
- outline: none;
- background: #f4f1ff;
-}
-
-.image-picker-option strong {
- max-width: 100%;
- overflow: hidden;
- color: #202733;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.image-picker-option small {
- color: #7f8998;
- font-size: 11px;
-}
-
-.theme-dark .image-picker-list {
- border-color: #485262;
- background: #252a33;
-}
-
-.theme-dark .image-picker-option {
- border-color: #485262;
-}
-
-.theme-dark .image-picker-option:hover,
-.theme-dark .image-picker-option:focus-visible {
- background: #342b54;
-}
-
-.theme-dark .image-picker-option strong {
- color: #e7edf7;
-}
-
-.form-section input.input-invalid {
- border-color: #d64d68;
- box-shadow: 0 0 0 3px rgba(214, 77, 104, 0.1);
-}
-
-.field-validation-error {
- display: block;
- margin-top: 6px;
- color: #c93f5c;
- font-size: 11px;
- font-weight: 600;
- line-height: 1.4;
-}
-
-.theme-dark .form-section input.input-invalid {
- border-color: #e06b82;
- box-shadow: 0 0 0 3px rgba(224, 107, 130, 0.13);
-}
-
-.theme-dark .field-validation-error {
- color: #f08aa0;
-}
-
-.code-editor-shell {
- width: 100%;
- overflow: hidden;
- border: 1px solid #3c3c3c;
- border-radius: 10px;
- background: #1e1e1e;
- color: #d4d4d4;
- box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
-}
-
-.code-editor-header {
- min-height: 34px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 0 11px;
- border-bottom: 1px solid #333333;
- background: #252526;
-}
-
-.code-editor-header span,
-.code-editor-header em {
- min-width: 0;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- margin: 0;
- font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
- font-size: 10px;
- font-style: normal;
- line-height: 1;
-}
-
-.code-editor-header span {
- overflow: hidden;
- color: #cccccc;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.code-editor-header span svg {
- flex: 0 0 auto;
- color: #4ec9b0;
-}
-
-.code-editor-header em {
- flex: 0 0 auto;
- color: #858585;
-}
-
-.code-editor-body {
- position: relative;
- min-width: 0;
- background: #1e1e1e;
- overflow: hidden;
-}
-
-.code-editor-lines {
- position: absolute;
- z-index: 3;
- top: 12px;
- left: 0;
- width: 42px;
- display: flex;
- flex-direction: column;
- color: #858585;
- font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
- font-size: 11px;
- line-height: 20px;
- text-align: right;
- pointer-events: none;
- will-change: transform;
-}
-
-.code-editor-lines span {
- height: 20px;
- padding-right: 10px;
-}
-
-.code-editor-body::after {
- content: "";
- position: absolute;
- z-index: 0;
- top: 0;
- bottom: 0;
- left: 42px;
- border-left: 1px solid #2b2b2b;
- pointer-events: none;
-}
-
-.code-editor-body textarea,
-.code-editor-viewer pre,
-.code-editor-highlight,
-.job-config-summary .code-editor-viewer pre {
- width: 100%;
- margin: 0;
- padding: 12px 14px 12px 54px;
- border: 0;
- border-radius: 0;
- outline: 0;
- background: #1e1e1e;
- color: #d4d4d4;
- font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
- font-size: 11px;
- line-height: 20px;
- tab-size: 4;
- white-space: pre;
- word-break: normal;
- overflow: auto;
- box-shadow: none;
- scrollbar-color: #424242 #1e1e1e;
-}
-
-.code-editor-highlight {
- position: absolute;
- z-index: 1;
- inset: 0;
- pointer-events: none;
- color: #d4d4d4;
- will-change: transform;
-}
-
-.code-editor-highlight code {
- display: block;
- width: max-content;
- min-width: 100%;
- color: inherit;
- font: inherit;
-}
-
-.code-editor-highlight-line {
- display: inline;
-}
-
-.code-token-keyword {
- color: #c586c0;
- font-weight: 700;
-}
-
-.code-token-flag {
- color: #9cdcfe;
-}
-
-.code-token-value {
- color: #ce9178;
-}
-
-.code-editor-body textarea {
- position: relative;
- z-index: 2;
- display: block;
- resize: vertical;
- caret-color: #ffffff;
- color: #d4d4d4;
- background: transparent;
-}
-
-.code-editor-body textarea::placeholder {
- color: #6a6a6a;
- opacity: 1;
-}
-
-.code-editor-body textarea::selection,
-.code-editor-viewer code::selection {
- background: #264f78;
-}
-
-.code-editor-input:focus-within {
- border-color: #007acc;
- box-shadow:
- 0 0 0 2px rgba(0, 122, 204, 0.22),
- 0 8px 20px rgba(15, 23, 42, 0.16);
-}
-
-.code-editor-viewer {
- margin-top: 7px;
- box-shadow: none;
-}
-
-.code-editor-viewer pre,
-.job-config-summary .code-editor-viewer pre {
- max-height: 260px;
-}
-
-.code-editor-viewer pre code,
-.job-config-summary .code-editor-viewer pre code {
- display: inline;
- margin: 0;
- padding: 0;
- border: 0;
- border-radius: 0;
- color: inherit;
- background: transparent;
- font: inherit;
- white-space: inherit;
-}
-
-.env-row {
- display: grid;
- grid-template-columns: 1fr 1.5fr 32px;
- gap: 8px;
- margin-top: 8px;
- align-items: center;
-}
-
-.device-row {
- display: grid;
- grid-template-columns: 1fr 1fr 32px;
- gap: 8px;
- margin-top: 8px;
- align-items: center;
-}
-
-.device-row select {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 10px 11px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
- cursor: pointer;
-}
-
-.device-row input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 10px 11px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
-}
-
-.theme-dark .device-row select,
-.theme-dark .device-row input {
- background: #151d2b;
- color: #c4cbd6;
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.mount-row {
- display: grid;
- grid-template-columns:
- auto minmax(0, 1fr) minmax(0, 1fr) minmax(140px, 0.45fr)
- 32px;
- gap: 8px;
- margin-top: 8px;
- align-items: center;
-}
-
-.mount-size-field {
- max-width: 240px;
-}
-
-.mount-field-box {
- min-width: 0;
- display: grid;
- grid-template-columns: minmax(88px, 0.34fr) minmax(0, 1fr);
- align-items: stretch;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fff;
- overflow: hidden;
-}
-
-.mount-field-box > span {
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 0;
- padding: 0 10px;
- border-right: 1px solid var(--line);
- background: #f3f6fb;
- color: var(--muted, #8a94a6);
- font-size: 11px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.mount-type-toggle {
- display: inline-flex;
- border: 1px solid var(--line);
- border-radius: 10px;
- overflow: hidden;
- cursor: pointer;
-}
-
-.mount-type-toggle button {
- border: none;
- background: transparent;
- padding: 8px 10px;
- font-size: 12px;
- color: var(--muted, #8a94a6);
- cursor: pointer;
- white-space: nowrap;
-}
-
-.mount-type-toggle button.active {
- background: var(--blue, #6366f1);
- color: #fff;
-}
-
-.theme-dark .mount-type-toggle {
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.theme-dark .mount-type-toggle button {
- color: #8a94a6;
-}
-
-.theme-dark .mount-type-toggle button.active {
- background: var(--blue, #6366f1);
- color: #fff;
-}
-
-.mount-row select,
-.env-row select {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 10px 11px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
- cursor: pointer;
-}
-
-.mount-row input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 10px 11px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
-}
-
-.mount-row .mount-field-box select,
-.mount-row .mount-field-box input {
- border: 0;
- border-radius: 0;
- background: transparent;
-}
-
-.theme-dark .mount-row select,
-.theme-dark .mount-row input,
-.theme-dark .env-row select {
- background: #151d2b;
- border-color: var(--line);
- color: #d8e0ee;
-}
-
-.theme-dark .mount-field-box {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .mount-field-box > span {
- background: #111a29;
- border-color: var(--line);
- color: #9ca8ba;
-}
-
-.theme-dark .mount-row .mount-field-box select,
-.theme-dark .mount-row .mount-field-box input {
- background: transparent;
-}
-
-.env-row input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 10px 11px;
- color: #27303d;
- outline: 0;
- font-size: 12px;
- background: #fff;
-}
-
-.theme-dark .subpage-tabs button,
-.theme-dark .job-config-summary > div,
-.theme-dark .form-section,
-.theme-dark .ssh-key-select-list,
-.theme-dark .role-template.selectable button,
-.theme-dark .env-row input {
- background: #151d2b;
- border-color: var(--line);
- color: #d8e0ee;
-}
-
-.theme-dark .job-config-summary strong,
-.theme-dark .form-section-head strong,
-.theme-dark .role-resource-card > strong {
- color: #f5f7fb;
-}
-
-.theme-dark .job-config-summary code,
-.theme-dark .job-config-summary pre {
- background: #101827;
- color: #d7e4f6;
-}
-
-.theme-dark .job-config-summary .code-editor-viewer pre {
- background: #1e1e1e;
- color: #d4d4d4;
-}
-
-.theme-dark .job-config-summary .code-editor-viewer pre code {
- display: inline;
- margin: 0;
- padding: 0;
- background: transparent;
- color: inherit;
- font: inherit;
-}
-
-.theme-dark .subpage-tabs button.active,
-.theme-dark .role-template.selectable button.active {
- background: rgba(167, 139, 250, 0.16);
- border-color: #5b21b6;
- color: #c4b5fd;
-}
-
-.jobs-table-panel .link-cell {
- border: 0;
- background: transparent;
- padding: 0;
- text-align: left;
- color: inherit;
-}
-
-.jobs-table-panel .link-cell strong {
- display: block;
- color: var(--blue);
- font-size: 13px;
-}
-
-.jobs-table-panel th:first-child,
-.jobs-table-panel td:first-child {
- width: 240px;
- min-width: 240px;
-}
-
-.job-id-cell-wrap {
- display: grid;
- gap: 3px;
-}
-
-.jobs-table-panel .job-id-cell {
- width: 100%;
- min-width: 0;
-}
-
-.jobs-table-panel .job-id-cell strong,
-.jobs-table-panel .job-id-copy small {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.job-id-copy {
- width: fit-content;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- color: #7a8494;
- font-size: 11px;
-}
-
-.job-id-copy:hover {
- color: var(--blue);
-}
-
-.job-id-copy small {
- color: inherit;
- font-size: inherit;
-}
-
-.job-detail-resource-line {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.jobs-table-panel .job-id-cell strong {
- line-height: 1.35;
-}
-
-.jobs-table-panel .job-id-cell.is-long strong {
- font-size: 11px;
- line-height: 1.3;
-}
-
-.jobs-table-panel .link-cell small {
- display: block;
- color: #98a1af;
- font-size: 10px;
- margin-top: 3px;
-}
-
-.inline-code {
- display: inline-block;
- padding: 5px 7px;
- border-radius: 8px;
- background: #f0f4fa;
- color: #26313f;
- font-family: "JetBrains Mono", monospace;
- font-size: 10px;
-}
-
-.back-button {
- margin-bottom: 10px;
-}
-
-.create-stepper {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 0;
- padding: 16px 24px;
- border-bottom: 1px solid var(--line);
- background: #fbfcfe;
-}
-
-.create-stepper button {
- position: relative;
- height: 44px;
- border: 0;
- background: transparent;
- color: #8792a1;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- font-size: 12px;
- font-weight: 850;
-}
-
-.create-stepper button:not(:last-child)::after {
- content: "";
- position: absolute;
- right: -25%;
- width: 50%;
- height: 2px;
- background: #dfe6ef;
- z-index: 0;
-}
-
-.create-stepper span {
- position: relative;
- z-index: 1;
- width: 26px;
- height: 26px;
- border-radius: 50%;
- display: grid;
- place-items: center;
- background: #e8edf4;
- color: #8792a1;
-}
-
-.create-stepper button.active {
- color: var(--blue);
-}
-
-.create-stepper button.active span,
-.create-stepper button.active:not(:last-child)::after {
- background: var(--blue);
- color: #fff;
-}
-
-.yaml-preview {
- border: 1px solid var(--line);
- border-radius: 18px;
- overflow: hidden;
- background: #fbfcfe;
-}
-
-.yaml-preview > div {
- display: flex;
- justify-content: space-between;
- gap: 14px;
- padding: 14px 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.yaml-preview strong {
- color: #202733;
-}
-
-.yaml-preview small {
- color: #7f8998;
-}
-
-.yaml-preview pre {
- margin: 0;
- padding: 18px;
- max-height: 430px;
- overflow: auto;
- background: #101827;
- color: #d7e4f6;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.65;
-}
-
-.theme-dark .inline-code,
-.theme-dark .create-stepper,
-.theme-dark .yaml-preview {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .inline-code {
- color: #d9e2f0;
-}
-
-.theme-dark .yaml-preview strong {
- color: #f5f7fb;
-}
-
-.theme-dark .yaml-preview small,
-.theme-dark .jobs-table-panel .link-cell small {
- color: #9ca8ba;
-}
-
-.theme-dark .resource-row,
-.theme-dark .cluster-models > div,
-.theme-dark .robot-state-list > div,
-.theme-dark .cluster-pill,
-.theme-dark .role-template,
-.theme-dark .worker-table,
-.theme-dark .job-worker-main,
-.theme-dark .job-worker-side,
-.theme-dark .job-detail-summary-card,
-.theme-dark .job-detail-summary-section,
-.theme-dark .worker-role-strip > div,
-.theme-dark .worker-primary-panel,
-.theme-dark .job-observe-panel,
-.theme-dark .worker-detail-grid > div,
-.theme-dark .pod-subtable,
-.theme-dark .worker-ssh-access,
-.theme-dark .empty-inline,
-.theme-dark .worker-metric-card {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .resource-row strong,
-.theme-dark .cluster-models strong,
-.theme-dark .robot-state-list strong,
-.theme-dark .cluster-pill strong,
-.theme-dark .resource-row b,
-.theme-dark .job-worker-main h3,
-.theme-dark .job-detail-summary-head h2,
-.theme-dark .job-detail-summary-section h3,
-.theme-dark .job-worker-side strong,
-.theme-dark .worker-role-strip strong,
-.theme-dark .worker-panel-head h3,
-.theme-dark .observe-panel-head h3,
-.theme-dark .worker-node-cell strong,
-.theme-dark .worker-detail-grid strong,
-.theme-dark .worker-metric-card > div strong,
-.theme-dark .worker-metric-card label span {
- color: #f5f7fb;
-}
-
-.theme-dark .resource-row small,
-.theme-dark .cluster-models span,
-.theme-dark .robot-state-list small,
-.theme-dark .cluster-pill small,
-.theme-dark .job-worker-main p,
-.theme-dark .job-detail-summary-head p,
-.theme-dark .job-detail-summary-status small,
-.theme-dark .job-worker-side span,
-.theme-dark .job-worker-side small,
-.theme-dark .worker-role-strip span,
-.theme-dark .worker-role-strip small,
-.theme-dark .worker-panel-head small,
-.theme-dark .worker-node-cell small,
-.theme-dark .worker-detail-grid span,
-.theme-dark .worker-ssh-access span,
-.theme-dark .worker-metric-card label,
-.theme-dark .worker-metric-card small,
-.theme-dark .empty-inline {
- color: #9ca8ba;
-}
-
-.theme-dark .worker-ssh-access code {
- color: #d7e4f6;
-}
-
-.theme-dark .pod-subtable {
- background: #111a29;
-}
-
-.theme-dark .pod-subtable th {
- background: #151d2b;
- color: #9ca8ba;
-}
-
-.theme-dark .pod-subtable td {
- background: #172235;
- border-color: var(--line);
- color: #d7e4f6;
-}
-
-.theme-dark .pod-subtable tbody tr:nth-child(even) td {
- background: #141f31;
-}
-
-.theme-dark .pod-subtable tbody tr:hover td {
- background: #1d2a3f;
-}
-
-.theme-dark .pod-subtable .inline-code {
- background: #101827;
- color: #d7e4f6;
-}
-
-.theme-dark .job-worker-side code,
-.theme-dark .worker-resource-cell span {
- background: #111a29;
- color: #d7e4f6;
-}
-
-.theme-dark .role-worker-group,
-.theme-dark .worker-access-card,
-.theme-dark .config-section,
-.theme-dark .config-kv-card,
-.theme-dark .config-value-list,
-.theme-dark .role-config-card,
-.theme-dark .role-runtime-config,
-.theme-dark .role-runtime-facts {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .role-runtime-tabs button,
-.theme-dark .role-runtime-meta span {
- background: #111a29;
- border-color: var(--line);
-}
-
-.theme-dark .role-runtime-tabs button.active {
- background: #202b3d;
- border-color: #5b4a93;
- color: #c4b5fd;
-}
-
-.theme-dark .role-runtime-heading strong,
-.theme-dark .role-runtime-facts strong,
-.theme-dark .role-runtime-facts code {
- color: #f5f7fb;
-}
-
-.theme-dark .role-runtime-image code {
- background: #111a29;
- border-color: var(--line);
- color: #d7e4f6;
-}
-
-.theme-dark .role-runtime-summary {
- background: #111a29;
- border-color: var(--line);
-}
-
-.theme-dark .role-runtime-resource-summary strong {
- background: #151d2b;
- border-color: var(--line);
- color: #f5f7fb;
-}
-
-.theme-dark .sub-tabs,
-.theme-dark .metrics-scope-toggle,
-.theme-dark .worker-filter-bar,
-.theme-dark .metrics-filter-bar select,
-.theme-dark .worker-filter-bar select,
-.theme-dark .time-series-card,
-.theme-dark .public-config-card,
-.theme-dark .task-summary-metric,
-.theme-dark .log-list,
-.theme-dark .log-search-field,
-.theme-dark .stream-toggle,
-.theme-dark .worker-table-actions .icon-button {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .job-detail-summary-section,
-.theme-dark .task-summary-metric {
- background: #111a29;
-}
-
-.theme-dark .sub-tabs button.active,
-.theme-dark .metrics-scope-toggle button.active {
- background: #202b3d;
- color: #c4b5fd;
-}
-
-.theme-dark .worker-total-count,
-.theme-dark .node-kind-chip {
- background: #202b3d;
- color: #c4b5fd;
-}
-
-.theme-dark .log-list-row p,
-.theme-dark .metrics-source-label {
- color: #c7d1df;
-}
-
-.theme-dark .log-list-head {
- border-color: var(--line);
- background: #111a29;
-}
-
-.theme-dark .metrics-integration-state {
- background: #111a29;
-}
-
-.theme-dark .metrics-integration-icon {
- background: #202b3d;
-}
-
-.theme-dark .copyable-code-block,
-.theme-dark .public-card-head a,
-.theme-dark .worker-filter-bar,
-.theme-dark .log-search-field {
- background: #111a29;
-}
-
-.theme-dark .copyable-code-block > div {
- border-color: var(--line);
-}
-
-.theme-dark .copyable-code-block code {
- color: #d7e4f6;
-}
-
-.theme-dark .command-code-block {
- background: #111a29;
- border-color: var(--line);
-}
-
-.theme-dark .command-code-block .icon-button {
- background: #151d2b;
-}
-
-.theme-dark .command-line-number {
- border-color: var(--line);
- color: #6f7d91;
-}
-
-.theme-dark .command-line-content {
- color: #d7e4f6;
-}
-
-.theme-dark .command-token-keyword {
- color: #c4b5fd;
-}
-
-.theme-dark .command-token-flag {
- color: #93c5fd;
-}
-
-.theme-dark .command-token-value {
- color: #6ee7b7;
-}
-
-.theme-dark .public-command-card,
-.theme-dark .public-basic-config-card,
-.theme-dark .public-compact-config-card,
-.theme-dark .role-runtime-command-card,
-.theme-dark .role-runtime-table-card,
-.theme-dark .role-runtime-selector-card,
-.theme-dark .worker-ssh-inline {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .public-basic-config-list div,
-.theme-dark .public-compact-config-table th,
-.theme-dark .role-runtime-command-card > span,
-.theme-dark .role-runtime-table-card th,
-.theme-dark .role-runtime-selector-card > span,
-.theme-dark .role-runtime-selector-card p,
-.theme-dark .public-config-table th {
- background: #111a29;
- border-color: var(--line);
-}
-
-.theme-dark .public-basic-config-list div {
- background: transparent;
-}
-
-.theme-dark .public-basic-config-list {
- border-color: var(--line);
-}
-
-.theme-dark .public-basic-config-list span {
- background: #111a29;
- border-color: var(--line);
- color: #9ca8ba;
-}
-
-.theme-dark .public-basic-config-list code {
- border-color: var(--line);
- background: transparent;
-}
-
-.theme-dark .public-basic-config-list code,
-.theme-dark .public-compact-config-table code,
-.theme-dark .role-runtime-table-card code,
-.theme-dark .role-runtime-selector-card code,
-.theme-dark .worker-ssh-inline code {
- color: #d7e4f6;
-}
-
-.theme-dark .role-runtime-table-card td {
- background: #172235;
- border-color: var(--line);
-}
-
-.theme-dark .role-runtime-table-card tbody tr:nth-child(even) td {
- background: #141f31;
-}
-
-.theme-dark .role-runtime-table-card code,
-.theme-dark .role-runtime-selector-card code {
- background: #101827;
-}
-
-.theme-dark .role-runtime-selector-card code {
- border-color: var(--line);
- background: #111a29;
- color: #d7e4f6;
-}
-
-.theme-dark .role-runtime-selector-card b {
- background: #202b3d;
- color: #9ca8ba;
-}
-
-.theme-dark .role-worker-group-head,
-.theme-dark .config-section-head {
- background: #192333;
- border-color: var(--line);
-}
-
-.theme-dark .role-worker-group-head > div > strong,
-.theme-dark .role-worker-resource-summary strong,
-.theme-dark .worker-access-head strong,
-.theme-dark .worker-access-meta b,
-.theme-dark .config-section-head h3,
-.theme-dark .config-kv-card strong,
-.theme-dark .role-config-head strong,
-.theme-dark .role-config-facts dd {
- color: #f5f7fb;
-}
-
-.theme-dark .role-worker-group-head small,
-.theme-dark .role-worker-resource-summary span,
-.theme-dark .role-worker-resource-summary small,
-.theme-dark .worker-access-head small,
-.theme-dark .worker-access-meta span,
-.theme-dark .config-section-head small,
-.theme-dark .config-kv-card > span,
-.theme-dark .config-kv-card small,
-.theme-dark .config-command > span,
-.theme-dark .config-value-list > span,
-.theme-dark .config-value-list small,
-.theme-dark .role-config-facts dt {
- color: #9ca8ba;
-}
-
-.theme-dark .worker-access-meta span,
-.theme-dark .config-value-list code {
- background: #111a29;
- color: #d7e4f6;
-}
-
-.theme-dark .config-ssh-card code,
-.theme-dark .role-config-facts code {
- color: #cbd5e1;
-}
-
-.cluster-overview-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 16px;
- margin-bottom: 18px;
-}
-
-.cluster-topology-grid {
- display: grid;
- grid-template-columns: 1.1fr 0.9fr;
- gap: 18px;
- margin-bottom: 18px;
-}
-
-.cluster-map-card,
-.selected-cluster-panel,
-.cluster-list-panel,
-.node-resource-detail {
- min-width: 0;
-}
-
-.cluster-map {
- height: 330px;
- margin-top: 16px;
- border: 1px solid var(--line);
- border-radius: 22px;
- position: relative;
- overflow: hidden;
- background:
- radial-gradient(
- circle at 28% 42%,
- rgba(124, 58, 237, 0.16),
- transparent 18%
- ),
- radial-gradient(
- circle at 72% 48%,
- rgba(54, 201, 143, 0.18),
- transparent 18%
- ),
- linear-gradient(145deg, #f8fbff, #eef3fa);
-}
-
-.map-grid {
- position: absolute;
- inset: 0;
- opacity: 0.6;
- background-image:
- linear-gradient(#dfe6f1 1px, transparent 1px),
- linear-gradient(90deg, #dfe6f1 1px, transparent 1px);
- background-size: 34px 34px;
-}
-
-.map-pin {
- position: absolute;
- z-index: 1;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.9);
- box-shadow: var(--shadow-soft);
- min-width: 138px;
- min-height: 72px;
- padding: 10px;
- display: grid;
- grid-template-columns: 34px 1fr;
- gap: 8px;
- text-align: left;
- align-items: center;
-}
-
-.map-pin span {
- grid-row: 1 / 3;
- width: 34px;
- height: 34px;
- border-radius: 12px;
- display: grid;
- place-items: center;
- background: #f3eefe;
- color: var(--blue);
-}
-
-.map-pin strong {
- color: #202733;
- font-size: 13px;
- align-self: end;
-}
-
-.map-pin small {
- color: #8d97a6;
- font-size: 10px;
- align-self: start;
-}
-
-.map-pin.active {
- border-color: var(--blue);
- box-shadow: 0 12px 28px rgba(124, 58, 237, 0.16);
-}
-
-.pin-0 {
- left: 18%;
- top: 34%;
-}
-
-.pin-1 {
- left: 49%;
- top: 22%;
-}
-
-.pin-2 {
- left: 36%;
- top: 58%;
-}
-
-.pin-3 {
- right: 12%;
- bottom: 18%;
-}
-
-.cluster-detail-stats {
- display: grid;
- grid-template-columns: 1.15fr 0.85fr 0.85fr;
- gap: 10px;
- margin: 18px 0 14px;
-}
-
-.cluster-detail-stats > div {
- border: 1px solid var(--line);
- border-radius: 16px;
- padding: 13px;
- background: #fbfcff;
-}
-
-.cluster-detail-stats span {
- display: block;
- color: #8d97a6;
- font-size: 11px;
-}
-
-.cluster-detail-stats strong {
- display: block;
- margin-top: 5px;
- color: #202733;
- font-size: 15px;
-}
-
-.cluster-detail-stats small {
- display: block;
- margin-top: 4px;
- color: #9aa4b2;
- font-size: 10px;
-}
-
-.cluster-card-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 14px;
- margin-top: 16px;
-}
-
-.cluster-card {
- border: 1px solid var(--line);
- border-radius: 20px;
- background: #fff;
- padding: 16px;
- text-align: left;
- box-shadow: var(--shadow-soft);
-}
-
-.cluster-card.selected {
- border-color: var(--blue);
- box-shadow: 0 14px 30px rgba(124, 58, 237, 0.13);
-}
-
-.cluster-card-head,
-.cluster-card-foot {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.cluster-card-head > span {
- width: 38px;
- height: 38px;
- display: grid;
- place-items: center;
- border-radius: 13px;
-}
-
-.cluster-card-head > span.cloud {
- background: #f3eefe;
- color: var(--blue);
-}
-
-.cluster-card-head > span.embodied {
- background: #e7f8f1;
- color: #1f9c70;
-}
-
-.cluster-card > strong {
- display: block;
- margin-top: 16px;
- color: #202733;
- font-size: 14px;
-}
-
-.cluster-card > small {
- display: block;
- margin-top: 5px;
- color: #8d97a6;
- font-size: 11px;
-}
-
-.cluster-loads {
- display: grid;
- gap: 7px;
- margin: 16px 0 12px;
-}
-
-.cluster-loads i {
- display: block;
- height: 5px;
- border-radius: 999px;
- background: #e8edf4;
- overflow: hidden;
-}
-
-.cluster-loads b {
- display: block;
- height: 100%;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--blue), #c4b5fd);
-}
-
-.cluster-card-foot span {
- color: #7f8998;
- font-size: 11px;
- font-weight: 700;
-}
-
-.nodes-resource-section {
- margin-top: 22px;
-}
-
-.section-heading.compact {
- margin-top: 4px;
- margin-bottom: 16px;
-}
-
-.node-filter-bar {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 14px;
-}
-
-.node-filter-bar button {
- height: 34px;
- padding: 0 12px;
- border: 1px solid var(--line);
- border-radius: 999px;
- background: #fff;
- color: #667182;
- font-size: 12px;
- font-weight: 800;
-}
-
-.node-filter-bar button.active {
- background: #f3eefe;
- color: var(--blue);
- border-color: #ddd0fe;
-}
-
-.node-detail-grid {
- display: grid;
- grid-template-columns: 1fr 1.05fr;
- gap: 18px;
-}
-
-.node-card-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
- align-content: start;
-}
-
-.node-resource-card {
- min-height: 94px;
- display: grid;
- grid-template-columns: 40px 1fr auto;
- align-items: center;
- gap: 12px;
- border: 1px solid var(--line);
- border-radius: 18px;
- padding: 13px;
- background: #fff;
- text-align: left;
- box-shadow: var(--shadow-soft);
-}
-
-.node-resource-card.selected {
- border-color: var(--blue);
- box-shadow: 0 12px 26px rgba(124, 58, 237, 0.12);
-}
-
-.node-resource-card strong {
- display: block;
- color: #202733;
- font-size: 13px;
-}
-
-.node-resource-card small,
-.node-resource-card em {
- display: block;
- color: #8d97a6;
- font-size: 10px;
- font-style: normal;
- margin-top: 3px;
-}
-
-.node-resource-detail {
- border: 1px solid var(--line);
- border-radius: 22px;
- background: #fff;
- box-shadow: var(--shadow-soft);
- padding: 20px;
-}
-
-.compact-health {
- grid-template-columns: repeat(4, 1fr);
- margin-top: 18px;
-}
-
-.robot-channel-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- margin-top: 16px;
-}
-
-.robot-camera {
- min-height: 250px;
-}
-
-.robot-endpoints {
- display: grid;
- gap: 10px;
-}
-
-.robot-endpoints > div {
- border: 1px solid var(--line);
- border-radius: 16px;
- padding: 13px;
- background: #fbfcff;
-}
-
-.robot-endpoints span {
- display: block;
- color: #8d97a6;
- font-size: 11px;
-}
-
-.robot-endpoints strong,
-.robot-endpoints code {
- display: block;
- margin-top: 5px;
- color: #202733;
- font-size: 13px;
- word-break: break-all;
-}
-
-.compact-map {
- height: 290px;
- margin-top: 16px;
-}
-
-.theme-dark .cluster-map,
-.theme-dark .cluster-detail-stats > div,
-.theme-dark .cluster-card,
-.theme-dark .node-filter-bar button,
-.theme-dark .node-resource-card,
-.theme-dark .node-resource-detail,
-.theme-dark .robot-endpoints > div,
-.theme-dark .map-pin {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .cluster-map {
- background:
- radial-gradient(
- circle at 28% 42%,
- rgba(167, 139, 250, 0.14),
- transparent 18%
- ),
- radial-gradient(
- circle at 72% 48%,
- rgba(72, 216, 162, 0.12),
- transparent 18%
- ),
- linear-gradient(145deg, #101827, #121b2a);
-}
-
-.theme-dark .cluster-detail-stats strong,
-.theme-dark .cluster-card > strong,
-.theme-dark .node-resource-card strong,
-.theme-dark .robot-endpoints strong,
-.theme-dark .robot-endpoints code,
-.theme-dark .map-pin strong {
- color: #f5f7fb;
-}
-
-.theme-dark .cluster-detail-stats span,
-.theme-dark .cluster-detail-stats small,
-.theme-dark .cluster-card > small,
-.theme-dark .cluster-card-foot span,
-.theme-dark .node-resource-card small,
-.theme-dark .node-resource-card em,
-.theme-dark .robot-endpoints span,
-.theme-dark .map-pin small {
- color: #9ca8ba;
-}
-
-.cert-panel {
- max-width: 720px;
- padding: 28px;
-}
-
-.cert-form {
- display: flex;
- gap: 14px;
- align-items: flex-end;
-}
-
-.cert-form label {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.cert-form label span {
- font-size: 13px;
- font-weight: 600;
- color: var(--muted);
-}
-
-.cert-form input {
- padding: 10px 14px;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- font-size: 14px;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.cert-form input:focus {
- border-color: var(--blue);
-}
-
-.cert-error {
- margin-top: 16px;
- padding: 12px 16px;
- background: rgba(239, 90, 122, 0.08);
- border: 1px solid rgba(239, 90, 122, 0.25);
- border-radius: 10px;
- color: var(--red);
- font-size: 13px;
- word-break: break-all;
-}
-
-.cert-result {
- margin-top: 24px;
-}
-
-.cert-result-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-bottom: 14px;
- border-bottom: 1px solid var(--line);
-}
-
-.cert-result-header div {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.cert-result-header div svg {
- color: var(--green);
-}
-
-.cert-result-header small {
- color: var(--muted);
- font-size: 12px;
-}
-
-.row-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.jobs-list-page .row-actions {
- justify-content: flex-end;
- gap: 6px;
- white-space: nowrap;
-}
-
-.jobs-list-page .action-tooltip::after {
- right: 50%;
- transform: translate(50%, 3px);
-}
-
-.jobs-list-page .action-tooltip:hover::after,
-.jobs-list-page .action-tooltip:focus-within::after {
- transform: translate(50%, 0);
-}
-
-.job-row-action {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- min-height: 32px;
- padding: 0 9px;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: var(--surface);
- color: var(--text);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
-}
-
-.job-row-action:hover:not(:disabled) {
- border-color: rgba(124, 77, 255, 0.35);
- color: var(--purple);
-}
-
-.job-row-action:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.job-quick-lifecycle.start {
- border-color: rgba(5, 150, 105, 0.24);
- background: rgba(236, 253, 245, 0.9);
- color: #059669;
-}
-
-.job-quick-lifecycle.stop {
- border-color: rgba(217, 119, 6, 0.22);
- background: rgba(255, 251, 235, 0.92);
- color: #b45309;
-}
-
-.job-quick-lifecycle.start:hover:not(:disabled) {
- border-color: rgba(5, 150, 105, 0.42);
- background: #d1fae5;
-}
-
-.job-quick-lifecycle.stop:hover:not(:disabled) {
- border-color: rgba(217, 119, 6, 0.4);
- background: #fef3c7;
-}
-
-.theme-dark .job-quick-lifecycle.start {
- border-color: rgba(52, 211, 153, 0.25);
- background: rgba(16, 185, 129, 0.12);
- color: #6ee7b7;
-}
-
-.theme-dark .job-quick-lifecycle.stop {
- border-color: rgba(251, 191, 36, 0.24);
- background: rgba(245, 158, 11, 0.12);
- color: #fcd34d;
-}
-
-.action-dropdown {
- position: absolute;
- top: calc(100% + 4px);
- right: 0;
- z-index: 50;
- min-width: 140px;
- padding: 4px;
- border-radius: 10px;
- background: var(--surface, #fff);
- border: 1px solid var(--border, #e5e5ec);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.action-dropdown-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 7px 10px;
- border: none;
- background: transparent;
- border-radius: 6px;
- font-size: 13px;
- color: var(--text, #1a1a2e);
- cursor: pointer;
- transition: background 0.12s;
-}
-
-.action-dropdown-item:hover:not(:disabled) {
- background: var(--hover, #f5f5fa);
-}
-
-.action-dropdown-item:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-.action-dropdown-item.danger {
- color: var(--red, #cf3f61);
-}
-
-.clickable-row {
- cursor: pointer;
-}
-
-.clickable-row:hover {
- background: var(--surface-hover, rgba(0, 0, 0, 0.03));
-}
-
-.pagination-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- justify-content: flex-end;
- margin-top: 12px;
- padding-top: 8px;
- min-height: 40px;
- color: var(--muted);
-}
-
-.pagination-summary {
- margin-right: auto;
-}
-
-.pagination-size {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
-}
-
-.pagination-size select {
- width: auto;
- min-width: 66px;
- height: 32px;
- padding: 0 26px 0 10px;
-}
-
-.pagination-bar .icon-button:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-.files-breadcrumb button {
- border: 0;
- background: transparent;
- color: var(--blue);
- padding: 2px 4px;
- border-radius: 5px;
- cursor: pointer;
- font: inherit;
-}
-
-.files-breadcrumb button:hover,
-.files-breadcrumb button:focus-visible {
- background: var(--hover);
- outline: none;
-}
-
-.selector-chips-area:focus-visible {
- outline: 2px solid var(--blue);
- outline-offset: 2px;
-}
-
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- scroll-behavior: auto !important;
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
-}
-
-/* Unified native form controls */
-select {
- appearance: none;
- -webkit-appearance: none;
- min-height: 38px;
- padding-right: 36px !important;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- background-color: var(--panel);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%237c8492' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 12px center;
- background-size: 14px;
- color: var(--ink);
- cursor: pointer;
- outline: none;
- transition:
- border-color 0.16s ease,
- box-shadow 0.16s ease,
- background-color 0.16s ease;
-}
-
-select:hover {
- border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
-}
-
-select:focus-visible {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
-}
-
-select:disabled {
- opacity: 0.55;
- cursor: not-allowed;
-}
-
-select option {
- background: var(--panel);
- color: var(--ink);
-}
-
-.toolbar-filter {
- border-radius: 999px;
- padding-left: 12px;
- padding-right: 8px;
- transition:
- border-color 0.16s ease,
- box-shadow 0.16s ease,
- background-color 0.16s ease;
-}
-
-.toolbar-filter:hover {
- border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
-}
-
-.toolbar-filter:focus-within {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
-}
-
-.toolbar-filter select {
- min-height: 36px;
- padding-left: 2px;
- padding-right: 30px !important;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%237c8492' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 7px center;
- box-shadow: none;
-}
-
-.pagination-size {
- min-height: 34px;
- padding-left: 11px;
- border: 1px solid var(--line);
- border-radius: 999px;
- background: var(--panel);
- color: var(--muted);
- transition:
- border-color 0.16s ease,
- box-shadow 0.16s ease;
-}
-
-.pagination-size:hover {
- border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
-}
-
-.pagination-size:focus-within {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
-}
-
-.pagination-size select {
- min-width: 60px;
- min-height: 32px;
- height: 32px;
- padding-left: 7px;
- padding-right: 27px !important;
- border: 0;
- border-radius: 999px;
- background-color: transparent;
- background-position: right 7px center;
- box-shadow: none;
- font-size: 12px;
- font-weight: 700;
-}
-
-input[type="checkbox"],
-input[type="radio"] {
- appearance: none;
- -webkit-appearance: none;
- width: 17px;
- height: 17px;
- min-width: 17px;
- margin: 0;
- border: 1.5px solid var(--line-strong);
- background-color: var(--panel);
- background-repeat: no-repeat;
- background-position: center;
- cursor: pointer;
- transition:
- border-color 0.15s ease,
- background-color 0.15s ease,
- box-shadow 0.15s ease;
-}
-
-input[type="checkbox"] {
- border-radius: 5px;
-}
-
-input[type="radio"] {
- border-radius: 50%;
-}
-
-input[type="checkbox"]:hover,
-input[type="radio"]:hover {
- border-color: var(--blue);
-}
-
-input[type="checkbox"]:checked {
- border-color: var(--blue);
- background-color: var(--blue);
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 4 4L19 6'/%3E%3C/svg%3E");
-}
-
-input[type="radio"]:checked {
- border: 5px solid var(--blue);
- background-color: var(--panel);
-}
-
-input[type="checkbox"]:focus-visible,
-input[type="radio"]:focus-visible {
- outline: none;
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.16);
-}
-
-input[type="checkbox"]:disabled,
-input[type="radio"]:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.theme-dark select,
-.theme-dark select option,
-.theme-dark .pagination-size,
-.theme-dark input[type="checkbox"],
-.theme-dark input[type="radio"] {
- background-color: #151d2b;
- border-color: #33425b;
- color: #e6edf7;
-}
-
-.theme-dark select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca8ba' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
-}
-
-.theme-dark .pagination-size select,
-.theme-dark .toolbar-filter select {
- background-color: transparent;
-}
-
-.theme-dark .toolbar-filter select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca8ba' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 7px center;
- background-size: 14px 14px;
-}
-
-.theme-dark input[type="checkbox"]:checked {
- background-color: var(--blue);
- border-color: var(--blue);
-}
-
-.theme-dark input[type="radio"]:checked {
- background-color: #151d2b;
- border-color: var(--blue);
-}
-
-/* Node insight detail */
-.node-resource-detail.node-insight-detail {
- position: static;
- top: auto;
- padding: 0;
- overflow: hidden;
- border-radius: 24px;
- background: var(--panel);
-}
-
-.node-insight-hero {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20px;
- padding: 24px 26px;
- border-bottom: 1px solid var(--line);
- background:
- radial-gradient(circle at 8% 0%, rgba(124, 58, 237, 0.12), transparent 36%),
- linear-gradient(
- 135deg,
- color-mix(in srgb, var(--panel) 94%, #ede9fe),
- var(--panel)
- );
-}
-
-.node-insight-identity {
- min-width: 0;
- display: flex;
- align-items: center;
- gap: 15px;
-}
-
-.node-insight-icon {
- width: 48px;
- height: 48px;
- flex: 0 0 48px;
- display: grid;
- place-items: center;
- border-radius: 15px;
- background: #efeafe;
- color: var(--blue);
- box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.08);
-}
-
-.node-insight-icon.online {
- background: var(--green-soft);
- color: var(--green);
-}
-
-.node-insight-icon.offline {
- background: rgba(239, 90, 122, 0.1);
- color: var(--red);
-}
-
-.node-insight-identity h3 {
- margin: 4px 0 5px;
- color: var(--ink);
- font-size: 25px;
- letter-spacing: -0.65px;
-}
-
-.node-insight-identity p {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 7px;
- margin: 0;
- color: var(--muted);
- font-size: 12px;
-}
-
-.node-insight-identity p span {
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: var(--soft);
-}
-
-.node-insight-state {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.schedule-chip {
- padding: 5px 9px;
- border: 1px solid rgba(54, 201, 143, 0.2);
- border-radius: 999px;
- background: var(--green-soft);
- color: #168a61;
- font-size: 10px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.schedule-chip.blocked {
- border-color: rgba(239, 90, 122, 0.2);
- background: rgba(239, 90, 122, 0.1);
- color: var(--red);
-}
-
-.node-health-message {
- display: flex;
- align-items: center;
- gap: 8px;
- margin: 18px 24px 0;
- padding: 10px 12px;
- border: 1px solid rgba(245, 158, 53, 0.2);
- border-radius: 11px;
- background: rgba(245, 158, 53, 0.08);
- color: #b86f1b;
- font-size: 12px;
-}
-
-.node-insight-facts {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- margin: 20px 24px 0;
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 15px;
- background: var(--canvas);
-}
-
-.node-insight-facts > div {
- min-width: 0;
- padding: 13px 15px;
- border-right: 1px solid var(--line);
-}
-
-.node-insight-facts > div:last-child {
- border-right: 0;
-}
-
-.node-insight-facts small,
-.node-insight-facts strong {
- display: block;
-}
-
-.node-insight-facts small {
- margin-bottom: 5px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-insight-facts strong {
- overflow: hidden;
- color: var(--ink);
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-insight-layout {
- display: grid;
- grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.85fr);
- gap: 16px;
- padding: 18px 24px 24px;
-}
-
-.node-insight-main,
-.node-insight-side {
- min-width: 0;
- display: grid;
- align-content: start;
- gap: 16px;
-}
-
-.node-insight-main > .node-insight-section {
- height: 100%;
-}
-
-.node-insight-main {
- align-content: stretch;
- grid-template-rows: minmax(0, 1fr);
-}
-
-.node-insight-section {
- min-width: 0;
- padding: 17px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: var(--panel);
-}
-
-.node-insight-section-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 13px;
-}
-
-.node-insight-section-head span,
-.node-insight-section-head small {
- display: block;
-}
-
-.node-insight-section-head span {
- color: var(--ink);
- font-size: 13px;
- font-weight: 850;
-}
-
-.node-insight-section-head small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-capacity-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 10px;
-}
-
-.node-capacity-card {
- min-width: 0;
- padding: 12px;
- border: 1px solid var(--line);
- border-radius: 13px;
- background: var(--canvas);
-}
-
-.node-capacity-title {
- display: flex;
- align-items: center;
- gap: 7px;
-}
-
-.node-capacity-title > span {
- width: 27px;
- height: 27px;
- display: grid;
- place-items: center;
- border-radius: 8px;
- background: #efeafe;
- color: var(--blue);
-}
-
-.node-capacity-title strong {
- color: var(--ink);
- font-size: 12px;
-}
-
-.node-capacity-title b {
- margin-left: auto;
- color: var(--ink);
- font-size: 12px;
-}
-
-.node-capacity-track {
- height: 6px;
- margin: 12px 0 9px;
- overflow: hidden;
- border-radius: 999px;
- background: var(--line-strong);
-}
-
-.node-capacity-track i {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(90deg, var(--blue), var(--blue-2));
-}
-
-.node-pressure-card.is-warning {
- border-color: color-mix(in srgb, var(--danger) 38%, var(--line));
- background: color-mix(in srgb, var(--danger) 5%, var(--canvas));
-}
-
-.node-pressure-card.is-warning .node-capacity-title > span,
-.node-pressure-card.is-warning .node-capacity-track i {
- background: var(--danger);
- color: #fff;
-}
-
-.node-pressure-card.is-warning .node-capacity-title b {
- color: var(--danger);
-}
-
-.node-capacity-card > small {
- display: block;
- overflow: hidden;
- color: var(--muted);
- font-size: 9px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-capacity-card > small span {
- color: var(--soft);
-}
-
-.node-capacity-amounts {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 5px 10px;
-}
-
-.node-capacity-amounts > span {
- min-width: 0;
- display: grid;
- gap: 2px;
-}
-
-.node-capacity-amounts em,
-.node-capacity-amounts strong,
-.node-capacity-amounts small {
- overflow: hidden;
- font-style: normal;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-capacity-amounts em,
-.node-capacity-amounts small {
- color: var(--muted);
- font-size: 9px;
-}
-
-.node-capacity-amounts strong {
- color: var(--ink);
- font-size: 11px;
-}
-
-.node-capacity-amounts small {
- grid-column: 1 / -1;
- padding-top: 2px;
- border-top: 1px dashed var(--line);
-}
-
-.node-task-callout {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 11px;
- padding: 13px;
- border: 1px dashed var(--line-strong);
- border-radius: 13px;
- background: var(--canvas);
- color: var(--ink);
- font: inherit;
- text-align: left;
-}
-
-.node-task-callout:disabled {
- cursor: default;
- opacity: 1;
-}
-
-.node-task-callout.interactive {
- cursor: pointer;
- transition:
- border-color 0.16s ease,
- background-color 0.16s ease,
- box-shadow 0.16s ease,
- transform 0.16s ease;
-}
-
-.node-task-callout.interactive:hover {
- border-color: rgba(54, 201, 143, 0.55);
- background: rgba(54, 201, 143, 0.11);
- box-shadow: 0 8px 18px rgba(35, 156, 112, 0.09);
- transform: translateY(-1px);
-}
-
-.node-task-callout.interactive:focus-visible {
- outline: 2px solid var(--green);
- outline-offset: 2px;
-}
-
-.node-task-callout > span {
- width: 36px;
- height: 36px;
- flex: 0 0 36px;
- display: grid;
- place-items: center;
- border-radius: 11px;
- background: var(--panel);
- color: var(--muted);
-}
-
-.node-task-callout > div {
- min-width: 0;
-}
-
-.node-task-callout strong,
-.node-task-callout small {
- display: block;
-}
-
-.node-task-callout strong {
- overflow: hidden;
- color: var(--ink);
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-task-callout small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-task-callout > b {
- margin-left: auto;
- padding: 4px 8px;
- border-radius: 999px;
- background: var(--line);
- color: var(--muted);
- font-size: 9px;
- white-space: nowrap;
-}
-
-.node-task-callout.active {
- border-style: solid;
- border-color: rgba(54, 201, 143, 0.24);
- background: rgba(54, 201, 143, 0.07);
-}
-
-.node-task-callout.active > span {
- background: var(--green-soft);
- color: var(--green);
-}
-
-.node-task-callout.active > b {
- background: var(--green-soft);
- color: #168a61;
-}
-
-.node-worker-table-wrap {
- overflow-x: scroll;
- border: 1px solid var(--line);
- border-radius: 12px;
- scrollbar-color: #9eabbd #edf1f6;
- scrollbar-width: auto;
-}
-
-.node-worker-table-wrap::-webkit-scrollbar {
- height: 12px;
-}
-
-.node-worker-table-wrap::-webkit-scrollbar-track {
- background: #edf1f6;
-}
-
-.node-worker-table-wrap::-webkit-scrollbar-thumb {
- min-width: 56px;
- border: 3px solid #edf1f6;
- border-radius: 999px;
- background: #9eabbd;
-}
-
-.node-worker-panel {
- margin-top: 18px;
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 20px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.node-worker-panel .worker-panel-head {
- padding: 18px 20px;
- border-bottom: 1px solid var(--line);
-}
-
-.node-worker-panel .node-worker-table-wrap {
- border: 0;
- border-radius: 0;
-}
-
-.node-worker-table-wrap .node-worker-table {
- width: 100%;
- min-width: 1280px;
- border-collapse: collapse;
-}
-
-.node-worker-table th,
-.node-worker-table td {
- padding: 10px 12px;
- border-bottom: 1px solid var(--line);
- text-align: left;
- white-space: nowrap;
-}
-
-.node-worker-table th {
- color: var(--muted);
- font-size: 11px;
- font-weight: 700;
-}
-
-.node-worker-table > thead > tr > th:last-child,
-.node-worker-table > tbody > tr > td:last-child {
- position: sticky;
- right: 0;
- z-index: 1;
- background: var(--panel);
- box-shadow: -8px 0 12px -12px rgba(15, 23, 42, 0.5);
-}
-
-.node-worker-table > thead > tr > th:last-child {
- z-index: 2;
-}
-
-.node-worker-table > tbody > tr:last-child > td {
- border-bottom: 0;
-}
-
-.node-worker-job-link {
- max-width: 180px;
- overflow: hidden;
- color: var(--primary);
- text-overflow: ellipsis;
-}
-
-.node-worker-empty {
- padding: 24px;
- border: 1px dashed var(--line);
- border-radius: 12px;
- color: var(--muted);
- text-align: center;
-}
-
-.node-worker-expanded td {
- position: static !important;
- background: var(--surface-2);
-}
-
-.node-worker-expanded .worker-detail-drawer {
- position: sticky;
- left: 0;
- width: min(calc(100vw - 150px), 100%);
- max-width: calc(100vw - 150px);
- box-sizing: border-box;
-}
-
-.node-worker-expanded .worker-detail-head {
- min-width: 0;
-}
-
-.node-worker-expanded .worker-ssh-inline {
- width: min(52%, 520px);
- max-width: min(52%, 520px);
- flex: 0 1 520px;
-}
-
-.node-task-link-icon {
- flex: none;
- color: var(--green);
-}
-
-/* Node image pull progress list (NodeDetailReal). */
-.node-pull-progress-list {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.node-pull-progress-entry {
- padding: 10px 12px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--canvas);
-}
-
-.node-pull-progress-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
-}
-
-.node-pull-image {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- color: var(--blue);
- font-size: 11px;
- font-weight: 600;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-pull-status {
- flex: none;
- padding: 2px 8px;
- border-radius: 999px;
- background: var(--line-strong);
- color: var(--ink);
- font-size: 10px;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.node-pull-status.chip-pulling {
- background: rgba(54, 201, 143, 0.16);
- color: var(--green);
-}
-
-.node-pull-status.chip-completed {
- background: rgba(63, 140, 255, 0.16);
- color: var(--blue);
-}
-
-.node-pull-status.chip-failed {
- background: rgba(229, 99, 99, 0.16);
- color: var(--red, #e56363);
-}
-
-.node-pull-progress-bar {
- position: relative;
- display: flex;
- align-items: center;
- height: 18px;
- margin: 9px 0 6px;
-}
-
-.node-pull-progress-bar > i {
- position: absolute;
- inset: 6px 0;
- display: block;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--blue), var(--blue-2));
- transition: width 0.18s ease;
-}
-
-.node-pull-progress-bar > b {
- position: relative;
- z-index: 1;
- padding-left: 8px;
- color: var(--ink);
- font-size: 10px;
- font-weight: 700;
-}
-
-.node-pull-progress-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-info-list {
- margin: 0;
-}
-
-.node-info-list > div {
- display: grid;
- grid-template-columns: 76px minmax(0, 1fr);
- gap: 10px;
- padding: 9px 0;
- border-bottom: 1px solid var(--line);
-}
-
-.node-info-list > div:last-child {
- border-bottom: 0;
- padding-bottom: 0;
-}
-
-.node-info-list dt {
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-info-list dd {
- min-width: 0;
- margin: 0;
- overflow: hidden;
- color: var(--ink);
- font-size: 11px;
- font-weight: 700;
- text-align: right;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-info-list code {
- color: var(--blue);
- font-size: 10px;
-}
-
-.node-label-section .label-list {
- max-width: none;
- gap: 7px;
-}
-
-.node-label-section .label-chip {
- max-width: 100%;
- padding: 5px 7px;
- background: var(--canvas);
- white-space: nowrap;
-}
-
-.node-label-section .label-chip code {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.theme-dark .node-insight-hero {
- background:
- radial-gradient(
- circle at 8% 0%,
- rgba(167, 139, 250, 0.15),
- transparent 36%
- ),
- linear-gradient(135deg, #161f30, #151d2b);
-}
-
-.theme-dark .node-insight-facts,
-.theme-dark .node-capacity-card,
-.theme-dark .node-task-callout,
-.theme-dark .node-label-section .label-chip {
- background: #111a29;
- border-color: #2b3850;
-}
-
-.theme-dark .node-insight-section {
- background: #151d2b;
- border-color: #2b3850;
-}
-
-.theme-dark .node-task-callout > span {
- background: #1b2637;
-}
-
-.theme-dark .node-task-callout.active,
-.theme-dark .node-task-callout.active > span,
-.theme-dark .node-task-callout.active > b {
- background: rgba(54, 201, 143, 0.1);
-}
-
-@media (max-width: 1050px) {
- .node-insight-layout {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 780px) {
- .node-insight-hero {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .node-insight-facts,
- .node-capacity-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .node-insight-facts > div:nth-child(2) {
- border-right: 0;
- }
-
- .node-insight-facts > div:nth-child(-n + 2) {
- border-bottom: 1px solid var(--line);
- }
-}
-
-@media (max-width: 520px) {
- .node-insight-hero,
- .node-insight-layout {
- padding-left: 16px;
- padding-right: 16px;
- }
-
- .node-insight-facts {
- margin-left: 16px;
- margin-right: 16px;
- grid-template-columns: 1fr;
- }
-
- .node-insight-facts > div {
- border-right: 0;
- border-bottom: 1px solid var(--line);
- }
-
- .node-capacity-grid {
- grid-template-columns: 1fr;
- }
-}
-
-/* Admin node operations */
-.admin-node-insight {
- display: grid;
- gap: 16px;
-}
-
-.admin-node-actionbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20px;
- padding: 17px 19px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.admin-node-actionbar > div:first-child {
- min-width: 0;
-}
-
-.admin-node-actionbar strong,
-.admin-node-actionbar small {
- display: block;
-}
-
-.admin-node-actionbar strong {
- margin-top: 4px;
- color: var(--ink);
- font-size: 14px;
-}
-
-.admin-node-actionbar small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.admin-node-actionbar .row-actions {
- flex: 0 0 auto;
-}
-
-.admin-cordon-button {
- color: var(--red);
- border-color: rgba(239, 90, 122, 0.24);
- background: rgba(239, 90, 122, 0.05);
-}
-
-.admin-cordon-button:hover {
- background: rgba(239, 90, 122, 0.1);
-}
-
-.admin-node-insight > .node-insight-detail {
- box-shadow: var(--shadow-soft);
-}
-
-.admin-node-labels {
- padding: 20px;
- box-shadow: var(--shadow-soft);
-}
-
-.admin-node-labels .node-insight-section-head > b {
- padding: 4px 8px;
- border-radius: 999px;
- background: var(--canvas);
- color: var(--muted);
- font-size: 10px;
-}
-
-.admin-node-labels .label-list {
- max-width: none;
- gap: 8px;
-}
-
-.admin-node-labels .label-chip {
- max-width: min(100%, 360px);
- padding: 6px 9px;
- border-radius: 8px;
-}
-
-.admin-label-editor {
- display: grid;
- gap: 9px;
-}
-
-.admin-node-managed-fields {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- padding: 14px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--canvas);
-}
-
-.admin-node-managed-field {
- display: grid;
- gap: 7px;
-}
-
-.admin-node-managed-field label {
- color: var(--soft);
- font-size: 11px;
- font-weight: 700;
-}
-
-.admin-node-managed-field input {
- width: 100%;
- height: 38px;
- padding: 0 11px;
- border: 1px solid var(--line-strong);
- border-radius: 9px;
- background: var(--panel);
- color: var(--ink);
- box-sizing: border-box;
-}
-
-.admin-node-managed-categories {
- grid-column: 1 / -1;
-}
-
-.admin-node-extension-head {
- margin-top: 8px;
-}
-
-.admin-node-extension-head strong,
-.admin-node-extension-head small {
- display: block;
-}
-
-.admin-node-extension-head strong {
- font-size: 12px;
-}
-
-.admin-node-extension-head small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.admin-label-editor .label-edit-row {
- grid-template-columns: minmax(180px, 0.8fr) minmax(180px, 1.2fr) 36px;
- padding: 8px;
- border: 1px solid var(--line);
- border-radius: 11px;
- background: var(--canvas);
-}
-
-.admin-label-editor .label-edit-row code {
- overflow: hidden;
- color: var(--blue);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-label-editor .label-edit-row input,
-.admin-label-editor .label-add-row input {
- height: 36px;
- border: 1px solid var(--line-strong);
- border-radius: 9px;
- background: var(--panel);
- color: var(--ink);
- outline: none;
-}
-
-.admin-label-editor .label-edit-row input:focus,
-.admin-label-editor .label-add-row input:focus {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
-}
-
-.admin-label-editor .label-add-row {
- grid-template-columns: minmax(180px, 0.8fr) minmax(180px, 1.2fr) auto;
- padding: 10px;
- border: 1px dashed var(--line-strong);
- border-radius: 11px;
- background: var(--panel);
-}
-
-.admin-label-actions {
- display: flex;
- justify-content: flex-end;
- gap: 9px;
- padding-top: 5px;
-}
-
-.admin-label-empty {
- width: 100%;
- min-height: 86px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- gap: 9px;
- border: 1px dashed var(--line-strong);
- border-radius: 12px;
- background: var(--canvas);
- color: var(--muted);
-}
-
-.theme-dark .admin-node-actionbar,
-.theme-dark .admin-node-labels {
- background: #151d2b;
- border-color: #2b3850;
-}
-
-.theme-dark .admin-label-editor .label-edit-row,
-.theme-dark .admin-label-empty {
- background: #111a29;
- border-color: #33425b;
-}
-
-.theme-dark .admin-label-editor .label-edit-row input,
-.theme-dark .admin-label-editor .label-add-row,
-.theme-dark .admin-label-editor .label-add-row input {
- background: #151d2b;
- border-color: #33425b;
- color: #e6edf7;
-}
-
-@media (max-width: 780px) {
- .admin-node-actionbar {
- align-items: stretch;
- flex-direction: column;
- }
-
- .admin-node-actionbar .row-actions {
- justify-content: flex-end;
- }
-
- .admin-label-editor .label-edit-row,
- .admin-label-editor .label-add-row {
- grid-template-columns: 1fr;
- }
-
- .admin-node-managed-fields {
- grid-template-columns: 1fr;
- }
-}
-
-.sort-th {
- background: none;
- border: none;
- padding: 0;
- font: inherit;
- color: inherit;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 2px;
-}
-
-.sort-th:hover {
- color: var(--accent, #6366f1);
-}
-
-.domain-detail-page .section-heading {
- flex-wrap: wrap;
- gap: 12px;
-}
-
-.icon-button.danger {
- color: var(--red);
- border-color: rgba(239, 90, 122, 0.25);
-}
-
-.icon-button.danger:hover {
- background: rgba(239, 90, 122, 0.08);
- border-color: var(--red);
-}
-
-.admin-node-table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.admin-node-table th {
- text-align: left;
- font-size: 12px;
- font-weight: 600;
- color: var(--muted);
- padding: 14px 16px;
- border-bottom: 1px solid var(--line);
- white-space: nowrap;
-}
-
-.admin-node-table td {
- padding: 14px 16px;
- border-bottom: 1px solid var(--line);
- font-size: 14px;
- vertical-align: middle;
-}
-
-.admin-node-table tr:hover {
- background: var(--hover);
-}
-
-.label-list {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- max-width: 400px;
- align-items: center;
-}
-
-.label-chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 8px 2px 8px;
- background: var(--canvas);
- border: 1px solid var(--line);
- border-radius: 6px;
- font-size: 12px;
- white-space: nowrap;
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.label-chip code {
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- color: var(--blue);
- font-weight: 600;
-}
-
-.label-chip i {
- font-style: normal;
- color: var(--soft);
- font-size: 11px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.label-toggle {
- display: inline-flex;
- align-items: center;
- padding: 2px 8px;
- background: transparent;
- border: 1px dashed var(--line-strong);
- border-radius: 6px;
- font-size: 11px;
- color: var(--muted);
- cursor: pointer;
- white-space: nowrap;
-}
-
-.label-toggle:hover {
- background: var(--hover);
- color: var(--blue);
- border-color: var(--blue);
-}
-
-.label-editor {
- display: flex;
- flex-direction: column;
- gap: 8px;
- min-width: 320px;
-}
-
-.label-edit-row {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.label-edit-row code {
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- color: var(--soft);
- min-width: 120px;
-}
-
-.label-edit-row input {
- flex: 1;
- padding: 6px 10px;
- border: 1px solid var(--line-strong);
- border-radius: 8px;
- font-size: 13px;
- outline: none;
-}
-
-.label-edit-row input:focus {
- border-color: var(--blue);
-}
-
-.label-add-row {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
-}
-
-.label-add-row input {
- flex: 1;
- padding: 6px 10px;
- border: 1px solid var(--line-strong);
- border-radius: 8px;
- font-size: 13px;
- outline: none;
-}
-
-.label-add-row input:focus {
- border-color: var(--blue);
-}
-
-.muted {
- color: var(--muted);
-}
-
-.cert-files {
- margin-top: 16px;
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.cert-file-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 14px 18px;
- background: var(--canvas);
- border: 1px solid var(--line);
- border-radius: 12px;
-}
-
-.cert-file-row div {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.cert-file-row strong {
- font-size: 14px;
-}
-
-.cert-file-row code {
- font-family: "JetBrains Mono", monospace;
- font-size: 12px;
- color: var(--soft);
-}
-
-.cert-preview {
- margin-top: 18px;
-}
-
-.cert-preview-head {
- margin-bottom: 8px;
-}
-
-.cert-preview pre {
- background: var(--canvas);
- border: 1px solid var(--line);
- border-radius: 10px;
- padding: 16px;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.6;
- color: var(--muted);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 240px;
- overflow-y: auto;
-}
-
-.cert-yaml-block {
- margin-top: 16px;
-}
-
-.cert-yaml-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
- gap: 12px;
-}
-
-.cert-yaml-head strong {
- font-size: 13px;
- color: var(--muted);
-}
-
-.cert-yaml-block pre {
- background: var(--canvas);
- border: 1px solid var(--line);
- border-radius: 10px;
- padding: 16px;
- font-family: "JetBrains Mono", monospace;
- font-size: 11px;
- line-height: 1.6;
- color: var(--muted);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 480px;
- overflow-y: auto;
-}
-
-.cert-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.cert-list-item {
- border: 1px solid var(--line);
- border-radius: 10px;
- overflow: hidden;
-}
-
-.cert-list-row {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.cert-list-row:hover {
- background: var(--hover);
-}
-
-.cert-list-row.expanded {
- background: var(--hover);
-}
-
-.cert-list-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--green);
- flex-shrink: 0;
-}
-
-.cert-list-name {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
-}
-
-.cert-list-date {
- font-size: 12px;
- color: var(--soft);
-}
-
-.cert-list-chevron {
- color: var(--soft);
- transition: transform 0.2s;
-}
-
-.cert-list-chevron.rotated {
- transform: rotate(90deg);
-}
-
-@media (max-width: 1300px) {
- .page-content {
- padding-left: 22px;
- padding-right: 22px;
- }
-
- .topbar {
- padding-left: 22px;
- padding-right: 22px;
- }
-
- .metric-grid {
- grid-template-columns: repeat(2, 1fr);
- }
-
- .cluster-overview-grid,
- .cluster-card-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .cluster-topology-grid,
- .node-detail-grid {
- grid-template-columns: 1fr;
- }
-
- .node-admin-layout {
- display: flex;
- flex-direction: column;
- }
-
- .node-resource-detail {
- position: static;
- width: 100%;
- }
-
- .dashboard-grid,
- .bottom-grid {
- grid-template-columns: 1fr;
- }
-
- .overview-page,
- .cluster-overview-page {
- overflow-x: hidden;
- overflow-y: auto;
- }
-
- .overview-page .dashboard-grid,
- .overview-page .bottom-grid {
- flex: 0 0 auto;
- min-height: auto;
- }
-
- .overview-page .dashboard-grid > *,
- .overview-page .bottom-grid > * {
- min-width: 0;
- }
-
- .overview-page .resource-split {
- flex: 0 0 auto;
- }
-
- .overview-page .resource-split .resource-row {
- flex: 0 0 auto;
- min-height: 62px;
- }
-
- .overview-page .chart-panel,
- .overview-page .workload-panel,
- .overview-page .bottom-grid > .panel {
- min-height: 220px;
- }
-}
-
-@media (max-width: 1050px) {
- .app-shell {
- grid-template-columns: 246px minmax(0, 1fr);
- }
-
- .app-shell.sidebar-collapsed {
- grid-template-columns: 80px minmax(0, 1fr);
- }
-
- .sidebar-collapsed .sidebar {
- padding: 16px 10px;
- }
-
- .sidebar-collapsed .brand {
- height: 48px;
- margin-bottom: 18px;
- padding: 0 8px;
- }
-
- .sidebar-collapsed .brand-logo {
- width: 46px;
- height: 46px;
- object-fit: cover;
- object-position: left center;
- }
-
- .sidebar-collapsed .sidebar nav button span,
- .sidebar-collapsed .sidebar nav button em,
- .sidebar-collapsed .sidebar .nav-label,
- .sidebar-collapsed .sidebar .environment-card div,
- .sidebar-collapsed .sidebar .environment-card i,
- .sidebar-collapsed .sidebar .sidebar-bottom > button span {
- display: none;
- }
-
- .sidebar-collapsed nav button,
- .sidebar-collapsed .sidebar-bottom > button {
- justify-content: center;
- padding: 0;
- }
-
- .sidebar-collapsed .nav-children {
- padding-left: 0;
- }
-
- .sidebar-collapsed .environment-card {
- grid-template-columns: 1fr;
- min-height: 56px;
- padding: 9px;
- }
-
- .sidebar-collapsed .sidebar .environment-card > span {
- margin: auto;
- }
-
- .topbar {
- padding-left: 18px;
- padding-right: 18px;
- }
-
- .topbar-context {
- min-width: 110px;
- }
-
- .page-content {
- padding-left: 18px;
- padding-right: 18px;
- }
-
- .overview-china-layout {
- grid-template-columns: 1fr;
- }
-
- .overview-china-map {
- min-height: 360px;
- }
-
- .overview-china-aside {
- display: grid;
- grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr);
- }
-
- .overview-city-summary {
- grid-column: 1 / -1;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
-
- .cluster-detail-stats {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
-
- .resource-input-row {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .mount-row {
- grid-template-columns:
- minmax(80px, auto) minmax(160px, 1fr) minmax(160px, 1fr)
- 32px;
- }
-
- .overview-page .resource-split .resource-row {
- grid-template-columns: minmax(0, 1fr) auto;
- grid-template-rows: auto auto;
- gap: 9px 12px;
- padding: 12px 14px;
- }
-
- .overview-page .resource-split .resource-row > div {
- grid-column: 1;
- grid-row: 1;
- min-width: 0;
- }
-
- .overview-page .resource-split .resource-row > b {
- grid-column: 2;
- grid-row: 1;
- align-self: center;
- }
-
- .overview-page .resource-split .resource-row > span {
- grid-column: 1 / -1;
- grid-row: 2;
- width: 100%;
- height: 8px;
- }
-}
-
-@media (max-width: 780px) {
- .topbar {
- height: 64px;
- gap: 10px;
- padding: 0 14px;
- }
-
- .topbar-context > span,
- .environment-status,
- .notification-button {
- display: none;
- }
-
- .topbar-context {
- min-width: 0;
- }
-
- .topbar-context h1 {
- font-size: 14px;
- white-space: nowrap;
- }
-
- .topbar-actions {
- gap: 5px;
- }
-
- .segmented-control {
- height: 36px;
- padding: 3px;
- }
-
- .segmented-control button {
- min-width: 30px;
- padding: 0 7px;
- }
-
- .topbar-actions > .primary-button {
- width: 38px;
- padding: 0;
- justify-content: center;
- }
-
- .topbar-actions > .primary-button span {
- display: none;
- }
-
- .avatar {
- width: 34px;
- height: 34px;
- }
-
- .page-content {
- padding: 18px 14px 28px;
- }
-
- .hero-strip,
- .section-heading {
- align-items: flex-start;
- gap: 14px;
- }
-
- .hero-strip {
- flex-direction: column;
- }
-
- .hero-health {
- width: 100%;
- min-width: 0;
- }
-
- .metric-grid,
- .cluster-overview-grid,
- .cluster-card-grid {
- gap: 12px;
- }
-
- .node-card-grid,
- .cluster-detail-stats {
- grid-template-columns: 1fr;
- }
-
- .create-job-modal {
- width: calc(100vw - 24px);
- max-height: calc(100vh - 24px);
- }
-
- .create-job-body {
- max-height: calc(100vh - 180px);
- }
-
- .create-stepper {
- padding-left: 10px;
- padding-right: 10px;
- }
-
- .create-stepper button {
- gap: 4px;
- font-size: 10px;
- }
-
- .create-stepper button:not(:last-child)::after {
- display: none;
- }
-
- .form-row,
- .resource-input-row,
- .env-row,
- .mount-row {
- grid-template-columns: 1fr;
- }
-
- .page-toolbar > small {
- width: 100%;
- margin-left: 0;
- text-align: right;
- }
-}
-
-.admin-job-actions {
- display: inline-flex;
- justify-content: flex-end;
- gap: 6px;
- white-space: nowrap;
-}
-
-.admin-job-actions .icon-button {
- width: 32px;
- height: 32px;
-}
-
-.admin-job-actions .icon-button.danger,
-.job-detail-actions .secondary-button.danger {
- color: #dc2626;
- border-color: rgba(220, 38, 38, 0.24);
- background: rgba(254, 242, 242, 0.88);
-}
-
-.admin-job-actions .icon-button.danger:hover,
-.job-detail-actions .secondary-button.danger:hover {
- border-color: rgba(220, 38, 38, 0.44);
- background: #fee2e2;
-}
-
-.job-detail-actions {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 6px;
- max-width: 520px;
-}
-
-.job-detail-actions .secondary-button {
- min-height: 32px;
- height: 32px;
- padding: 0 10px;
- gap: 5px;
- font-size: 11px;
-}
-
-.job-detail-actions .secondary-button.primary-action {
- border-color: rgba(37, 134, 90, 0.28);
- background: rgba(37, 134, 90, 0.08);
- color: #25865a;
-}
-
-.job-detail-actions .secondary-button:disabled {
- opacity: 0.55;
- cursor: wait;
-}
-
-.job-action-loading {
- animation: job-action-spin 0.8s linear infinite;
-}
-
-@keyframes job-action-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.job-detail-action-error {
- max-width: 520px;
- color: #d64d68 !important;
- font-size: 10px !important;
- font-weight: 650 !important;
- text-align: right;
-}
-
-.theme-dark .admin-job-actions .icon-button.danger,
-.theme-dark .job-detail-actions .secondary-button.danger {
- color: #fca5a5;
- border-color: rgba(248, 113, 113, 0.3);
- background: rgba(127, 29, 29, 0.24);
-}
-
-.theme-dark .job-detail-actions .secondary-button.primary-action {
- border-color: rgba(74, 222, 128, 0.28);
- background: rgba(22, 101, 52, 0.2);
- color: #86efac;
-}
-
-@media (max-width: 620px) {
- .metric-grid,
- .cluster-overview-grid,
- .cluster-card-grid {
- grid-template-columns: 1fr;
- }
-
- .theme-control {
- display: none;
- }
-
- .overview-china-heading {
- flex-direction: column;
- }
-
- .overview-china-legend {
- justify-content: flex-start;
- }
-
- .overview-china-map {
- min-height: 320px;
- }
-
- .overview-china-map > svg {
- inset: 8px 2px 5px;
- width: calc(100% - 4px);
- }
-
- .overview-china-aside {
- display: flex;
- }
-
- .overview-city-summary {
- grid-template-columns: 1fr;
- }
-}
-
-.brand-logo-dark {
- display: none;
-}
-
-.theme-dark .brand-logo-light {
- display: none;
-}
-
-.theme-dark .brand-logo-dark {
- display: block;
-}
-
-.files-page {
- min-height: calc(100vh - 80px);
-}
-
-.files-breadcrumb {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 12px 16px;
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 12px;
- margin-bottom: 16px;
- font-size: 13px;
-}
-
-.files-breadcrumb .breadcrumb-label {
- color: var(--muted);
- font-weight: 500;
- margin-right: 8px;
-}
-
-.files-breadcrumb span {
- color: var(--blue);
- cursor: pointer;
- padding: 2px 6px;
- border-radius: 6px;
- transition: background 0.15s;
- font-weight: 500;
-}
-
-.files-breadcrumb span:hover {
- background: var(--hover);
-}
-
-.files-breadcrumb span:not(:last-child)::after {
- content: "/";
- margin-left: 4px;
- color: var(--soft);
- cursor: default;
-}
-
-.files-breadcrumb span:not(:last-child):hover::after {
- color: var(--soft);
-}
-
-.files-table {
- margin-top: 0;
-}
-
-.files-table tbody td {
- font-size: 14px;
-}
-
-.btn-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border: none;
- background: transparent;
- border-radius: 8px;
- color: var(--muted);
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.btn-icon:hover {
- background: var(--hover);
- color: var(--blue);
-}
-
-.btn-icon-danger {
- color: var(--muted);
-}
-
-.btn-icon-danger:hover {
- color: var(--red);
- background: rgba(239, 90, 122, 0.08);
-}
-
-tr.clickable {
- cursor: pointer;
-}
-
-tr.clickable:hover {
- background: var(--hover);
-}
-
-@media (prefers-color-scheme: dark) {
- .files-breadcrumb {
- background: var(--panel);
- border-color: var(--line);
- }
-
- .files-breadcrumb span:hover {
- background: var(--hover);
- }
-}
-
-.theme-dark .label-chip {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .label-chip code {
- color: #c4b5fd;
-}
-
-.theme-dark .label-chip i {
- color: #9ca8ba;
-}
-
-.theme-dark .label-toggle {
- border-color: var(--line-strong);
- color: #9ca8ba;
-}
-
-.theme-dark .label-toggle:hover {
- background: rgba(167, 139, 250, 0.12);
- color: #c4b5fd;
- border-color: #a78bfa;
-}
-
-.theme-dark .label-edit-row input,
-.theme-dark .label-add-row input {
- background: #111a29;
- border-color: var(--line);
- color: #d8e0ee;
-}
-
-.theme-dark .admin-node-table tr:hover {
- background: rgba(255, 255, 255, 0.03);
-}
-
-.node-selector-picker {
- position: relative;
-}
-
-.selector-chips-area {
- min-height: 38px;
- border: 1px solid var(--line);
- border-radius: 10px;
- padding: 6px 30px 6px 8px;
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- align-items: center;
- cursor: pointer;
- position: relative;
- background: #fff;
-}
-
-.selector-chips-area:hover {
- border-color: var(--blue, #7c3aed);
-}
-
-.selector-placeholder {
- color: var(--muted, #8b95a7);
- font-size: 13px;
-}
-
-.selector-hint {
- margin-top: 6px;
- font-size: 12px;
- color: var(--muted, #8b95a7);
- line-height: 1.5;
-}
-
-.field-hint {
- display: block;
- margin-top: 4px;
- font-size: 11px;
- color: var(--muted, #8b95a7);
-}
-
-.label-row {
- display: flex;
- align-items: baseline;
- gap: 2px;
-}
-
-.label-hint {
- font-size: 11px;
- color: var(--muted, #8b95a7);
-}
-
-.selector-chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- background: #f3eefe;
- color: #7c3aed;
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 12px;
- font-family: var(--mono, monospace);
- cursor: pointer;
-}
-
-.selector-chip:hover {
- background: #ede9fe;
-}
-
-.selector-chevron {
- position: absolute;
- right: 10px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--muted, #8b95a7);
-}
-
-.selector-dropdown {
- position: absolute;
- top: calc(100% + 4px);
- left: 0;
- right: 0;
- z-index: 100;
- background: #fff;
- border: 1px solid var(--line);
- border-radius: 12px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
- max-height: 320px;
- overflow-y: auto;
- padding: 8px;
-}
-
-.selector-loading,
-.selector-empty,
-.selector-no-match {
- padding: 12px;
- text-align: center;
- color: var(--muted, #8b95a7);
- font-size: 13px;
-}
-
-.selector-group {
- margin-bottom: 8px;
-}
-
-.selector-group-head {
- margin-bottom: 4px;
-}
-
-.selector-group-head code {
- font-size: 11px;
- font-weight: 600;
- color: var(--muted, #8b95a7);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.selector-group-values {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.selector-value-chip {
- border: 1px solid var(--line);
- border-radius: 6px;
- padding: 3px 10px;
- font-size: 12px;
- background: #fff;
- color: #475569;
- cursor: pointer;
- transition: all 0.12s;
-}
-
-.selector-value-chip:hover {
- border-color: var(--blue, #7c3aed);
- color: var(--blue, #7c3aed);
-}
-
-.selector-value-chip.active {
- background: #7c3aed;
- border-color: #7c3aed;
- color: #fff;
-}
-
-.selector-matched {
- margin-top: 8px;
- border-top: 1px solid var(--line);
- padding-top: 8px;
-}
-
-.selector-matched-head {
- font-size: 11px;
- font-weight: 600;
- color: var(--muted, #8b95a7);
- margin-bottom: 4px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.selector-matched-node {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 2px 0;
-}
-
-.selector-matched-node code {
- font-size: 12px;
- color: #475569;
-}
-
-.node-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #22c55e;
- flex-shrink: 0;
-}
-
-.node-dot.offline {
- background: #ef4444;
-}
-
-.selector-text-input {
- margin-top: 8px;
- border: 1px solid var(--line);
- border-radius: 10px;
- padding: 8px 12px;
- font-size: 13px;
- width: 100%;
- background: #fff;
- color: #202733;
-}
-
-.selector-text-input:focus {
- outline: none;
- border-color: var(--blue, #7c3aed);
-}
-
-.theme-dark .selector-chips-area {
- background: #111a29;
- border-color: var(--line-strong);
-}
-
-.theme-dark .selector-chips-area:hover {
- border-color: #a78bfa;
-}
-
-.theme-dark .selector-chip {
- background: rgba(167, 139, 250, 0.15);
- color: #c4b5fd;
-}
-
-.theme-dark .selector-chip:hover {
- background: rgba(167, 139, 250, 0.25);
-}
-
-.theme-dark .selector-dropdown {
- background: #111a29;
- border-color: var(--line-strong);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
-}
-
-.theme-dark .selector-value-chip {
- background: #111a29;
- border-color: var(--line);
- color: #9ca8ba;
-}
-
-.theme-dark .selector-value-chip:hover {
- border-color: #a78bfa;
- color: #c4b5fd;
-}
-
-.theme-dark .selector-value-chip.active {
- background: #a78bfa;
- border-color: #a78bfa;
- color: #fff;
-}
-
-.theme-dark .selector-text-input {
- background: #111a29;
- border-color: var(--line-strong);
- color: #d8e0ee;
-}
-
-.theme-dark .selector-matched-node code {
- color: #9ca8ba;
-}
-
-.selector-matched-inline {
- margin-top: 8px;
- border: 1px solid var(--line);
- border-radius: 10px;
- padding: 8px 10px;
- background: #f7f9fc;
-}
-
-.selector-matched-list {
- display: flex;
- flex-wrap: wrap;
- gap: 6px 14px;
-}
-
-.selector-matched-inline .selector-matched-node {
- padding: 0;
-}
-
-.theme-dark .selector-matched-inline {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.selector-no-match {
- margin-top: 8px;
- padding: 6px 10px;
- border-radius: 8px;
- background: rgba(239, 90, 122, 0.08);
- border: 1px solid rgba(239, 90, 122, 0.25);
- color: var(--red);
- font-size: 12px;
-}
-
-.node-admin-layout {
- display: grid;
- grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
- align-items: start;
- gap: 16px;
-}
-
-.node-category-grid {
- min-width: 0;
-}
-
-.node-category-column {
- padding: 16px;
- max-height: 600px;
- overflow-y: auto;
-}
-
-.node-category-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 12px;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--line);
-}
-
-.node-category-header strong {
- font-size: 14px;
-}
-
-.node-category-header small {
- display: block;
- font-size: 11px;
- color: #8e97a6;
- margin-top: 2px;
-}
-
-.node-category-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- background: var(--skyblue-bg);
- color: var(--blue);
-}
-
-.cat-cloud .node-category-icon {
- background: rgba(54, 124, 232, 0.1);
- color: #367ce8;
-}
-.cat-edge .node-category-icon {
- background: rgba(54, 201, 143, 0.1);
- color: #36c98f;
-}
-.cat-robot .node-category-icon {
- background: rgba(123, 97, 255, 0.1);
- color: #7b61ff;
-}
-.cat-unknown .node-category-icon {
- background: rgba(142, 151, 166, 0.1);
- color: #8e97a6;
-}
-
-.node-category-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.node-row {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-radius: 8px;
- border: 1px solid transparent;
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.node-row-chevron {
- color: var(--soft);
- flex: none;
-}
-
-.node-row:hover {
- background: var(--skyblue-bg);
-}
-
-.node-row.selected {
- background: rgba(54, 124, 232, 0.08);
- border: 1px solid rgba(54, 124, 232, 0.2);
-}
-
-.node-row .node-status-ring {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- padding: 0;
- background: var(--gray);
- flex-shrink: 0;
-}
-
-.node-row .node-status-ring.online {
- background: var(--green);
-}
-
-.node-row .node-status-ring.offline {
- background: var(--red);
-}
-
-.node-row-name {
- flex: 1;
- font-size: 13px;
- font-weight: 500;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.nodes-resource-section .node-admin-layout {
- display: block;
- width: 100%;
-}
-
-.nodes-resource-section .node-category-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 16px;
- width: 100%;
-}
-
-.nodes-resource-section .node-category-column {
- width: 100%;
- max-height: none;
- overflow: hidden;
-}
-
-.node-resource-browser {
- display: flex;
- flex-direction: column;
- gap: 14px;
- min-width: 0;
-}
-
-.node-category-tabs {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- padding: 5px;
- overflow-x: auto;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
- scrollbar-width: thin;
-}
-
-.node-category-tab {
- --tab-accent: #5f6f85;
- --tab-accent-rgb: 95, 111, 133;
- min-width: max-content;
- height: 42px;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0 13px 0 9px;
- border: 1px solid transparent;
- border-radius: 10px;
- background: transparent;
- color: var(--muted);
- font: inherit;
- font-size: 12px;
- font-weight: 700;
- cursor: pointer;
- transition: 0.16s ease;
-}
-
-.node-category-tab:hover {
- color: var(--tab-accent);
- background: rgba(var(--tab-accent-rgb), 0.07);
-}
-
-.node-category-tab.active {
- color: var(--tab-accent);
- border-color: rgba(var(--tab-accent-rgb), 0.25);
- background: rgba(var(--tab-accent-rgb), 0.1);
- box-shadow: 0 3px 10px rgba(var(--tab-accent-rgb), 0.1);
-}
-
-.node-category-tab:focus-visible {
- outline: 2px solid rgba(var(--tab-accent-rgb), 0.38);
- outline-offset: 2px;
-}
-
-.node-category-tab.cat-cloud {
- --tab-accent: #367ce8;
- --tab-accent-rgb: 54, 124, 232;
-}
-
-.node-category-tab.cat-edge {
- --tab-accent: #21a976;
- --tab-accent-rgb: 33, 169, 118;
-}
-
-.node-category-tab.cat-robot {
- --tab-accent: #7357e8;
- --tab-accent-rgb: 115, 87, 232;
-}
-
-.node-category-tab.cat-unknown {
- --tab-accent: #b77426;
- --tab-accent-rgb: 183, 116, 38;
-}
-
-.node-category-tab-icon {
- width: 28px;
- height: 28px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- color: var(--tab-accent);
- background: rgba(var(--tab-accent-rgb), 0.09);
- transition: 0.16s ease;
-}
-
-.node-category-tab b {
- min-width: 22px;
- height: 20px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0 6px;
- border-radius: 999px;
- background: var(--canvas);
- color: var(--soft);
- font-size: 10px;
-}
-
-.node-category-tab.active b {
- background: rgba(var(--tab-accent-rgb), 0.14);
- color: var(--tab-accent);
-}
-
-.node-category-tab.active .node-category-tab-icon {
- background: rgba(var(--tab-accent-rgb), 0.16);
- box-shadow: inset 0 0 0 1px rgba(var(--tab-accent-rgb), 0.08);
-}
-
-.node-resource-table-panel {
- padding: 0;
- overflow: hidden;
- border-radius: 14px;
-}
-
-.node-resource-table-summary {
- min-height: 62px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 12px 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.node-resource-table-summary > div {
- display: flex;
- align-items: baseline;
- gap: 10px;
-}
-
-.node-resource-table-summary strong {
- font-size: 14px;
-}
-
-.node-resource-table-summary small,
-.node-resource-table-summary > span {
- color: var(--muted);
- font-size: 11px;
-}
-
-.node-resource-table {
- width: 100%;
- overflow-x: auto;
-}
-
-.node-resource-table-head,
-.node-resource-row {
- min-width: 1380px;
- display: grid;
- grid-template-columns:
- minmax(170px, 1.35fr)
- minmax(110px, 0.75fr)
- minmax(100px, 0.7fr)
- minmax(150px, 1fr)
- minmax(140px, 0.95fr)
- minmax(130px, 0.85fr)
- minmax(220px, 1.4fr)
- minmax(160px, 1.05fr)
- 20px;
- align-items: center;
- column-gap: 14px;
-}
-
-.node-resource-table-head {
- padding: 10px 16px;
- border-bottom: 1px solid var(--line);
- background: var(--canvas);
- color: var(--muted);
- font-size: 9px;
- font-weight: 700;
- letter-spacing: 0.04em;
-}
-
-.node-resource-row {
- width: 100%;
- min-height: 58px;
- padding: 9px 16px;
- border: 0;
- border-bottom: 1px solid var(--line);
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: default;
- transition: background 0.15s ease;
-}
-
-.node-detail-link {
- min-width: 0;
- padding: 5px 7px;
- margin: -5px -7px;
- border: 0;
- border-radius: 7px;
- background: transparent;
- color: inherit;
- text-align: left;
- cursor: pointer;
-}
-
-.node-detail-link:hover,
-.node-detail-link:focus-visible {
- background: rgba(124, 58, 237, 0.09);
- color: var(--blue);
- outline: none;
-}
-
-.node-resource-row:last-child {
- border-bottom: 0;
-}
-
-.node-resource-row:hover {
- background: color-mix(in srgb, var(--skyblue-bg) 72%, var(--panel));
-}
-
-.node-resource-table-head.has-admin-actions,
-.node-resource-row.has-admin-actions {
- min-width: 1510px;
- grid-template-columns:
- minmax(170px, 1.35fr)
- minmax(110px, 0.75fr)
- minmax(100px, 0.7fr)
- minmax(150px, 1fr)
- minmax(140px, 0.95fr)
- minmax(130px, 0.85fr)
- minmax(220px, 1.4fr)
- minmax(160px, 1.05fr)
- minmax(190px, 1.15fr);
-}
-
-.node-resource-table-head.has-selection,
-.node-resource-row.has-selection {
- min-width: 1430px;
- grid-template-columns:
- 34px
- minmax(170px, 1.35fr)
- minmax(110px, 0.75fr)
- minmax(100px, 0.7fr)
- minmax(150px, 1fr)
- minmax(140px, 0.95fr)
- minmax(130px, 0.85fr)
- minmax(220px, 1.4fr)
- minmax(160px, 1.05fr)
- 20px;
-}
-
-.node-resource-table-head.has-admin-actions.has-selection,
-.node-resource-row.has-admin-actions.has-selection {
- min-width: 1560px;
- grid-template-columns:
- 34px
- minmax(170px, 1.35fr)
- minmax(110px, 0.75fr)
- minmax(100px, 0.7fr)
- minmax(150px, 1fr)
- minmax(140px, 0.95fr)
- minmax(130px, 0.85fr)
- minmax(220px, 1.4fr)
- minmax(160px, 1.05fr)
- minmax(190px, 1.15fr);
-}
-
-.node-resource-row.selected {
- background: var(--skyblue-bg);
-}
-
-.node-batch-checkbox {
- display: grid;
- place-items: center;
-}
-
-.node-batch-checkbox input,
-.node-batch-select-all input {
- width: 16px;
- height: 16px;
- accent-color: var(--blue);
-}
-
-.node-batch-select-all {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- color: var(--muted);
- font-size: 11px;
- cursor: pointer;
-}
-
-.node-batch-selection-actions {
- min-height: 32px;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- align-self: center;
- border: 1px solid var(--line);
- border-radius: 9px;
- padding: 3px;
- background: var(--panel-soft);
-}
-
-.node-resource-table-summary > .node-batch-selection-actions {
- align-items: center;
- gap: 4px;
-}
-
-.node-batch-selection-actions .node-batch-select-all {
- min-height: 26px;
- align-items: center;
- border-radius: 6px;
- padding: 0 7px;
- color: var(--ink-soft);
- background: var(--panel);
- line-height: 1;
-}
-
-.node-batch-selection-actions .node-batch-select-all input {
- flex: 0 0 auto;
- margin: 0;
-}
-
-.node-batch-selection-actions .plain-button {
- min-height: 26px;
- display: inline-flex;
- align-items: center;
- border-radius: 6px;
- padding: 0 8px;
- color: var(--blue);
- line-height: 1;
- font-size: 11px;
-}
-
-.node-batch-selection-actions .plain-button:hover:not(:disabled) {
- background: rgba(73, 93, 230, 0.09);
-}
-
-.node-batch-selection-actions .plain-button:disabled {
- color: var(--muted);
- cursor: not-allowed;
- opacity: 0.55;
-}
-
-.admin-node-list-actions,
-.admin-node-batch-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.admin-node-list-actions {
- flex-wrap: wrap;
- justify-content: flex-end;
-}
-
-.admin-node-management-page {
- display: flex;
- flex-direction: column;
- gap: 14px;
- padding-top: 18px;
-}
-
-.admin-node-management-page > .section-heading {
- margin-bottom: 0;
-}
-
-.admin-node-page-heading {
- align-items: flex-end;
- gap: 24px;
- min-height: 74px;
- padding: 4px 0 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.admin-node-page-heading h2 {
- margin: 5px 0 6px;
- font-size: 24px;
- line-height: 1.2;
-}
-
-.admin-node-page-heading p {
- max-width: 620px;
- margin: 0;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.6;
-}
-
-.admin-node-page-heading .admin-node-list-actions {
- flex: 0 0 auto;
- max-width: 540px;
- padding-bottom: 1px;
-}
-
-.admin-node-page-heading .admin-node-list-actions button {
- min-height: 34px;
-}
-
-.admin-node-overview {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.admin-node-overview > div {
- min-width: 0;
- display: grid;
- grid-template-columns: 34px minmax(0, 1fr) auto;
- grid-template-rows: auto auto;
- align-items: center;
- column-gap: 10px;
- padding: 13px 16px;
- border-right: 1px solid var(--line);
-}
-
-.admin-node-overview > div:last-child {
- border-right: 0;
-}
-
-.admin-node-overview-icon {
- grid-row: 1 / 3;
- width: 34px;
- height: 34px;
- display: grid;
- place-items: center;
- border-radius: 10px;
- color: #495de6;
- background: rgba(73, 93, 230, 0.1);
-}
-
-.admin-node-overview-icon.online,
-.admin-node-overview-icon.ready {
- color: #14875e;
- background: rgba(20, 135, 94, 0.1);
-}
-
-.admin-node-overview-icon.clusters {
- color: #7c3aed;
- background: rgba(124, 58, 237, 0.1);
-}
-
-.admin-node-overview small {
- align-self: end;
- overflow: hidden;
- color: var(--muted);
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-node-overview strong {
- grid-column: 3;
- grid-row: 1 / 3;
- font-size: 22px;
- line-height: 1;
-}
-
-.admin-node-batch-panel {
- margin-bottom: 16px;
- padding: 16px;
-}
-
-.admin-node-batch-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
-}
-
-.admin-node-batch-head strong,
-.admin-node-batch-head small {
- display: block;
-}
-
-.admin-node-batch-head strong {
- margin-top: 4px;
- color: var(--ink);
- font-size: 15px;
-}
-
-.admin-node-batch-head small {
- margin-top: 4px;
- color: var(--muted);
- font-size: 11px;
-}
-
-.admin-node-batch-fields {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin-top: 14px;
-}
-
-.admin-node-batch-field {
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--panel);
- transition:
- border-color 0.16s ease,
- background-color 0.16s ease;
-}
-
-.admin-node-batch-field.active {
- border-color: rgba(79, 70, 229, 0.5);
- background: rgba(79, 70, 229, 0.035);
-}
-
-.admin-node-batch-field-toggle {
- width: 100%;
- display: grid;
- grid-template-columns: 32px minmax(0, 1fr) auto;
- align-items: center;
- gap: 9px;
- border: 0;
- padding: 10px;
- color: var(--ink);
- background: transparent;
- cursor: pointer;
- text-align: left;
-}
-
-.admin-node-batch-field-toggle:hover {
- background: var(--panel-soft);
-}
-
-.admin-node-batch-field.active .admin-node-batch-field-toggle {
- background: transparent;
-}
-
-.admin-node-batch-field-icon {
- width: 32px;
- height: 32px;
- display: grid;
- place-items: center;
- border-radius: 9px;
- color: var(--blue);
- background: rgba(73, 93, 230, 0.09);
-}
-
-.admin-node-batch-field-toggle strong,
-.admin-node-batch-field-toggle small {
- display: block;
-}
-
-.admin-node-batch-field-toggle strong {
- font-size: 12px;
-}
-
-.admin-node-batch-field-toggle small {
- overflow: hidden;
- margin-top: 2px;
- color: var(--muted);
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-node-batch-field-toggle i {
- border-radius: 999px;
- padding: 4px 7px;
- color: var(--muted);
- background: var(--panel-soft);
- font-size: 9px;
- font-style: normal;
- font-weight: 700;
-}
-
-.admin-node-batch-field.active .admin-node-batch-field-toggle i {
- border: 1px solid rgba(79, 70, 229, 0.28);
- color: #4f46e5;
- background: rgba(79, 70, 229, 0.08);
-}
-
-.admin-node-batch-field.active .admin-node-batch-field-icon {
- color: #4f46e5;
- background: rgba(79, 70, 229, 0.12);
-}
-
-.admin-node-batch-field > input {
- width: 100%;
- height: 36px;
- box-sizing: border-box;
- border: 0;
- border-top: 1px solid var(--line);
- padding: 0 10px;
- color: var(--ink);
- background: rgba(255, 255, 255, 0.78);
- outline: 0;
-}
-
-.admin-node-batch-field > input:focus {
- background: var(--panel-soft);
-}
-
-.admin-node-batch-examples {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 5px;
- border-top: 1px solid var(--line);
- padding: 7px 10px 9px;
- background: var(--panel);
-}
-
-.admin-node-batch-examples > span {
- margin-right: 2px;
- color: var(--muted);
- font-size: 9px;
- font-weight: 700;
-}
-
-.admin-node-batch-examples button {
- border: 1px solid rgba(79, 70, 229, 0.18);
- border-radius: 999px;
- padding: 3px 7px;
- color: #4f46e5;
- background: rgba(79, 70, 229, 0.06);
- cursor: pointer;
- font-size: 9px;
-}
-
-.admin-node-batch-examples button:hover {
- border-color: rgba(79, 70, 229, 0.38);
- background: rgba(79, 70, 229, 0.12);
-}
-
-.admin-node-category-chips {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 6px;
- border-top: 1px solid var(--line);
- padding: 8px 10px;
- background: var(--panel);
-}
-
-.admin-node-category-chips button {
- border: 1px solid var(--line);
- border-radius: 999px;
- padding: 5px 10px;
- color: var(--muted);
- background: var(--panel-soft);
- cursor: pointer;
- font-size: 10px;
-}
-
-.admin-node-category-chips button.selected {
- border-color: rgba(73, 93, 230, 0.4);
- color: var(--blue);
- background: rgba(73, 93, 230, 0.1);
-}
-
-.admin-node-batch-hint {
- display: block;
- border-top: 1px solid var(--line);
- padding: 7px 10px 9px;
- color: var(--muted);
- background: var(--panel);
- font-size: 9px;
- line-height: 1.5;
-}
-
-.node-type-list {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.admin-node-batch-actions {
- justify-content: flex-end;
- margin-top: 14px;
-}
-
-.admin-node-batch-actions > span {
- margin-right: auto;
- color: var(--muted);
- font-size: 11px;
-}
-
-@media (max-width: 760px) {
- .admin-node-batch-fields {
- grid-template-columns: 1fr;
- }
-}
-
-.node-scheduling-actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.node-scheduling-state {
- display: inline-flex;
- align-items: center;
- padding: 3px 7px;
- border-radius: 999px;
- color: #14875e;
- background: rgba(54, 201, 143, 0.11);
- font-size: 10px;
- font-weight: 700;
-}
-
-.node-scheduling-state.cordoned {
- color: #c2410c;
- background: rgba(249, 115, 22, 0.12);
-}
-
-.node-scheduling-button {
- min-width: 68px;
- min-height: 30px;
- padding: 5px 9px;
- font-size: 11px;
-}
-
-.node-scheduling-button.danger {
- color: #c2410c;
- border-color: rgba(234, 88, 12, 0.25);
- background: rgba(255, 247, 237, 0.9);
-}
-
-.node-resource-row:focus-visible {
- outline: 2px solid var(--purple);
- outline-offset: -2px;
-}
-
-.node-resource-row .node-status-ring {
- width: 8px;
- height: 8px;
- flex: none;
- border-radius: 50%;
- background: var(--gray);
-}
-
-.node-resource-row .node-status-ring.online {
- background: var(--green);
- box-shadow: 0 0 0 3px rgba(54, 201, 143, 0.1);
-}
-
-.node-resource-row .node-status-ring.offline {
- background: var(--red);
-}
-
-.node-type-cell {
- width: fit-content;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 8px;
- border-radius: 7px;
- background: var(--canvas);
- color: var(--muted);
- font-size: 11px;
- font-weight: 700;
-}
-
-.node-type-cell.cat-cloud {
- color: #367ce8;
- background: rgba(54, 124, 232, 0.09);
-}
-.node-type-cell.cat-edge {
- color: #21a976;
- background: rgba(54, 201, 143, 0.1);
-}
-.node-type-cell.cat-robot {
- color: #7b61ff;
- background: rgba(123, 97, 255, 0.1);
-}
-.node-type-cell.cat-unknown {
- color: var(--muted);
- background: rgba(142, 151, 166, 0.1);
-}
-
-.node-resource-empty {
- min-height: 190px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 7px;
- color: var(--soft);
-}
-
-.node-resource-empty strong {
- color: var(--ink);
- font-size: 13px;
-}
-
-.node-resource-empty small {
- color: var(--muted);
-}
-
-.theme-dark .node-category-tabs,
-.theme-dark .node-resource-table-panel {
- border-color: #2b3950;
- background: #151f2f;
-}
-
-.theme-dark .node-category-tab:hover {
- background: rgba(var(--tab-accent-rgb), 0.11);
-}
-
-.theme-dark .node-category-tab b,
-.theme-dark .node-resource-table-head {
- background: #101827;
-}
-
-.theme-dark .node-category-tab.active {
- background: rgba(var(--tab-accent-rgb), 0.18);
- border-color: rgba(var(--tab-accent-rgb), 0.42);
- color: color-mix(in srgb, var(--tab-accent) 72%, white);
-}
-
-.theme-dark .node-category-tab.active b {
- background: rgba(var(--tab-accent-rgb), 0.24);
- color: color-mix(in srgb, var(--tab-accent) 68%, white);
-}
-
-.theme-dark .node-category-tab.active .node-category-tab-icon {
- background: rgba(var(--tab-accent-rgb), 0.25);
- color: color-mix(in srgb, var(--tab-accent) 68%, white);
-}
-
-.theme-dark .node-resource-table-head,
-.theme-dark .node-resource-row,
-.theme-dark .node-resource-table-summary {
- border-color: #2a374c;
-}
-
-.theme-dark .node-resource-row:hover {
- background: #19263a;
-}
-
-.theme-dark .node-batch-selection-actions {
- background: #111a29;
-}
-
-.theme-dark .node-batch-selection-actions .node-batch-select-all {
- background: #182337;
-}
-
-.theme-dark .admin-node-batch-field.active {
- border-color: rgba(167, 139, 250, 0.5);
- background: rgba(139, 92, 246, 0.08);
-}
-
-.theme-dark .admin-node-batch-field > input {
- background: rgba(17, 26, 41, 0.74);
-}
-
-.theme-dark .admin-node-batch-field.active .admin-node-batch-field-toggle i {
- border-color: rgba(167, 139, 250, 0.32);
- color: #c4b5fd;
- background: rgba(139, 92, 246, 0.12);
-}
-
-.theme-dark .admin-node-batch-field.active .admin-node-batch-field-icon {
- color: #c4b5fd;
- background: rgba(139, 92, 246, 0.14);
-}
-
-/* Cluster management and detail */
-.cluster-management-page {
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-
-.cluster-management-page > .section-heading,
-.cluster-detail-nodes .section-heading {
- margin-bottom: 0;
-}
-
-.cluster-management-table-panel {
- padding: 0;
- overflow-x: auto;
-}
-
-.cluster-management-table-head,
-.cluster-management-row {
- min-width: 1040px;
- display: grid;
- grid-template-columns:
- minmax(210px, 1.4fr)
- minmax(100px, 0.7fr)
- minmax(80px, 0.55fr)
- minmax(80px, 0.55fr)
- minmax(80px, 0.55fr)
- minmax(150px, 1fr)
- minmax(120px, 0.8fr)
- 20px;
- align-items: center;
- column-gap: 14px;
-}
-
-.cluster-management-table-head {
- padding: 10px 16px;
- border-bottom: 1px solid var(--line);
- background: var(--canvas);
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.04em;
-}
-
-.cluster-management-row {
- width: 100%;
- min-height: 66px;
- padding: 10px 16px;
- border: 0;
- border-bottom: 1px solid var(--line);
- background: transparent;
- color: var(--ink);
- text-align: left;
- transition: background-color 0.15s ease;
-}
-
-.cluster-management-row:last-child {
- border-bottom: 0;
-}
-
-.cluster-management-row:hover {
- background: var(--skyblue-bg);
-}
-
-.cluster-management-row > svg {
- color: var(--soft);
-}
-
-.cluster-management-name {
- min-width: 0;
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.cluster-management-name > i {
- width: 34px;
- height: 34px;
- flex: none;
- display: grid;
- place-items: center;
- border-radius: 9px;
- background: rgba(123, 97, 255, 0.1);
- color: var(--purple);
-}
-
-.cluster-management-name > span {
- min-width: 0;
-}
-
-.cluster-management-name strong,
-.cluster-management-name small {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.cluster-management-name strong {
- font-size: 13px;
-}
-
-.cluster-management-name small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.cluster-type-chip {
- width: fit-content;
- padding: 4px 8px;
- border-radius: 7px;
- background: rgba(123, 97, 255, 0.09);
- color: var(--purple);
- font-size: 10px;
- font-weight: 700;
-}
-
-.cluster-health-badge {
- width: fit-content;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 9px;
- border-radius: 999px;
- background: rgba(142, 151, 166, 0.1);
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
- white-space: nowrap;
-}
-
-.cluster-health-badge i {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
-}
-
-.cluster-health-badge.online {
- background: rgba(54, 201, 143, 0.12);
- color: #15956a;
-}
-
-.cluster-health-badge.degraded {
- background: rgba(245, 158, 53, 0.13);
- color: #c77312;
-}
-
-.cluster-health-badge.offline {
- background: rgba(239, 90, 122, 0.12);
- color: var(--red);
-}
-
-.cluster-management-empty {
- min-width: 1040px;
- min-height: 180px;
- display: grid;
- place-items: center;
- color: var(--muted);
- font-size: 12px;
-}
-
-.cluster-detail-page {
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
-}
-
-.cluster-detail-hero {
- min-height: 126px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20px;
- padding: 22px;
- background:
- radial-gradient(
- circle at 8% 10%,
- rgba(123, 97, 255, 0.14),
- transparent 30%
- ),
- var(--panel);
-}
-
-.cluster-detail-identity {
- display: flex;
- align-items: center;
- gap: 14px;
-}
-
-.cluster-detail-identity > span {
- width: 48px;
- height: 48px;
- display: grid;
- place-items: center;
- border-radius: 14px;
- background: rgba(123, 97, 255, 0.11);
- color: var(--purple);
-}
-
-.cluster-detail-identity small {
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
-}
-
-.cluster-detail-identity h2 {
- margin: 3px 0 4px;
- font-size: 24px;
-}
-
-.cluster-detail-identity p {
- margin: 0;
- display: flex;
- align-items: center;
- gap: 5px;
- color: var(--muted);
- font-size: 11px;
-}
-
-.cluster-detail-metrics {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 14px;
-}
-
-.cluster-detail-metrics .metric-card {
- min-height: 128px;
-}
-
-.cluster-detail-nodes {
- display: grid;
- gap: 12px;
-}
-
-.theme-dark .cluster-management-table-head {
- background: #101827;
-}
-
-.theme-dark .cluster-management-row,
-.theme-dark .cluster-management-table-head {
- border-color: #2a374c;
-}
-
-.theme-dark .cluster-management-row:hover {
- background: #19263a;
-}
-
-.theme-dark .cluster-detail-hero {
- background:
- radial-gradient(
- circle at 8% 10%,
- rgba(151, 123, 255, 0.16),
- transparent 30%
- ),
- #151f2f;
-}
-
-@media (max-width: 1100px) {
- .cluster-detail-metrics {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 620px) {
- .cluster-detail-metrics {
- grid-template-columns: 1fr;
- }
-}
-
-.node-category-table {
- width: 100%;
- overflow-x: auto;
-}
-
-.node-category-table-head,
-.node-category-table .node-row {
- display: grid;
- grid-template-columns:
- minmax(170px, 1.35fr)
- minmax(100px, 0.7fr)
- minmax(160px, 1.15fr)
- minmax(130px, 0.9fr)
- minmax(110px, 0.8fr)
- minmax(180px, 1.35fr)
- 20px;
- align-items: center;
- column-gap: 16px;
- min-width: 1000px;
-}
-
-.node-category-table-head {
- padding: 8px 12px;
- border-bottom: 1px solid var(--line);
- background: var(--canvas);
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.05em;
- text-transform: uppercase;
-}
-
-.node-category-table .node-category-list {
- gap: 0;
-}
-
-.node-category-table .node-row {
- padding: 11px 12px;
- border: 0;
- border-bottom: 1px solid var(--line);
- border-radius: 0;
-}
-
-.node-category-table .node-row:last-child {
- border-bottom: 0;
-}
-
-.node-row-primary {
- min-width: 0;
- display: flex;
- align-items: center;
- gap: 9px;
-}
-
-.node-row-status,
-.node-row-meta,
-.node-row-location,
-.node-row-ip,
-.node-row-task {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-row-meta,
-.node-row-location,
-.node-row-ip,
-.node-row-task {
- color: var(--muted);
- font-size: 12px;
-}
-
-.node-row-location {
- color: var(--ink);
- font-size: 12px;
- font-weight: 650;
-}
-
-.node-row-resource {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 3px;
- overflow: hidden;
-}
-
-.node-row-resource-line {
- min-width: 0;
- display: flex;
- align-items: baseline;
- gap: 5px;
- line-height: 1.25;
-}
-
-.node-row-resource-line > i {
- flex: none;
- color: var(--soft);
- font-size: 9px;
- font-style: normal;
-}
-
-.node-row-resource-line > strong {
- min-width: 0;
-}
-
-.node-row-resource-line > strong.unlabeled {
- color: var(--muted);
- font-weight: 600;
-}
-
-.node-row-resource-line > small {
- flex: none;
-}
-
-.node-row-resource strong,
-.node-row-resource small {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.node-row-resource strong {
- color: var(--ink);
- font-size: 11px;
-}
-
-.node-row-resource small {
- color: var(--muted);
- font-size: 10px;
-}
-
-.node-row-task {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.embodied-task-dot {
- width: 7px;
- height: 7px;
- flex: none;
- border-radius: 50%;
- background: var(--gray);
-}
-
-.embodied-task-dot.active {
- background: var(--green);
- box-shadow: 0 0 0 3px rgba(54, 201, 143, 0.1);
-}
-
-.node-row-ip {
- font-family: "JetBrains Mono", monospace;
-}
-
-.embodied-task-state {
- width: fit-content;
- padding: 4px 8px;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 700;
-}
-
-.embodied-task-state.active {
- background: rgba(54, 201, 143, 0.12);
- color: var(--green);
-}
-
-.embodied-task-state.idle {
- background: rgba(142, 151, 166, 0.1);
- color: var(--muted);
-}
-
-.theme-dark .node-category-table-head {
- background: #111a29;
- border-color: #33425b;
- color: #9eacc1;
-}
-
-.theme-dark .node-category-table .node-row {
- border-color: #273348;
-}
-
-.theme-dark .node-row-meta,
-.theme-dark .node-row-location,
-.theme-dark .node-row-ip,
-.theme-dark .node-row-task {
- color: #b2bfd1;
-}
-
-/* Storage resources */
-.storage-class-page {
- max-width: none;
- width: 100%;
- margin: 0;
-}
-
-/* Keep storage detail sections aligned after the shared node-detail rules,
- which are declared later in this stylesheet. */
-.node-detail-body.storage-detail-layout {
- display: grid;
- grid-template-columns: 1fr;
- align-items: stretch;
-}
-
-.storage-overview-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 14px;
- margin-bottom: 18px;
-}
-
-.storage-overview-card {
- min-width: 0;
- min-height: 112px;
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 18px;
- border: 1px solid var(--line);
- border-radius: 20px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-overview-card > span {
- width: 42px;
- height: 42px;
- flex: 0 0 auto;
- display: grid;
- place-items: center;
- border-radius: 14px;
-}
-
-.storage-overview-card.purple > span {
- color: #7c3aed;
- background: #f1eafe;
-}
-
-.storage-overview-card.green > span {
- color: #159b6b;
- background: #e5f8f1;
-}
-
-.storage-overview-card.orange > span {
- color: #e47a14;
- background: #fff1df;
-}
-
-.storage-overview-card div {
- min-width: 0;
-}
-
-.storage-overview-card small,
-.storage-overview-card strong,
-.storage-overview-card em {
- display: block;
-}
-
-.storage-overview-card small {
- color: var(--muted);
- font-size: 11px;
- font-weight: 700;
-}
-
-.storage-overview-card strong {
- margin-top: 3px;
- color: var(--ink);
- font-size: 24px;
- line-height: 1.15;
-}
-
-.storage-overview-card em {
- max-width: 100%;
- margin-top: 4px;
- overflow: hidden;
- color: var(--muted);
- font-size: 10px;
- font-style: normal;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-class-table-panel {
- overflow-x: auto;
-}
-
-.storage-class-table-panel table {
- min-width: 900px;
-}
-
-.storage-table-heading {
- min-width: 900px;
- min-height: 68px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 18px;
- padding: 14px 18px;
- border-bottom: 1px solid var(--line);
-}
-
-.storage-table-heading strong,
-.storage-table-heading small {
- display: block;
-}
-
-.storage-table-heading strong {
- color: var(--ink);
- font-size: 14px;
-}
-
-.storage-table-heading small,
-.storage-table-heading > span {
- margin-top: 4px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.storage-table-heading > span {
- flex: 0 0 auto;
- margin: 0;
- padding: 6px 9px;
- border-radius: 999px;
- background: #f2f4f8;
- font-weight: 700;
-}
-
-.storage-class-table-panel tbody tr:last-child td {
- border-bottom: 0;
-}
-
-.storage-empty-cell {
- height: 180px;
- color: var(--muted);
- text-align: center;
-}
-
-.storage-name-cell {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- border: 0;
- background: transparent;
- color: inherit;
- text-align: left;
-}
-
-.storage-name-cell > span:first-child {
- width: 34px;
- height: 34px;
- flex: 0 0 auto;
- display: grid;
- place-items: center;
- border-radius: 11px;
- background: #f1eafe;
- color: var(--blue);
-}
-
-.storage-name-cell strong,
-.storage-name-cell small {
- display: block;
-}
-
-.storage-name-cell strong {
- color: var(--ink);
- font-size: 12px;
-}
-
-.storage-name-cell small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.storage-provider-chip,
-.storage-cluster-list span {
- display: inline-flex;
- align-items: center;
- min-height: 24px;
- padding: 4px 8px;
- border-radius: 999px;
- background: #f2f4f8;
- color: #596579;
- font-size: 10px;
- font-weight: 700;
- white-space: nowrap;
-}
-
-.storage-provider-chip {
- background: #e8f8f2;
- color: #168764;
-}
-
-.storage-cluster-list {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-
-.storage-description {
- display: -webkit-box;
- max-width: 320px;
- overflow: hidden;
- line-height: 1.5;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.storage-class-table-panel .row-actions {
- justify-content: flex-end;
-}
-
-.storage-files-page {
- max-width: none;
- width: 100%;
- margin: 0;
-}
-
-.storage-files-hero {
- align-items: center;
- margin-bottom: 16px;
- padding: 22px 24px;
- border: 1px solid var(--line);
- border-radius: 24px;
- background:
- radial-gradient(
- circle at 78% 15%,
- rgba(124, 58, 237, 0.1),
- transparent 25%
- ),
- linear-gradient(135deg, #ffffff 35%, #f8f5ff 100%);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-files-hero h2 {
- margin-top: 8px;
- font-size: 28px;
-}
-
-.storage-files-summary {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- margin-bottom: 14px;
-}
-
-.storage-files-summary > div {
- min-width: 0;
- min-height: 80px;
- display: grid;
- grid-template-columns: 36px 1fr;
- grid-template-rows: auto auto;
- align-content: center;
- column-gap: 11px;
- padding: 13px 15px;
- border: 1px solid var(--line);
- border-radius: 17px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-files-summary > div > span {
- grid-row: 1 / 3;
- width: 36px;
- height: 36px;
- display: grid;
- place-items: center;
- border-radius: 11px;
- background: #f1eafe;
- color: var(--blue);
-}
-
-.storage-files-summary > div:nth-child(2) > span {
- background: #e5f8f1;
- color: #159b6b;
-}
-
-.storage-files-summary > div:nth-child(3) > span {
- background: #fff1df;
- color: #e47a14;
-}
-
-.storage-files-summary small {
- align-self: end;
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
-}
-
-.storage-files-summary strong {
- min-width: 0;
- align-self: start;
- margin-top: 3px;
- overflow: hidden;
- color: var(--ink);
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-files-summary strong em {
- color: var(--muted);
- font-size: 10px;
- font-style: normal;
- font-weight: 600;
-}
-
-.storage-files-page .files-breadcrumb {
- min-height: 46px;
- gap: 6px;
- margin-bottom: 12px;
- padding: 10px 14px;
- border-radius: 14px;
- box-shadow: 0 5px 16px rgba(28, 39, 58, 0.035);
-}
-
-.storage-files-page .files-breadcrumb > svg {
- flex: 0 0 auto;
- color: var(--blue);
-}
-
-.storage-files-page .files-breadcrumb .breadcrumb-label {
- margin-right: 2px;
- font-size: 11px;
-}
-
-.storage-files-page .files-breadcrumb button,
-.storage-files-page .files-breadcrumb > span:not(.breadcrumb-label) {
- min-height: 25px;
- display: inline-flex;
- align-items: center;
- padding: 3px 8px;
- border-radius: 8px;
- background: rgba(124, 58, 237, 0.07);
- color: var(--blue);
- font-size: 11px;
- font-weight: 700;
-}
-
-.storage-files-toolbar {
- margin-bottom: 12px;
- padding: 0;
-}
-
-.storage-files-toolbar .search-field {
- min-width: min(360px, 100%);
-}
-
-.storage-files-table-panel {
- overflow-x: auto;
-}
-
-.storage-files-table-panel .storage-table-heading,
-.storage-files-table-panel table {
- min-width: 820px;
-}
-
-.storage-files-table-panel th:nth-child(1) {
- width: 44%;
-}
-
-.storage-files-table-panel th:nth-child(2) {
- width: 15%;
-}
-
-.storage-files-table-panel th:nth-child(3) {
- width: 25%;
-}
-
-.storage-files-table-panel th:last-child {
- width: 120px;
- text-align: right;
-}
-
-.storage-files-table-panel td:last-child {
- text-align: right;
-}
-
-.storage-files-table-panel tbody tr:last-child td {
- border-bottom: 0;
-}
-
-.storage-file-name {
- min-width: 0;
- display: inline-flex;
- align-items: center;
- gap: 10px;
-}
-
-.storage-file-name > i {
- width: 34px;
- height: 34px;
- flex: 0 0 auto;
- display: grid;
- place-items: center;
- border-radius: 10px;
- background: #f1eafe;
- color: var(--blue);
- font-style: normal;
-}
-
-.storage-file-name.file > i {
- background: #eef2f7;
- color: #718096;
-}
-
-.storage-file-name > span {
- min-width: 0;
-}
-
-.storage-file-name strong,
-.storage-file-name small {
- display: block;
-}
-
-.storage-file-name strong {
- max-width: 480px;
- overflow: hidden;
- color: var(--ink);
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-file-name small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 9px;
- font-weight: 600;
-}
-
-.storage-files-table-panel .row-actions {
- justify-content: flex-end;
-}
-
-.storage-files-error {
- display: grid;
- grid-template-columns: 34px 1fr auto;
- align-items: center;
- gap: 11px;
- margin-bottom: 12px;
- padding: 11px 12px;
- border: 1px solid rgba(239, 90, 122, 0.2);
- border-radius: 14px;
- background: rgba(239, 90, 122, 0.06);
- color: var(--red);
-}
-
-.storage-files-error > svg {
- justify-self: center;
-}
-
-.storage-files-error strong,
-.storage-files-error span {
- display: block;
-}
-
-.storage-files-error strong {
- font-size: 11px;
-}
-
-.storage-files-error span {
- margin-top: 2px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.storage-files-error .secondary-button {
- min-height: 34px;
- height: 34px;
-}
-
-.storage-go-up-row td {
- color: var(--blue);
- font-size: 11px;
- font-weight: 800;
-}
-
-.storage-go-up-row td svg {
- margin-right: 7px;
- vertical-align: middle;
-}
-
-.storage-files-state {
- min-height: 170px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 8px;
- color: var(--muted);
- text-align: center;
-}
-
-.storage-files-state > span {
- width: 44px;
- height: 44px;
- display: grid;
- place-items: center;
- border-radius: 14px;
- background: #f1eafe;
- color: var(--blue);
-}
-
-.storage-files-state strong {
- color: var(--ink);
- font-size: 13px;
-}
-
-.storage-files-state small {
- color: var(--muted);
- font-size: 10px;
-}
-
-.storage-files-spinner svg {
- animation: storage-files-spin 0.9s linear infinite;
-}
-
-@keyframes storage-files-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.storage-detail-page {
- max-width: none;
- width: 100%;
- margin: 0;
-}
-
-.storage-detail-hero {
- position: relative;
- align-items: center;
- margin-bottom: 18px;
- padding: 24px 26px;
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 24px;
- background:
- radial-gradient(
- circle at 10% 30%,
- rgba(124, 58, 237, 0.13),
- transparent 28%
- ),
- linear-gradient(135deg, #ffffff 30%, #f7f3ff 100%);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-detail-hero::after {
- content: "";
- position: absolute;
- top: -70px;
- right: 120px;
- width: 190px;
- height: 190px;
- border-radius: 50%;
- background: rgba(124, 58, 237, 0.05);
- pointer-events: none;
-}
-
-.storage-detail-hero > * {
- position: relative;
- z-index: 1;
-}
-
-.storage-detail-hero h2 {
- margin-top: 8px;
- font-size: 30px;
-}
-
-.storage-detail-badges {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
- margin-top: 13px;
-}
-
-.storage-detail-badges span {
- display: inline-flex;
- align-items: center;
- min-height: 26px;
- padding: 4px 9px;
- border: 1px solid rgba(124, 58, 237, 0.12);
- border-radius: 999px;
- background: rgba(124, 58, 237, 0.08);
- color: #6d3fd3;
- font-size: 10px;
- font-weight: 800;
-}
-
-.storage-detail-layout {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- align-items: start;
- gap: 16px;
-}
-
-.storage-detail-panel {
- min-width: 0;
- padding: 20px;
- border: 1px solid var(--line);
- border-radius: 22px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-detail-panel .node-detail-label {
- display: flex;
- align-items: center;
- gap: 7px;
- color: var(--ink);
- font-size: 13px;
- font-weight: 800;
- text-transform: none;
-}
-
-.storage-detail-panel .node-detail-label svg {
- color: var(--blue);
-}
-
-.storage-detail-section-copy {
- margin: -3px 0 8px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.storage-detail-info-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
-}
-
-.storage-detail-info-grid > div {
- min-width: 0;
- min-height: 82px;
- justify-content: center;
- padding: 13px 14px;
- border: 1px solid var(--line);
- border-radius: 15px;
- background: #f8f9fc;
-}
-
-.storage-detail-info-grid .muted {
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
-}
-
-.storage-detail-info-grid strong {
- margin-top: 5px;
- overflow-wrap: anywhere;
- color: var(--ink);
- font-size: 12px;
- line-height: 1.45;
-}
-
-.theme-dark .storage-overview-card {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .storage-files-hero {
- background:
- radial-gradient(
- circle at 78% 15%,
- rgba(124, 58, 237, 0.18),
- transparent 25%
- ),
- linear-gradient(135deg, #151d2b 35%, #1a2030 100%);
- border-color: var(--line);
-}
-
-.theme-dark .storage-files-summary > div {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .storage-files-summary > div > span,
-.theme-dark .storage-file-name.folder > i,
-.theme-dark .storage-files-state > span {
- background: rgba(124, 58, 237, 0.18);
- color: #b79af8;
-}
-
-.theme-dark .storage-files-summary > div:nth-child(2) > span {
- background: rgba(54, 201, 143, 0.14);
- color: #62dbaf;
-}
-
-.theme-dark .storage-files-summary > div:nth-child(3) > span {
- background: rgba(244, 143, 46, 0.15);
- color: #f5a75d;
-}
-
-.theme-dark .storage-file-name.file > i {
- background: #1e293a;
- color: #aebbd0;
-}
-
-.theme-dark .storage-files-page .files-breadcrumb button,
-.theme-dark
- .storage-files-page
- .files-breadcrumb
- > span:not(.breadcrumb-label) {
- background: rgba(124, 58, 237, 0.18);
- color: #c4b5fd;
-}
-
-.theme-dark .storage-detail-hero {
- background:
- radial-gradient(
- circle at 10% 30%,
- rgba(124, 58, 237, 0.2),
- transparent 28%
- ),
- linear-gradient(135deg, #151d2b 30%, #1a2030 100%);
- border-color: var(--line);
-}
-
-.theme-dark .storage-detail-badges span {
- border-color: rgba(183, 154, 248, 0.18);
- background: rgba(124, 58, 237, 0.18);
- color: #c4b5fd;
-}
-
-.theme-dark .storage-detail-panel,
-.theme-dark .storage-detail-info-grid > div {
- border-color: var(--line);
- background: #151d2b;
-}
-
-.theme-dark .storage-detail-info-grid > div {
- background: #111a29;
-}
-
-.theme-dark .storage-overview-card.purple > span,
-.theme-dark .storage-name-cell > span {
- background: rgba(124, 58, 237, 0.18);
- color: #b79af8;
-}
-
-.theme-dark .storage-overview-card.green > span {
- background: rgba(54, 201, 143, 0.14);
- color: #62dbaf;
-}
-
-.theme-dark .storage-overview-card.orange > span {
- background: rgba(244, 143, 46, 0.15);
- color: #f5a75d;
-}
-
-.theme-dark .storage-table-heading > span,
-.theme-dark .storage-cluster-list span {
- background: #1e293a;
- color: #b4c0d1;
-}
-
-.theme-dark .storage-provider-chip {
- background: rgba(54, 201, 143, 0.14);
- color: #62dbaf;
-}
-
-.theme-dark .storage-class-page .toolbar-filter select {
- background-position: right 8px center !important;
- background-repeat: no-repeat !important;
- background-size: 14px 14px !important;
-}
-
-.theme-dark .storage-class-table-panel .inline-code {
- color: #c5d1e2;
-}
-
-@media (max-width: 900px) {
- .storage-overview-grid {
- grid-template-columns: 1fr;
- }
-
- .storage-files-summary {
- grid-template-columns: 1fr;
- }
-
- .storage-detail-layout {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 620px) {
- .storage-detail-hero {
- align-items: flex-start;
- padding: 20px;
- }
-
- .storage-detail-info-grid {
- grid-template-columns: 1fr;
- }
-}
-
-/* Storage class creation dialog */
-.storage-create-backdrop {
- padding: 24px;
- align-items: center;
- justify-content: center;
-}
-
-.storage-create-modal {
- width: min(920px, calc(100vw - 48px));
- max-height: min(860px, calc(100vh - 48px));
- display: flex;
- flex-direction: column;
- overflow: hidden;
- border-radius: 20px;
-}
-
-.storage-create-head {
- padding: 22px 26px;
- flex: none;
-}
-
-.storage-create-head > div:first-child {
- min-width: 0;
-}
-
-.storage-create-head h2 {
- margin: 6px 0 4px;
- font-size: 22px;
-}
-
-.storage-create-head p {
- margin: 0;
- color: var(--muted);
- font-size: 12px;
-}
-
-.storage-create-close {
- flex: none;
- font-size: 0;
- width: 38px;
- height: 38px;
- border-radius: 999px;
-}
-
-.storage-create-form {
- min-height: 0;
- overflow-y: auto;
- padding: 22px 26px 0;
- display: grid;
- gap: 16px;
- background: var(--canvas);
-}
-
-.storage-create-form .form-section {
- margin: 0;
- padding: 18px;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.storage-create-form .form-section > strong {
- display: block;
- margin-bottom: 14px;
- color: var(--ink);
- font-size: 13px;
-}
-
-.storage-create-form .form-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 16px;
-}
-
-.storage-create-form label {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 7px;
- color: var(--ink);
- font-size: 11px;
- font-weight: 700;
-}
-
-.storage-create-form input:not([type="checkbox"]),
-.storage-create-form select,
-.storage-create-form textarea {
- width: 100%;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- background: var(--panel);
- color: var(--ink);
- outline: none;
- transition:
- border-color 0.15s,
- box-shadow 0.15s;
-}
-
-.storage-create-form input:not([type="checkbox"]),
-.storage-create-form select {
- height: 42px;
- padding: 0 12px;
-}
-
-.storage-create-form textarea {
- min-height: 92px;
- padding: 11px 12px;
- resize: vertical;
- line-height: 1.55;
-}
-
-.storage-cluster-picker {
- position: relative;
- display: grid;
- gap: 0;
- width: 100%;
-}
-
-.storage-cluster-select {
- width: 100%;
- min-height: 42px;
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 10px;
- padding: 7px 12px;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- background: var(--panel);
- color: var(--ink);
- text-align: left;
- cursor: pointer;
- transition:
- border-color 0.15s,
- box-shadow 0.15s;
-}
-
-.storage-cluster-select.open,
-.storage-cluster-select:focus-visible {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
- outline: 0;
-}
-
-.storage-cluster-select span {
- min-width: 0;
- color: var(--muted);
- font-size: 12px;
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-cluster-select strong {
- min-width: 0;
- margin-top: 2px;
- display: block;
- color: var(--ink);
- font-size: 11px;
- font-weight: 800;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-cluster-select svg {
- color: var(--muted);
- transition: transform 0.15s ease;
-}
-
-.storage-cluster-select.open svg {
- transform: rotate(90deg);
-}
-
-.storage-cluster-dropdown {
- position: absolute;
- z-index: 20;
- top: calc(100% + 6px);
- left: 0;
- right: 0;
- display: grid;
- gap: 8px;
- border: 1px solid var(--line-strong);
- border-radius: 12px;
- background: var(--panel);
- box-shadow: 0 18px 50px rgba(13, 22, 38, 0.16);
- padding: 10px;
-}
-
-.storage-cluster-picker-search {
- position: relative;
- display: grid;
- grid-template-columns: 28px minmax(0, 1fr) auto;
- align-items: center;
- height: 38px;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- background: var(--panel);
-}
-
-.storage-cluster-picker-search svg {
- justify-self: center;
- color: var(--muted);
-}
-
-.storage-create-form
- .storage-cluster-picker-search
- input:not([type="checkbox"]) {
- height: 36px;
- border: 0;
- border-radius: 0;
- background: transparent;
- padding: 0 8px 0 0;
- box-shadow: none;
-}
-
-.storage-cluster-picker-search small {
- padding: 0 10px;
- color: var(--muted);
- font-size: 10px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.storage-cluster-picker-options {
- display: grid;
- gap: 2px;
- max-height: 188px;
- overflow-y: auto;
- padding-right: 4px;
- scrollbar-width: thin;
-}
-
-.storage-cluster-select-all,
-.storage-cluster-picker-options label {
- min-width: 0;
- display: grid;
- align-items: center;
- gap: 8px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- padding: 8px;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
-}
-
-.storage-cluster-select-all {
- grid-template-columns: 18px minmax(0, 1fr) auto;
- width: 100%;
- border-top: 1px solid var(--line);
- border-bottom: 1px solid var(--line);
- border-radius: 0;
-}
-
-.storage-cluster-picker-options label {
- grid-template-columns: 18px minmax(0, 1fr);
-}
-
-.storage-cluster-select-all:hover,
-.storage-cluster-picker-options label:hover,
-.storage-cluster-picker-options label.active {
- background: #f4f7fb;
-}
-
-.storage-cluster-select-all input,
-.storage-cluster-picker-options input {
- width: 15px;
- height: 15px;
- margin: 0;
- accent-color: var(--blue);
-}
-
-.storage-cluster-picker-options label > span {
- min-width: 0;
- display: grid;
- gap: 2px;
-}
-
-.storage-cluster-picker-options strong {
- min-width: 0;
- overflow: hidden;
- color: inherit;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-cluster-picker-options small {
- min-width: 0;
- overflow: hidden;
- color: var(--muted);
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.storage-cluster-picker-options p {
- margin: 0;
- padding: 10px;
- color: var(--muted);
- font-size: 12px;
- text-align: center;
-}
-
-.storage-cluster-select-all em {
- color: var(--muted);
- font-size: 10px;
- font-style: normal;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.storage-create-form input:focus,
-.storage-create-form select:focus,
-.storage-create-form textarea:focus {
- border-color: var(--blue);
- box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
-}
-
-.storage-create-form .form-grid > label:has(> input[type="checkbox"]) {
- min-height: 42px;
- margin-top: 18px;
- padding: 0 12px;
- flex-direction: row;
- align-items: center;
- gap: 10px;
- border: 1px solid var(--line-strong);
- border-radius: 10px;
- background: var(--canvas);
-}
-
-.storage-create-form input[type="checkbox"] {
- width: 16px;
- height: 16px;
- margin: 0;
- accent-color: var(--blue);
-}
-
-.storage-create-form .error-text {
- margin: 0;
- padding: 10px 12px;
- border: 1px solid rgba(239, 90, 122, 0.25);
- border-radius: 10px;
- background: rgba(239, 90, 122, 0.08);
- color: var(--red);
- font-size: 12px;
-}
-
-.storage-create-form .form-actions {
- position: sticky;
- bottom: 0;
- z-index: 2;
- margin: 0 -26px;
- padding: 16px 26px;
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- border-top: 1px solid var(--line);
- background: color-mix(in srgb, var(--panel) 94%, transparent);
- backdrop-filter: blur(12px);
-}
-
-.theme-dark .storage-create-form {
- background: #0f1623;
-}
-
-.theme-dark .storage-create-form .form-section,
-.theme-dark .storage-create-form input:not([type="checkbox"]),
-.theme-dark .storage-create-form select,
-.theme-dark .storage-create-form textarea {
- background: #151d2b;
- border-color: #33425b;
- color: #e6edf7;
-}
-
-.theme-dark
- .storage-create-form
- .form-grid
- > label:has(> input[type="checkbox"]) {
- background: #111a29;
- border-color: #33425b;
-}
-
-.theme-dark .storage-cluster-select,
-.theme-dark .storage-cluster-dropdown,
-.theme-dark .storage-cluster-picker-search,
-.theme-dark .storage-cluster-select-all,
-.theme-dark .storage-cluster-picker-options label {
- background: #111a29;
- border-color: #33425b;
- color: #e6edf7;
-}
-
-.theme-dark
- .storage-create-form
- .storage-cluster-picker-search
- input:not([type="checkbox"]) {
- background: transparent;
- border-color: transparent;
- color: #e6edf7;
-}
-
-.theme-dark .storage-cluster-select-all:hover,
-.theme-dark .storage-cluster-picker-options label:hover,
-.theme-dark .storage-cluster-picker-options label.active {
- background: rgba(139, 92, 246, 0.16);
-}
-
-@media (max-width: 760px) {
- .storage-create-backdrop {
- padding: 12px;
- }
-
- .storage-create-modal {
- width: calc(100vw - 24px);
- max-height: calc(100vh - 24px);
- }
-
- .storage-create-head,
- .storage-create-form {
- padding-left: 16px;
- padding-right: 16px;
- }
-
- .storage-create-form .form-grid {
- grid-template-columns: 1fr;
- }
-
- .storage-cluster-dropdown {
- position: static;
- margin-top: 6px;
- }
-
- .storage-create-form .form-actions {
- margin-left: -16px;
- margin-right: -16px;
- padding-left: 16px;
- padding-right: 16px;
- }
-}
-
-.node-detail-panel {
- padding: 20px;
- display: flex;
- flex-direction: column;
- height: 100%;
-}
-
-.node-resource-detail {
- position: sticky;
- top: 88px;
- scroll-margin-top: 88px;
-}
-
-.node-detail-header {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 20px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.node-detail-header .node-status-ring {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--skyblue-bg);
- color: var(--blue);
- flex-shrink: 0;
-}
-
-.node-detail-header h3 {
- margin: 0;
- font-size: 18px;
-}
-
-.node-detail-header small {
- display: block;
- margin-top: 4px;
- font-size: 12px;
- color: #8e97a6;
-}
-
-.node-detail-header .icon-button {
- margin-left: auto;
-}
-
-.node-detail-body {
- display: flex;
- flex-direction: column;
- gap: 20px;
- flex: 1;
- min-height: 0;
-}
-
-.node-detail-section {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.node-detail-scroll {
- max-height: 240px;
- overflow-y: auto;
-}
-
-.node-detail-label {
- font-size: 12px;
- color: #8e97a6;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.node-detail-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 12px;
-}
-
-.node-detail-grid > div {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.node-detail-grid strong {
- font-size: 14px;
-}
-
-.node-resource-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-
-.node-resource-table th {
- text-align: left;
- padding: 3px 10px;
- height: auto;
- font-size: 11px;
- color: #8e97a6;
- border-bottom: 1px solid var(--line);
- position: sticky;
- top: 0;
- background: var(--panel);
- z-index: 1;
-}
-
-.node-resource-table td {
- padding: 2px 10px;
- height: auto;
- line-height: 1.4;
- border-bottom: 1px solid var(--line);
-}
-
-.theme-dark .node-row:hover,
-.theme-dark .node-row.selected {
- background: rgba(54, 124, 232, 0.12);
-}
-
-.theme-dark .node-detail-grid strong {
- color: var(--text);
-}
-
-/* ── Admin Dashboard ── */
-.admin-dashboard-page {
- display: grid;
- align-content: start;
- gap: 18px;
-}
-
-.admin-dashboard-hero {
- position: relative;
- min-height: 164px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 28px;
- padding: 26px 28px;
- overflow: hidden;
- border: 1px solid rgba(124, 58, 237, 0.12);
- border-radius: 22px;
- background:
- radial-gradient(
- circle at 78% -25%,
- rgba(124, 58, 237, 0.2),
- transparent 40%
- ),
- linear-gradient(135deg, #fff 28%, #f5f1ff 100%);
- box-shadow: var(--shadow-soft);
-}
-
-.admin-dashboard-hero::after {
- content: "";
- position: absolute;
- width: 260px;
- height: 260px;
- right: 8%;
- top: -175px;
- border: 42px solid rgba(124, 58, 237, 0.045);
- border-radius: 50%;
- pointer-events: none;
-}
-
-.admin-dashboard-hero > * {
- position: relative;
- z-index: 1;
-}
-
-.admin-dashboard-hero h2 {
- margin: 8px 0 7px;
- color: var(--ink);
- font-size: 30px;
-}
-
-.admin-dashboard-hero p {
- max-width: 620px;
- margin: 0;
- color: var(--muted);
- font-size: 13px;
-}
-
-.admin-dashboard-sync {
- min-width: 184px;
- display: grid;
- justify-items: end;
- gap: 8px;
-}
-
-.admin-dashboard-sync > span {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- border-radius: 999px;
- font-size: 11px;
- font-weight: 800;
-}
-
-.admin-dashboard-sync > span.healthy {
- color: #13845b;
- background: #e5f8f0;
-}
-
-.admin-dashboard-sync > span.warning {
- color: #b66b18;
- background: #fff1dc;
-}
-
-.admin-dashboard-sync > small {
- color: var(--muted);
- font-size: 10px;
-}
-
-.admin-dashboard-sync .spin {
- animation: status-spin 1s linear infinite;
-}
-
-.admin-dashboard-metrics {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
-}
-
-.admin-dashboard-metric {
- min-width: 0;
- min-height: 116px;
- display: grid;
- grid-template-columns: 42px minmax(0, 1fr) auto;
- align-items: center;
- gap: 12px;
- padding: 17px;
- border: 1px solid var(--line);
- border-radius: 17px;
- background: var(--panel);
- color: var(--ink);
- text-align: left;
- box-shadow: var(--shadow-soft);
- cursor: pointer;
- transition:
- transform 0.18s ease,
- border-color 0.18s ease,
- box-shadow 0.18s ease;
-}
-
-.admin-dashboard-metric:hover {
- transform: translateY(-2px);
- border-color: rgba(124, 58, 237, 0.28);
- box-shadow: 0 12px 30px rgba(36, 26, 70, 0.09);
-}
-
-.admin-dashboard-metric > span,
-.admin-action-grid button > span {
- width: 42px;
- height: 42px;
- display: grid;
- place-items: center;
- border-radius: 12px;
- background: #eee9ff;
- color: #7440dc;
-}
-
-.admin-dashboard-metric.mint > span {
- background: #e1f7ef;
- color: #16936a;
-}
-
-.admin-dashboard-metric.blue > span {
- background: #e7f1ff;
- color: #3478d4;
-}
-
-.admin-dashboard-metric.orange > span {
- background: #fff0df;
- color: #d47b20;
-}
-
-.admin-dashboard-metric > div {
- min-width: 0;
- display: grid;
- gap: 3px;
-}
-
-.admin-dashboard-metric small,
-.admin-dashboard-metric strong,
-.admin-dashboard-metric em {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-dashboard-metric small {
- color: var(--muted);
- font-size: 10px;
- font-weight: 700;
-}
-
-.admin-dashboard-metric strong {
- font-size: 24px;
-}
-
-.admin-dashboard-metric em {
- color: var(--soft);
- font-size: 9px;
- font-style: normal;
-}
-
-.admin-dashboard-metric > svg,
-.admin-action-grid button > svg,
-.admin-attention-list button > svg,
-.admin-recent-list button > svg {
- color: var(--soft);
-}
-
-.admin-dashboard-main-grid {
- display: grid;
- grid-template-columns: minmax(0, 1.55fr) minmax(310px, 0.75fr);
- gap: 16px;
-}
-
-.admin-dashboard-panel {
- min-width: 0;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: var(--panel);
- box-shadow: var(--shadow-soft);
-}
-
-.admin-dashboard-panel-head {
- min-height: 65px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 14px 17px;
- border-bottom: 1px solid var(--line);
-}
-
-.admin-dashboard-panel-head span,
-.admin-dashboard-panel-head small {
- display: block;
-}
-
-.admin-dashboard-panel-head span {
- color: var(--ink);
- font-size: 13px;
- font-weight: 850;
-}
-
-.admin-dashboard-panel-head small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 10px;
-}
-
-.admin-dashboard-panel-head > b {
- min-width: 26px;
- height: 26px;
- display: grid;
- place-items: center;
- border-radius: 999px;
- background: #fff0df;
- color: #be6c19;
- font-size: 11px;
-}
-
-.admin-action-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
- padding: 14px;
-}
-
-.admin-action-grid button {
- min-width: 0;
- min-height: 82px;
- display: grid;
- grid-template-columns: 38px minmax(0, 1fr) auto;
- align-items: center;
- gap: 9px;
- padding: 12px;
- border: 1px solid var(--line);
- border-radius: 13px;
- background: var(--canvas);
- color: var(--ink);
- text-align: left;
- cursor: pointer;
- transition:
- border-color 0.16s ease,
- background 0.16s ease;
-}
-
-.admin-action-grid button:hover {
- border-color: rgba(124, 58, 237, 0.28);
- background: #faf8ff;
-}
-
-.admin-action-grid button > span {
- width: 38px;
- height: 38px;
- border-radius: 10px;
-}
-
-.admin-action-grid button div {
- min-width: 0;
-}
-
-.admin-action-grid button strong,
-.admin-action-grid button small {
- display: block;
-}
-
-.admin-action-grid button strong {
- font-size: 11px;
-}
-
-.admin-action-grid button small {
- margin-top: 4px;
- overflow: hidden;
- color: var(--muted);
- font-size: 9px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-attention-list {
- max-height: 198px;
- overflow-y: auto;
- padding: 7px 12px 12px;
-}
-
-.admin-attention-list button {
- width: 100%;
- display: grid;
- grid-template-columns: 32px minmax(0, 1fr) auto;
- align-items: center;
- gap: 9px;
- padding: 9px 4px;
- border: 0;
- border-bottom: 1px solid var(--line);
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
-}
-
-.admin-attention-list button > span {
- width: 30px;
- height: 30px;
- display: grid;
- place-items: center;
- border-radius: 9px;
-}
-
-.admin-attention-list button > span.danger {
- color: #d44d68;
- background: #ffeaef;
-}
-
-.admin-attention-list button > span.pending {
- color: #c47620;
- background: #fff0dc;
-}
-
-.admin-attention-list strong,
-.admin-attention-list small {
- display: block;
-}
-
-.admin-attention-list strong {
- font-size: 10px;
-}
-
-.admin-attention-list small {
- margin-top: 3px;
- color: var(--muted);
- font-size: 9px;
-}
-
-.admin-attention-empty {
- min-height: 128px;
- display: grid;
- place-items: center;
- align-content: center;
- gap: 6px;
- color: #199269;
- text-align: center;
-}
-
-.admin-attention-empty strong {
- color: var(--ink);
- font-size: 11px;
-}
-
-.admin-attention-empty small {
- color: var(--muted);
- font-size: 9px;
-}
-
-.admin-recent-list {
- display: grid;
- padding: 4px 14px 12px;
-}
-
-.admin-recent-list button {
- min-width: 0;
- min-height: 45px;
- display: grid;
- grid-template-columns: 54px minmax(0, 1fr) 82px 150px auto;
- align-items: center;
- gap: 12px;
- padding: 7px 3px;
- border: 0;
- border-bottom: 1px solid var(--line);
- background: transparent;
- color: var(--ink);
- text-align: left;
- cursor: pointer;
-}
-
-.admin-recent-list button:last-child {
- border-bottom: 0;
-}
-
-.admin-recent-type {
- width: max-content;
- padding: 4px 7px;
- border-radius: 7px;
- background: #eee9ff;
- color: #7041ce;
- font-size: 9px;
- font-weight: 800;
-}
-
-.admin-recent-list strong {
- overflow: hidden;
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-recent-list small,
-.admin-recent-list time {
- color: var(--muted);
- font-size: 9px;
-}
-
-.admin-recent-empty {
- padding: 24px;
- color: var(--muted);
- font-size: 11px;
- text-align: center;
-}
-
-.theme-dark .admin-dashboard-hero {
- border-color: #2a3448;
- background:
- radial-gradient(
- circle at 78% -25%,
- rgba(124, 58, 237, 0.22),
- transparent 40%
- ),
- linear-gradient(135deg, #151d2b 28%, #1b1830 100%);
-}
-
-.theme-dark .admin-dashboard-metric,
-.theme-dark .admin-dashboard-panel {
- background: #151d2b;
- border-color: var(--line);
-}
-
-.theme-dark .admin-action-grid button {
- background: #111a29;
-}
-
-.theme-dark .admin-action-grid button:hover {
- background: #1a2030;
-}
-
-@media (max-width: 1180px) {
- .admin-dashboard-metrics {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .admin-dashboard-main-grid {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 760px) {
- .admin-dashboard-hero {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .admin-dashboard-sync {
- justify-items: start;
- }
-
- .admin-action-grid {
- grid-template-columns: 1fr;
- }
-
- .admin-recent-list button {
- grid-template-columns: 52px minmax(0, 1fr) auto;
- }
-
- .admin-recent-list button small,
- .admin-recent-list button time {
- display: none;
- }
-}
-
-@media (max-width: 520px) {
- .admin-dashboard-metrics {
- grid-template-columns: 1fr;
- }
-}
-
-/* ── Admin Login ── */
-.admin-login-page {
- min-height: 100vh;
- position: relative;
- overflow: hidden;
- background:
- linear-gradient(rgba(255, 255, 255, 0.76), rgba(247, 249, 255, 0.88)),
- radial-gradient(circle at 18% 15%, #dbeafe 0, transparent 34%),
- radial-gradient(circle at 82% 82%, #ede9fe 0, transparent 32%), #f5f7fb;
- display: flex;
- flex-direction: column;
-}
-
-.admin-login-page::before {
- content: "";
- position: absolute;
- inset: 0;
- opacity: 0.32;
- background-image:
- linear-gradient(rgba(90, 105, 160, 0.08) 1px, transparent 1px),
- linear-gradient(90deg, rgba(90, 105, 160, 0.08) 1px, transparent 1px);
- background-size: 48px 48px;
- mask-image: linear-gradient(to bottom, black, transparent 78%);
- pointer-events: none;
-}
-
-.user-login-orb {
- position: absolute;
- border-radius: 999px;
- filter: blur(2px);
- pointer-events: none;
-}
-
-.user-login-orb-one {
- width: 360px;
- height: 360px;
- top: -180px;
- right: -100px;
- background: rgba(99, 102, 241, 0.13);
-}
-
-.user-login-orb-two {
- width: 280px;
- height: 280px;
- bottom: -150px;
- left: -80px;
- background: rgba(59, 130, 246, 0.12);
-}
-
-.login-inline-error {
- width: 100%;
- min-height: 20px;
- display: flex;
- align-items: center;
- gap: 6px;
- margin: -8px 0 2px;
- color: #dc2626;
- font-size: 11px;
- line-height: 1.4;
-}
-
-.admin-login-body {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 40px 20px;
- position: relative;
- z-index: 1;
-}
-
-.admin-login-panel {
- position: relative;
- width: 420px;
- max-width: 100%;
-}
-
-.admin-login-card {
- width: 100%;
- max-width: 100%;
- background: var(--panel);
- border: 1px solid rgba(255, 255, 255, 0.9);
- border-radius: 24px;
- padding: 38px 38px 30px;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- box-shadow:
- 0 28px 70px rgba(45, 55, 100, 0.14),
- 0 4px 14px rgba(45, 55, 100, 0.06);
- backdrop-filter: blur(18px);
-}
-
-.admin-login-card h2 {
- margin: 5px 0 4px;
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--ink);
-}
-
-.admin-login-card .muted {
- font-size: 13px;
- color: var(--muted);
- margin: 0;
- text-align: center;
-}
-
-.user-login-brand-logo {
- width: 190px;
- max-width: 100%;
- height: auto;
- object-fit: contain;
-}
-
-.user-login-brand {
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- padding-bottom: 17px;
- margin-bottom: 10px;
- border-bottom: 1px solid var(--line);
-}
-
-.user-login-heading {
- width: 100%;
- margin-bottom: 18px;
- text-align: center;
-}
-
-.user-login-heading .muted {
- text-align: center;
-}
-
-.admin-login-card-brand {
- position: relative;
-}
-
-.admin-login-card-brand .user-login-brand-logo {
- width: 176px;
-}
-
-.admin-login-badge {
- position: absolute;
- right: 0;
- bottom: 16px;
- padding: 4px 7px;
- border: 1px solid rgba(99, 102, 241, 0.2);
- border-radius: 6px;
- color: #5b55d6;
- background: rgba(99, 102, 241, 0.08);
- font-size: 9px;
- font-weight: 800;
-}
-
-.admin-login-field {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-bottom: 16px;
-}
-
-.admin-login-field label {
- font-size: 12px;
- font-weight: 650;
- color: var(--soft);
-}
-
-.admin-login-field input {
- width: 100%;
- height: 46px;
- padding: 0 15px;
- border: 1px solid var(--line);
- border-radius: 10px;
- font-size: 14px;
- color: var(--ink);
- background: #f8f9fd;
- outline: none;
- transition:
- border-color 0.15s,
- box-shadow 0.15s,
- background 0.15s;
- box-sizing: border-box;
-}
-
-.admin-login-field input:focus {
- border-color: var(--blue);
- background: #fff;
- box-shadow: 0 0 0 3px rgba(109, 93, 252, 0.11);
-}
-
-.admin-login-field input::placeholder {
- color: var(--muted);
- opacity: 0.48;
- font-weight: 400;
-}
-
-.admin-login-password {
- position: relative;
- width: 100%;
-}
-
-.admin-login-password input {
- padding-right: 44px;
-}
-
-.admin-login-password-toggle {
- position: absolute;
- top: 50%;
- right: 8px;
- width: 32px;
- height: 32px;
- padding: 0;
- transform: translateY(-50%);
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: var(--muted);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
-}
-
-.admin-login-password-toggle:hover,
-.admin-login-password-toggle:focus-visible {
- color: var(--blue);
- background: var(--hover);
- outline: none;
-}
-
-.admin-login-btn {
- width: 100%;
- height: 48px;
- margin-top: 10px;
- font-size: 14px;
- font-weight: 600;
- justify-content: center;
- gap: 9px;
- border: 0;
- border-radius: 11px;
- background: linear-gradient(135deg, #635bff, #7c3aed);
- box-shadow: 0 12px 24px rgba(109, 72, 240, 0.24);
- transition:
- transform 0.15s,
- box-shadow 0.15s;
-}
-
-.admin-login-btn:not(:disabled):hover {
- transform: translateY(-1px);
- box-shadow: 0 15px 28px rgba(109, 72, 240, 0.3);
-}
-
-.admin-login-back {
- margin-top: 17px;
- font-size: 12px;
- color: var(--muted);
- text-decoration: none;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- transition: color 0.15s;
-}
-
-.admin-login-back:hover {
- color: var(--blue);
-}
-
-.admin-login-back svg {
- transform: rotate(180deg);
-}
-
-.theme-dark .admin-login-card {
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
-}
-
-.theme-dark .login-inline-error {
- color: #fca5a5;
-}
-
-.theme-dark .admin-login-badge {
- border-color: rgba(196, 181, 253, 0.24);
- color: #c4b5fd;
- background: rgba(167, 139, 250, 0.12);
-}
-
-.theme-dark .admin-login-field input {
- background: var(--canvas);
-}
-
-@media (max-width: 520px) {
- .admin-login-card {
- padding: 30px 24px 26px;
- border-radius: 20px;
- }
-}
-
-/* ── DAG Editor ── */
-.dag-toolbar {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
-}
-
-.dag-canvas {
- position: relative;
- width: 100%;
- min-height: 420px;
- max-height: 500px;
- overflow: auto;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--canvas);
- user-select: none;
-}
-
-.dag-canvas-content {
- position: relative;
- width: 900px;
- height: 500px;
-}
-
-.dag-svg {
- position: absolute;
- top: 0;
- left: 0;
- pointer-events: none;
- z-index: 10;
-}
-
-.dag-edge {
- pointer-events: stroke;
- cursor: pointer;
-}
-
-.dag-edge:hover {
- stroke: #ef4444 !important;
- stroke-width: 3;
-}
-
-.dag-temp-line {
- pointer-events: none;
-}
-
-.dag-node {
- position: absolute;
- width: 200px;
- height: 56px;
- display: flex;
- align-items: stretch;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--panel);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
- cursor: grab;
-}
-
-.dag-node:active {
- cursor: grabbing;
-}
-
-.dag-node.selected {
- border-color: var(--blue);
- box-shadow:
- 0 0 0 2px var(--blue),
- 0 2px 12px rgba(124, 58, 237, 0.2);
-}
-
-.dag-node {
- z-index: 1;
-}
-
-.dag-node-body {
- flex: 1;
- min-width: 0;
- padding: 6px 4px 6px 8px;
- display: flex;
- align-items: center;
- gap: 6px;
- overflow: hidden;
-}
-
-.dag-node-body strong {
- font-size: 13px;
- font-weight: 600;
- color: var(--ink);
- line-height: 1.3;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1;
- cursor: text;
-}
-
-.dag-node-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--ink);
- background: transparent;
- border: 1px solid transparent;
- border-radius: 4px;
- padding: 1px 4px;
- outline: none;
- width: 100%;
-}
-
-.dag-node-name:focus {
- border-color: var(--blue);
- background: var(--canvas);
-}
-
-.dag-node-type {
- font-size: 9px;
- color: var(--muted);
- background: var(--hover);
- padding: 2px 5px;
- border-radius: 4px;
- flex-shrink: 0;
- white-space: nowrap;
- line-height: 1.3;
-}
-
-.dag-node-delete {
- position: absolute;
- top: -6px;
- right: -6px;
- width: 16px;
- height: 16px;
- border: 1px solid var(--line);
- border-radius: 50%;
- background: var(--panel);
- color: var(--soft);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 2;
- padding: 0;
-}
-
-.dag-node-delete:hover {
- background: #ef4444;
- color: #fff;
- border-color: #ef4444;
-}
-
-.dag-node-port {
- width: 12px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
-}
-
-.dag-node-port::after {
- content: "";
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--panel);
- border: 2px solid var(--blue);
- transition: transform 0.15s;
-}
-
-.dag-node-port.output {
- cursor: crosshair;
-}
-
-.dag-node-port.output::after {
- background: var(--blue);
-}
-
-.dag-node-port:hover::after {
- transform: scale(1.3);
-}
-
-.dag-hint {
- margin-top: 8px;
- font-size: 11px;
- color: var(--muted);
- line-height: 1.5;
-}
-
-.theme-dark .dag-canvas {
- background: var(--canvas);
-}
-
-.theme-dark .dag-node {
- background: var(--panel);
-}
-
-/* Final responsive safeguards for resource views. These rules intentionally
- live after the resource components so later component styles cannot undo
- the compact layout. */
-@media (max-width: 1250px) {
- .node-admin-layout {
- display: flex;
- flex-direction: column;
- }
-
- .node-resource-detail {
- position: static;
- width: 100%;
- }
-}
-
-@media (max-width: 780px) {
- .job-worker-overview,
- .job-detail-summary-head,
- .job-detail-summary-grid,
- .job-detail-summary-columns,
- .worker-detail-grid {
- grid-template-columns: 1fr;
- }
-
- .job-detail-summary-card {
- padding: 16px;
- }
-
- .job-detail-summary-status {
- justify-items: start;
- }
-
- .role-worker-group-head,
- .config-section-head {
- flex-direction: column;
- }
-
- .role-worker-resource-summary,
- .config-section-head small {
- min-width: 0;
- max-width: none;
- text-align: left;
- }
-
- .worker-access-grid,
- .role-config-grid {
- grid-template-columns: 1fr;
- }
-
- .worker-access-meta {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .worker-access-meta span:last-child {
- grid-column: 1 / -1;
- }
-
- .job-detail-summary-grid,
- .job-detail-summary-columns,
- .time-series-grid {
- grid-template-columns: 1fr;
- }
-
- .metrics-source-label {
- width: 100%;
- margin-left: 0;
- }
-
- .log-list-row {
- grid-template-columns: 1fr;
- gap: 4px;
- }
-
- .log-list-head {
- display: none;
- }
-
- .worker-role-strip,
- .worker-metrics-grid {
- grid-template-columns: 1fr;
- }
-
- .worker-panel-head,
- .observe-panel-head {
- flex-direction: column;
- }
-
- .role-runtime-tabs {
- width: 100%;
- justify-content: flex-start;
- }
-
- .role-runtime-summary {
- display: flex;
- align-items: stretch;
- flex-wrap: wrap;
- }
-
- .role-runtime-image {
- flex-basis: 100%;
- }
-
- .role-runtime-details {
- grid-template-columns: 1fr 1fr;
- }
-
- .worker-panel-head small {
- max-width: none;
- text-align: left;
- }
-
- .worker-primary-panel .worker-table {
- overflow-x: auto;
- }
-
- .worker-primary-panel .worker-table table {
- min-width: 820px;
- }
-
- .node-category-grid {
- grid-template-columns: 1fr !important;
- width: 100%;
- }
-
- .node-category-column {
- max-height: none;
- overflow: visible;
- }
-
- .node-detail-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .node-detail-panel {
- padding: 16px;
- }
-}
-
-@media (max-width: 520px) {
- .node-detail-grid {
- grid-template-columns: 1fr;
- }
-}
-
-/* --- Addon Page --- */
-.addon-catalog-grid {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.addon-card {
- padding: 14px 16px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fbfcff;
- cursor: pointer;
- transition:
- border-color 0.15s,
- box-shadow 0.15s;
-}
-
-.addon-card:hover {
- border-color: var(--blue);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
-}
-
-.addon-card.selected {
- border-color: var(--blue);
- background: #f0ecff;
-}
-
-.addon-card-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 6px;
-}
-
-.addon-card-header strong {
- font-size: 14px;
- color: var(--text);
-}
-
-.addon-card-version {
- font-size: 11px;
- color: #7f8897;
- background: #eef1f6;
- padding: 2px 7px;
- border-radius: 999px;
- margin-left: 6px;
-}
-
-.addon-card-desc {
- font-size: 12px;
- color: #7f8897;
- line-height: 1.5;
- margin: 0 0 8px;
-}
-
-.addon-card-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.addon-card-category {
- font-size: 11px;
- color: #6366f1;
- background: #eef0ff;
- padding: 2px 8px;
- border-radius: 999px;
-}
-
-.addon-detail-panel {
- padding: 18px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: #fbfcff;
- min-height: 200px;
-}
-
-.addon-installed-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-
-.addon-installed-table th {
- text-align: left;
- padding: 8px 12px;
- border-bottom: 2px solid var(--line);
- font-weight: 600;
- color: #7f8897;
- font-size: 12px;
-}
-
-.addon-installed-table td {
- padding: 9px 12px;
- border-bottom: 1px solid #f0f1f4;
- color: var(--text);
-}
-
-.addon-textarea {
- width: 100%;
- min-height: 120px;
- padding: 8px 10px;
- border: 1px solid var(--line);
- border-radius: 6px;
- font-size: 13px;
- font-family:
- "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas, monospace;
- resize: vertical;
- line-height: 1.5;
- background: #fff;
- color: var(--text);
- box-sizing: border-box;
-}
-
-.addon-textarea::placeholder {
- color: #9ca3af;
- font-size: 12px;
-}
-
-.addon-textarea:focus {
- outline: none;
- border-color: #6366f1;
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
-}
-
-/* Addon drawer */
-.addon-drawer-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.35);
- z-index: 1000;
-}
-
-.addon-drawer {
- position: fixed;
- top: 0;
- right: 0;
- width: 460px;
- max-width: 90vw;
- height: 100vh;
- background: #fff;
- z-index: 1001;
- display: flex;
- flex-direction: column;
- box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
-}
-
-.addon-drawer-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 20px;
- border-bottom: 1px solid var(--line);
- flex-shrink: 0;
-}
-
-.addon-drawer-header h3 {
- margin: 0;
- font-size: 16px;
- font-weight: 600;
- color: var(--text);
-}
-
-.addon-drawer-close {
- background: none;
- border: none;
- font-size: 20px;
- color: #9ca3af;
- cursor: pointer;
- padding: 4px 8px;
- border-radius: 4px;
- line-height: 1;
-}
-
-.addon-drawer-close:hover {
- background: #f0f1f4;
- color: var(--text);
-}
-
-.theme-dark .addon-card,
-.theme-dark .addon-detail-panel {
- border-color: var(--line);
- background: var(--panel);
-}
-
-.theme-dark .addon-card:hover {
- border-color: #7c8cff;
- background: #172238;
- box-shadow: none;
-}
-
-.theme-dark .addon-card.selected {
- border-color: #7c8cff;
- background: rgba(99, 102, 241, 0.12);
-}
-
-.theme-dark .addon-card-header strong,
-.theme-dark .addon-installed-table td {
- color: var(--ink);
-}
-
-.theme-dark .addon-card-version {
- color: #aab5c7;
- background: #202c40;
-}
-
-.theme-dark .addon-card-desc {
- color: #9ca8ba;
-}
-
-.theme-dark .addon-card-category {
- color: #b9c2ff;
- background: rgba(99, 102, 241, 0.15);
-}
-
-.theme-dark .addon-installed-table th,
-.theme-dark .addon-installed-table td {
- border-color: var(--line);
-}
-
-.theme-dark .addon-textarea,
-.theme-dark .addon-drawer {
- border-color: var(--line);
- color: var(--ink);
- background: var(--panel);
-}
-
-.theme-dark .addon-drawer-close:hover {
- color: var(--ink);
- background: #202c40;
-}
-
-.addon-drawer-body {
- flex: 1;
- overflow-y: auto;
- padding: 20px;
-}
-
-.addon-drawer-footer {
- padding: 16px 20px;
- border-top: 1px solid var(--line);
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- flex-shrink: 0;
-}
-
-/* Late responsive overrides for resource tables defined after the base breakpoints. */
-@media (max-width: 1300px) {
- .cluster-management-table-head,
- .cluster-management-row {
- min-width: 760px;
- grid-template-columns:
- minmax(170px, 1.35fr)
- minmax(66px, 0.55fr)
- minmax(48px, 0.42fr)
- minmax(48px, 0.42fr)
- minmax(48px, 0.42fr)
- minmax(104px, 0.8fr)
- minmax(82px, 0.65fr)
- 16px;
- column-gap: 8px;
- }
-
- .cluster-management-empty {
- min-width: 760px;
- }
-
- .node-resource-table-head,
- .node-resource-row {
- min-width: 1120px;
- grid-template-columns:
- minmax(130px, 1.25fr)
- minmax(76px, 0.65fr)
- minmax(80px, 0.68fr)
- minmax(100px, 0.85fr)
- minmax(104px, 0.85fr)
- minmax(98px, 0.8fr)
- minmax(170px, 1.25fr)
- minmax(120px, 0.95fr)
- 16px;
- column-gap: 7px;
- }
-
- .node-resource-table-head.has-admin-actions,
- .node-resource-row.has-admin-actions {
- min-width: 1250px;
- grid-template-columns:
- minmax(130px, 1.25fr)
- minmax(76px, 0.65fr)
- minmax(80px, 0.68fr)
- minmax(100px, 0.85fr)
- minmax(104px, 0.85fr)
- minmax(98px, 0.8fr)
- minmax(170px, 1.25fr)
- minmax(120px, 0.95fr)
- minmax(170px, 1fr);
- }
-
- .storage-class-table-panel table,
- .storage-class-table-panel .storage-table-heading {
- min-width: 780px;
- }
-
- .storage-files-table-panel table,
- .storage-files-table-panel .storage-table-heading {
- min-width: 740px;
- }
-
- .jobs-table-panel table {
- min-width: 820px;
- }
-}
-
-/* Keep content usable on tablet-sized windows without requiring a manual
- sidebar toggle. This final rule wins over the broader 1050px breakpoint. */
-@media (max-width: 780px) {
- .app-shell,
- .app-shell.sidebar-collapsed {
- grid-template-columns: 72px minmax(0, 1fr);
- }
-
- .sidebar {
- padding: 14px 10px;
- }
-
- .sidebar .brand {
- height: 46px;
- padding: 0 5px;
- margin-bottom: 16px;
- }
-
- .sidebar .brand-logo {
- width: 44px;
- height: 44px;
- object-fit: cover;
- object-position: left center;
- }
-
- .sidebar nav button span,
- .sidebar nav button em,
- .sidebar .nav-label,
- .sidebar .environment-card div,
- .sidebar .environment-card i,
- .sidebar .sidebar-bottom > button span {
- display: none;
- }
-
- .sidebar nav button,
- .sidebar .sidebar-bottom > button {
- justify-content: center;
- padding-inline: 0;
- }
-
- .sidebar .nav-children {
- padding-left: 0;
- }
-
- .sidebar .environment-card {
- grid-template-columns: 1fr;
- min-height: 54px;
- padding: 8px;
- }
-
- .sidebar .environment-card > span {
- margin: auto;
- }
-}
-
-@media (min-width: 621px) and (max-width: 780px) {
- .job-detail-page {
- gap: 10px;
- }
-
- .job-detail-summary-card {
- gap: 12px;
- padding: 14px;
- }
-
- .job-detail-summary-head {
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 12px;
- padding-bottom: 10px;
- }
-
- .job-detail-summary-head h2 {
- margin: 5px 0;
- font-size: 22px;
- }
-
- .job-detail-summary-head p {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .job-detail-summary-status {
- justify-items: end;
- }
-
- .job-detail-summary-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
- }
-
- .task-summary-metric {
- padding: 8px 12px;
- }
-
- .task-summary-metric strong {
- margin: 4px 0 2px;
- font-size: 15px;
- }
-
- .job-detail-summary-columns {
- grid-template-columns: 1fr;
- gap: 10px;
- align-items: start;
- }
-
- .job-detail-summary-section {
- padding: 9px 12px;
- }
-
- .public-card-head h3 {
- font-size: 14px;
- }
-
- .header-worker-identity {
- margin-top: 10px;
- }
-
- .copyable-code-block {
- margin-top: 9px;
- }
-
- .copyable-code-block code {
- padding: 8px 10px;
- line-height: 1.45;
- }
-
- .config-value-list > div {
- display: grid;
- gap: 5px;
- margin-top: 7px;
- }
-
- .config-value-list code {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow-wrap: normal;
- }
-
- .sub-tabs {
- height: 42px;
- }
-
- .sub-tabs button {
- height: 34px;
- }
-}
-
-@media (max-width: 620px) {
- .job-detail-summary-head,
- .job-detail-summary-grid,
- .job-detail-summary-columns {
- grid-template-columns: 1fr;
- }
-
- .job-detail-summary-status {
- justify-items: start;
- }
-
- .public-runtime-card,
- .public-command-card {
- grid-column: auto;
- grid-row: auto;
- }
-
- .public-runtime-topology,
- .public-runtime-tables,
- .role-runtime-config-tables {
- grid-template-columns: 1fr;
- }
-
- .role-runtime-config {
- padding: 12px;
- }
-
- .role-runtime-meta {
- min-width: 0;
- flex-wrap: wrap;
- }
-
- .role-runtime-details {
- grid-template-columns: 1fr;
- }
-
- .worker-detail-head,
- .worker-ssh-access {
- align-items: stretch;
- flex-direction: column;
- }
-
- .worker-ssh-inline {
- width: 100%;
- max-width: 100%;
- flex-basis: auto;
- box-sizing: border-box;
- }
-
- .worker-detail-head .worker-ssh-button,
- .worker-ssh-access .secondary-button {
- width: fit-content;
- }
-}
-
-@media (max-width: 1080px) {
- .admin-node-page-heading {
- align-items: flex-start;
- flex-direction: column;
- gap: 14px;
- }
-
- .admin-node-page-heading .admin-node-list-actions {
- max-width: none;
- justify-content: flex-start;
- }
-
- .admin-node-overview {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .admin-node-overview > div:nth-child(2) {
- border-right: 0;
- }
-
- .admin-node-overview > div:nth-child(-n + 2) {
- border-bottom: 1px solid var(--line);
- }
-}
-
-@media (max-width: 640px) {
- .admin-node-page-heading {
- min-height: auto;
- padding: 2px 0 14px;
- }
-
- .admin-node-page-heading .admin-node-list-actions {
- width: 100%;
- }
-
- .admin-node-page-heading .admin-node-list-actions button {
- flex: 1 1 calc(50% - 8px);
- }
-
- .admin-node-overview > div {
- grid-template-columns: 30px minmax(0, 1fr) auto;
- padding: 11px;
- }
-
- .admin-node-overview-icon {
- width: 30px;
- height: 30px;
- }
-}
+/* Foundation: tokens, reset, application shell, and shared primitives. */
+@import "./styles/foundation/shell.css";
+
+/* Early page foundations. */
+@import "./styles/overview.css";
+@import "./styles/clusters/overview.css";
+@import "./styles/shared/content.css";
+
+/* Job detail layers. */
+@import "./styles/jobs/detail-foundation.css";
+@import "./styles/jobs/detail-runtime.css";
+@import "./styles/jobs/detail-console.css";
+
+/* Job creation and list layers. */
+@import "./styles/jobs/create.css";
+@import "./styles/jobs/code-editor-and-list.css";
+
+/* Cluster, table, and node foundations. */
+@import "./styles/clusters/node-legacy.css";
+@import "./styles/shared/table-actions-and-controls.css";
+@import "./styles/nodes/insight.css";
+@import "./styles/nodes/admin-insight.css";
+
+/* Administrative and interaction layers. */
+@import "./styles/admin/domain-and-responsive.css";
+@import "./styles/admin/settings-and-certificates.css";
+@import "./styles/jobs/actions-and-refresh.css";
+@import "./styles/nodes/files-and-selector.css";
+@import "./styles/nodes/resource-browser.css";
+
+/* Cluster and node management layers. */
+@import "./styles/clusters/management.css";
+@import "./styles/nodes/category-table.css";
+
+/* Storage layers. */
+@import "./styles/storage/index.css";
+@import "./styles/storage/create-dialog.css";
+
+/* Late page overrides. Keep these after storage to preserve the cascade. */
+@import "./styles/nodes/detail-panel-overrides.css";
+@import "./styles/admin/dashboard.css";
+@import "./styles/auth/admin-login.css";
+@import "./styles/components/dag-editor.css";
+
+/* Resource-level responsive overrides. */
+@import "./styles/overrides/resource-responsive.css";
+
+/* Addon page styles precede the final global responsive overrides. */
+@import "./styles/addons.css";
+@import "./styles/overrides/global-responsive.css";
+
+/* Final component and job editing layers. */
+@import "./styles/components/tag-and-filter.css";
+@import "./styles/jobs/tags-and-editing.css";
diff --git a/apps/rlark-ui/src/styles/addons.css b/apps/rlark-ui/src/styles/addons.css
new file mode 100644
index 0000000..664c236
--- /dev/null
+++ b/apps/rlark-ui/src/styles/addons.css
@@ -0,0 +1,250 @@
+/* --- Addon Page --- */
+.addon-catalog-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.addon-card {
+ padding: 14px 16px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fbfcff;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+
+.addon-card:hover {
+ border-color: var(--blue);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+
+.addon-card.selected {
+ border-color: var(--blue);
+ background: #f0ecff;
+}
+
+.addon-card-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 6px;
+}
+
+.addon-card-header strong {
+ font-size: 14px;
+ color: var(--text);
+}
+
+.addon-card-version {
+ font-size: 11px;
+ color: #7f8897;
+ background: #eef1f6;
+ padding: 2px 7px;
+ border-radius: 999px;
+ margin-left: 6px;
+}
+
+.addon-card-desc {
+ font-size: 12px;
+ color: #7f8897;
+ line-height: 1.5;
+ margin: 0 0 8px;
+}
+
+.addon-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.addon-card-category {
+ font-size: 11px;
+ color: #6366f1;
+ background: #eef0ff;
+ padding: 2px 8px;
+ border-radius: 999px;
+}
+
+.addon-detail-panel {
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fbfcff;
+ min-height: 200px;
+}
+
+.addon-installed-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.addon-installed-table th {
+ text-align: left;
+ padding: 8px 12px;
+ border-bottom: 2px solid var(--line);
+ font-weight: 600;
+ color: #7f8897;
+ font-size: 12px;
+}
+
+.addon-installed-table td {
+ padding: 9px 12px;
+ border-bottom: 1px solid #f0f1f4;
+ color: var(--text);
+}
+
+.addon-textarea {
+ width: 100%;
+ min-height: 120px;
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ font-size: 13px;
+ font-family:
+ "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas, monospace;
+ resize: vertical;
+ line-height: 1.5;
+ background: #fff;
+ color: var(--text);
+ box-sizing: border-box;
+}
+
+.addon-textarea::placeholder {
+ color: #9ca3af;
+ font-size: 12px;
+}
+
+.addon-textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+/* Addon drawer */
+.addon-drawer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 1000;
+}
+
+.addon-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 460px;
+ max-width: 90vw;
+ height: 100vh;
+ background: #fff;
+ z-index: 1001;
+ display: flex;
+ flex-direction: column;
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
+}
+
+.addon-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--line);
+ flex-shrink: 0;
+}
+
+.addon-drawer-header h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.addon-drawer-close {
+ background: none;
+ border: none;
+ font-size: 20px;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ line-height: 1;
+}
+
+.addon-drawer-close:hover {
+ background: #f0f1f4;
+ color: var(--text);
+}
+
+.theme-dark .addon-card,
+.theme-dark .addon-detail-panel {
+ border-color: var(--line);
+ background: var(--panel);
+}
+
+.theme-dark .addon-card:hover {
+ border-color: #7c8cff;
+ background: #172238;
+ box-shadow: none;
+}
+
+.theme-dark .addon-card.selected {
+ border-color: #7c8cff;
+ background: rgba(99, 102, 241, 0.12);
+}
+
+.theme-dark .addon-card-header strong,
+.theme-dark .addon-installed-table td {
+ color: var(--ink);
+}
+
+.theme-dark .addon-card-version {
+ color: #aab5c7;
+ background: #202c40;
+}
+
+.theme-dark .addon-card-desc {
+ color: #9ca8ba;
+}
+
+.theme-dark .addon-card-category {
+ color: #b9c2ff;
+ background: rgba(99, 102, 241, 0.15);
+}
+
+.theme-dark .addon-installed-table th,
+.theme-dark .addon-installed-table td {
+ border-color: var(--line);
+}
+
+.theme-dark .addon-textarea,
+.theme-dark .addon-drawer {
+ border-color: var(--line);
+ color: var(--ink);
+ background: var(--panel);
+}
+
+.theme-dark .addon-drawer-close:hover {
+ color: var(--ink);
+ background: #202c40;
+}
+
+.addon-drawer-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+}
+
+.addon-drawer-footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--line);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-shrink: 0;
+}
diff --git a/apps/rlark-ui/src/styles/admin/dashboard.css b/apps/rlark-ui/src/styles/admin/dashboard.css
new file mode 100644
index 0000000..4fbf6d2
--- /dev/null
+++ b/apps/rlark-ui/src/styles/admin/dashboard.css
@@ -0,0 +1,494 @@
+/* ── Admin Dashboard ── */
+.admin-dashboard-page {
+ display: grid;
+ align-content: start;
+ gap: 18px;
+}
+
+.admin-dashboard-hero {
+ position: relative;
+ min-height: 164px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 28px;
+ padding: 26px 28px;
+ overflow: hidden;
+ border: 1px solid rgba(124, 58, 237, 0.12);
+ border-radius: 22px;
+ background:
+ radial-gradient(
+ circle at 78% -25%,
+ rgba(124, 58, 237, 0.2),
+ transparent 40%
+ ),
+ linear-gradient(135deg, #fff 28%, #f5f1ff 100%);
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-dashboard-hero::after {
+ content: "";
+ position: absolute;
+ width: 260px;
+ height: 260px;
+ right: 8%;
+ top: -175px;
+ border: 42px solid rgba(124, 58, 237, 0.045);
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+.admin-dashboard-hero > * {
+ position: relative;
+ z-index: 1;
+}
+
+.admin-dashboard-hero h2 {
+ margin: 8px 0 7px;
+ color: var(--ink);
+ font-size: 30px;
+}
+
+.admin-dashboard-hero p {
+ max-width: 620px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.admin-dashboard-sync {
+ min-width: 184px;
+ display: grid;
+ justify-items: end;
+ gap: 8px;
+}
+
+.admin-dashboard-sync > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.admin-dashboard-sync > span.healthy {
+ color: #13845b;
+ background: #e5f8f0;
+}
+
+.admin-dashboard-sync > span.warning {
+ color: #b66b18;
+ background: #fff1dc;
+}
+
+.admin-dashboard-sync > small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.admin-dashboard-sync .spin {
+ animation: status-spin 1s linear infinite;
+}
+
+.admin-dashboard-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.admin-dashboard-metric {
+ min-width: 0;
+ min-height: 116px;
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 17px;
+ border: 1px solid var(--line);
+ border-radius: 17px;
+ background: var(--panel);
+ color: var(--ink);
+ text-align: left;
+ box-shadow: var(--shadow-soft);
+ cursor: pointer;
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.admin-dashboard-metric:hover {
+ transform: translateY(-2px);
+ border-color: rgba(124, 58, 237, 0.28);
+ box-shadow: 0 12px 30px rgba(36, 26, 70, 0.09);
+}
+
+.admin-dashboard-metric > span,
+.admin-action-grid button > span {
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ background: #eee9ff;
+ color: #7440dc;
+}
+
+.admin-dashboard-metric.mint > span {
+ background: #e1f7ef;
+ color: #16936a;
+}
+
+.admin-dashboard-metric.blue > span {
+ background: #e7f1ff;
+ color: #3478d4;
+}
+
+.admin-dashboard-metric.orange > span {
+ background: #fff0df;
+ color: #d47b20;
+}
+
+.admin-dashboard-metric > div {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+}
+
+.admin-dashboard-metric small,
+.admin-dashboard-metric strong,
+.admin-dashboard-metric em {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-dashboard-metric small {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.admin-dashboard-metric strong {
+ font-size: 24px;
+}
+
+.admin-dashboard-metric em {
+ color: var(--soft);
+ font-size: 9px;
+ font-style: normal;
+}
+
+.admin-dashboard-metric > svg,
+.admin-action-grid button > svg,
+.admin-attention-list button > svg,
+.admin-recent-list button > svg {
+ color: var(--soft);
+}
+
+.admin-dashboard-main-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.55fr) minmax(310px, 0.75fr);
+ gap: 16px;
+}
+
+.admin-dashboard-panel {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-dashboard-panel-head {
+ min-height: 65px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 17px;
+ border-bottom: 1px solid var(--line);
+}
+
+.admin-dashboard-panel-head span,
+.admin-dashboard-panel-head small {
+ display: block;
+}
+
+.admin-dashboard-panel-head span {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 850;
+}
+
+.admin-dashboard-panel-head small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.admin-dashboard-panel-head > b {
+ min-width: 26px;
+ height: 26px;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ background: #fff0df;
+ color: #be6c19;
+ font-size: 11px;
+}
+
+.admin-action-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ padding: 14px;
+}
+
+.admin-action-grid button {
+ min-width: 0;
+ min-height: 82px;
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 9px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ background: var(--canvas);
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 0.16s ease,
+ background 0.16s ease;
+}
+
+.admin-action-grid button:hover {
+ border-color: rgba(124, 58, 237, 0.28);
+ background: #faf8ff;
+}
+
+.admin-action-grid button > span {
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+}
+
+.admin-action-grid button div {
+ min-width: 0;
+}
+
+.admin-action-grid button strong,
+.admin-action-grid button small {
+ display: block;
+}
+
+.admin-action-grid button strong {
+ font-size: 11px;
+}
+
+.admin-action-grid button small {
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-attention-list {
+ max-height: 198px;
+ overflow-y: auto;
+ padding: 7px 12px 12px;
+}
+
+.admin-attention-list button {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 9px;
+ padding: 9px 4px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+}
+
+.admin-attention-list button > span {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+}
+
+.admin-attention-list button > span.danger {
+ color: #d44d68;
+ background: #ffeaef;
+}
+
+.admin-attention-list button > span.pending {
+ color: #c47620;
+ background: #fff0dc;
+}
+
+.admin-attention-list strong,
+.admin-attention-list small {
+ display: block;
+}
+
+.admin-attention-list strong {
+ font-size: 10px;
+}
+
+.admin-attention-list small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.admin-attention-empty {
+ min-height: 128px;
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: 6px;
+ color: #199269;
+ text-align: center;
+}
+
+.admin-attention-empty strong {
+ color: var(--ink);
+ font-size: 11px;
+}
+
+.admin-attention-empty small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.admin-recent-list {
+ display: grid;
+ padding: 4px 14px 12px;
+}
+
+.admin-recent-list button {
+ min-width: 0;
+ min-height: 45px;
+ display: grid;
+ grid-template-columns: 54px minmax(0, 1fr) 82px 150px auto;
+ align-items: center;
+ gap: 12px;
+ padding: 7px 3px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+}
+
+.admin-recent-list button:last-child {
+ border-bottom: 0;
+}
+
+.admin-recent-type {
+ width: max-content;
+ padding: 4px 7px;
+ border-radius: 7px;
+ background: #eee9ff;
+ color: #7041ce;
+ font-size: 9px;
+ font-weight: 800;
+}
+
+.admin-recent-list strong {
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-recent-list small,
+.admin-recent-list time {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.admin-recent-empty {
+ padding: 24px;
+ color: var(--muted);
+ font-size: 11px;
+ text-align: center;
+}
+
+.theme-dark .admin-dashboard-hero {
+ border-color: #2a3448;
+ background:
+ radial-gradient(
+ circle at 78% -25%,
+ rgba(124, 58, 237, 0.22),
+ transparent 40%
+ ),
+ linear-gradient(135deg, #151d2b 28%, #1b1830 100%);
+}
+
+.theme-dark .admin-dashboard-metric,
+.theme-dark .admin-dashboard-panel {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .admin-action-grid button {
+ background: #111a29;
+}
+
+.theme-dark .admin-action-grid button:hover {
+ background: #1a2030;
+}
+
+@media (max-width: 1180px) {
+ .admin-dashboard-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .admin-dashboard-main-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 760px) {
+ .admin-dashboard-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .admin-dashboard-sync {
+ justify-items: start;
+ }
+
+ .admin-action-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-recent-list button {
+ grid-template-columns: 52px minmax(0, 1fr) auto;
+ }
+
+ .admin-recent-list button small,
+ .admin-recent-list button time {
+ display: none;
+ }
+}
+
+@media (max-width: 520px) {
+ .admin-dashboard-metrics {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/admin/domain-and-responsive.css b/apps/rlark-ui/src/styles/admin/domain-and-responsive.css
new file mode 100644
index 0000000..2fd681c
--- /dev/null
+++ b/apps/rlark-ui/src/styles/admin/domain-and-responsive.css
@@ -0,0 +1,634 @@
+.sort-th {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.sort-th:hover {
+ color: var(--accent, #6366f1);
+}
+
+.domain-detail-page .section-heading {
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.domain-detail-heading-copy {
+ flex: 1 1 480px;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.icon-button.danger {
+ color: var(--red);
+ border-color: rgba(239, 90, 122, 0.25);
+}
+
+.icon-button.danger:hover {
+ background: rgba(239, 90, 122, 0.08);
+ border-color: var(--red);
+}
+
+.admin-node-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.admin-node-table th {
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ white-space: nowrap;
+}
+
+.admin-node-table td {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ font-size: 14px;
+ vertical-align: middle;
+}
+
+.admin-node-table tr:hover {
+ background: var(--hover);
+}
+
+.label-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ max-width: 400px;
+ align-items: center;
+}
+
+.label-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px 2px 8px;
+ background: var(--canvas);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ font-size: 12px;
+ white-space: nowrap;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.label-chip code {
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ color: var(--blue);
+ font-weight: 600;
+}
+
+.label-chip i {
+ font-style: normal;
+ color: var(--soft);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.label-toggle {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ background: transparent;
+ border: 1px dashed var(--line-strong);
+ border-radius: 6px;
+ font-size: 11px;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.label-toggle:hover {
+ background: var(--hover);
+ color: var(--blue);
+ border-color: var(--blue);
+}
+
+.label-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 320px;
+}
+
+.label-edit-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.label-edit-row code {
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ color: var(--soft);
+ min-width: 120px;
+}
+
+.label-edit-row input {
+ flex: 1;
+ padding: 6px 10px;
+ border: 1px solid var(--line-strong);
+ border-radius: 8px;
+ font-size: 13px;
+ outline: none;
+}
+
+.label-edit-row input:focus {
+ border-color: var(--blue);
+}
+
+.label-add-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.label-add-row input {
+ flex: 1;
+ padding: 6px 10px;
+ border: 1px solid var(--line-strong);
+ border-radius: 8px;
+ font-size: 13px;
+ outline: none;
+}
+
+.label-add-row input:focus {
+ border-color: var(--blue);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.cert-files {
+ margin-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.cert-file-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 18px;
+ background: var(--canvas);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+}
+
+.cert-file-row div {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.cert-file-row strong {
+ font-size: 14px;
+}
+
+.cert-file-row code {
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ color: var(--soft);
+}
+
+.cert-preview {
+ margin-top: 18px;
+}
+
+.cert-preview-head {
+ margin-bottom: 8px;
+}
+
+.cert-preview pre {
+ background: var(--canvas);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 16px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--muted);
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+.cert-yaml-block {
+ margin-top: 16px;
+}
+
+.cert-yaml-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ gap: 12px;
+}
+
+.cert-yaml-head strong {
+ font-size: 13px;
+ color: var(--muted);
+}
+
+.cert-yaml-block pre {
+ background: var(--canvas);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 16px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--muted);
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 480px;
+ overflow-y: auto;
+}
+
+.cert-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.cert-list-item {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.cert-list-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.cert-list-row:hover {
+ background: var(--hover);
+}
+
+.cert-list-row.expanded {
+ background: var(--hover);
+}
+
+.cert-list-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+ flex-shrink: 0;
+}
+
+.cert-list-name {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.cert-list-date {
+ font-size: 12px;
+ color: var(--soft);
+}
+
+.cert-list-chevron {
+ color: var(--soft);
+ transition: transform 0.2s;
+}
+
+.cert-list-chevron.rotated {
+ transform: rotate(90deg);
+}
+
+@media (max-width: 1300px) {
+ .page-content {
+ padding-left: 22px;
+ padding-right: 22px;
+ }
+
+ .topbar {
+ padding-left: 22px;
+ padding-right: 22px;
+ }
+
+ .metric-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .cluster-overview-grid,
+ .cluster-card-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .cluster-topology-grid,
+ .node-detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .node-admin-layout {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .node-resource-detail {
+ position: static;
+ width: 100%;
+ }
+
+ .dashboard-grid,
+ .bottom-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-page .dashboard-grid,
+ .overview-page .bottom-grid {
+ flex: 0 0 auto;
+ min-height: auto;
+ }
+
+ .overview-page .dashboard-grid > *,
+ .overview-page .bottom-grid > * {
+ min-width: 0;
+ }
+
+ .overview-page .resource-split {
+ flex: 0 0 auto;
+ }
+
+ .overview-page .resource-split .resource-row {
+ flex: 0 0 auto;
+ min-height: 62px;
+ }
+
+ .overview-page .chart-panel,
+ .overview-page .workload-panel,
+ .overview-page .bottom-grid > .panel {
+ min-height: 220px;
+ }
+}
+
+@media (max-width: 1050px) {
+ .app-shell {
+ grid-template-columns: 246px minmax(0, 1fr);
+ }
+
+ .app-shell.sidebar-collapsed {
+ grid-template-columns: 80px minmax(0, 1fr);
+ }
+
+ .sidebar-collapsed .sidebar {
+ padding: 16px 10px;
+ }
+
+ .sidebar-collapsed .brand {
+ height: 48px;
+ margin-bottom: 18px;
+ padding: 0 8px;
+ }
+
+ .sidebar-collapsed .brand-logo {
+ width: 46px;
+ height: 46px;
+ object-fit: cover;
+ object-position: left center;
+ }
+
+ .sidebar-collapsed .sidebar nav button span,
+ .sidebar-collapsed .sidebar nav button em,
+ .sidebar-collapsed .sidebar .nav-label,
+ .sidebar-collapsed .sidebar .environment-card div,
+ .sidebar-collapsed .sidebar .environment-card i,
+ .sidebar-collapsed .sidebar .sidebar-bottom > button span {
+ display: none;
+ }
+
+ .sidebar-collapsed nav button,
+ .sidebar-collapsed .sidebar-bottom > button {
+ justify-content: center;
+ padding: 0;
+ }
+
+ .sidebar-collapsed .nav-children {
+ padding-left: 0;
+ }
+
+ .sidebar-collapsed .environment-card {
+ grid-template-columns: 1fr;
+ min-height: 56px;
+ padding: 9px;
+ }
+
+ .sidebar-collapsed .sidebar .environment-card > span {
+ margin: auto;
+ }
+
+ .topbar {
+ padding-left: 18px;
+ padding-right: 18px;
+ }
+
+ .topbar-context {
+ min-width: 110px;
+ }
+
+ .page-content {
+ padding-left: 18px;
+ padding-right: 18px;
+ }
+
+ .overview-china-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-china-map {
+ min-height: 360px;
+ }
+
+ .overview-china-aside {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr);
+ }
+
+ .overview-city-summary {
+ grid-column: 1 / -1;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .cluster-detail-stats {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .resource-input-row {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .mount-row {
+ grid-template-columns:
+ minmax(80px, auto) minmax(160px, 1fr) minmax(160px, 1fr)
+ 32px;
+ }
+
+ .overview-page .resource-split .resource-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ gap: 9px 12px;
+ padding: 12px 14px;
+ }
+
+ .overview-page .resource-split .resource-row > div {
+ grid-column: 1;
+ grid-row: 1;
+ min-width: 0;
+ }
+
+ .overview-page .resource-split .resource-row > b {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+ }
+
+ .overview-page .resource-split .resource-row > span {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ width: 100%;
+ height: 8px;
+ }
+}
+
+@media (max-width: 780px) {
+ .topbar {
+ height: 64px;
+ gap: 10px;
+ padding: 0 14px;
+ }
+
+ .topbar-context > span,
+ .environment-status,
+ .notification-button {
+ display: none;
+ }
+
+ .topbar-context {
+ min-width: 0;
+ }
+
+ .topbar-context h1 {
+ font-size: 14px;
+ white-space: nowrap;
+ }
+
+ .topbar-actions {
+ gap: 5px;
+ }
+
+ .segmented-control {
+ height: 36px;
+ padding: 3px;
+ }
+
+ .segmented-control button {
+ min-width: 30px;
+ padding: 0 7px;
+ }
+
+ .topbar-actions > .primary-button {
+ width: 38px;
+ padding: 0;
+ justify-content: center;
+ }
+
+ .topbar-actions > .primary-button span {
+ display: none;
+ }
+
+ .avatar {
+ width: 34px;
+ height: 34px;
+ }
+
+ .page-content {
+ padding: 18px 14px 28px;
+ }
+
+ .hero-strip,
+ .section-heading {
+ align-items: flex-start;
+ gap: 14px;
+ }
+
+ .hero-strip {
+ flex-direction: column;
+ }
+
+ .hero-health {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .metric-grid,
+ .cluster-overview-grid,
+ .cluster-card-grid {
+ gap: 12px;
+ }
+
+ .node-card-grid,
+ .cluster-detail-stats {
+ grid-template-columns: 1fr;
+ }
+
+ .create-job-modal {
+ width: calc(100vw - 24px);
+ max-height: calc(100vh - 24px);
+ }
+
+ .create-job-body {
+ max-height: calc(100vh - 180px);
+ }
+
+ .create-stepper {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+
+ .create-stepper button {
+ gap: 4px;
+ font-size: 10px;
+ }
+
+ .create-stepper button:not(:last-child)::after {
+ display: none;
+ }
+
+ .form-row,
+ .resource-input-row,
+ .env-row,
+ .mount-row {
+ grid-template-columns: 1fr;
+ }
+
+ .page-toolbar > small {
+ width: 100%;
+ margin-left: 0;
+ text-align: right;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/admin/settings-and-certificates.css b/apps/rlark-ui/src/styles/admin/settings-and-certificates.css
new file mode 100644
index 0000000..9f52f8d
--- /dev/null
+++ b/apps/rlark-ui/src/styles/admin/settings-and-certificates.css
@@ -0,0 +1,389 @@
+.system-config-page,
+.create-cluster-page {
+ max-width: none;
+}
+
+.system-config-heading,
+.create-cluster-heading {
+ padding-bottom: 4px;
+}
+
+.system-config-heading > div:first-child,
+.create-cluster-heading > div:first-child {
+ max-width: 720px;
+}
+
+.system-config-overview {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin: 6px 0 18px;
+}
+
+.system-config-overview > div {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ column-gap: 11px;
+ padding: 15px 16px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: linear-gradient(145deg, var(--panel), rgba(124, 58, 237, 0.035));
+}
+
+.system-config-overview span {
+ grid-row: 1 / 3;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 11px;
+ color: #7042d8;
+ background: rgba(112, 66, 216, 0.1);
+}
+
+.system-config-overview small {
+ color: var(--soft);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.system-config-overview strong {
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.system-config-category-bar {
+ margin-bottom: 14px;
+ border-radius: 14px;
+}
+
+.system-config-panel {
+ overflow: hidden;
+ border-radius: 16px;
+ box-shadow: 0 12px 35px rgba(35, 42, 60, 0.055);
+}
+
+.system-config-panel .storage-table-heading {
+ padding: 18px 20px;
+ background: linear-gradient(
+ 90deg,
+ rgba(124, 58, 237, 0.055),
+ transparent 60%
+ );
+}
+
+.system-config-panel .storage-table-heading strong {
+ font-size: 14px;
+}
+
+.system-config-form-grid {
+ align-items: start;
+}
+
+.system-config-preview-panel {
+ border-color: rgba(79, 70, 229, 0.18);
+}
+
+.create-cluster-heading p {
+ max-width: 690px;
+}
+
+.cluster-enrollment-flow {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+}
+
+.cluster-enrollment-card,
+.cluster-enrollment-result,
+.signed-clusters-panel {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: var(--panel);
+ box-shadow: 0 16px 45px rgba(35, 42, 60, 0.07);
+}
+
+.cluster-enrollment-card {
+ padding: 20px 22px;
+}
+
+.cluster-enrollment-card-head {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 22px;
+}
+
+.cluster-enrollment-step {
+ display: grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ flex: none;
+ border-radius: 12px;
+ color: #fff;
+ background: linear-gradient(135deg, #7042d8, #4f46e5);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.cluster-enrollment-card-head div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.cluster-enrollment-card-head strong {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.cluster-enrollment-card-head small,
+.signed-clusters-heading p {
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.55;
+}
+
+.cluster-enrollment-card .cert-form {
+ align-items: flex-end;
+ flex-direction: row;
+}
+
+.cluster-enrollment-card .cert-form input {
+ height: 44px;
+ background: var(--canvas);
+}
+
+.cluster-enrollment-card .cert-form button {
+ height: 42px;
+ min-width: 150px;
+ justify-content: center;
+}
+
+.cluster-enrollment-notes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 9px;
+ margin-top: 15px;
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+}
+
+.cluster-enrollment-notes span {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.cluster-enrollment-notes svg {
+ color: #7042d8;
+}
+
+.cluster-enrollment-result {
+ min-width: 0;
+ margin-top: 0;
+ padding: 18px 20px 20px;
+}
+
+.cluster-enrollment-result .cert-result-header {
+ padding: 0 2px 16px;
+}
+
+.cluster-yaml-card {
+ overflow: hidden;
+ margin-top: 16px;
+ border: 1px solid #202b3d;
+ border-radius: 14px;
+ background: #0b1320;
+}
+
+.cluster-yaml-card .cert-yaml-head {
+ margin: 0;
+ padding: 11px 13px;
+ border-bottom: 1px solid #253247;
+ background: #111c2c;
+}
+
+.cluster-yaml-card .cert-yaml-head > div,
+.cert-yaml-head button {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.cluster-yaml-card .cert-yaml-head strong,
+.cluster-yaml-card .cert-yaml-head svg {
+ color: #cbd5e1;
+}
+
+.cluster-yaml-card pre {
+ width: 100%;
+ max-height: 560px;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ color: #b9c7da;
+ background: transparent;
+}
+
+.signed-clusters-panel {
+ margin-top: 22px;
+ padding: 20px;
+}
+
+.signed-clusters-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 16px;
+}
+
+.signed-clusters-heading h3,
+.signed-clusters-heading p {
+ margin: 0;
+}
+
+.signed-clusters-heading > span {
+ min-width: 34px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ color: #7042d8;
+ background: rgba(112, 66, 216, 0.1);
+ font-size: 12px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.create-cluster-page .cert-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.create-cluster-page .cert-list-item {
+ border-radius: 13px;
+ background: var(--panel);
+ transition:
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.create-cluster-page .cert-list-item:has(.expanded) {
+ border-color: rgba(112, 66, 216, 0.24);
+ box-shadow: 0 12px 30px rgba(35, 42, 60, 0.07);
+}
+
+.create-cluster-page .cert-list-row {
+ min-height: 54px;
+ padding: 10px 14px;
+}
+
+.create-cluster-page .cert-list-row.expanded {
+ border-bottom: 1px solid var(--line);
+ background: rgba(112, 66, 216, 0.045);
+}
+
+.signed-cluster-detail {
+ padding: 14px;
+ background: rgba(35, 42, 60, 0.025);
+}
+
+.signed-cluster-yaml {
+ margin-top: 0;
+}
+
+.signed-cluster-yaml pre {
+ max-height: 460px;
+}
+
+.signed-cluster-loading {
+ min-height: 86px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border: 1px dashed var(--line-strong);
+ border-radius: 12px;
+ color: var(--muted);
+ background: var(--canvas);
+ font-size: 12px;
+}
+
+.signed-cluster-loading span {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(112, 66, 216, 0.2);
+ border-top-color: #7042d8;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.cert-list-icon {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ flex: none;
+ border-radius: 9px;
+ color: #7042d8;
+ background: rgba(112, 66, 216, 0.09);
+}
+
+.theme-dark .system-config-overview > div,
+.theme-dark .cluster-enrollment-card,
+.theme-dark .cluster-enrollment-result,
+.theme-dark .signed-clusters-panel {
+ background: #151d2b;
+}
+
+.theme-dark .signed-cluster-detail {
+ background: rgba(0, 0, 0, 0.12);
+}
+
+@media (max-width: 900px) {
+ .system-config-overview {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 620px) {
+ .create-cluster-page .cert-list {
+ grid-template-columns: 1fr;
+ }
+
+ .system-config-panel .storage-create-form {
+ padding: 14px !important;
+ }
+
+ .system-config-form-grid {
+ grid-template-columns: 1fr !important;
+ }
+
+ .cluster-enrollment-result .cert-result-header,
+ .signed-clusters-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .cluster-enrollment-card .cert-form {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .cluster-enrollment-card .cert-form button {
+ width: 100%;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/auth/admin-login.css b/apps/rlark-ui/src/styles/auth/admin-login.css
new file mode 100644
index 0000000..6f90686
--- /dev/null
+++ b/apps/rlark-ui/src/styles/auth/admin-login.css
@@ -0,0 +1,299 @@
+/* ── Admin Login ── */
+.admin-login-page {
+ min-height: 100vh;
+ position: relative;
+ overflow: hidden;
+ background:
+ linear-gradient(rgba(255, 255, 255, 0.76), rgba(247, 249, 255, 0.88)),
+ radial-gradient(circle at 18% 15%, #dbeafe 0, transparent 34%),
+ radial-gradient(circle at 82% 82%, #ede9fe 0, transparent 32%), #f5f7fb;
+ display: flex;
+ flex-direction: column;
+}
+
+.admin-login-page::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ opacity: 0.32;
+ background-image:
+ linear-gradient(rgba(90, 105, 160, 0.08) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(90, 105, 160, 0.08) 1px, transparent 1px);
+ background-size: 48px 48px;
+ mask-image: linear-gradient(to bottom, black, transparent 78%);
+ pointer-events: none;
+}
+
+.user-login-orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(2px);
+ pointer-events: none;
+}
+
+.user-login-orb-one {
+ width: 360px;
+ height: 360px;
+ top: -180px;
+ right: -100px;
+ background: rgba(99, 102, 241, 0.13);
+}
+
+.user-login-orb-two {
+ width: 280px;
+ height: 280px;
+ bottom: -150px;
+ left: -80px;
+ background: rgba(59, 130, 246, 0.12);
+}
+
+.login-inline-error {
+ width: 100%;
+ min-height: 20px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: -8px 0 2px;
+ color: #dc2626;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.admin-login-body {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ position: relative;
+ z-index: 1;
+}
+
+.admin-login-panel {
+ position: relative;
+ width: 420px;
+ max-width: 100%;
+}
+
+.admin-login-card {
+ width: 100%;
+ max-width: 100%;
+ background: var(--panel);
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: 24px;
+ padding: 38px 38px 30px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ box-shadow:
+ 0 28px 70px rgba(45, 55, 100, 0.14),
+ 0 4px 14px rgba(45, 55, 100, 0.06);
+ backdrop-filter: blur(18px);
+}
+
+.admin-login-card h2 {
+ margin: 5px 0 4px;
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+}
+
+.admin-login-card .muted {
+ font-size: 13px;
+ color: var(--muted);
+ margin: 0;
+ text-align: center;
+}
+
+.user-login-brand-logo {
+ width: 190px;
+ max-width: 100%;
+ height: auto;
+ object-fit: contain;
+}
+
+.user-login-brand {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-bottom: 17px;
+ margin-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.user-login-heading {
+ width: 100%;
+ margin-bottom: 18px;
+ text-align: center;
+}
+
+.user-login-heading .muted {
+ text-align: center;
+}
+
+.admin-login-card-brand {
+ position: relative;
+}
+
+.admin-login-card-brand .user-login-brand-logo {
+ width: 176px;
+}
+
+.admin-login-badge {
+ position: absolute;
+ right: 0;
+ bottom: 16px;
+ padding: 4px 7px;
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 6px;
+ color: #5b55d6;
+ background: rgba(99, 102, 241, 0.08);
+ font-size: 9px;
+ font-weight: 800;
+}
+
+.admin-login-field {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 16px;
+}
+
+.admin-login-field label {
+ font-size: 12px;
+ font-weight: 650;
+ color: var(--soft);
+}
+
+.admin-login-field input {
+ width: 100%;
+ height: 46px;
+ padding: 0 15px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ font-size: 14px;
+ color: var(--ink);
+ background: #f8f9fd;
+ outline: none;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s,
+ background 0.15s;
+ box-sizing: border-box;
+}
+
+.admin-login-field input:focus {
+ border-color: var(--blue);
+ background: #fff;
+ box-shadow: 0 0 0 3px rgba(109, 93, 252, 0.11);
+}
+
+.admin-login-field input::placeholder {
+ color: var(--muted);
+ opacity: 0.48;
+ font-weight: 400;
+}
+
+.admin-login-password {
+ position: relative;
+ width: 100%;
+}
+
+.admin-login-password input {
+ padding-right: 44px;
+}
+
+.admin-login-password-toggle {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ transform: translateY(-50%);
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.admin-login-password-toggle:hover,
+.admin-login-password-toggle:focus-visible {
+ color: var(--blue);
+ background: var(--hover);
+ outline: none;
+}
+
+.admin-login-btn {
+ width: 100%;
+ height: 48px;
+ margin-top: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ justify-content: center;
+ gap: 9px;
+ border: 0;
+ border-radius: 11px;
+ background: linear-gradient(135deg, #635bff, #7c3aed);
+ box-shadow: 0 12px 24px rgba(109, 72, 240, 0.24);
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+
+.admin-login-btn:not(:disabled):hover {
+ transform: translateY(-1px);
+ box-shadow: 0 15px 28px rgba(109, 72, 240, 0.3);
+}
+
+.admin-login-back {
+ margin-top: 17px;
+ font-size: 12px;
+ color: var(--muted);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: color 0.15s;
+}
+
+.admin-login-back:hover {
+ color: var(--blue);
+}
+
+.admin-login-back svg {
+ transform: rotate(180deg);
+}
+
+.theme-dark .admin-login-card {
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
+}
+
+.theme-dark .login-inline-error {
+ color: #fca5a5;
+}
+
+.theme-dark .admin-login-badge {
+ border-color: rgba(196, 181, 253, 0.24);
+ color: #c4b5fd;
+ background: rgba(167, 139, 250, 0.12);
+}
+
+.theme-dark .admin-login-field input {
+ background: var(--canvas);
+}
+
+@media (max-width: 520px) {
+ .admin-login-card {
+ padding: 30px 24px 26px;
+ border-radius: 20px;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/clusters/management.css b/apps/rlark-ui/src/styles/clusters/management.css
new file mode 100644
index 0000000..a39e740
--- /dev/null
+++ b/apps/rlark-ui/src/styles/clusters/management.css
@@ -0,0 +1,268 @@
+/* Cluster management and detail */
+.cluster-management-page {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.cluster-management-page > .section-heading,
+.cluster-detail-nodes .section-heading {
+ margin-bottom: 0;
+}
+
+.cluster-management-table-panel {
+ padding: 0;
+ overflow-x: auto;
+}
+
+.cluster-management-table-head,
+.cluster-management-row {
+ min-width: 1040px;
+ display: grid;
+ grid-template-columns:
+ minmax(210px, 1.4fr)
+ minmax(100px, 0.7fr)
+ minmax(80px, 0.55fr)
+ minmax(80px, 0.55fr)
+ minmax(80px, 0.55fr)
+ minmax(150px, 1fr)
+ minmax(120px, 0.8fr)
+ 20px;
+ align-items: center;
+ column-gap: 14px;
+}
+
+.cluster-management-table-head {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--line);
+ background: var(--canvas);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.cluster-management-row {
+ width: 100%;
+ min-height: 66px;
+ padding: 10px 16px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ transition: background-color 0.15s ease;
+}
+
+.cluster-management-row:last-child {
+ border-bottom: 0;
+}
+
+.cluster-management-row:hover {
+ background: var(--skyblue-bg);
+}
+
+.cluster-management-row > svg {
+ color: var(--soft);
+}
+
+.cluster-management-name {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.cluster-management-name > i {
+ width: 34px;
+ height: 34px;
+ flex: none;
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+ background: rgba(123, 97, 255, 0.1);
+ color: var(--purple);
+}
+
+.cluster-management-name > span {
+ min-width: 0;
+}
+
+.cluster-management-name strong,
+.cluster-management-name small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cluster-management-name strong {
+ font-size: 13px;
+}
+
+.cluster-management-name small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.cluster-type-chip {
+ width: fit-content;
+ padding: 4px 8px;
+ border-radius: 7px;
+ background: rgba(123, 97, 255, 0.09);
+ color: var(--purple);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.cluster-health-badge {
+ width: fit-content;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 9px;
+ border-radius: 999px;
+ background: rgba(142, 151, 166, 0.1);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.cluster-health-badge i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.cluster-health-badge.online {
+ background: rgba(54, 201, 143, 0.12);
+ color: #15956a;
+}
+
+.cluster-health-badge.degraded {
+ background: rgba(245, 158, 53, 0.13);
+ color: #c77312;
+}
+
+.cluster-health-badge.offline {
+ background: rgba(239, 90, 122, 0.12);
+ color: var(--red);
+}
+
+.cluster-management-empty {
+ min-width: 1040px;
+ min-height: 180px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.cluster-detail-page {
+ overscroll-behavior: contain;
+}
+
+.cluster-detail-hero {
+ min-height: 126px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 22px;
+ background:
+ radial-gradient(
+ circle at 8% 10%,
+ rgba(123, 97, 255, 0.14),
+ transparent 30%
+ ),
+ var(--panel);
+}
+
+.cluster-detail-identity {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.cluster-detail-identity > span {
+ width: 48px;
+ height: 48px;
+ display: grid;
+ place-items: center;
+ border-radius: 14px;
+ background: rgba(123, 97, 255, 0.11);
+ color: var(--purple);
+}
+
+.cluster-detail-identity small {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.cluster-detail-identity h2 {
+ margin: 3px 0 4px;
+ font-size: 24px;
+}
+
+.cluster-detail-identity p {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.cluster-detail-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.cluster-detail-metrics .metric-card {
+ min-height: 128px;
+}
+
+.cluster-detail-nodes {
+ display: grid;
+ gap: 12px;
+}
+
+.theme-dark .cluster-management-table-head {
+ background: #101827;
+}
+
+.theme-dark .cluster-management-row,
+.theme-dark .cluster-management-table-head {
+ border-color: #2a374c;
+}
+
+.theme-dark .cluster-management-row:hover {
+ background: #19263a;
+}
+
+.theme-dark .cluster-detail-hero {
+ background:
+ radial-gradient(
+ circle at 8% 10%,
+ rgba(151, 123, 255, 0.16),
+ transparent 30%
+ ),
+ #151f2f;
+}
+
+@media (max-width: 1100px) {
+ .cluster-detail-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 620px) {
+ .cluster-detail-metrics {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/clusters/node-legacy.css b/apps/rlark-ui/src/styles/clusters/node-legacy.css
new file mode 100644
index 0000000..25c4afd
--- /dev/null
+++ b/apps/rlark-ui/src/styles/clusters/node-legacy.css
@@ -0,0 +1,400 @@
+.cluster-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+.cluster-topology-grid {
+ display: grid;
+ grid-template-columns: 1.1fr 0.9fr;
+ gap: 18px;
+ margin-bottom: 18px;
+}
+
+.cluster-map-card,
+.selected-cluster-panel,
+.cluster-list-panel,
+.node-resource-detail {
+ min-width: 0;
+}
+
+.cluster-map {
+ height: 330px;
+ margin-top: 16px;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(
+ circle at 28% 42%,
+ rgba(124, 58, 237, 0.16),
+ transparent 18%
+ ),
+ radial-gradient(
+ circle at 72% 48%,
+ rgba(54, 201, 143, 0.18),
+ transparent 18%
+ ),
+ linear-gradient(145deg, #f8fbff, #eef3fa);
+}
+
+.map-grid {
+ position: absolute;
+ inset: 0;
+ opacity: 0.6;
+ background-image:
+ linear-gradient(#dfe6f1 1px, transparent 1px),
+ linear-gradient(90deg, #dfe6f1 1px, transparent 1px);
+ background-size: 34px 34px;
+}
+
+.map-pin {
+ position: absolute;
+ z-index: 1;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.9);
+ box-shadow: var(--shadow-soft);
+ min-width: 138px;
+ min-height: 72px;
+ padding: 10px;
+ display: grid;
+ grid-template-columns: 34px 1fr;
+ gap: 8px;
+ text-align: left;
+ align-items: center;
+}
+
+.map-pin span {
+ grid-row: 1 / 3;
+ width: 34px;
+ height: 34px;
+ border-radius: 12px;
+ display: grid;
+ place-items: center;
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.map-pin strong {
+ color: #202733;
+ font-size: 13px;
+ align-self: end;
+}
+
+.map-pin small {
+ color: #8d97a6;
+ font-size: 10px;
+ align-self: start;
+}
+
+.map-pin.active {
+ border-color: var(--blue);
+ box-shadow: 0 12px 28px rgba(124, 58, 237, 0.16);
+}
+
+.pin-0 {
+ left: 18%;
+ top: 34%;
+}
+
+.pin-1 {
+ left: 49%;
+ top: 22%;
+}
+
+.pin-2 {
+ left: 36%;
+ top: 58%;
+}
+
+.pin-3 {
+ right: 12%;
+ bottom: 18%;
+}
+
+.cluster-detail-stats {
+ display: grid;
+ grid-template-columns: 1.15fr 0.85fr 0.85fr;
+ gap: 10px;
+ margin: 18px 0 14px;
+}
+
+.cluster-detail-stats > div {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 13px;
+ background: #fbfcff;
+}
+
+.cluster-detail-stats span {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+}
+
+.cluster-detail-stats strong {
+ display: block;
+ margin-top: 5px;
+ color: #202733;
+ font-size: 15px;
+}
+
+.cluster-detail-stats small {
+ display: block;
+ margin-top: 4px;
+ color: #9aa4b2;
+ font-size: 10px;
+}
+
+.cluster-card-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 14px;
+ margin-top: 16px;
+}
+
+.cluster-card {
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: #fff;
+ padding: 16px;
+ text-align: left;
+ box-shadow: var(--shadow-soft);
+}
+
+.cluster-card.selected {
+ border-color: var(--blue);
+ box-shadow: 0 14px 30px rgba(124, 58, 237, 0.13);
+}
+
+.cluster-card-head,
+.cluster-card-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.cluster-card-head > span {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border-radius: 13px;
+}
+
+.cluster-card-head > span.cloud {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.cluster-card-head > span.embodied {
+ background: #e7f8f1;
+ color: #1f9c70;
+}
+
+.cluster-card > strong {
+ display: block;
+ margin-top: 16px;
+ color: #202733;
+ font-size: 14px;
+}
+
+.cluster-card > small {
+ display: block;
+ margin-top: 5px;
+ color: #8d97a6;
+ font-size: 11px;
+}
+
+.cluster-loads {
+ display: grid;
+ gap: 7px;
+ margin: 16px 0 12px;
+}
+
+.cluster-loads i {
+ display: block;
+ height: 5px;
+ border-radius: 999px;
+ background: #e8edf4;
+ overflow: hidden;
+}
+
+.cluster-loads b {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--blue), #c4b5fd);
+}
+
+.cluster-card-foot span {
+ color: #7f8998;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.nodes-resource-section {
+ margin-top: 22px;
+}
+
+.section-heading.compact {
+ margin-top: 4px;
+ margin-bottom: 16px;
+}
+
+.node-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+.node-filter-bar button {
+ height: 34px;
+ padding: 0 12px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: #fff;
+ color: #667182;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.node-filter-bar button.active {
+ background: #f3eefe;
+ color: var(--blue);
+ border-color: #ddd0fe;
+}
+
+.node-detail-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.05fr;
+ gap: 18px;
+}
+
+.node-card-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ align-content: start;
+}
+
+.node-resource-card {
+ min-height: 94px;
+ display: grid;
+ grid-template-columns: 40px 1fr auto;
+ align-items: center;
+ gap: 12px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 13px;
+ background: #fff;
+ text-align: left;
+ box-shadow: var(--shadow-soft);
+}
+
+.node-resource-card.selected {
+ border-color: var(--blue);
+ box-shadow: 0 12px 26px rgba(124, 58, 237, 0.12);
+}
+
+.node-resource-card strong {
+ display: block;
+ color: #202733;
+ font-size: 13px;
+}
+
+.node-resource-card small,
+.node-resource-card em {
+ display: block;
+ color: #8d97a6;
+ font-size: 10px;
+ font-style: normal;
+ margin-top: 3px;
+}
+
+.node-resource-detail {
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+ padding: 20px;
+}
+
+.compact-health {
+ grid-template-columns: repeat(4, 1fr);
+ margin-top: 18px;
+}
+
+.robot-channel-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+ margin-top: 16px;
+}
+
+.robot-camera {
+ min-height: 250px;
+}
+
+.robot-endpoints {
+ display: grid;
+ gap: 10px;
+}
+
+.robot-endpoints > div {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 13px;
+ background: #fbfcff;
+}
+
+.robot-endpoints span {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+}
+
+.robot-endpoints strong,
+.robot-endpoints code {
+ display: block;
+ margin-top: 5px;
+ color: #202733;
+ font-size: 13px;
+ word-break: break-all;
+}
+
+.compact-map {
+ height: 290px;
+ margin-top: 16px;
+}
+
+.theme-dark .cluster-map,
+.theme-dark .cluster-detail-stats > div,
+.theme-dark .cluster-card,
+.theme-dark .node-filter-bar button,
+.theme-dark .node-resource-card,
+.theme-dark .node-resource-detail,
+.theme-dark .robot-endpoints > div,
+.theme-dark .map-pin {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .cluster-map {
+ background:
+ radial-gradient(
+ circle at 28% 42%,
+ rgba(167, 139, 250, 0.14),
+ transparent 18%
+ ),
+ radial-gradient(
+ circle at 72% 48%,
+ rgba(72, 216, 162, 0.12),
+ transparent 18%
+ ),
+ linear-gradient(145deg, #101827, #121b2a);
+}
diff --git a/apps/rlark-ui/src/styles/clusters/overview.css b/apps/rlark-ui/src/styles/clusters/overview.css
new file mode 100644
index 0000000..2fbeeac
--- /dev/null
+++ b/apps/rlark-ui/src/styles/clusters/overview.css
@@ -0,0 +1,253 @@
+.cluster-overview-page {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.cluster-overview-page .section-heading {
+ flex-shrink: 0;
+}
+.cluster-overview-page .cluster-overview-grid {
+ flex-shrink: 0;
+ margin-bottom: 0;
+}
+.cluster-overview-page .cluster-overview-grid .metric-card {
+ min-height: 0;
+ padding: 10px 14px 9px;
+}
+.cluster-overview-page .cluster-overview-grid .metric-card .metric-head {
+ margin-bottom: 8px;
+}
+.cluster-overview-page
+ .cluster-overview-grid
+ .metric-card
+ .metric-value-row
+ strong {
+ font-size: 22px;
+}
+.cluster-overview-page .cluster-overview-grid .metric-card > small {
+ font-size: 10px;
+}
+.cluster-overview-page .cluster-topology-grid {
+ flex-shrink: 0;
+ margin-bottom: 0;
+ height: 320px;
+}
+.cluster-overview-page .cluster-topology-grid > * {
+ min-height: 0;
+ max-height: 100%;
+}
+.cluster-overview-page .cluster-map-card {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.cluster-overview-page .cluster-map-card .cluster-map {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+.cluster-overview-page .selected-cluster-panel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+}
+.cluster-overview-page .selected-cluster-panel .cluster-detail-header {
+ flex-shrink: 0;
+}
+.cluster-overview-page .selected-cluster-panel .cluster-node-table-wrap {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+.cluster-overview-page .cluster-list-panel {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.cluster-overview-page .cluster-list-panel .panel-title {
+ flex-shrink: 0;
+}
+.cluster-overview-page .cluster-list-panel .cluster-list-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ margin-top: 12px;
+}
+
+.cluster-list-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+.cluster-list-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: #f7f8fa;
+ text-align: left;
+ padding: 9px 14px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #8d97a6;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--line);
+}
+.cluster-list-table tbody tr {
+ cursor: pointer;
+ border-bottom: 1px solid #f0f1f4;
+ transition: background 0.15s;
+}
+.cluster-list-table tbody tr:hover {
+ background: #f7f8ff;
+}
+.cluster-list-table tbody tr.selected {
+ background: #f0ecff;
+}
+.cluster-list-table tbody tr.selected td {
+ color: var(--blue);
+}
+.cluster-list-table td {
+ padding: 10px 14px;
+ color: #4a5568;
+ vertical-align: middle;
+}
+.cluster-list-name {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.cluster-list-name strong {
+ font-size: 13px;
+ color: #202733;
+}
+.cluster-list-rate {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.cluster-list-rate i {
+ display: block;
+ width: 50px;
+ height: 5px;
+ border-radius: 999px;
+ background: #e8edf4;
+ overflow: hidden;
+}
+.cluster-list-rate b {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--blue), #c4b5fd);
+}
+.cluster-list-rate small {
+ font-size: 11px;
+ color: #8d97a6;
+ min-width: 32px;
+}
+
+.cluster-detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 18px;
+ border-bottom: 1px solid var(--line);
+ flex-shrink: 0;
+}
+.cluster-detail-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.cluster-detail-title strong {
+ font-size: 15px;
+ color: #202733;
+}
+.cluster-detail-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: #7f8998;
+}
+.cluster-detail-meta .dot {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #c4c9d4;
+ display: inline-block;
+}
+
+.cluster-node-table-wrap {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+.cluster-node-table {
+ width: 100%;
+ min-width: 940px;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+.cluster-node-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: #f7f8fa;
+ text-align: left;
+ padding: 9px 14px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #8d97a6;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--line);
+}
+.cluster-node-table td {
+ padding: 9px 14px;
+ color: #4a5568;
+ border-bottom: 1px solid #f0f1f4;
+ vertical-align: middle;
+}
+.cluster-node-table td strong {
+ color: #202733;
+ font-size: 13px;
+}
+.cluster-node-table td small {
+ color: #8d97a6;
+ font-size: 12px;
+}
+
+.table-sort-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ max-width: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: inherit;
+ letter-spacing: inherit;
+ text-transform: inherit;
+ cursor: pointer;
+ /* 防止在 grid 表头里被 stretch 拉满整个 cell */
+ justify-self: start;
+ width: fit-content;
+}
+
+.table-sort-button:hover,
+.table-sort-button.active {
+ color: var(--blue);
+}
+
+.table-sort-placeholder {
+ font-size: 11px;
+ line-height: 1;
+ opacity: 0.45;
+}
diff --git a/apps/rlark-ui/src/styles/components/dag-editor.css b/apps/rlark-ui/src/styles/components/dag-editor.css
new file mode 100644
index 0000000..6295c9e
--- /dev/null
+++ b/apps/rlark-ui/src/styles/components/dag-editor.css
@@ -0,0 +1,195 @@
+/* ── DAG Editor ── */
+.dag-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.dag-canvas {
+ position: relative;
+ width: 100%;
+ min-height: 420px;
+ max-height: 500px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--canvas);
+ user-select: none;
+}
+
+.dag-canvas-content {
+ position: relative;
+ width: 900px;
+ height: 500px;
+}
+
+.dag-svg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ z-index: 10;
+}
+
+.dag-edge {
+ pointer-events: stroke;
+ cursor: pointer;
+}
+
+.dag-edge:hover {
+ stroke: #ef4444 !important;
+ stroke-width: 3;
+}
+
+.dag-temp-line {
+ pointer-events: none;
+}
+
+.dag-node {
+ position: absolute;
+ width: 200px;
+ height: 56px;
+ display: flex;
+ align-items: stretch;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ cursor: grab;
+}
+
+.dag-node:active {
+ cursor: grabbing;
+}
+
+.dag-node.selected {
+ border-color: var(--blue);
+ box-shadow:
+ 0 0 0 2px var(--blue),
+ 0 2px 12px rgba(124, 58, 237, 0.2);
+}
+
+.dag-node {
+ z-index: 1;
+}
+
+.dag-node-body {
+ flex: 1;
+ min-width: 0;
+ padding: 6px 4px 6px 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+}
+
+.dag-node-body strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink);
+ line-height: 1.3;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ cursor: text;
+}
+
+.dag-node-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 1px 4px;
+ outline: none;
+ width: 100%;
+}
+
+.dag-node-name:focus {
+ border-color: var(--blue);
+ background: var(--canvas);
+}
+
+.dag-node-type {
+ font-size: 9px;
+ color: var(--muted);
+ background: var(--hover);
+ padding: 2px 5px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ line-height: 1.3;
+}
+
+.dag-node-delete {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: var(--panel);
+ color: var(--soft);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+ padding: 0;
+}
+
+.dag-node-delete:hover {
+ background: #ef4444;
+ color: #fff;
+ border-color: #ef4444;
+}
+
+.dag-node-port {
+ width: 12px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.dag-node-port::after {
+ content: "";
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--panel);
+ border: 2px solid var(--blue);
+ transition: transform 0.15s;
+}
+
+.dag-node-port.output {
+ cursor: crosshair;
+}
+
+.dag-node-port.output::after {
+ background: var(--blue);
+}
+
+.dag-node-port:hover::after {
+ transform: scale(1.3);
+}
+
+.dag-hint {
+ margin-top: 8px;
+ font-size: 11px;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.theme-dark .dag-canvas {
+ background: var(--canvas);
+}
+
+.theme-dark .dag-node {
+ background: var(--panel);
+}
diff --git a/apps/rlark-ui/src/styles/components/tag-and-filter.css b/apps/rlark-ui/src/styles/components/tag-and-filter.css
new file mode 100644
index 0000000..8672384
--- /dev/null
+++ b/apps/rlark-ui/src/styles/components/tag-and-filter.css
@@ -0,0 +1,592 @@
+/* ============ TagEditor ============ */
+.tag-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.tag-editor-compact {
+ gap: 6px;
+}
+
+.tag-editor-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.tag-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.tag-input-field {
+ display: flex;
+ flex: 0 0 240px;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.tag-input-field + .tag-sep + .tag-input-field {
+ flex: 1 1 240px;
+}
+
+.tag-input-field > .tag-combobox {
+ position: relative;
+}
+
+.tag-input-hint {
+ min-height: 16px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 16px;
+}
+
+.tag-values-editor {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ width: 100%;
+ height: 37.33px;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-width: 0;
+ padding: 4px 6px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg);
+}
+
+.tag-values-editor:focus-within {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.tag-values-editor .tag-combobox {
+ position: relative;
+ flex: 1 1 130px;
+ min-width: 120px;
+}
+
+.tag-values-editor .tag-combobox-input {
+ height: 27px;
+ padding: 2px 4px;
+ border: 0;
+ box-shadow: none;
+}
+
+.tag-values-editor .tag-combobox-input:focus {
+ box-shadow: none;
+}
+
+.tag-value-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ max-width: 100%;
+ padding: 3px 6px;
+ border-radius: 4px;
+ background: var(--hover, #f3f4f6);
+ color: var(--fg);
+ font-size: 12px;
+}
+
+.tag-value-chip button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.tag-value-chip button:hover {
+ color: var(--danger, #dc2626);
+}
+
+.tag-row .tag-sep {
+ display: flex;
+ align-self: flex-start;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ height: 37.33px;
+ padding: 0 2px;
+ color: var(--muted);
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
+.tag-row .tag-remove-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all 0.15s ease;
+}
+
+.tag-row .tag-remove-btn:hover {
+ background: var(--danger-bg, #fef2f2);
+ border-color: var(--danger-border, #fecaca);
+ color: var(--danger, #dc2626);
+}
+
+.tag-combobox-input {
+ width: 100%;
+ padding: 6px 10px;
+ font-size: 13px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--fg);
+ outline: none;
+ transition: border-color 0.15s ease;
+}
+
+.tag-combobox-input:focus {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+/* 标签键与其他行重复时的红色边框提示 */
+.tag-input-field-duplicate .tag-combobox-input {
+ border-color: var(--danger, #dc2626);
+}
+
+.tag-input-field-duplicate .tag-combobox-input:focus {
+ border-color: var(--danger, #dc2626);
+ box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
+}
+
+.tag-input-field-duplicate .tag-input-hint {
+ color: var(--danger, #dc2626);
+}
+
+.tag-combobox-list {
+ position: fixed;
+ z-index: 1000;
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ gap: 4px;
+ margin: 0;
+ padding: 6px;
+ background: var(--panel, #fff);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
+ list-style: none;
+ max-height: 200px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ box-sizing: border-box;
+}
+
+/* 输入框下方空间不足时向上翻转:top 锚定输入框顶部,整体上移自身高度 + 4px 间距 */
+.tag-combobox-list.open-up {
+ transform: translateY(calc(-100% - 4px));
+}
+
+.tag-combobox-list li {
+ max-width: 100%;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: var(--hover, #f3f4f6);
+ font-size: 13px;
+ line-height: 18px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.tag-combobox-list li:hover,
+.tag-combobox-list li.active {
+ background: rgba(99, 102, 241, 0.14);
+ color: var(--blue);
+}
+
+.tag-add-btn {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+}
+
+.tag-add-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.tag-editor-error {
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--danger, #dc2626);
+ background-color: #fef2f2;
+ opacity: 1;
+ border: 1px solid var(--danger-border, #fecaca);
+ border-radius: 6px;
+}
+
+/* ============ TagFilterPopover ============ */
+.tag-filter-popover {
+ position: fixed;
+ width: 440px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
+ z-index: 1000;
+ overflow: hidden;
+ animation: tagFilterFadeIn 0.15s ease;
+}
+
+@keyframes tagFilterFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.tag-filter-head {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.tag-filter-search {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ background: var(--input-bg, #f9fafb);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+}
+
+.tag-filter-search svg {
+ color: var(--muted);
+ flex-shrink: 0;
+}
+
+.tag-filter-search input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ font-size: 13px;
+ color: var(--fg);
+}
+
+.tag-filter-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ /* 定高 + minmax(0, 1fr) 行轨道,使左右两列成为受限高的滚动容器;
+ 否则行轨道会随内容撑开,overflow-y: auto 无法触发,key 过多时被裁剪。 */
+ grid-template-rows: minmax(0, 1fr);
+ height: 360px;
+}
+
+.tag-filter-keys,
+.tag-filter-values {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ min-height: 0;
+ border-right: 1px solid var(--line);
+}
+
+.tag-filter-values {
+ border-right: none;
+}
+
+.tag-filter-col-head {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--line);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ position: sticky;
+ top: 0;
+ /* --bg 未定义会解析为透明,key 上翻时会与列头文字重叠,改用弹层背景色 --panel */
+ background: var(--panel);
+ z-index: 1;
+}
+
+.tag-filter-col-title {
+ font-weight: 600;
+}
+
+.tag-filter-link-btn {
+ background: transparent;
+ border: none;
+ color: var(--blue);
+ font-size: 12px;
+ cursor: pointer;
+ padding: 0;
+}
+
+.tag-filter-link-btn:hover {
+ text-decoration: underline;
+}
+
+.tag-filter-key-item,
+.tag-filter-value-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.1s;
+ border-right: 2px solid transparent;
+}
+
+.tag-filter-key-item:hover,
+.tag-filter-value-item:hover {
+ background: var(--hover, #f3f4f6);
+}
+
+.tag-filter-key-item.selected {
+ background: var(--blue-bg, #eef2ff);
+ border-right-color: var(--blue);
+}
+
+.tag-filter-key-item.hovered {
+ background: var(--hover, #f3f4f6);
+}
+
+.tag-filter-key-item input,
+.tag-filter-value-item input {
+ margin: 0;
+ accent-color: var(--blue);
+}
+
+.tag-filter-key-count {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.tag-filter-value-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-top: auto;
+ padding: 8px 12px;
+ border-top: 1px solid var(--line);
+ background: var(--bg);
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.tag-filter-value-pagination button {
+ border: none;
+ padding: 0;
+ background: transparent;
+ color: var(--blue);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.tag-filter-value-pagination button:disabled {
+ color: var(--muted);
+ cursor: not-allowed;
+}
+
+.tag-filter-empty {
+ padding: 24px 12px;
+ font-size: 12px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.tag-filter-foot {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border-top: 1px solid var(--line);
+ background: var(--bg);
+}
+
+.tag-filter-reset {
+ font-size: 13px;
+}
+
+.tag-filter-confirm {
+ font-size: 13px;
+}
+
+/* ============ 表头列多选筛选(ColumnFilterPopover) ============ */
+.col-filter-popover {
+ position: fixed;
+ width: 240px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
+ z-index: 1000;
+ overflow: hidden;
+ animation: colFilterFadeIn 0.15s ease;
+}
+
+@keyframes colFilterFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.col-filter-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.col-filter-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.col-filter-link-btn {
+ background: transparent;
+ border: none;
+ color: var(--blue);
+ font-size: 12px;
+ cursor: pointer;
+ padding: 0;
+}
+
+.col-filter-link-btn:hover {
+ text-decoration: underline;
+}
+
+.col-filter-list {
+ max-height: 320px;
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.col-filter-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.col-filter-item:hover {
+ background: var(--hover, #f3f4f6);
+}
+
+.col-filter-item.selected {
+ background: var(--blue-bg, #eef2ff);
+}
+
+.col-filter-item input {
+ margin: 0;
+ accent-color: var(--blue);
+}
+
+.col-filter-empty {
+ padding: 20px 12px;
+ font-size: 12px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.col-filter-foot {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border-top: 1px solid var(--line);
+ background: var(--bg);
+}
+
+.col-filter-reset,
+.col-filter-confirm {
+ font-size: 13px;
+}
+
+.col-filter-reset:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* 表头筛选触发按钮,与 .table-sort-button 视觉对称 */
+.col-filter-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: none;
+ border: none;
+ font-size: inherit;
+ font-weight: inherit;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ position: relative;
+ /* grid/flex 父容器默认会 stretch 子项,把按钮拉满整个 cell。
+ 显式靠左收缩到内容宽度,避免点击图标右侧空白也触发。 */
+ justify-self: start;
+ width: fit-content;
+}
+
+.col-filter-button svg {
+ color: var(--muted);
+ flex-shrink: 0;
+}
+
+.col-filter-button:hover svg {
+ color: var(--blue);
+}
+
+.col-filter-button.active svg {
+ color: var(--blue);
+}
+
+.col-filter-badge {
+ position: absolute;
+ top: -4px;
+ right: -8px;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--blue);
+ color: #fff;
+ font-size: 10px;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
diff --git a/apps/rlark-ui/src/styles/foundation/shell.css b/apps/rlark-ui/src/styles/foundation/shell.css
new file mode 100644
index 0000000..1d50be9
--- /dev/null
+++ b/apps/rlark-ui/src/styles/foundation/shell.css
@@ -0,0 +1,1550 @@
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap");
+
+:root {
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ color: #171b25;
+ background: #eef1f5;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+
+ --ink: #171b25;
+ --muted: #7c8492;
+ --soft: #a9b0bd;
+ --line: #e7ebf0;
+ --line-strong: #dce2ea;
+ --panel: #ffffff;
+ --canvas: #f4f6f9;
+ --blue: #7c3aed;
+ --blue-2: #a78bfa;
+ --green: #36c98f;
+ --green-soft: #e7f8f1;
+ --red: #ef5a7a;
+ --danger: #e05270;
+ --danger-strong: #d84062;
+ --danger-soft: #f47d96;
+ --orange: #f59e35;
+ --shadow: 0 18px 50px rgba(28, 39, 58, 0.07);
+ --shadow-soft: 0 8px 24px rgba(28, 39, 58, 0.055);
+ --app-bg: #f4f6f9;
+ --hover: #f5f0fe;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ background: #e9edf3;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ margin: 0;
+ min-width: 0;
+ min-height: 100%;
+ background:
+ radial-gradient(
+ circle at 82% 8%,
+ rgba(124, 58, 237, 0.08),
+ transparent 26%
+ ),
+ linear-gradient(180deg, #eef1f5 0%, #f6f8fb 100%);
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+.app-shell {
+ width: 100%;
+ height: 100vh;
+ min-height: 0;
+ margin: 0;
+ display: grid;
+ grid-template-columns: 256px minmax(0, 1fr);
+ overflow: hidden;
+ background: var(--canvas);
+ transition: grid-template-columns 0.25s ease;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ align-self: start;
+ z-index: 5;
+ height: 100vh;
+ min-height: 0;
+ padding: 24px 16px 22px;
+ background: rgba(255, 255, 255, 0.88);
+ border-right: 1px solid var(--line);
+ color: var(--ink);
+ display: flex;
+ flex-direction: column;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.brand {
+ height: 70px;
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ margin-bottom: 24px;
+}
+
+.brand-logo {
+ width: 198px;
+ height: 58px;
+ object-fit: contain;
+ object-position: left center;
+ flex-shrink: 0;
+ display: block;
+ background: transparent;
+}
+
+.brand-logo-light {
+ width: 198px;
+}
+
+.brand-logo-dark {
+ display: none;
+}
+
+.brand-mark {
+ width: 36px;
+ height: 36px;
+ position: relative;
+ border-radius: 11px;
+ background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
+ box-shadow: 0 9px 18px rgba(124, 58, 237, 0.25);
+}
+
+.brand-mark span {
+ position: absolute;
+ display: block;
+ width: 8px;
+ height: 8px;
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.95);
+}
+
+.brand-mark span:nth-child(1) {
+ left: 6px;
+ top: 6px;
+ opacity: 0.9;
+}
+
+.brand-mark span:nth-child(2) {
+ right: 6px;
+ top: 6px;
+ opacity: 0.65;
+}
+
+.brand-mark span:nth-child(3) {
+ left: 10px;
+ bottom: 6px;
+ opacity: 0.8;
+}
+
+.brand strong {
+ display: block;
+ color: #131722;
+ font-size: 20px;
+ line-height: 20px;
+ letter-spacing: -0.8px;
+}
+
+.brand small {
+ color: #8f98a8;
+ font-size: 8px;
+ letter-spacing: 1.6px;
+ font-weight: 800;
+}
+
+.sidebar nav {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.nav-label {
+ font-size: 10px;
+ letter-spacing: 1.2px;
+ color: #a5adbb;
+ font-weight: 800;
+ padding: 18px 12px 7px;
+}
+
+.sidebar nav button,
+.sidebar-bottom > button {
+ width: 100%;
+ border: 0;
+ color: #4e5768;
+ background: transparent;
+ border-radius: 12px;
+ height: 44px;
+ padding: 0 12px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 13px;
+ font-weight: 650;
+ text-align: left;
+ position: relative;
+ transition: 0.18s ease;
+ white-space: nowrap;
+}
+
+.sidebar nav button:hover {
+ background: #f3f6fb;
+ color: #1f2530;
+}
+
+.sidebar nav button.active {
+ color: var(--blue);
+ background: #f3eefe;
+}
+
+.sidebar nav button.active::before {
+ content: "";
+ position: absolute;
+ left: -16px;
+ width: 4px;
+ height: 28px;
+ background: var(--blue);
+ border-radius: 0 8px 8px 0;
+}
+
+.sidebar nav button em {
+ margin-left: auto;
+ min-width: 24px;
+ height: 20px;
+ display: grid;
+ place-items: center;
+ font-size: 10px;
+ color: #14a771;
+ background: #dff8ed;
+ border-radius: 999px;
+ font-style: normal;
+}
+
+.nav-children {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-left: 16px;
+}
+
+.nav-children button {
+ height: 38px;
+ font-size: 12px;
+ font-weight: 550;
+}
+
+.nav-children button.active::before {
+ height: 22px;
+}
+
+.sidebar nav button.nav-parent-expanded {
+ color: #4e5768;
+ background: transparent;
+ font-weight: 650;
+}
+
+.sidebar nav button.nav-parent-expanded:hover {
+ background: #f3f6fb;
+}
+
+.sidebar-bottom {
+ margin-top: auto;
+ display: grid;
+ gap: 12px;
+}
+
+.environment-card {
+ min-height: 118px;
+ background:
+ radial-gradient(
+ circle at 80% 25%,
+ rgba(255, 255, 255, 0.18),
+ transparent 28%
+ ),
+ linear-gradient(145deg, #6d28d9 0%, #5b21b6 100%);
+ border: 0;
+ border-radius: 18px;
+ padding: 16px;
+ display: grid;
+ grid-template-columns: 38px 1fr 8px;
+ gap: 11px;
+ align-items: start;
+ box-shadow: 0 14px 28px rgba(124, 58, 237, 0.24);
+ color: white;
+}
+
+.environment-card > span {
+ width: 38px;
+ height: 38px;
+ background: rgba(255, 255, 255, 0.16);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 12px;
+ color: #fff;
+ display: grid;
+ place-items: center;
+}
+
+.environment-card small {
+ display: block;
+ font-size: 9px;
+ letter-spacing: 1px;
+ color: rgba(255, 255, 255, 0.66);
+ margin-top: 2px;
+}
+
+.environment-card strong {
+ display: block;
+ color: #fff;
+ font-size: 13px;
+ margin-top: 5px;
+}
+
+.env-meta {
+ display: block;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 10px;
+ font-weight: 500;
+ margin-top: 10px;
+}
+
+.environment-card > i {
+ width: 8px;
+ height: 8px;
+ background: #73ffbf;
+ border-radius: 50%;
+ box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.14);
+ margin-top: 5px;
+}
+
+.sidebar-bottom > button {
+ color: #828b9a;
+ justify-content: flex-start;
+}
+
+.main-area {
+ min-width: 0;
+ width: 100%;
+ height: 100vh;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ background: #f6f8fb;
+ height: 100vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.platform-footer {
+ flex: 0 0 auto;
+ padding: 10px 24px 11px;
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+ background: var(--canvas);
+ text-align: center;
+ font-size: 11px;
+ letter-spacing: 0.15px;
+}
+
+.platform-footer-links {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 11px;
+}
+
+.platform-footer-links strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.platform-footer-links > span {
+ color: var(--line-strong);
+}
+
+.platform-footer-links .platform-footer-maintainer {
+ color: var(--muted);
+}
+
+.platform-footer a {
+ color: var(--ink);
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.platform-footer a:hover {
+ color: var(--blue);
+ text-decoration: underline;
+}
+
+.topbar {
+ height: 72px;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.86);
+ backdrop-filter: blur(18px);
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 30px 0 28px;
+ position: sticky;
+ top: 0;
+ z-index: 1200;
+}
+
+.breadcrumbs {
+ display: none;
+}
+
+.topbar-context {
+ min-width: 150px;
+ display: grid;
+ gap: 2px;
+}
+
+.topbar-context > span {
+ color: #9aa4b2;
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0.7px;
+ text-transform: uppercase;
+}
+
+.topbar-context h1 {
+ margin: 0;
+ color: var(--ink);
+ font-size: 15px;
+ line-height: 1.2;
+ letter-spacing: -0.25px;
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: nowrap;
+ min-width: 0;
+}
+
+.segmented-control {
+ height: 38px;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ padding: 4px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: #fff;
+ box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
+ flex: none;
+}
+
+.segmented-control button {
+ height: 28px;
+ min-width: 34px;
+ border: 0;
+ border-radius: 999px;
+ padding: 0 10px;
+ background: transparent;
+ color: #7a8494;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 850;
+ white-space: nowrap;
+}
+
+.segmented-control button.active {
+ background: #f3eefe;
+ color: var(--blue);
+ box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.08);
+}
+
+.theme-control button {
+ min-width: 46px;
+}
+
+.cluster-picker,
+.secondary-button {
+ height: 38px;
+ border: 1px solid var(--line);
+ background: #fff;
+ color: #525b6a;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 12px;
+ font-size: 12px;
+ font-weight: 700;
+ box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
+ white-space: nowrap;
+ flex: none;
+}
+
+.online-pulse {
+ width: 8px;
+ height: 8px;
+ background: #32c98d;
+ border-radius: 50%;
+ box-shadow: 0 0 0 4px #def7ec;
+}
+
+.icon-button {
+ width: 38px;
+ height: 38px;
+ border: 1px solid var(--line);
+ color: #5f6978;
+ background: #fff;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ position: relative;
+ padding: 0;
+ box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
+}
+
+.icon-button em {
+ position: absolute;
+ right: -2px;
+ top: -3px;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ background: #ff6b70;
+ color: #fff;
+ font-style: normal;
+ font-size: 8px;
+ display: grid;
+ place-items: center;
+ border: 2px solid #fff;
+}
+
+.icon-button.small {
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+}
+
+.primary-button {
+ height: 40px;
+ padding: 0 14px;
+ border: 0;
+ background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%);
+ color: #fff;
+ border-radius: 13px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 800;
+ box-shadow: 0 10px 22px rgba(124, 58, 237, 0.26);
+ white-space: nowrap;
+ flex: none;
+}
+
+.avatar {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: linear-gradient(145deg, #ffe0b6, #ffd1c8);
+ color: #68412f;
+ font-size: 11px;
+ font-weight: 800;
+ margin-left: 2px;
+ border: 3px solid #fff;
+ box-shadow: 0 0 0 1px #e1e6ed;
+}
+
+.environment-status {
+ cursor: default;
+}
+
+.topbar-menu {
+ position: relative;
+ flex: none;
+}
+
+.topbar-popover {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 10px);
+ right: 0;
+ width: 230px;
+ padding: 14px;
+ display: grid;
+ gap: 8px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow);
+ color: var(--ink);
+}
+
+.topbar-popover strong {
+ font-size: 12px;
+}
+
+.topbar-popover span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.topbar-popover button {
+ height: 32px;
+ margin-top: 4px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: transparent;
+ color: var(--ink);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.page-content {
+ padding: 28px 28px 38px;
+ flex: 1;
+ width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 0;
+ scrollbar-gutter: stable;
+}
+
+.hero-strip {
+ min-height: 74px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin-bottom: 18px;
+}
+
+.eyebrow {
+ color: #8f98a8;
+ font-size: 10px;
+ letter-spacing: 0.8px;
+ font-weight: 800;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ text-transform: uppercase;
+}
+
+.hero-strip h2,
+.section-heading h2 {
+ margin: 7px 0 7px;
+ font-size: 28px;
+ line-height: 1.05;
+ letter-spacing: -1.1px;
+ color: #151922;
+}
+
+.hero-strip p,
+.section-heading p {
+ margin: 0;
+ color: #7f8897;
+ font-size: 13px;
+}
+
+.hero-health {
+ min-width: 210px;
+ padding: 14px 16px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ display: grid;
+ gap: 4px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+}
+
+.hero-health > span {
+ color: #8993a3;
+ font-size: 11px;
+}
+
+.hero-health strong {
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.hero-health strong i {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 0 5px #e0f8ee;
+}
+
+.hero-health small {
+ color: #a6afbc;
+ font-size: 10px;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 18px;
+ margin: 14px 0 18px;
+}
+
+.metric-card,
+.panel,
+.table-panel,
+.master-detail,
+.node-layout,
+.api-layout {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ box-shadow: var(--shadow-soft);
+}
+
+.metric-card {
+ padding: 20px 20px 18px;
+ min-height: 138px;
+ position: relative;
+ overflow: hidden;
+}
+
+.metric-card-action {
+ width: 100%;
+ border: 1px solid var(--line);
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.metric-card-action:hover {
+ transform: translateY(-2px);
+ border-color: rgba(124, 58, 237, 0.32);
+ box-shadow: 0 20px 54px rgba(124, 58, 237, 0.12);
+}
+
+.metric-card-action:focus-visible {
+ outline: 3px solid rgba(124, 58, 237, 0.22);
+ outline-offset: 3px;
+}
+
+.metric-card::after {
+ content: "";
+ position: absolute;
+ right: -40px;
+ top: -42px;
+ width: 112px;
+ height: 112px;
+ border-radius: 50%;
+ background: rgba(124, 58, 237, 0.055);
+}
+
+.metric-head {
+ display: flex;
+ justify-content: space-between;
+ color: #a2abb8;
+ margin-bottom: 18px;
+}
+
+.metric-icon {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+}
+
+.tone-mint .metric-icon {
+ background: #e4f8f0;
+ color: #20a875;
+}
+
+.tone-blue .metric-icon {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.tone-violet .metric-icon {
+ background: #f2edff;
+ color: #7b61ff;
+}
+
+.tone-orange .metric-icon {
+ background: #fff2df;
+ color: #e58a20;
+}
+
+.metric-label {
+ color: #4a5260;
+ font-size: 13px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.metric-value-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: 8px 0 4px;
+}
+
+.metric-value-row strong {
+ font-size: 30px;
+ line-height: 1;
+ letter-spacing: -1.4px;
+}
+
+.delta {
+ font-size: 11px;
+ font-weight: 800;
+ background: #dcf8eb;
+ color: #17a672;
+ border-radius: 999px;
+ padding: 4px 8px;
+}
+
+.metric-card > small {
+ font-size: 11px;
+ color: #98a1af;
+ display: block;
+ line-height: 1.45;
+ max-width: 100%;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: 1.55fr 0.95fr;
+ gap: 18px;
+ margin-bottom: 18px;
+}
+
+.bottom-grid {
+ display: grid;
+ grid-template-columns: 1.45fr 1fr;
+ gap: 18px;
+}
+
+.panel {
+ padding: 20px;
+}
+
+.panel-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.panel-title > div {
+ min-width: 0;
+}
+
+.panel-title .plain-button,
+.panel-title > .icon-button {
+ flex: 0 0 auto;
+}
+
+.panel-title span {
+ color: #8e97a6;
+ font-size: 11px;
+}
+
+.panel-title h3 {
+ margin: 4px 0 0;
+ font-size: 16px;
+ letter-spacing: -0.35px;
+}
+
+.legend {
+ font-size: 11px;
+ color: #8c96a5;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.legend i {
+ width: 8px;
+ height: 8px;
+ background: var(--blue);
+ border-radius: 50%;
+}
+
+.legend strong {
+ color: #222a36;
+ margin-left: 5px;
+}
+
+.plain-button {
+ border: 0;
+ background: transparent;
+ color: #596576;
+ font-size: 12px;
+ font-weight: 800;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.resource-chart {
+ height: 194px;
+ margin: 15px 0 10px;
+ position: relative;
+}
+
+.resource-chart svg {
+ width: 100%;
+ height: 166px;
+ overflow: visible;
+}
+
+.grid-lines line {
+ stroke: #edf1f6;
+ stroke-width: 1;
+}
+
+.resource-chart .area {
+ fill: url(#area);
+}
+
+.resource-chart .line {
+ fill: none;
+ stroke: var(--blue);
+ stroke-width: 2.8;
+ vector-effect: non-scaling-stroke;
+ filter: drop-shadow(0 5px 8px rgba(124, 58, 237, 0.2));
+}
+
+.chart-labels {
+ display: flex;
+ justify-content: space-between;
+ color: #a2abb8;
+ font-size: 10px;
+}
+
+.resource-summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ border-top: 1px solid var(--line);
+ padding-top: 14px;
+ gap: 18px;
+}
+
+.resource-summary > div {
+ position: relative;
+ padding-bottom: 10px;
+}
+
+.resource-summary span {
+ display: block;
+ color: #8a94a4;
+ font-size: 11px;
+}
+
+.resource-summary strong {
+ display: block;
+ font-size: 14px;
+ margin-top: 5px;
+}
+
+.resource-summary small {
+ font-size: 10px;
+ color: #98a1af;
+ font-weight: 500;
+}
+
+.resource-summary b {
+ height: 4px;
+ background: linear-gradient(90deg, var(--blue), #c4b5fd);
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ border-radius: 999px;
+}
+
+.cluster-resource-line {
+ display: block;
+ white-space: nowrap;
+}
+
+.mini-bars {
+ height: 178px;
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+ padding: 22px 8px 14px;
+ border-bottom: 1px solid var(--line);
+}
+
+.mini-bars i {
+ flex: 1;
+ background: #e4e9ef;
+ border-radius: 12px;
+ min-width: 12px;
+}
+
+.mini-bars i.active {
+ background: linear-gradient(180deg, #7c3aed, #a78bfa);
+ box-shadow: 0 8px 20px rgba(124, 58, 237, 0.18);
+}
+
+.phase-summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 9px;
+ padding-top: 14px;
+}
+
+.phase-summary > div {
+ display: grid;
+ grid-template-columns: 8px 1fr auto;
+ align-items: center;
+ gap: 7px;
+}
+
+.phase-summary i {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.dot-running {
+ background: var(--green);
+}
+
+.dot-pending {
+ background: var(--orange);
+}
+
+.dot-failed {
+ background: var(--red);
+}
+
+.phase-summary span {
+ font-size: 11px;
+ color: #7e8796;
+}
+
+.phase-summary strong {
+ font-size: 13px;
+}
+
+.workflow-list {
+ margin-top: 10px;
+}
+
+.workflow-list > button {
+ width: 100%;
+ height: 66px;
+ display: grid;
+ grid-template-columns: 40px 1fr auto 118px 18px;
+ align-items: center;
+ gap: 12px;
+ border: 0;
+ border-top: 1px solid var(--line);
+ background: transparent;
+ text-align: left;
+ color: #6f7988;
+}
+
+.workflow-list > button:hover {
+ background: #f8faff;
+}
+
+.workflow-symbol {
+ width: 36px;
+ height: 36px;
+ border-radius: 12px;
+ display: grid;
+ place-items: center;
+}
+
+.workflow-symbol.running {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.workflow-symbol.succeeded {
+ background: #e4f8f0;
+ color: #1f9c70;
+}
+
+.workflow-symbol.failed {
+ background: #ffe9ee;
+ color: #d94767;
+}
+
+.workflow-symbol.pending {
+ background: #fff2df;
+ color: #d48220;
+}
+
+.workflow-info strong,
+.master-list strong {
+ display: block;
+ color: #272d38;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.workflow-info small,
+.master-list small {
+ display: block;
+ color: #98a1af;
+ font-size: 11px;
+ margin-top: 3px;
+}
+
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 800;
+ border-radius: 999px;
+ padding: 5px 9px;
+ width: max-content;
+ white-space: nowrap;
+ flex: none;
+}
+
+.status i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.status-icon {
+ width: 12px;
+ height: 12px;
+ stroke-width: 2.5;
+}
+
+.status-running .status-icon,
+.status-stopping .status-icon,
+.status-deleting .status-icon {
+ animation: status-spin 1.25s linear infinite;
+}
+
+@keyframes status-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.status-running,
+.status-online {
+ background: #e7f8f1;
+ color: #168a61;
+}
+
+.status-running i,
+.status-online i {
+ background: var(--green);
+}
+
+.status-succeeded {
+ background: #e9f1ff;
+ color: #2563c9;
+}
+
+.status-succeeded i {
+ background: #3b82f6;
+}
+
+.status-failed,
+.status-offline {
+ background: #ffe9ee;
+ color: #cf3f61;
+}
+
+.status-failed i,
+.status-offline i {
+ background: var(--red);
+}
+
+.status-pending {
+ background: #fff3df;
+ color: #bd7424;
+}
+
+.status-pending i {
+ background: var(--orange);
+}
+
+.status-stopping,
+.status-deleting,
+.status-stopped {
+ background: #f0f0f5;
+ color: #6b6b80;
+}
+
+.status-stopped i {
+ background: #9a9ab0;
+}
+
+.status-with-info {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.status-info {
+ position: relative;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: 20px;
+ height: 20px;
+ box-sizing: border-box;
+ border: 1px solid #d9e0ea;
+ border-radius: 50%;
+ background: #f6f8fb;
+ cursor: help;
+ color: #7d8999;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease,
+ color 0.15s ease;
+}
+
+.status-info:hover,
+.status-info:focus {
+ border-color: #b7a5ed;
+ outline: none;
+ background: #f2effc;
+ color: #7051c7;
+}
+
+.status-info.status-info-danger {
+ color: var(--danger);
+}
+
+.status-info.status-info-danger:hover,
+.status-info.status-info-danger:focus {
+ color: var(--danger);
+}
+
+.status-info-tooltip {
+ position: absolute;
+ bottom: calc(100% + 10px);
+ left: 50%;
+ width: min(360px, calc(100vw - 24px));
+ box-sizing: border-box;
+ padding: 12px 13px;
+ border: 1px solid #dfe4ec;
+ border-radius: 10px;
+ background: #ffffff;
+ color: #273142;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.55;
+ white-space: normal;
+ box-shadow: 0 12px 30px rgba(31, 45, 65, 0.13);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s;
+ z-index: 999;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.status-info-tooltip-arrow {
+ position: absolute;
+ bottom: -5px;
+ width: 10px;
+ height: 10px;
+ box-sizing: border-box;
+ border-right: 1px solid #dfe4ec;
+ border-bottom: 1px solid #dfe4ec;
+ background: #ffffff;
+ transform: rotate(45deg);
+}
+
+/* Flipped-below variant: the arrow is moved to the top edge of the tooltip.
+ Position itself is handled by inline style (position: fixed) set from JS
+ in PullProgressInfo, so only the arrow needs to be flipped here. */
+.status-info-tooltip.status-info-tooltip-below .status-info-tooltip-arrow {
+ top: -5px;
+ bottom: auto;
+ border: 0;
+ border-top: 1px solid #dfe4ec;
+ border-left: 1px solid #dfe4ec;
+}
+
+.status-info-tooltip.status-info-tooltip-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.status-info-tooltip .pull-entry {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ border-top: 1px solid #edf0f5;
+ padding-top: 6px;
+}
+
+.status-info-tooltip .pull-entry:first-of-type {
+ border-top: none;
+ padding-top: 0;
+}
+
+.status-info-tooltip .pending-empty-message {
+ color: #667286;
+ line-height: 1.55;
+}
+
+.status-info-tooltip .status-message-entry {
+ color: #c0392b;
+ line-height: 1.55;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.status-info-tooltip.status-info-tooltip-danger .status-message-entry {
+ color: var(--danger);
+}
+
+.status-info-tooltip .pull-entry code {
+ font-size: 10px;
+ color: #5f49aa;
+ background: #f4f1fc;
+ padding: 2px 5px;
+ border-radius: 5px;
+ word-break: break-all;
+}
+
+.status-info-tooltip .pull-detail {
+ color: #7a8698;
+ font-size: 10px;
+}
+
+.status-info-tooltip .pull-status {
+ color: #9b661d;
+}
+
+/* Section heading for the "Node Events" block when both pull progress and
+ events are rendered. Mirrors .pull-entry's top border so the events
+ section reads as a sibling group. */
+.status-info-tooltip .status-info-tooltip-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ border-top: 1px solid #edf0f5;
+ padding-top: 6px;
+ margin-top: 2px;
+}
+
+.status-info-tooltip .status-info-tooltip-section small {
+ color: #8a95a6;
+ font-size: 9px;
+ font-weight: 500;
+}
+
+/* First section heading (Image Pull Progress) has no preceding border. */
+.status-info-tooltip > strong:first-child {
+ border-top: none;
+ padding-top: 0;
+ margin-top: 0;
+}
+
+.status-info-tooltip .event-entry .event-chip {
+ display: inline-block;
+ align-self: flex-start;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+ background: #edf1f6;
+ color: #667286;
+}
+
+.status-info-tooltip .event-entry .event-chip.event-pending {
+ background: #fff4df;
+ color: #b86c16;
+}
+
+.status-info-tooltip .event-entry .event-chip.event-failed {
+ background: #fff0f1;
+ color: #c6485e;
+}
+
+.status-info-tooltip .event-entry .event-chip.event-normal {
+ background: #edf8f2;
+ color: #2f8a60;
+}
+
+.theme-dark .status-info {
+ border-color: #344156;
+ background: #1b2637;
+ color: #9aa7ba;
+}
+
+.theme-dark .status-info:hover,
+.theme-dark .status-info:focus {
+ border-color: #7562b8;
+ background: #292442;
+ color: #c4b5fd;
+}
+
+.theme-dark .status-info-tooltip {
+ border-color: #334056;
+ background: #182231;
+ color: #edf1f7;
+ box-shadow: 0 14px 34px rgba(0, 0, 0, 0.32);
+}
+
+.theme-dark .status-info-tooltip-arrow {
+ border-color: #334056;
+ background: #182231;
+}
+
+.theme-dark
+ .status-info-tooltip.status-info-tooltip-below
+ .status-info-tooltip-arrow {
+ border-color: #334056;
+}
+
+.theme-dark .status-info-tooltip .pending-empty-message,
+.theme-dark .status-info-tooltip .event-entry .event-message {
+ color: #aeb9c9;
+}
+
+.theme-dark .status-info-tooltip .pull-entry,
+.theme-dark .status-info-tooltip .status-info-tooltip-section {
+ border-color: #2d394c;
+}
+
+.theme-dark .status-info-tooltip .status-message-entry {
+ color: #ff6b6b;
+}
+
+.progress-cell {
+ display: grid;
+ grid-template-columns: 1fr 32px;
+ align-items: center;
+ gap: 9px;
+}
+
+.progress-cell > i,
+.inline-progress > i {
+ display: block;
+ width: 100%;
+ min-width: 40px;
+ height: 5px;
+ background: #e8edf4;
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress-cell b,
+.inline-progress b {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, var(--blue), #c4b5fd);
+ border-radius: 999px;
+}
+
+.progress-cell small {
+ font-size: 10px;
+ color: #7f8897;
+}
+
+.activity-list {
+ margin-top: 12px;
+}
+
+.activity-list > div {
+ height: 54px;
+ border-top: 1px solid var(--line);
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+}
+
+.activity-dot {
+ width: 21px;
+ height: 21px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #eef2f7;
+}
+
+.activity-dot i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+
+.activity-dot.success i,
+.activity-dot.running i {
+ background: var(--green);
+}
+
+.activity-dot.warning i {
+ background: var(--orange);
+}
+
+.activity-dot.error i {
+ background: var(--red);
+}
+
+.activity-list strong {
+ display: block;
+ font-size: 12px;
+}
+
+.activity-list small {
+ font-size: 10px;
+ color: #98a1af;
+}
+
+.activity-list time {
+ font-size: 10px;
+ color: #a2abb8;
+}
diff --git a/apps/rlark-ui/src/styles/jobs/actions-and-refresh.css b/apps/rlark-ui/src/styles/jobs/actions-and-refresh.css
new file mode 100644
index 0000000..024d99f
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/actions-and-refresh.css
@@ -0,0 +1,186 @@
+.admin-job-actions {
+ display: inline-flex;
+ justify-content: flex-end;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.admin-job-actions .icon-button {
+ width: 32px;
+ height: 32px;
+}
+
+.admin-job-actions .icon-button.danger,
+.job-detail-actions .secondary-button.danger {
+ color: #dc2626;
+ border-color: rgba(220, 38, 38, 0.24);
+ background: rgba(254, 242, 242, 0.88);
+}
+
+.admin-job-actions .icon-button.danger:hover,
+.job-detail-actions .secondary-button.danger:hover {
+ border-color: rgba(220, 38, 38, 0.44);
+ background: #fee2e2;
+}
+
+.job-detail-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 6px;
+ max-width: 520px;
+}
+
+.job-detail-actions .secondary-button {
+ min-height: 32px;
+ height: 32px;
+ padding: 0 10px;
+ gap: 5px;
+ font-size: 11px;
+}
+
+.job-detail-actions .secondary-button.primary-action {
+ border-color: rgba(37, 134, 90, 0.28);
+ background: rgba(37, 134, 90, 0.08);
+ color: #25865a;
+}
+
+.job-detail-actions .secondary-button:disabled {
+ opacity: 0.55;
+ cursor: wait;
+}
+
+.job-action-loading {
+ animation: job-action-spin 0.8s linear infinite;
+}
+
+@keyframes job-action-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.refreshable-region {
+ position: relative;
+}
+
+.refreshable-region > :not(.refreshable-region-overlay) {
+ transition:
+ opacity 0.16s ease,
+ filter 0.16s ease;
+}
+
+.refreshable-region.is-refreshing {
+ cursor: progress;
+}
+
+.refreshable-region.is-refreshing > :not(.refreshable-region-overlay) {
+ opacity: 0.42;
+ filter: saturate(0.45);
+ pointer-events: none;
+ user-select: none;
+}
+
+.refreshable-region.page-refresh-region > .section-heading,
+.refreshable-region.page-refresh-region > .admin-dashboard-hero,
+.refreshable-region > .refresh-region-toolbar {
+ position: relative;
+ z-index: 21;
+}
+
+.refreshable-region.is-refreshing.page-refresh-region > .section-heading,
+.refreshable-region.is-refreshing.page-refresh-region > .admin-dashboard-hero,
+.refreshable-region.is-refreshing > .refresh-region-toolbar {
+ opacity: 1;
+ filter: none;
+ pointer-events: auto;
+ user-select: auto;
+}
+
+.refreshable-region-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: grid;
+ place-items: center;
+ background: rgba(255, 255, 255, 0.2);
+ cursor: progress;
+}
+
+.refreshable-region-spinner {
+ color: var(--blue);
+ filter: drop-shadow(0 2px 5px rgba(80, 70, 210, 0.2));
+ animation: job-action-spin 0.8s linear infinite;
+}
+
+.theme-dark .refreshable-region-overlay {
+ background: rgba(14, 20, 31, 0.18);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .refreshable-region > :not(.refreshable-region-overlay) {
+ transition: none;
+ }
+
+ .refreshable-region-spinner {
+ animation: none;
+ }
+}
+
+.job-detail-action-error {
+ max-width: 520px;
+ color: #d64d68 !important;
+ font-size: 10px !important;
+ font-weight: 650 !important;
+ text-align: right;
+}
+
+.theme-dark .admin-job-actions .icon-button.danger,
+.theme-dark .job-detail-actions .secondary-button.danger {
+ color: #fca5a5;
+ border-color: rgba(248, 113, 113, 0.3);
+ background: rgba(127, 29, 29, 0.24);
+}
+
+.theme-dark .job-detail-actions .secondary-button.primary-action {
+ border-color: rgba(74, 222, 128, 0.28);
+ background: rgba(22, 101, 52, 0.2);
+ color: #86efac;
+}
+
+@media (max-width: 620px) {
+ .metric-grid,
+ .cluster-overview-grid,
+ .cluster-card-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .theme-control {
+ display: none;
+ }
+
+ .overview-china-heading {
+ flex-direction: column;
+ }
+
+ .overview-china-legend {
+ justify-content: flex-start;
+ }
+
+ .overview-china-map {
+ min-height: 320px;
+ }
+
+ .overview-china-map > svg {
+ inset: 8px 2px 5px;
+ width: calc(100% - 4px);
+ }
+
+ .overview-china-aside {
+ display: flex;
+ }
+
+ .overview-city-summary {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/jobs/code-editor-and-list.css b/apps/rlark-ui/src/styles/jobs/code-editor-and-list.css
new file mode 100644
index 0000000..f662032
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/code-editor-and-list.css
@@ -0,0 +1,998 @@
+.code-editor-shell {
+ width: 100%;
+ overflow: hidden;
+ border: 1px solid #3c3c3c;
+ border-radius: 10px;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
+}
+
+.code-editor-header {
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 11px;
+ border-bottom: 1px solid #333333;
+ background: #252526;
+}
+
+.code-editor-header span,
+.code-editor-header em {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 10px;
+ font-style: normal;
+ line-height: 1;
+}
+
+.code-editor-header span {
+ overflow: hidden;
+ color: #cccccc;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.code-editor-header span svg {
+ flex: 0 0 auto;
+ color: #4ec9b0;
+}
+
+.code-editor-header em {
+ flex: 0 0 auto;
+ color: #858585;
+}
+
+.code-editor-body {
+ position: relative;
+ min-width: 0;
+ background: #1e1e1e;
+ overflow: hidden;
+}
+
+.code-editor-lines {
+ position: absolute;
+ z-index: 3;
+ top: 12px;
+ left: 0;
+ width: 42px;
+ display: flex;
+ flex-direction: column;
+ color: #858585;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 11px;
+ line-height: 20px;
+ text-align: right;
+ pointer-events: none;
+ will-change: transform;
+}
+
+.code-editor-lines span {
+ height: 20px;
+ padding-right: 10px;
+}
+
+.code-editor-body::after {
+ content: "";
+ position: absolute;
+ z-index: 0;
+ top: 0;
+ bottom: 0;
+ left: 42px;
+ border-left: 1px solid #2b2b2b;
+ pointer-events: none;
+}
+
+.code-editor-body textarea,
+.code-editor-viewer pre,
+.code-editor-highlight,
+.job-config-summary .code-editor-viewer pre {
+ width: 100%;
+ margin: 0;
+ padding: 12px 14px 12px 54px;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 11px;
+ line-height: 20px;
+ tab-size: 4;
+ white-space: pre;
+ word-break: normal;
+ overflow: auto;
+ box-shadow: none;
+ scrollbar-color: #424242 #1e1e1e;
+}
+
+.code-editor-highlight {
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ pointer-events: none;
+ color: #d4d4d4;
+ will-change: transform;
+}
+
+.code-editor-highlight code {
+ display: block;
+ width: max-content;
+ min-width: 100%;
+ color: inherit;
+ font: inherit;
+}
+
+.code-editor-highlight-line {
+ display: inline;
+}
+
+.code-token-keyword {
+ color: #c586c0;
+ font-weight: 700;
+}
+
+.code-token-flag {
+ color: #9cdcfe;
+}
+
+.code-token-value {
+ color: #ce9178;
+}
+
+.code-editor-body textarea {
+ position: relative;
+ z-index: 2;
+ display: block;
+ resize: vertical;
+ caret-color: #ffffff;
+ color: #d4d4d4;
+ background: transparent;
+}
+
+.code-editor-body textarea::placeholder {
+ color: #6a6a6a;
+ opacity: 1;
+}
+
+.code-editor-body textarea::selection,
+.code-editor-viewer code::selection {
+ background: #264f78;
+}
+
+.code-editor-input:focus-within {
+ border-color: #007acc;
+ box-shadow:
+ 0 0 0 2px rgba(0, 122, 204, 0.22),
+ 0 8px 20px rgba(15, 23, 42, 0.16);
+}
+
+.code-editor-viewer {
+ margin-top: 7px;
+ box-shadow: none;
+}
+
+.code-editor-viewer pre,
+.job-config-summary .code-editor-viewer pre {
+ max-height: 260px;
+}
+
+.code-editor-viewer pre code,
+.job-config-summary .code-editor-viewer pre code {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ background: transparent;
+ font: inherit;
+ white-space: inherit;
+}
+
+.env-row {
+ display: grid;
+ grid-template-columns: 1fr 1.5fr 32px;
+ gap: 8px;
+ margin-top: 8px;
+ align-items: start;
+}
+
+.device-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr 32px;
+ gap: 8px;
+ margin-top: 8px;
+ align-items: center;
+}
+
+.device-row select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 11px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.device-row input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 11px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+}
+
+.theme-dark .device-row select,
+.theme-dark .device-row input {
+ background: #151d2b;
+ color: #c4cbd6;
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.mount-row {
+ display: grid;
+ grid-template-columns:
+ auto minmax(0, 1fr) minmax(0, 1fr) minmax(140px, 0.45fr)
+ 32px;
+ gap: 8px;
+ margin-top: 8px;
+ align-items: start;
+}
+
+.mount-size-field {
+ max-width: 240px;
+}
+
+.mount-field-box {
+ min-width: 0;
+ height: 40px;
+ display: grid;
+ grid-template-columns: minmax(88px, 0.34fr) minmax(0, 1fr);
+ align-items: stretch;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ overflow: hidden;
+}
+
+.mount-field-box > span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ padding: 0 10px;
+ border-right: 1px solid var(--line);
+ background: #f3f6fb;
+ color: var(--muted, #8a94a6);
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.mount-type-toggle {
+ display: inline-flex;
+ height: 40px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.mount-type-toggle button {
+ border: none;
+ background: transparent;
+ padding: 8px 10px;
+ font-size: 12px;
+ color: var(--muted, #8a94a6);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.mount-type-toggle button.active {
+ background: var(--blue, #6366f1);
+ color: #fff;
+}
+
+.theme-dark .mount-type-toggle {
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.theme-dark .mount-type-toggle button {
+ color: #8a94a6;
+}
+
+.theme-dark .mount-type-toggle button.active {
+ background: var(--blue, #6366f1);
+ color: #fff;
+}
+
+.mount-row select,
+.env-row select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 11px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.mount-row input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 11px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+}
+
+.mount-row .mount-field-box select,
+.mount-row .mount-field-box input {
+ min-height: 0;
+ height: 100%;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.theme-dark .mount-row select,
+.theme-dark .mount-row input,
+.theme-dark .env-row select {
+ background: #151d2b;
+ border-color: var(--line);
+ color: #d8e0ee;
+}
+
+.theme-dark .mount-field-box {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .mount-field-box > span {
+ background: #111a29;
+ border-color: var(--line);
+ color: #9ca8ba;
+}
+
+.theme-dark .mount-row .mount-field-box select,
+.theme-dark .mount-row .mount-field-box input {
+ background: transparent;
+}
+
+.env-row input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 11px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+}
+
+.theme-dark .subpage-tabs button,
+.theme-dark .job-config-summary > div,
+.theme-dark .form-section,
+.theme-dark .ssh-key-select-list,
+.theme-dark .role-template.selectable button,
+.theme-dark .env-row input {
+ background: #151d2b;
+ border-color: var(--line);
+ color: #d8e0ee;
+}
+
+.theme-dark .job-config-summary strong,
+.theme-dark .form-section-head strong,
+.theme-dark .role-resource-card > strong {
+ color: #f5f7fb;
+}
+
+.theme-dark .job-config-summary code,
+.theme-dark .job-config-summary pre {
+ background: #101827;
+ color: #d7e4f6;
+}
+
+.theme-dark .job-config-summary .code-editor-viewer pre {
+ background: #1e1e1e;
+ color: #d4d4d4;
+}
+
+.theme-dark .job-config-summary .code-editor-viewer pre code {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+}
+
+.theme-dark .subpage-tabs button.active,
+.theme-dark .role-template.selectable button.active {
+ background: rgba(167, 139, 250, 0.16);
+ border-color: #5b21b6;
+ color: #c4b5fd;
+}
+
+.jobs-table-panel .link-cell {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ text-align: left;
+ color: inherit;
+}
+
+.jobs-table-panel .link-cell strong {
+ display: block;
+ color: var(--blue);
+ font-size: 13px;
+}
+
+.jobs-table-panel th:first-child,
+.jobs-table-panel td:first-child {
+ width: 240px;
+ min-width: 240px;
+}
+
+.job-id-cell-wrap {
+ display: grid;
+ gap: 3px;
+}
+
+.jobs-table-panel .job-id-cell {
+ width: 100%;
+ min-width: 0;
+}
+
+.jobs-table-panel .job-id-cell strong,
+.jobs-table-panel .job-id-copy small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.job-id-copy {
+ width: fit-content;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: #7a8494;
+ font-size: 11px;
+}
+
+.job-id-copy:hover {
+ color: var(--blue);
+}
+
+.job-id-copy small {
+ color: inherit;
+ font-size: inherit;
+}
+
+.job-detail-resource-line {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.jobs-table-panel .job-id-cell strong {
+ line-height: 1.35;
+}
+
+.jobs-table-panel .job-id-cell.is-long strong {
+ font-size: 11px;
+ line-height: 1.3;
+}
+
+.jobs-table-panel .link-cell small {
+ display: block;
+ color: #98a1af;
+ font-size: 10px;
+ margin-top: 3px;
+}
+
+.inline-code {
+ display: inline-block;
+ padding: 5px 7px;
+ border-radius: 8px;
+ background: #f0f4fa;
+ color: #26313f;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+}
+
+.back-button {
+ margin-bottom: 10px;
+}
+
+.create-stepper {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0;
+ padding: 16px 24px;
+ border-bottom: 1px solid var(--line);
+ background: #fbfcfe;
+}
+
+.create-stepper button {
+ position: relative;
+ height: 44px;
+ border: 0;
+ background: transparent;
+ color: #8792a1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.create-stepper button:not(:last-child)::after {
+ content: "";
+ position: absolute;
+ right: -25%;
+ width: 50%;
+ height: 2px;
+ background: #dfe6ef;
+ z-index: 0;
+}
+
+.create-stepper span {
+ position: relative;
+ z-index: 1;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: #e8edf4;
+ color: #8792a1;
+}
+
+.create-stepper button.active {
+ color: var(--blue);
+}
+
+.create-stepper button.active span,
+.create-stepper button.active:not(:last-child)::after {
+ background: var(--blue);
+ color: #fff;
+}
+
+.yaml-preview {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ overflow: hidden;
+ background: #fbfcfe;
+}
+
+.yaml-preview > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.yaml-preview strong {
+ color: #202733;
+}
+
+.yaml-preview small {
+ color: #7f8998;
+}
+
+.yaml-preview pre {
+ margin: 0;
+ padding: 18px;
+ max-height: 430px;
+ overflow: auto;
+ background: #101827;
+ color: #d7e4f6;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.65;
+}
+
+.theme-dark .inline-code,
+.theme-dark .create-stepper,
+.theme-dark .yaml-preview {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .inline-code {
+ color: #d9e2f0;
+}
+
+.theme-dark .yaml-preview strong {
+ color: #f5f7fb;
+}
+
+.theme-dark .yaml-preview small,
+.theme-dark .jobs-table-panel .link-cell small {
+ color: #9ca8ba;
+}
+
+.theme-dark .resource-row,
+.theme-dark .cluster-models > div,
+.theme-dark .robot-state-list > div,
+.theme-dark .cluster-pill,
+.theme-dark .role-template,
+.theme-dark .worker-table,
+.theme-dark .job-worker-main,
+.theme-dark .job-worker-side,
+.theme-dark .job-detail-summary-card,
+.theme-dark .job-detail-summary-section,
+.theme-dark .worker-role-strip > div,
+.theme-dark .worker-primary-panel,
+.theme-dark .job-observe-panel,
+.theme-dark .worker-detail-grid > div,
+.theme-dark .pod-subtable,
+.theme-dark .worker-ssh-access,
+.theme-dark .empty-inline,
+.theme-dark .worker-metric-card {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .resource-row strong,
+.theme-dark .cluster-models strong,
+.theme-dark .robot-state-list strong,
+.theme-dark .cluster-pill strong,
+.theme-dark .resource-row b,
+.theme-dark .job-worker-main h3,
+.theme-dark .job-detail-summary-head h2,
+.theme-dark .job-detail-summary-section h3,
+.theme-dark .job-worker-side strong,
+.theme-dark .worker-role-strip strong,
+.theme-dark .worker-panel-head h3,
+.theme-dark .observe-panel-head h3,
+.theme-dark .worker-node-cell strong,
+.theme-dark .worker-detail-grid strong,
+.theme-dark .worker-metric-card > div strong,
+.theme-dark .worker-metric-card label span {
+ color: #f5f7fb;
+}
+
+.theme-dark .resource-row small,
+.theme-dark .cluster-models span,
+.theme-dark .robot-state-list small,
+.theme-dark .cluster-pill small,
+.theme-dark .job-worker-main p,
+.theme-dark .job-detail-summary-head p,
+.theme-dark .job-detail-summary-status small,
+.theme-dark .job-worker-side span,
+.theme-dark .job-worker-side small,
+.theme-dark .worker-role-strip span,
+.theme-dark .worker-role-strip small,
+.theme-dark .worker-panel-head small,
+.theme-dark .worker-node-cell small,
+.theme-dark .worker-detail-grid span,
+.theme-dark .worker-ssh-access span,
+.theme-dark .worker-metric-card label,
+.theme-dark .worker-metric-card small,
+.theme-dark .empty-inline {
+ color: #9ca8ba;
+}
+
+.theme-dark .worker-ssh-access code {
+ color: #d7e4f6;
+}
+
+.theme-dark .pod-subtable {
+ background: #111a29;
+}
+
+.theme-dark .pod-subtable th {
+ background: #151d2b;
+ color: #9ca8ba;
+}
+
+.theme-dark .pod-subtable td {
+ background: #172235;
+ border-color: var(--line);
+ color: #d7e4f6;
+}
+
+.theme-dark .pod-subtable tbody tr:nth-child(even) td {
+ background: #141f31;
+}
+
+.theme-dark .pod-subtable tbody tr:hover td {
+ background: #1d2a3f;
+}
+
+.theme-dark .pod-subtable .inline-code {
+ background: #101827;
+ color: #d7e4f6;
+}
+
+.theme-dark .job-worker-side code,
+.theme-dark .worker-resource-cell span {
+ background: #111a29;
+ color: #d7e4f6;
+}
+
+.theme-dark .role-worker-group,
+.theme-dark .worker-access-card,
+.theme-dark .config-section,
+.theme-dark .config-kv-card,
+.theme-dark .config-value-list,
+.theme-dark .role-config-card,
+.theme-dark .role-runtime-config,
+.theme-dark .role-runtime-facts {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .role-runtime-tabs button,
+.theme-dark .role-runtime-meta span {
+ background: #111a29;
+ border-color: var(--line);
+}
+
+.theme-dark .role-runtime-tabs button.active {
+ background: #202b3d;
+ border-color: #5b4a93;
+ color: #c4b5fd;
+}
+
+.theme-dark .role-runtime-heading strong,
+.theme-dark .role-runtime-facts strong,
+.theme-dark .role-runtime-facts code {
+ color: #f5f7fb;
+}
+
+.theme-dark .role-runtime-image code {
+ background: #111a29;
+ border-color: var(--line);
+ color: #d7e4f6;
+}
+
+.theme-dark .role-runtime-summary {
+ background: #111a29;
+ border-color: var(--line);
+}
+
+.theme-dark .role-runtime-resource-summary strong {
+ background: #151d2b;
+ border-color: var(--line);
+ color: #f5f7fb;
+}
+
+.theme-dark .sub-tabs,
+.theme-dark .metrics-scope-toggle,
+.theme-dark .worker-filter-bar,
+.theme-dark .metrics-filter-bar select,
+.theme-dark .worker-filter-bar select,
+.theme-dark .time-series-card,
+.theme-dark .public-config-card,
+.theme-dark .task-summary-metric,
+.theme-dark .log-list,
+.theme-dark .log-search-field,
+.theme-dark .stream-toggle,
+.theme-dark .worker-table-actions .icon-button {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .job-detail-summary-section,
+.theme-dark .task-summary-metric {
+ background: #111a29;
+}
+
+.theme-dark .sub-tabs button.active,
+.theme-dark .metrics-scope-toggle button.active {
+ background: #202b3d;
+ color: #c4b5fd;
+}
+
+.theme-dark .worker-total-count,
+.theme-dark .node-kind-chip {
+ background: #202b3d;
+ color: #c4b5fd;
+}
+
+.theme-dark .log-list-row p,
+.theme-dark .metrics-source-label {
+ color: #c7d1df;
+}
+
+.theme-dark .log-list-head {
+ border-color: var(--line);
+ background: #111a29;
+}
+
+.theme-dark .metrics-integration-state {
+ background: #111a29;
+}
+
+.theme-dark .metrics-integration-icon {
+ background: #202b3d;
+}
+
+.theme-dark .copyable-code-block,
+.theme-dark .public-card-head a,
+.theme-dark .worker-filter-bar,
+.theme-dark .log-search-field {
+ background: #111a29;
+}
+
+.theme-dark .copyable-code-block > div {
+ border-color: var(--line);
+}
+
+.theme-dark .copyable-code-block code {
+ color: #d7e4f6;
+}
+
+.theme-dark .command-code-block {
+ background: #111a29;
+ border-color: var(--line);
+}
+
+.theme-dark .command-code-block .icon-button {
+ background: #151d2b;
+}
+
+.theme-dark .command-line-number {
+ border-color: var(--line);
+ color: #6f7d91;
+}
+
+.theme-dark .command-line-content {
+ color: #d7e4f6;
+}
+
+.theme-dark .command-token-keyword {
+ color: #c4b5fd;
+}
+
+.theme-dark .command-token-flag {
+ color: #93c5fd;
+}
+
+.theme-dark .command-token-value {
+ color: #6ee7b7;
+}
+
+.theme-dark .public-command-card,
+.theme-dark .public-basic-config-card,
+.theme-dark .public-compact-config-card,
+.theme-dark .role-runtime-command-card,
+.theme-dark .role-runtime-table-card,
+.theme-dark .role-runtime-selector-card,
+.theme-dark .worker-ssh-inline {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .public-basic-config-list div,
+.theme-dark .public-compact-config-table th,
+.theme-dark .role-runtime-command-card > span,
+.theme-dark .role-runtime-table-card th,
+.theme-dark .role-runtime-selector-card > span,
+.theme-dark .role-runtime-selector-card p,
+.theme-dark .public-config-table th {
+ background: #111a29;
+ border-color: var(--line);
+}
+
+.theme-dark .public-basic-config-list div {
+ background: transparent;
+}
+
+.theme-dark .public-basic-config-list {
+ border-color: var(--line);
+}
+
+.theme-dark .public-basic-config-list span {
+ background: #111a29;
+ border-color: var(--line);
+ color: #9ca8ba;
+}
+
+.theme-dark .public-basic-config-list code {
+ border-color: var(--line);
+ background: transparent;
+}
+
+.theme-dark .public-basic-config-list code,
+.theme-dark .public-compact-config-table code,
+.theme-dark .role-runtime-table-card code,
+.theme-dark .role-runtime-selector-card code,
+.theme-dark .worker-ssh-inline code {
+ color: #d7e4f6;
+}
+
+.theme-dark .role-runtime-table-card td {
+ background: #172235;
+ border-color: var(--line);
+}
+
+.theme-dark .role-runtime-table-card tbody tr:nth-child(even) td {
+ background: #141f31;
+}
+
+.theme-dark .role-runtime-table-card code,
+.theme-dark .role-runtime-selector-card code {
+ background: #101827;
+}
+
+.theme-dark .role-runtime-selector-card code {
+ border-color: var(--line);
+ background: #111a29;
+ color: #d7e4f6;
+}
+
+.theme-dark .role-runtime-selector-card b {
+ background: #202b3d;
+ color: #9ca8ba;
+}
+
+.theme-dark .role-worker-group-head,
+.theme-dark .config-section-head {
+ background: #192333;
+ border-color: var(--line);
+}
+
+.theme-dark .role-worker-group-head > div > strong,
+.theme-dark .role-worker-resource-summary strong,
+.theme-dark .worker-access-head strong,
+.theme-dark .worker-access-meta b,
+.theme-dark .config-section-head h3,
+.theme-dark .config-kv-card strong,
+.theme-dark .role-config-head strong,
+.theme-dark .role-config-facts dd {
+ color: #f5f7fb;
+}
+
+.theme-dark .role-worker-group-head small,
+.theme-dark .role-worker-resource-summary span,
+.theme-dark .role-worker-resource-summary small,
+.theme-dark .worker-access-head small,
+.theme-dark .worker-access-meta span,
+.theme-dark .config-section-head small,
+.theme-dark .config-kv-card > span,
+.theme-dark .config-kv-card small,
+.theme-dark .config-command > span,
+.theme-dark .config-value-list > span,
+.theme-dark .config-value-list small,
+.theme-dark .role-config-facts dt {
+ color: #9ca8ba;
+}
+
+.theme-dark .worker-access-meta span,
+.theme-dark .config-value-list code {
+ background: #111a29;
+ color: #d7e4f6;
+}
+
+.theme-dark .config-ssh-card code,
+.theme-dark .role-config-facts code {
+ color: #cbd5e1;
+}
diff --git a/apps/rlark-ui/src/styles/jobs/create.css b/apps/rlark-ui/src/styles/jobs/create.css
new file mode 100644
index 0000000..f13bfb1
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/create.css
@@ -0,0 +1,1395 @@
+.create-job-modal {
+ width: min(1120px, calc(100vw - 72px));
+ max-height: 90vh;
+}
+
+.create-job-body {
+ max-height: calc(90vh - 148px);
+ overflow: auto;
+}
+
+/* Keep the job name input and the job type select top-aligned so the
+ inline validation message never shifts them out of alignment. */
+.create-job-body .form-row {
+ align-items: start;
+}
+
+/* Reserve one line of space under the job name input for the
+ validation message so showing it does not reflow the row. */
+.job-name-field-error {
+ min-height: 17px;
+}
+
+.form-section {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 14px;
+ margin-bottom: 14px;
+ background: #fbfcfe;
+}
+
+.form-section-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.form-section-head strong {
+ color: #202733;
+ font-size: 13px;
+}
+
+.form-section-head small {
+ color: #7f8998;
+ font-size: 11px;
+}
+
+.ssh-key-select-list {
+ max-height: 216px;
+ overflow-y: auto;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+}
+
+.modal-body .ssh-key-select-option {
+ display: flex;
+ grid-template-columns: none;
+ align-items: center;
+ gap: 10px;
+ min-height: 42px;
+ margin: 0;
+ padding: 9px 12px;
+ cursor: pointer;
+ color: #4f5b6b;
+}
+
+.modal-body .ssh-key-select-option input[type="checkbox"] {
+ width: 17px;
+ height: 17px;
+ padding: 0;
+ flex: 0 0 17px;
+}
+
+.ssh-key-select-option + .ssh-key-select-option {
+ border-top: 1px solid var(--line);
+}
+
+.ssh-key-select-option.selected {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.ssh-key-select-option input {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+.ssh-key-select-option span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.role-template.selectable {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 9px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.role-template.selectable button {
+ min-height: 42px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ color: #4f5b6b;
+ padding: 8px 11px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.role-template.selectable button.active {
+ border-color: var(--blue);
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.role-template.selectable button.disabled,
+.role-template.selectable button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.role-template.selectable small {
+ color: #8d97a6;
+ font-size: 10px;
+}
+
+.role-edit-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.role-edit-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.role-edit-row:hover {
+ border-color: #c5cee0;
+}
+
+.role-edit-row.active {
+ border-color: var(--blue);
+ background: #f3eefe;
+}
+
+.role-edit-row.disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.role-edit-row.disabled:hover {
+ border-color: var(--line);
+}
+
+.role-edit-row input {
+ flex: 1;
+ border: 1px solid transparent;
+ background: transparent;
+ font-size: 13px;
+ font-weight: 600;
+ color: #202733;
+ padding: 4px 6px;
+ border-radius: 6px;
+ outline: none;
+}
+
+.role-edit-row input:focus {
+ border-color: var(--blue);
+ background: #fff;
+}
+
+.role-edit-row input.input-invalid {
+ border-color: var(--red);
+}
+
+.role-edit-row input.input-invalid:focus {
+ border-color: var(--red);
+}
+
+.role-name-field {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.role-edit-row small {
+ font-size: 10px;
+ color: #8d97a6;
+ white-space: nowrap;
+}
+
+.role-edit-row.active small {
+ color: var(--blue);
+}
+
+.role-edit-row small.field-error {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--red);
+ white-space: normal;
+}
+
+.role-edit-row.active small.field-error {
+ color: var(--red);
+}
+
+.theme-dark .role-edit-row {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .role-edit-row.active {
+ background: rgba(167, 139, 250, 0.16);
+ border-color: #5b21b6;
+}
+
+.theme-dark .role-edit-row input {
+ color: #f5f7fb;
+}
+
+.role-resource-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.role-config-tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.role-config-tabs button {
+ padding: 8px 16px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: none;
+ color: #6b7684;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ transition:
+ color 0.15s,
+ border-color 0.15s;
+}
+
+.role-config-tabs button:hover {
+ color: #27303d;
+}
+
+.role-config-tabs button.active {
+ color: var(--blue);
+ border-bottom-color: var(--blue);
+}
+
+.empty-state-hint {
+ padding: 40px 16px;
+ text-align: center;
+ color: #6b7684;
+ font-size: 14px;
+}
+
+.input-hint {
+ display: block;
+ margin-top: 4px;
+ font-size: 12px;
+ color: #9ca3af;
+}
+
+.label-with-hint .label-text {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.input-hint-inline {
+ font-size: 12px;
+ color: #9ca3af;
+ font-weight: 400;
+}
+
+.theme-dark .input-hint-inline {
+ color: #9ca8ba;
+}
+
+.step-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 16px;
+ border-top: 1px solid var(--line);
+ margin-top: 16px;
+}
+
+.theme-dark .role-config-tabs {
+ border-color: var(--line);
+}
+
+.theme-dark .role-config-tabs button {
+ color: #8b95a5;
+}
+
+.theme-dark .role-config-tabs button:hover {
+ color: #d8e0ee;
+}
+
+.theme-dark .role-config-tabs button.active {
+ color: #c4b5fd;
+ border-bottom-color: #c4b5fd;
+}
+
+.theme-dark .step-actions {
+ border-color: var(--line);
+}
+
+.role-resource-card {
+ border: 0;
+ border-radius: 0;
+ padding: 4px 0 0;
+ background: transparent;
+}
+
+.role-resource-card .form-section-head strong {
+ color: #202733;
+}
+
+.worker-config-section {
+ margin-top: 18px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+}
+
+.worker-placement-section {
+ background: transparent;
+}
+
+.worker-runtime-section {
+ padding-top: 20px;
+ border-top: 1px solid var(--line);
+ background: transparent;
+}
+
+.worker-config-section-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding-bottom: 12px;
+ border-bottom: 0;
+}
+
+.worker-config-section-index {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 28px;
+ height: 28px;
+ border-radius: 7px;
+ background: #7650df;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.worker-config-section-head > div {
+ display: grid;
+ gap: 4px;
+}
+
+.worker-config-section-head strong {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: #202733;
+ font-size: 13px;
+}
+
+.worker-config-section-head small {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.worker-cluster-field {
+ display: grid;
+ gap: 7px;
+ width: min(100%, 620px);
+ margin: 8px 0 0 32px !important;
+}
+
+.worker-cluster-field > span {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.worker-cluster-select {
+ position: relative;
+}
+
+.worker-cluster-trigger {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) auto auto 18px;
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ height: 42px;
+ box-sizing: border-box;
+ padding: 0 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--panel);
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+}
+
+.worker-cluster-trigger:hover,
+.worker-cluster-trigger[aria-expanded="true"] {
+ border-color: rgba(118, 80, 223, 0.58);
+ box-shadow: 0 0 0 3px rgba(118, 80, 223, 0.09);
+}
+
+.worker-cluster-trigger strong,
+.worker-cluster-options strong {
+ overflow: hidden;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-cluster-trigger > svg {
+ color: var(--muted);
+ transition: transform 0.18s ease;
+}
+
+.worker-cluster-trigger > svg.open {
+ transform: rotate(180deg);
+}
+
+.worker-cluster-placeholder {
+ grid-column: 1 / 4;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.worker-cluster-type,
+.worker-cluster-state {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ min-height: 22px;
+ box-sizing: border-box;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.worker-cluster-type {
+ background: rgba(118, 80, 223, 0.12);
+ color: #6842ca;
+}
+
+.worker-cluster-state {
+ background: rgba(37, 134, 90, 0.11);
+ color: #25865a;
+}
+
+.worker-cluster-state i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.worker-cluster-state.degraded,
+.worker-cluster-state.pending {
+ background: rgba(197, 135, 28, 0.13);
+ color: #a76d0f;
+}
+
+.worker-cluster-state.offline,
+.worker-cluster-state.failed,
+.worker-cluster-state.stopped {
+ background: rgba(214, 77, 104, 0.12);
+ color: #cf3f5d;
+}
+
+.worker-cluster-options {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 6px);
+ right: 0;
+ left: 0;
+ overflow: hidden;
+ padding: 5px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+ box-shadow: 0 16px 36px rgba(24, 31, 43, 0.16);
+}
+
+.worker-cluster-options > button {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) auto auto 18px;
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ min-height: 42px;
+ padding: 7px 9px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+}
+
+.worker-cluster-options > button:hover {
+ background: var(--soft);
+}
+
+.worker-cluster-options > button.active {
+ background: rgba(118, 80, 223, 0.08);
+}
+
+.worker-cluster-check {
+ color: var(--blue);
+}
+
+.theme-dark .worker-cluster-trigger,
+.theme-dark .worker-cluster-options {
+ background: #141d2c;
+}
+
+.theme-dark .worker-cluster-options {
+ box-shadow: 0 18px 42px rgba(0, 0, 0, 0.34);
+}
+
+.theme-dark .worker-cluster-type {
+ background: rgba(167, 139, 250, 0.16);
+ color: #c4b5fd;
+}
+
+.theme-dark .worker-cluster-state {
+ background: rgba(52, 211, 153, 0.14);
+ color: #6ee7b7;
+}
+
+.theme-dark .worker-cluster-state.degraded,
+.theme-dark .worker-cluster-state.pending {
+ background: rgba(251, 191, 36, 0.14);
+ color: #fcd34d;
+}
+
+.theme-dark .worker-cluster-state.offline,
+.theme-dark .worker-cluster-state.failed,
+.theme-dark .worker-cluster-state.stopped {
+ background: rgba(248, 113, 113, 0.14);
+ color: #fca5a5;
+}
+
+.worker-placement-section .placement-picker {
+ margin: 16px 0 0 32px;
+}
+
+.worker-runtime-section > .form-section {
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.theme-dark .worker-placement-section,
+.theme-dark .worker-runtime-section {
+ background: transparent;
+}
+
+.theme-dark .worker-config-section-head strong {
+ color: #eef0f6;
+}
+
+.placement-picker {
+ margin-top: 10px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: transparent;
+}
+
+.placement-section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 18px;
+ padding-top: 2px;
+}
+
+.placement-section-heading > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: #4d5667;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.placement-section-heading > small {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.placement-resource-config {
+ margin-top: 16px;
+}
+
+.placement-resource-options {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.placement-resource-options legend {
+ margin-bottom: 8px;
+ color: #4d5667;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.placement-resource-option-head,
+.placement-resource-options > label {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) 160px 150px;
+ align-items: center;
+ column-gap: 16px;
+}
+
+.placement-resource-option-head {
+ min-height: 28px;
+ padding: 0 12px 0 42px;
+ border-bottom: 1px solid #dfe4eb;
+ color: #8993a3;
+ font-size: 9px;
+ font-weight: 700;
+}
+
+.placement-resource-options > label {
+ position: relative;
+ min-height: 48px;
+ margin: 0 !important;
+ padding: 0 12px 0 42px;
+ border-bottom: 1px solid #e6e9ee;
+ cursor: pointer;
+ transition: background-color 120ms ease;
+}
+
+.placement-resource-options > label:hover {
+ background: #f7f7fa;
+}
+
+.placement-resource-options > label.active {
+ background: #f3f0fb;
+}
+
+.modal-body .placement-resource-options input[type="radio"] {
+ position: absolute;
+ left: 13px;
+ top: 50%;
+ width: 16px;
+ min-width: 16px;
+ height: 16px;
+ min-height: 16px;
+ margin: -8px 0 0;
+ padding: 0;
+ appearance: none;
+ border: 1.5px solid #9ca6b5;
+ border-radius: 50%;
+ background: #fff;
+}
+
+.modal-body .placement-resource-options input[type="radio"]:checked {
+ border: 5px solid #7650df;
+ box-shadow: none;
+}
+
+.placement-resource-option-name {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.placement-resource-option-name small {
+ flex: none;
+ min-width: 48px;
+ color: #7650df;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.placement-resource-option-name strong {
+ overflow: hidden;
+ color: #202733;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.placement-resource-option-stat {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ color: #202733;
+}
+
+.placement-resource-option-stat strong {
+ font-size: 13px;
+}
+
+.placement-resource-option-stat small {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.placement-plan-controls {
+ display: grid;
+ grid-template-columns: minmax(160px, 0.8fr) minmax(250px, 1.25fr) minmax(
+ 160px,
+ 0.8fr
+ );
+ align-items: start;
+ gap: 16px;
+ margin-top: 14px;
+ padding: 14px;
+ border-radius: 10px;
+ background: #f5f6f8;
+}
+
+.placement-field {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ margin: 0 !important;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.placement-field > small,
+.placement-scheduling-choice > small,
+.placement-derived-workers > small {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+.placement-field-label {
+ display: flex;
+ align-items: center;
+ height: 16px;
+ line-height: 16px;
+ white-space: nowrap;
+}
+
+.placement-field-label i {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: auto;
+ color: #8993a3;
+ font-size: 9px;
+ font-style: normal;
+ font-weight: 600;
+}
+
+.placement-field select,
+.placement-field input,
+.placement-readonly-control {
+ width: 100%;
+ height: 44px;
+ min-height: 44px;
+ box-sizing: border-box;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ font-size: 13px;
+}
+
+.placement-field select,
+.placement-field input {
+ padding: 0 14px;
+ line-height: 42px;
+}
+
+.placement-field select {
+ padding-right: 42px !important;
+ border-color: rgba(118, 80, 223, 0.28);
+ background-color: rgba(118, 80, 223, 0.055);
+ background-position: right 14px center;
+ color: #5631bd;
+ font-weight: 700;
+}
+
+.placement-field input {
+ border-color: #b9c2cf;
+ background: #fff;
+ color: #202733;
+ font-weight: 700;
+}
+
+.placement-readonly-control {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 0 14px;
+ border-color: #dfe4eb;
+ background: #eef1f5;
+ font-weight: 500;
+}
+
+.placement-readonly-control strong {
+ color: #202733;
+ font-size: 15px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.placement-readonly-control small {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.25;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.placement-scheduling-choice {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.placement-scheduling-choice legend {
+ height: 16px;
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 16px;
+}
+
+.placement-scheduling-choice > div {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ height: 44px;
+ padding: 3px;
+ box-sizing: border-box;
+ border: 1px solid #cfd5de;
+ border-radius: 12px;
+ background: #e8ebf0;
+}
+
+.placement-scheduling-choice label {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ margin: 0 !important;
+ border-radius: 9px;
+ color: #657083;
+ cursor: pointer;
+}
+
+.placement-scheduling-choice label.active {
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(20, 27, 38, 0.12);
+ color: #5631bd;
+}
+
+.modal-body .placement-scheduling-choice input[type="radio"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.placement-scheduling-choice label span {
+ overflow: hidden;
+ font-size: 11px;
+ font-weight: 750;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.placement-scheduling-choice > small {
+ display: block;
+ margin-top: 8px;
+}
+
+.placement-derived-workers {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.placement-derived-workers > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ height: 44px;
+ padding: 0 14px;
+ box-sizing: border-box;
+ border: 1px solid #d8dde5;
+ border-radius: 12px;
+ background: #eceff3;
+}
+
+.placement-derived-workers strong {
+ color: #202733;
+ font-size: 15px;
+}
+
+.placement-derived-workers div small {
+ color: #7c8695;
+ font-size: 10px;
+ font-weight: 600;
+}
+.placement-empty,
+.placement-manual-hint {
+ margin-top: 14px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.placement-manual-hint {
+ margin-bottom: 0;
+ padding: 0 2px;
+ line-height: 1.45;
+}
+
+.placement-node-grid {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 10px;
+ margin-top: 10px;
+ user-select: none;
+ touch-action: none;
+}
+
+.placement-selection-box {
+ position: absolute;
+ z-index: 10;
+ border: 1px solid #7650df;
+ border-radius: 5px;
+ background: rgba(118, 80, 223, 0.14);
+ box-shadow: 0 0 0 1px rgba(118, 80, 223, 0.08);
+ pointer-events: none;
+}
+
+.placement-node-card {
+ position: relative;
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ min-height: 122px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ color: #202733;
+ text-align: left;
+ cursor: pointer;
+}
+
+.placement-node-head {
+ min-width: 0;
+}
+
+.placement-node-head strong {
+ display: block;
+ width: 100%;
+ font-size: 14px;
+ line-height: 1.3;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.placement-node-head em {
+ flex: none;
+ padding: 3px 7px;
+ border-radius: 999px;
+ background: rgba(37, 134, 90, 0.1);
+ color: #25865a;
+ font-size: 10px;
+ font-style: normal;
+ font-weight: 700;
+}
+
+.placement-node-card.unavailable .placement-node-head em {
+ background: rgba(214, 77, 104, 0.12);
+ color: #d64d68;
+}
+
+.placement-node-location {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.placement-node-meta,
+.placement-node-state {
+ display: flex;
+ align-items: center;
+}
+
+.placement-node-meta {
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.placement-node-state {
+ flex: none;
+ justify-content: flex-end;
+ gap: 6px;
+}
+
+.placement-node-specs {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 8px;
+ border-top: 1px solid var(--line);
+}
+
+.placement-node-card .placement-node-specs > span {
+ display: flex;
+ min-width: 0;
+ overflow: hidden;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.placement-node-card .placement-node-specs > span:last-child {
+ flex: none;
+}
+
+.placement-node-specs b {
+ color: inherit;
+ font-size: 12px;
+}
+
+.placement-node-card:hover {
+ border-color: #a996ec;
+}
+.placement-node-card.active {
+ border-color: #7650df;
+ box-shadow: inset 0 0 0 1px #7650df;
+ background: #f5f2ff;
+}
+.placement-node-card:disabled {
+ opacity: 1;
+ cursor: not-allowed;
+}
+.placement-node-card.unavailable {
+ border-color: rgba(214, 77, 104, 0.3);
+ background: rgba(214, 77, 104, 0.06);
+}
+.placement-node-card small {
+ overflow: hidden;
+ color: var(--muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.placement-node-card span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--muted);
+ font-size: 11px;
+}
+.placement-node-card em {
+ color: #25865a;
+ font-size: 11px;
+ font-style: normal;
+}
+.placement-node-card.unavailable em {
+ color: #d64d68;
+ font-weight: 700;
+}
+.placement-node-state > svg {
+ color: #d64d68;
+}
+.placement-node-check {
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+}
+.placement-node-card.active .placement-node-check {
+ border-color: #7650df;
+ background: #7650df;
+ color: #fff;
+}
+.placement-selection-summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+.placement-selection-summary > div {
+ display: grid;
+ gap: 4px;
+}
+.placement-selection-summary small {
+ color: var(--muted);
+ font-size: 11px;
+}
+.placement-selection-summary button {
+ border: 0;
+ background: transparent;
+ color: #7650df;
+ cursor: pointer;
+}
+
+.placement-summary-validation {
+ display: grid;
+ justify-items: end;
+ gap: 3px;
+ text-align: right;
+}
+
+.placement-summary-validation strong {
+ color: #25865a;
+ font-size: 12px;
+}
+
+.placement-summary-validation small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.placement-summary-validation.invalid strong {
+ color: #d64d68;
+}
+
+.theme-dark .placement-picker {
+ border-color: #303a49;
+ background: transparent;
+}
+.theme-dark .placement-mode-tabs,
+.theme-dark .placement-readonly-control,
+.theme-dark .placement-node-card {
+ background: #20242f;
+}
+.theme-dark .placement-mode-tabs {
+ background: transparent;
+}
+.theme-dark .placement-section-heading > span {
+ color: #d8e0ee;
+}
+.theme-dark .placement-resource-options legend,
+.theme-dark .placement-resource-option-name strong,
+.theme-dark .placement-resource-option-stat {
+ color: #d8e0ee;
+}
+.theme-dark .placement-resource-option-head,
+.theme-dark .placement-resource-options > label {
+ border-color: #303a49;
+}
+.theme-dark .placement-resource-options > label:hover {
+ background: rgba(255, 255, 255, 0.035);
+}
+.theme-dark .placement-resource-options > label.active {
+ background: rgba(118, 80, 223, 0.14);
+}
+.theme-dark .placement-plan-controls {
+ background: #171d27;
+}
+.theme-dark .placement-scheduling-choice > div {
+ border-color: #3b475a;
+ background: #111722;
+}
+.theme-dark .placement-scheduling-choice label.active {
+ background: #29243d;
+ color: #c1aef8;
+ box-shadow: none;
+}
+.theme-dark .placement-derived-workers > div {
+ border-color: #303a49;
+ background: #252b36;
+}
+.theme-dark .placement-derived-workers strong {
+ color: #eef0f6;
+}
+.theme-dark .placement-field select {
+ border-color: rgba(155, 126, 238, 0.38);
+ background-color: rgba(118, 80, 223, 0.14);
+ color: #c1aef8;
+}
+.theme-dark .placement-field input {
+ border-color: #3b475a;
+ background: #111a29;
+ color: #d8e0ee;
+}
+.theme-dark .placement-readonly-control {
+ border-color: #303a49;
+ background: #252b36;
+}
+.theme-dark .placement-node-card.unavailable {
+ background: rgba(214, 77, 104, 0.1);
+}
+.theme-dark .placement-node-card,
+.theme-dark .placement-readonly-control strong {
+ color: #eef0f6;
+}
+.theme-dark .placement-node-card.active {
+ background: #29243d;
+}
+
+@media (max-width: 900px) {
+ .placement-plan-controls {
+ grid-template-columns: 1fr;
+ }
+
+ .placement-resource-option-head,
+ .placement-resource-options > label {
+ grid-template-columns: minmax(160px, 1fr) 120px 110px;
+ column-gap: 10px;
+ }
+}
+
+.resource-input-row {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.form-error-banner {
+ margin-bottom: 14px;
+ padding: 11px 13px;
+ border: 1px solid rgba(239, 90, 122, 0.28);
+ border-radius: 12px;
+ background: rgba(239, 90, 122, 0.08);
+ color: var(--red);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.image-picker {
+ position: relative;
+}
+
+.image-picker-list {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ max-height: 224px;
+ overflow-y: auto;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0 10px 24px rgba(31, 45, 65, 0.14);
+}
+
+.image-picker-option {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 3px;
+ padding: 9px 12px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.image-picker-option:last-child {
+ border-bottom: 0;
+}
+
+.image-picker-option:hover,
+.image-picker-option:focus-visible {
+ outline: none;
+ background: #f4f1ff;
+}
+
+.image-picker-option strong {
+ max-width: 100%;
+ overflow: hidden;
+ color: #202733;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.image-picker-option small {
+ color: #7f8998;
+ font-size: 11px;
+}
+
+.theme-dark .image-picker-list {
+ border-color: #485262;
+ background: #252a33;
+}
+
+.theme-dark .image-picker-option {
+ border-color: #485262;
+}
+
+.theme-dark .image-picker-option:hover,
+.theme-dark .image-picker-option:focus-visible {
+ background: #342b54;
+}
+
+.theme-dark .image-picker-option strong {
+ color: #e7edf7;
+}
+
+.form-section input.input-invalid {
+ border-color: #d64d68;
+ box-shadow: 0 0 0 3px rgba(214, 77, 104, 0.1);
+}
+
+.field-validation-error {
+ display: block;
+ margin-top: 6px;
+ color: #c93f5c;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.theme-dark .form-section input.input-invalid {
+ border-color: #e06b82;
+ box-shadow: 0 0 0 3px rgba(224, 107, 130, 0.13);
+}
+
+.theme-dark .field-validation-error {
+ color: #f08aa0;
+}
diff --git a/apps/rlark-ui/src/styles/jobs/detail-console.css b/apps/rlark-ui/src/styles/jobs/detail-console.css
new file mode 100644
index 0000000..ce960c5
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/detail-console.css
@@ -0,0 +1,393 @@
+.terminal-page {
+ width: 100vw;
+ height: 100vh;
+ padding: 12px;
+ box-sizing: border-box;
+ background: #101020;
+}
+
+.terminal-page-panel {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid #30334d;
+ border-radius: 12px;
+ background: #1a1a2e;
+ box-shadow: 0 12px 36px rgb(0 0 0 / 25%);
+}
+
+.terminal-toolbar {
+ flex: 0 0 auto;
+ min-height: 58px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #30334d;
+ background: #17182a;
+}
+
+.terminal-identity,
+.terminal-actions,
+.terminal-title span {
+ display: flex;
+ align-items: center;
+}
+
+.terminal-identity {
+ min-width: 0;
+ gap: 10px;
+}
+
+.terminal-app-icon {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 1px solid #3c4263;
+ border-radius: 9px;
+ color: #9dacff;
+ background: #22243b;
+}
+
+.terminal-title {
+ min-width: 0;
+}
+
+.terminal-title strong {
+ display: block;
+ overflow: hidden;
+ color: #f7f8ff;
+ font:
+ 650 14px/1.35 Menlo,
+ Monaco,
+ "Courier New",
+ monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.terminal-title small {
+ display: block;
+ overflow: hidden;
+ margin-bottom: 1px;
+ color: #8f96b3;
+ font-size: 10px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.terminal-title span {
+ gap: 5px;
+ margin-top: 2px;
+ color: #9da3bd;
+ font-size: 11px;
+}
+
+.terminal-worker-status {
+ border-radius: 4px;
+ padding: 1px 5px;
+ color: #d9dcf0;
+ background: #30334a;
+ font-style: normal;
+ line-height: 1.4;
+}
+
+.terminal-worker-status.running,
+.terminal-worker-status.online,
+.terminal-worker-status.succeeded {
+ color: #72e4af;
+ background: rgb(61 220 151 / 12%);
+}
+
+.terminal-worker-status.failed,
+.terminal-worker-status.offline {
+ color: #ff8797;
+ background: rgb(255 102 122 / 12%);
+}
+
+.terminal-status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #f3bd45;
+}
+
+.terminal-status-dot.connected {
+ background: #3ddc97;
+}
+
+.terminal-status-dot.disconnected {
+ background: #ff667a;
+}
+
+.terminal-actions {
+ flex: 0 0 auto;
+ gap: 6px;
+}
+
+.terminal-action-button,
+.terminal-download-submit {
+ display: inline-flex;
+ height: 34px;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: 1px solid #3c4263;
+ border-radius: 8px;
+ color: #e7e9f5;
+ background: #22243b;
+ cursor: pointer;
+}
+
+.terminal-action-button {
+ padding: 0 11px;
+ font-size: 12px;
+}
+
+.terminal-action-button:hover,
+.terminal-action-button.active {
+ border-color: #6574dc;
+ color: #fff;
+ background: #30365d;
+}
+
+.terminal-download-bar {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #30334d;
+ background: #151627;
+}
+
+.terminal-download-bar input {
+ min-width: 0;
+ height: 34px;
+ flex: 1;
+ box-sizing: border-box;
+ border: 1px solid #3c4263;
+ border-radius: 8px;
+ outline: none;
+ padding: 0 11px;
+ color: #f1f2f8;
+ background: #1d1f34;
+ font:
+ 12px Menlo,
+ Monaco,
+ "Courier New",
+ monospace;
+}
+
+.terminal-download-bar input:focus {
+ border-color: #7081f4;
+ box-shadow: 0 0 0 2px rgb(112 129 244 / 18%);
+}
+
+.terminal-download-submit {
+ min-width: 62px;
+ padding: 0 14px;
+}
+
+.terminal-download-submit:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.terminal-transfer-status {
+ flex: 0 0 auto;
+ padding: 5px 12px;
+ border-bottom: 1px solid #30334d;
+ color: #8ed0ff;
+ background: #181a2d;
+ font-size: 11px;
+}
+
+.terminal-body {
+ flex: 1;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+ background: #1a1a2e;
+ border-radius: 0 0 10px 10px;
+}
+
+.terminal-page-error {
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+ color: #f0f0fa;
+ background: #101020;
+}
+
+.terminal-container {
+ width: 100%;
+ height: 100%;
+ padding: 8px;
+ box-sizing: border-box;
+}
+
+.terminal-container .xterm {
+ height: 100%;
+}
+
+.terminal-container .xterm-viewport {
+ background-color: #1a1a2e !important;
+}
+
+.ssh-desc {
+ margin: 0 0 14px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.ssh-command-box {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px 14px;
+}
+
+.ssh-command-box code {
+ flex: 1;
+ font-family: var(--mono);
+ font-size: 14px;
+ color: var(--ink);
+ word-break: break-all;
+}
+
+.theme-dark .ssh-command-box {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.theme-dark .ssh-command-box code {
+ color: #e2e8f0;
+}
+
+.system-config-ssh-preview {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 12px 16px;
+ color: #24324a;
+ background: #f4f7fb;
+}
+
+.system-config-ssh-preview code {
+ min-width: 0;
+ color: inherit;
+ background: transparent;
+ font-family: var(--font-mono, monospace);
+ font-size: 13px;
+ line-height: 1.55;
+ word-break: break-all;
+}
+
+.system-config-copy-button {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ border: 1px solid rgba(79, 70, 229, 0.22);
+ border-radius: 7px;
+ padding: 6px 9px;
+ color: #4f46e5;
+ background: rgba(79, 70, 229, 0.07);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.system-config-copy-button:hover {
+ border-color: rgba(79, 70, 229, 0.4);
+ background: rgba(79, 70, 229, 0.13);
+}
+
+.system-config-page .section-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ flex: none;
+}
+
+.system-config-page .section-actions .primary-button,
+.system-config-page .section-actions .secondary-button {
+ height: 40px;
+ min-width: 94px;
+ justify-content: center;
+ transition:
+ border-color 0.18s ease,
+ background 0.18s ease,
+ color 0.18s ease,
+ box-shadow 0.18s ease,
+ transform 0.18s ease;
+}
+
+.system-config-page .section-actions .secondary-button:hover:not(:disabled) {
+ border-color: rgba(124, 58, 237, 0.3);
+ color: var(--blue);
+ background: #faf8ff;
+}
+
+.system-config-page .section-actions .primary-button:hover:not(:disabled) {
+ box-shadow: 0 12px 26px rgba(124, 58, 237, 0.32);
+ transform: translateY(-1px);
+}
+
+.system-config-page .section-actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+ box-shadow: none;
+ transform: none;
+}
+
+.theme-dark .system-config-ssh-preview {
+ border-color: #33445e;
+ color: #dbeafe;
+ background: #0b1320;
+ box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.04);
+}
+
+.theme-dark .system-config-copy-button {
+ border-color: rgba(147, 197, 253, 0.25);
+ color: #bfdbfe;
+ background: rgba(59, 130, 246, 0.12);
+}
+
+@media (max-width: 620px) {
+ .system-config-page .section-heading {
+ flex-direction: column;
+ }
+
+ .system-config-page .section-actions {
+ width: 100%;
+ }
+
+ .system-config-page .section-actions .primary-button,
+ .system-config-page .section-actions .secondary-button {
+ flex: 1 1 0;
+ }
+
+ .system-config-ssh-preview {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .system-config-copy-button {
+ align-self: flex-end;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/jobs/detail-foundation.css b/apps/rlark-ui/src/styles/jobs/detail-foundation.css
new file mode 100644
index 0000000..586eb62
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/detail-foundation.css
@@ -0,0 +1,2200 @@
+.platform-metrics {
+ grid-template-columns: 1fr 1.15fr 1.35fr 1fr;
+}
+
+.resource-split {
+ display: grid;
+ gap: 16px;
+ margin: 22px 0;
+}
+
+.resource-row {
+ display: grid;
+ grid-template-columns: 230px 1fr 48px;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fbfcff;
+}
+
+.resource-row strong {
+ display: block;
+ color: #252c38;
+ font-size: 14px;
+}
+
+.resource-row small {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+ margin-top: 4px;
+}
+
+.resource-row > span {
+ height: 9px;
+ border-radius: 999px;
+ background: #e8edf4;
+ overflow: hidden;
+}
+
+.resource-row > span i {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+}
+
+.resource-row.blue > span i {
+ background: linear-gradient(90deg, #7c3aed, #c4b5fd);
+}
+
+.resource-row.green > span i {
+ background: linear-gradient(90deg, #26b985, #67ddb2);
+}
+
+.resource-row.orange > span i {
+ background: linear-gradient(90deg, #f59e35, #ffc36f);
+}
+
+.resource-row b {
+ justify-self: end;
+ color: #27303d;
+ font-size: 14px;
+}
+
+.cluster-models {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+ border-top: 1px solid var(--line);
+ padding-top: 16px;
+}
+
+.cluster-models > div {
+ padding: 14px;
+ border-radius: 16px;
+ background: #f7f9fc;
+}
+
+.cluster-models span {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+}
+
+.cluster-models strong {
+ display: block;
+ margin-top: 5px;
+ color: #202733;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.robot-state-list {
+ display: grid;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.robot-state-list > div {
+ display: grid;
+ grid-template-columns: 38px 1fr auto;
+ align-items: center;
+ gap: 11px;
+ padding: 13px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: #fbfcff;
+}
+
+.robot-state-list strong {
+ display: block;
+ color: #252c38;
+ font-size: 13px;
+}
+
+.robot-state-list small {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+ margin-top: 3px;
+}
+
+.side-section-title {
+ color: #8d97a6;
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ padding: 10px 10px 8px;
+}
+
+.cluster-pill {
+ display: grid;
+ grid-template-columns: 38px 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 11px;
+ margin-bottom: 8px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: #fff;
+}
+
+.cluster-pill > span {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+}
+
+.cluster-pill > span.cloud {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.cluster-pill > span.embodied {
+ background: #e7f8f1;
+ color: #1f9c70;
+}
+
+.cluster-pill strong {
+ display: block;
+ color: #272d38;
+ font-size: 12px;
+}
+
+.cluster-pill small {
+ display: block;
+ color: #98a1af;
+ font-size: 10px;
+ margin-top: 4px;
+}
+
+.platform-node-layout {
+ grid-template-columns: 360px 1fr;
+}
+
+.platform-map {
+ height: 360px;
+}
+
+.job-detail-layout {
+ grid-template-columns: 330px 1fr;
+}
+
+.job-detail-heading {
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.job-detail-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.job-detail-page > * {
+ flex: 0 0 auto;
+}
+
+.job-worker-overview {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr);
+ gap: 16px;
+ margin: 18px 0 14px;
+}
+
+.job-worker-main,
+.job-worker-side {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+}
+
+.job-worker-main {
+ padding: 20px;
+}
+
+.job-worker-main h3 {
+ margin: 7px 0 8px;
+ color: var(--text);
+ font-size: 24px;
+ line-height: 1.2;
+ letter-spacing: 0;
+}
+
+.job-worker-main p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.job-worker-progress {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 12px;
+ align-items: center;
+ margin-top: 18px;
+}
+
+.job-worker-progress i,
+.worker-metric-card label i {
+ display: block;
+ height: 8px;
+ border-radius: 999px;
+ background: #e8edf5;
+ overflow: hidden;
+}
+
+.job-worker-progress b,
+.worker-metric-card label b {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--blue), #8b5cf6);
+}
+
+.job-worker-progress span {
+ color: var(--blue);
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.job-worker-side {
+ display: grid;
+ grid-template-columns: 1fr;
+ overflow: hidden;
+}
+
+.job-worker-side > div {
+ padding: 16px;
+ min-width: 0;
+}
+
+.job-worker-side > div + div {
+ border-top: 1px solid var(--line);
+}
+
+.job-worker-side span,
+.worker-role-strip span,
+.worker-detail-grid span {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+ margin-bottom: 6px;
+}
+
+.job-worker-side strong,
+.worker-role-strip strong,
+.worker-detail-grid strong {
+ display: block;
+ color: var(--text);
+ font-size: 15px;
+ line-height: 1.35;
+}
+
+.job-worker-side small,
+.worker-role-strip small {
+ display: block;
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.job-worker-side code {
+ display: block;
+ max-width: 100%;
+ color: #26313f;
+ background: #f0f4fa;
+ border-radius: 10px;
+ padding: 7px 8px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.worker-role-strip {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: 10px;
+ margin: 0 0 16px;
+}
+
+.worker-role-strip > div {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fbfcff;
+ padding: 12px;
+}
+
+.worker-primary-panel,
+.job-observe-panel {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+ overflow: hidden;
+ position: relative;
+ z-index: 1;
+}
+
+.worker-panel-head,
+.observe-panel-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.worker-panel-head {
+ flex-wrap: wrap;
+}
+
+.worker-list-role-tabs {
+ width: auto;
+ flex: 0 1 auto;
+}
+
+.worker-panel-head h3,
+.observe-panel-head h3 {
+ margin: 4px 0 0;
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.3;
+ letter-spacing: 0;
+}
+
+.worker-panel-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.worker-refresh-button {
+ min-height: 30px;
+ padding: 6px 10px;
+ border-radius: 9px;
+ font-size: 11px;
+}
+
+.worker-panel-head small {
+ max-width: 280px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+ text-align: right;
+}
+
+.worker-and-channel {
+ display: grid;
+ grid-template-columns: 1.3fr 0.9fr;
+ gap: 16px;
+ margin-top: 18px;
+ align-items: start;
+}
+
+.worker-and-channel > * {
+ min-width: 0;
+}
+
+.worker-table {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ /* overflow: hidden removed so the PullProgressInfo tooltip can extend
+ above the table when a Pending worker sits in the first row. Corner
+ rounding is preserved via per-cell border-radius below. */
+}
+
+.worker-primary-panel .worker-table {
+ border-width: 1px 0 0;
+ border-radius: 0;
+}
+
+.worker-table table {
+ table-layout: fixed;
+ width: 100%;
+}
+
+.worker-table table td {
+ word-break: break-word;
+}
+
+.worker-table th:first-child,
+.worker-table td:first-child {
+ width: 220px;
+}
+
+.worker-name-block {
+ min-width: 0;
+}
+
+.worker-name {
+ display: block;
+ min-width: 0;
+ max-width: 280px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-node-cell strong {
+ display: block;
+ color: var(--text);
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.worker-node-cell small {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ margin-top: 3px;
+}
+
+.worker-node-with-warning {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.worker-resource-cell {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.worker-resource-cell span {
+ padding: 5px 8px;
+ border-radius: 999px;
+ background: #f1f5fb;
+ color: #596579;
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+/* Round the corner cells to keep the rounded border look without overflow:
+ hidden, which would clip the worker-row pull-progress tooltip. */
+.worker-table thead th:first-child {
+ border-top-left-radius: 18px;
+}
+.worker-table thead th:last-child {
+ border-top-right-radius: 18px;
+}
+.worker-table tbody tr:last-child td:first-child {
+ border-bottom-left-radius: 18px;
+}
+.worker-table tbody tr:last-child td:last-child {
+ border-bottom-right-radius: 18px;
+}
+
+/* Move the hover background from to so the per-cell border-radius
+ clips the hover fill at the corners. Excludes .pod-detail-row to preserve
+ its surface-2 background. */
+.worker-table tbody tr:hover {
+ background: transparent;
+}
+.worker-table tbody tr:not(.pod-detail-row):hover > td {
+ background: #fbfdff;
+}
+.theme-dark .worker-table tbody tr:not(.pod-detail-row):hover > td {
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.pod-detail-row td {
+ padding: 0 !important;
+ background: var(--surface-2);
+}
+
+.worker-detail-drawer {
+ padding: 14px 18px 16px;
+ border-top: 1px solid var(--line);
+}
+
+.worker-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.worker-detail-grid > div {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+}
+
+.pod-subtable {
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #f8fafc;
+ overflow: hidden;
+}
+
+.pod-subtable table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ table-layout: auto;
+}
+
+.pod-subtable th {
+ background: #f3f6fb;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-2);
+ padding: 6px 10px;
+ text-align: left;
+}
+
+.pod-subtable td {
+ padding: 10px;
+ border-top: 1px solid var(--line);
+ background: #fbfdff;
+ font-size: 13px;
+}
+
+.pod-subtable tbody tr:nth-child(even) td {
+ background: #f6f9fd;
+}
+
+.pod-subtable tbody tr:hover td {
+ background: #f1f6ff;
+}
+
+.pod-subtable .inline-code {
+ background: #eef3fb;
+}
+
+.empty-inline {
+ padding: 12px;
+ border: 1px dashed var(--line);
+ border-radius: 12px;
+ color: var(--muted);
+ background: #fff;
+ font-size: 13px;
+}
+
+.job-observe-panel {
+ padding: 0;
+}
+
+.job-observe-panel > .log-toolbar,
+.job-observe-panel > .log-stream,
+.job-observe-panel > code,
+.job-observe-panel > .job-config-summary,
+.job-observe-panel > .worker-metrics-grid,
+.job-observe-panel > .embodied-channel {
+ margin: 16px 18px;
+}
+
+.log-loading-state {
+ position: relative;
+ min-height: 116px;
+ margin: 18px;
+ padding: 24px;
+ border: 1px solid #e5eaf3;
+ border-radius: 14px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ background: linear-gradient(135deg, #fafbff 0%, #f5f2ff 100%);
+}
+
+.log-loading-icon {
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ display: grid;
+ place-items: center;
+ color: #7c3aed;
+ background: #ede9fe;
+}
+
+.log-loading-icon svg {
+ animation: status-spin 1.1s linear infinite;
+}
+
+.log-loading-state div {
+ display: grid;
+ gap: 5px;
+}
+
+.log-loading-state strong {
+ color: var(--text);
+ font-size: 14px;
+}
+
+.log-loading-state small {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.log-loading-shimmer {
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.65),
+ transparent
+ );
+ animation: log-loading-shimmer 1.8s ease-in-out infinite;
+}
+
+@keyframes log-loading-shimmer {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+.theme-dark .log-loading-state {
+ border-color: #273348;
+ background: linear-gradient(135deg, #111a29 0%, #171429 100%);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status-icon,
+ .log-loading-icon svg,
+ .log-loading-shimmer {
+ animation: none;
+ }
+}
+
+.worker-metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.worker-metric-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fbfcff;
+ padding: 14px;
+}
+
+.worker-metric-card > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.worker-metric-card > div strong {
+ color: var(--text);
+ font-size: 13px;
+ margin-right: auto;
+}
+
+.worker-metric-card label {
+ display: grid;
+ grid-template-columns: 64px minmax(0, 1fr) 42px;
+ gap: 8px;
+ align-items: center;
+ margin-top: 9px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.worker-metric-card label span {
+ color: var(--text);
+ text-align: right;
+}
+
+.worker-metric-card small {
+ display: block;
+ margin-top: 12px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.embodied-channel {
+ display: grid;
+ gap: 12px;
+}
+
+.channel-screen {
+ min-height: 210px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background:
+ radial-gradient(
+ circle at 50% 38%,
+ rgba(124, 58, 237, 0.18),
+ transparent 32%
+ ),
+ linear-gradient(145deg, #101827, #17243a);
+ color: white;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ text-align: center;
+ padding: 24px;
+}
+
+.channel-screen strong {
+ margin-top: 12px;
+ font-size: 15px;
+}
+
+.channel-screen span {
+ margin-top: 8px;
+ color: rgba(255, 255, 255, 0.68);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.log-stream {
+ min-height: 132px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #101827;
+ padding: 14px;
+ display: grid;
+ align-content: start;
+ gap: 8px;
+}
+
+.log-stream code {
+ color: #c8d3e4;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+}
+
+.log-stream-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ margin-bottom: 8px;
+}
+
+.log-stream-head strong {
+ color: #e0e7f0;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+}
+
+.log-stream-head small {
+ color: #6b7891;
+ font-size: 11px;
+ margin-left: auto;
+}
+
+.log-content {
+ color: #c8d3e4;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-all;
+ margin: 0;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.log-error {
+ color: #ef4444 !important;
+}
+
+.log-error-banner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 0 18px 12px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ font-size: 13px;
+}
+
+.log-error-banner svg {
+ flex-shrink: 0;
+ color: #dc2626;
+}
+
+.log-error-banner span {
+ flex: 1;
+}
+
+.log-error-banner-close {
+ border: none;
+ background: none;
+ color: #991b1b;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ margin-left: 8px;
+}
+
+.log-error-banner-close:hover {
+ color: #7f1d1d;
+}
+
+.log-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ background: #0d1520;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 10px 12px;
+}
+
+.log-role-tabs {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.log-role-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.03);
+ color: #8b95a7;
+ border-radius: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.log-role-tab small {
+ font-size: 10px;
+ opacity: 0.6;
+}
+
+.log-role-tab:hover {
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #c8d3e4;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.log-role-tab.active {
+ background: rgba(99, 102, 241, 0.15);
+ border-color: rgba(99, 102, 241, 0.5);
+ color: #c7d2fe;
+}
+
+.log-role-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #4b5563;
+ flex-shrink: 0;
+}
+
+.log-role-dot.running {
+ background: #34d399;
+ box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
+}
+
+.log-pod-picker {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+}
+
+.log-pod-label {
+ font-size: 11px;
+ color: #6b7891;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+}
+
+.log-pod-select {
+ padding: 5px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.04);
+ color: #c8d3e4;
+ border-radius: 8px;
+ font-size: 12px;
+ font-family: "JetBrains Mono", monospace;
+ cursor: pointer;
+ max-width: 320px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.log-pod-select:hover,
+.log-pod-select:focus {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.role-template {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: #fbfcfe;
+}
+
+.role-template span {
+ padding: 7px 10px;
+ border-radius: 999px;
+ background: #f3eefe;
+ color: var(--blue);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.subpage-tabs {
+ display: flex;
+ gap: 8px;
+ margin: -4px 0 18px;
+}
+
+.subpage-tabs button {
+ height: 40px;
+ padding: 0 14px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: #fff;
+ color: #667182;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.subpage-tabs button.active {
+ background: #f3eefe;
+ border-color: #ddd0fe;
+ color: var(--blue);
+}
+
+.job-config-summary {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: 10px;
+ margin: 0 0 16px;
+}
+
+.job-config-summary > div {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 12px;
+ background: #fbfcff;
+ min-width: 0;
+}
+
+.job-config-summary span {
+ display: block;
+ color: #8d97a6;
+ font-size: 11px;
+ font-weight: 800;
+ margin-bottom: 7px;
+}
+
+.job-config-summary code,
+.job-config-summary pre {
+ display: block;
+ margin: 5px 0 0;
+ color: #26313f;
+ background: #f0f4fa;
+ border-radius: 10px;
+ padding: 7px 8px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: normal;
+}
+
+.job-config-summary strong {
+ display: block;
+ color: #202733;
+ font-size: 13px;
+}
+
+.job-detail-summary-card {
+ display: grid;
+ gap: 10px;
+ margin: 0 0 2px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+}
+
+.job-detail-summary-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 20px;
+ align-items: start;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.job-detail-summary-head h2 {
+ margin: 6px 0;
+ color: var(--text);
+ font-size: 26px;
+ line-height: 1.15;
+ letter-spacing: 0;
+}
+
+.job-detail-summary-head p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.job-detail-summary-status {
+ min-width: 126px;
+ display: grid;
+ justify-items: end;
+ gap: 8px;
+}
+
+.job-detail-summary-status small {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 750;
+}
+
+.job-detail-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.task-summary-metric {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fbfcff;
+ padding: 10px 12px;
+}
+
+.task-summary-metric.tone-blue {
+ border-color: #cfdcff;
+ background: #f7f9ff;
+}
+
+.task-summary-metric span,
+.task-summary-metric small {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.task-summary-metric strong {
+ display: block;
+ margin: 4px 0 2px;
+ color: var(--text);
+ font-size: 16px;
+ letter-spacing: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.task-summary-metric.tone-blue strong {
+ color: var(--blue);
+}
+
+.job-detail-summary-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px 12px;
+ align-items: stretch;
+}
+
+.job-detail-summary-section {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fbfcff;
+ padding: 12px;
+}
+
+.public-runtime-card {
+ grid-column: 1;
+ grid-row: auto;
+}
+
+.header-worker-card-legacy {
+ display: none;
+}
+
+.header-summary-metric {
+ position: relative;
+}
+
+.header-summary-metric a {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ display: grid;
+ place-items: center;
+ color: var(--blue);
+}
+
+.job-detail-clone-button {
+ min-height: 32px;
+ height: 32px;
+ gap: 6px;
+}
+
+.public-config-table {
+ width: 100%;
+ margin-top: 10px;
+ border-collapse: separate;
+ border-spacing: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.public-config-table th,
+.public-config-table td {
+ padding: 9px 11px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ font-size: 11px;
+}
+
+.public-config-table th {
+ height: 34px;
+}
+
+.public-config-table td {
+ height: 40px;
+}
+
+.public-config-table th {
+ color: var(--muted);
+ background: #f5f7fb;
+ font-weight: 800;
+}
+
+.public-config-table tr:last-child td {
+ border-bottom: 0;
+}
+
+.public-config-table td:first-child {
+ width: 150px;
+ color: var(--muted);
+ font-weight: 750;
+}
+
+.public-config-table code {
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ overflow-wrap: anywhere;
+}
+
+.public-config-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: #fff;
+ padding: 15px;
+ box-shadow: var(--shadow-soft);
+}
+
+.public-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.public-card-head h3 {
+ margin: 4px 0 0;
+ color: var(--text);
+ font-size: 15px;
+}
+
+.public-runtime-topology {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 0;
+}
+
+.public-command-card,
+.public-basic-config-card,
+.public-compact-config-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+}
+
+.public-command-card,
+.public-basic-config-card {
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.public-command-card .command-code-block {
+ flex: 1;
+}
+
+.public-config-title {
+ display: block;
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.public-command-card .copyable-code-block {
+ margin-top: 0;
+}
+
+.command-code-block {
+ position: relative;
+ min-height: 66px;
+ height: 100%;
+ border: 1px solid #1f2a3d;
+ border-radius: 10px;
+ background: #0f172a;
+ overflow: hidden;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.command-code-block .icon-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 1;
+ width: 26px;
+ height: 26px;
+ background: #172235;
+ border-color: #2c3a52;
+ color: #d7e4f6;
+}
+
+.command-code-block pre {
+ margin: 0;
+ padding: 12px 44px 12px 0;
+ overflow-x: auto;
+}
+
+.command-code-block code {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ min-height: 21px;
+ color: #d7e4f6;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.65;
+}
+
+.command-line-number {
+ padding-right: 10px;
+ border-right: 1px solid #26344d;
+ color: #64748b;
+ text-align: right;
+ user-select: none;
+}
+
+.command-line-content {
+ min-width: 0;
+ padding-left: 12px;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.command-token-keyword {
+ color: #c4b5fd;
+ font-weight: 850;
+}
+
+.command-token-flag {
+ color: #93c5fd;
+}
+
+.command-token-value {
+ color: #6ee7b7;
+}
+
+.public-basic-config-list {
+ display: grid;
+ gap: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.public-basic-config-list div {
+ display: grid;
+ grid-template-columns: 96px minmax(0, 1fr);
+ align-items: stretch;
+ gap: 0;
+ min-height: 28px;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+ background: transparent;
+ padding: 0;
+}
+
+.public-basic-config-list div:last-child {
+ border-bottom: 0;
+}
+
+.public-basic-config-list span {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ padding: 0 10px;
+ background: #f3f6fb;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.public-basic-config-list code {
+ min-width: 0;
+ display: block;
+ width: auto;
+ max-width: 100%;
+ border-radius: 0;
+ border-left: 1px solid var(--line);
+ background: transparent;
+ padding: 7px 10px;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ overflow-wrap: anywhere;
+}
+
+.public-basic-config-list code.public-config-truncated-value {
+ overflow-wrap: normal;
+}
+
+.public-basic-config-list .public-ssh-keys-row {
+ align-items: start;
+}
+
+.job-ssh-key-list {
+ min-width: 0;
+ max-height: 112px;
+ margin: 0;
+ padding: 4px 10px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ border-left: 1px solid var(--line);
+ list-style: none;
+ scrollbar-gutter: stable;
+}
+
+.public-basic-config-list .job-ssh-key-list li {
+ display: grid;
+ grid-template-columns: minmax(90px, 0.45fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ padding: 3px 0;
+}
+
+.public-basic-config-list .job-ssh-key-list li + li {
+ border-top: 1px dashed var(--line);
+}
+
+.job-ssh-key-list strong,
+.job-ssh-key-list code {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.job-ssh-key-list strong {
+ color: var(--ink);
+ font-size: 10px;
+}
+
+.public-basic-config-list .job-ssh-key-list code {
+ padding: 2px 0;
+ border: 0;
+ overflow-wrap: normal;
+}
+
+.public-compact-config-table code {
+ min-width: 0;
+ display: inline-flex;
+ width: fit-content;
+ max-width: 100%;
+ border-radius: 6px;
+ background: #f1f5fb;
+ padding: 5px 8px;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ overflow-wrap: anywhere;
+}
+
+.public-runtime-tables {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.public-runtime-tables > .public-compact-config-card:only-child {
+ grid-column: 1 / -1;
+}
+
+.public-compact-config-card {
+ overflow: hidden;
+}
+
+.public-compact-config-card .public-config-title {
+ margin: 0;
+ padding: 9px 11px 7px;
+}
+
+.public-compact-config-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.public-compact-config-table th,
+.public-compact-config-table td {
+ height: 34px;
+ padding: 7px 11px;
+ border-top: 1px solid var(--line);
+ text-align: left;
+ font-size: 11px;
+}
+
+.public-compact-config-table th {
+ color: var(--muted);
+ background: #f5f7fb;
+ font-weight: 800;
+}
+
+.public-compact-config-table td:first-child {
+ width: 44%;
+}
+
+.public-compact-config-table small {
+ color: var(--muted);
+}
+
+.public-card-head a {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 9px;
+ background: #f1f4ff;
+ color: var(--blue);
+}
+
+.header-worker-identity {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.header-worker-identity strong {
+ min-width: 0;
+ color: var(--text);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.copyable-code-block {
+ margin-top: 9px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #f7f9fc;
+ overflow: hidden;
+}
+
+.copyable-code-block > div {
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 5px 7px 5px 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.copyable-code-block span {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.copyable-code-block .icon-button {
+ width: 25px;
+ height: 25px;
+ flex: 0 0 auto;
+}
+
+.copyable-code-block code {
+ display: block;
+ padding: 8px 10px;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.worker-console-panel {
+ margin-bottom: 16px;
+}
+
+.role-runtime-config {
+ border-bottom: 1px solid var(--line);
+ background: #fbfcff;
+ padding: 14px 18px;
+}
+
+.job-detail-page .role-runtime-config.job-detail-summary-card {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+ padding: 16px;
+ position: relative;
+ z-index: 1;
+}
+
+.role-runtime-heading {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 11px;
+}
+
+.role-runtime-heading > div:first-child {
+ min-width: 0;
+}
+
+.role-runtime-heading strong {
+ display: block;
+ margin-top: 3px;
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.role-runtime-tabs {
+ min-width: 0;
+ width: 100%;
+ display: flex;
+ justify-content: flex-start;
+ gap: 5px;
+ overflow-x: auto;
+ padding-bottom: 1px;
+}
+
+.role-runtime-tabs button {
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex: 0 0 auto;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: #fff;
+ color: var(--muted);
+ padding: 0 9px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.role-runtime-tabs button span {
+ border-radius: 4px;
+ background: #ede9fe;
+ color: var(--blue);
+ padding: 2px 4px;
+ font-size: 9px;
+}
+
+.role-runtime-tabs button.active {
+ border-color: #cfc4ff;
+ background: #f4f0ff;
+ color: var(--blue);
+}
+
+.role-runtime-summary {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(190px, 0.8fr) auto auto;
+ align-items: center;
+ gap: 12px;
+ margin-top: 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #f8fafc;
+ padding: 9px 10px;
+}
+
+.role-runtime-image {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.role-runtime-image span {
+ flex: 0 0 auto;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.role-runtime-image code {
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid #e5ebf3;
+ border-radius: 7px;
+ background: #fff;
+ padding: 5px 7px;
+ color: #455166;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.role-runtime-resource-summary {
+ min-width: 0;
+}
+
+.role-runtime-resource-summary span,
+.role-runtime-resource-summary strong {
+ display: block;
+}
+
+.role-runtime-resource-summary span {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.role-runtime-resource-summary strong {
+ margin-top: 3px;
+ display: inline-flex;
+ max-width: 100%;
+ border: 1px solid #e5ebf3;
+ border-radius: 7px;
+ background: #fff;
+ padding: 5px 7px;
+ color: var(--text);
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.role-runtime-image .icon-button {
+ width: 26px;
+ height: 26px;
+ flex: 0 0 auto;
+}
+
+.role-runtime-meta {
+ display: flex;
+ gap: 6px;
+ flex: 0 0 auto;
+}
+
+.role-runtime-meta span {
+ border-radius: 6px;
+ background: #eef2f7;
+ color: #637086;
+ padding: 5px 7px;
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.role-runtime-toggle {
+ flex: 0 0 auto;
+ gap: 5px;
+}
+
+.role-runtime-toggle svg {
+ transition: transform 0.15s ease;
+}
+
+.role-runtime-details {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 8px;
+ margin-top: 10px;
+ align-items: stretch;
+}
+
+.role-runtime-all-summary {
+ margin: 12px 0 0;
+ padding-top: 11px;
+ border-top: 1px dashed var(--line);
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.role-runtime-details .copyable-code-block,
+.role-runtime-details .config-value-list.compact {
+ margin-top: 0;
+}
+
+.role-runtime-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ padding: 10px;
+}
+
+.role-runtime-facts div {
+ min-width: 0;
+}
+
+.role-runtime-facts span,
+.role-runtime-facts strong,
+.role-runtime-facts code {
+ display: block;
+}
+
+.role-runtime-facts span {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.role-runtime-facts strong,
+.role-runtime-facts code {
+ margin-top: 4px;
+ color: var(--text);
+ font-size: 10px;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
+}
+
+.role-runtime-facts code {
+ font-family: "JetBrains Mono", monospace;
+}
+
+.worker-total-count {
+ align-self: center;
+ border-radius: 999px;
+ background: #eef3ff;
+ color: var(--blue);
+ padding: 6px 10px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.restart-choice-backdrop {
+ z-index: 1200;
+}
+
+.delete-job-backdrop {
+ z-index: 1200;
+}
+
+.job-lifecycle-backdrop {
+ z-index: 1200;
+}
+
+.job-lifecycle-modal {
+ width: min(500px, calc(100vw - 32px));
+ border-radius: 18px;
+}
+
+.job-lifecycle-head {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) 34px;
+ gap: 12px;
+ align-items: start;
+ padding: 22px 22px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.job-lifecycle-icon {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ background: #eef8f4;
+ color: #16875d;
+}
+
+.job-lifecycle-modal.stop .job-lifecycle-icon {
+ background: #fff7e8;
+ color: #d97706;
+}
+
+.job-lifecycle-head h2 {
+ margin: 3px 0 0;
+ color: var(--text);
+ font-size: 20px;
+}
+
+.job-lifecycle-body {
+ padding: 18px 22px 16px;
+}
+
+.job-lifecycle-body > p {
+ margin: 0 0 14px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.job-lifecycle-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 9px;
+ padding: 14px 22px 20px;
+ border-top: 1px solid var(--line);
+}
+
+.job-lifecycle-actions button {
+ min-height: 36px;
+ padding: 0 15px;
+ border-radius: 10px;
+}
+
+.job-lifecycle-actions .primary-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.delete-job-modal {
+ width: min(500px, calc(100vw - 32px));
+ border-radius: 18px;
+}
+
+.delete-job-head {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) 34px;
+ gap: 12px;
+ align-items: start;
+ padding: 22px 22px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.delete-job-icon {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ background: #fff0f1;
+ color: #dc3545;
+}
+
+.delete-job-head h2 {
+ margin: 3px 0 0;
+ color: var(--text);
+ font-size: 20px;
+}
+
+.delete-job-body {
+ padding: 18px 22px 16px;
+}
+
+.delete-job-body > p {
+ margin: 0 0 14px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.delete-job-target {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ min-height: 48px;
+ padding: 10px 13px;
+ border: 1px solid #e2e6ee;
+ border-radius: 11px;
+ background: #fafbfc;
+}
+
+.delete-job-target span {
+ flex: none;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.delete-job-target strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.delete-job-warning {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ margin-top: 12px;
+ color: #a16207;
+ font-size: 11px;
+ line-height: 1.55;
+}
+
+.delete-job-warning svg {
+ flex: none;
+ margin-top: 1px;
+}
+
+.delete-job-error {
+ margin-top: 12px;
+ padding: 9px 11px;
+ border-radius: 9px;
+ background: #fff0f1;
+ color: #b42332;
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.delete-job-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 9px;
+ padding: 14px 22px 20px;
+ border-top: 1px solid var(--line);
+}
+
+.delete-job-actions .secondary-button,
+.delete-job-confirm {
+ min-height: 36px;
+ padding: 0 15px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.delete-job-confirm {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border: 1px solid #dc3545;
+ background: #dc3545;
+ color: #fff;
+ box-shadow: 0 7px 16px rgba(220, 53, 69, 0.18);
+}
+
+.delete-job-confirm:hover:not(:disabled) {
+ border-color: #c62d3c;
+ background: #c62d3c;
+}
+
+.delete-job-confirm:disabled {
+ cursor: not-allowed;
+ opacity: 0.65;
+}
+
+.restart-choice-modal {
+ width: min(520px, calc(100vw - 32px));
+ border-radius: 18px;
+}
+
+.restart-choice-head {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) 34px;
+ gap: 12px;
+ align-items: start;
+ padding: 22px 22px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.restart-choice-icon {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ background: #f1edff;
+ color: #6d45d8;
+}
+
+.restart-choice-head h2 {
+ margin: 3px 0 2px;
+ color: var(--text);
+ font-size: 20px;
+}
+
+.restart-choice-head p {
+ margin: 0;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.restart-choice-options {
+ display: grid;
+ gap: 10px;
+ padding: 18px 22px 12px;
+}
+
+.restart-choice-option {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) 18px;
+ grid-template-rows: auto auto;
+ column-gap: 12px;
+ align-items: center;
+ width: 100%;
+ padding: 13px 14px;
+ border: 1px solid #e2e6ee;
+ border-radius: 12px;
+ background: #fff;
+ color: var(--text);
+ text-align: left;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.restart-choice-option:hover {
+ border-color: #bfaef0;
+ box-shadow: 0 8px 22px rgba(83, 63, 148, 0.1);
+}
+
+.restart-choice-option > span {
+ grid-row: 1 / span 2;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: #f4f2fa;
+ color: #67558f;
+}
+
+.restart-choice-option.primary > span {
+ background: #ede8ff;
+ color: #6d45d8;
+}
+
+.restart-choice-option strong {
+ font-size: 13px;
+}
+
+.restart-choice-option small {
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.restart-choice-option > svg {
+ grid-column: 3;
+ grid-row: 1 / span 2;
+ color: #9aa3b2;
+}
+
+.restart-choice-note {
+ margin: 0 22px 20px;
+ color: #8a6a3f;
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.theme-dark .restart-choice-modal,
+.theme-dark .restart-choice-option {
+ background: #171b25;
+}
+
+.theme-dark .delete-job-modal {
+ background: #171b25;
+}
+
+.theme-dark .job-lifecycle-modal {
+ background: #121a28;
+ border-color: #2a3547;
+}
+
+.theme-dark .job-lifecycle-icon {
+ background: rgba(22, 135, 93, 0.16);
+}
+
+.theme-dark .delete-job-icon,
+.theme-dark .delete-job-error {
+ background: rgba(248, 113, 113, 0.12);
+ color: #fca5a5;
+}
+
+.theme-dark .delete-job-target {
+ border-color: #303744;
+ background: #1d222d;
+}
+
+.theme-dark .delete-job-warning {
+ color: #fcd34d;
+}
+
+.theme-dark .restart-choice-option {
+ border-color: #303744;
+}
+
+.theme-dark .restart-choice-icon,
+.theme-dark .restart-choice-option.primary > span {
+ background: rgba(139, 92, 246, 0.18);
+ color: #c4b5fd;
+}
+
+.theme-dark .restart-choice-option > span {
+ background: #252a36;
+ color: #c7ced9;
+}
diff --git a/apps/rlark-ui/src/styles/jobs/detail-runtime.css b/apps/rlark-ui/src/styles/jobs/detail-runtime.css
new file mode 100644
index 0000000..712451b
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/detail-runtime.css
@@ -0,0 +1,1547 @@
+.worker-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 18px;
+ border: 0;
+ background: #fff;
+}
+
+.worker-filter-bar label,
+.log-console-toolbar label {
+ display: grid;
+ gap: 5px;
+}
+
+.worker-filter-bar label > span,
+.log-console-toolbar label > span {
+ display: none;
+}
+
+.worker-filter-bar select,
+.log-console-toolbar select,
+.worker-role-filter-select {
+ height: 34px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: #fff;
+ color: var(--text);
+ padding: 0 28px 0 9px;
+ font-size: 12px;
+ outline: 0;
+}
+
+.worker-filter-bar::before {
+ content: "筛选";
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+ margin-right: 2px;
+}
+
+.worker-console-table {
+ border: 0;
+ border-radius: 0;
+}
+
+.worker-table-scroll {
+ overflow-x: auto;
+ cursor: grab;
+ overscroll-behavior-x: contain;
+ scrollbar-gutter: stable;
+ touch-action: pan-y;
+}
+
+.worker-table-scroll.dragging {
+ cursor: grabbing;
+ user-select: none;
+}
+
+.worker-table-scroll .table-sort-button,
+.worker-table-scroll button,
+.worker-table-scroll a {
+ cursor: pointer;
+}
+
+.worker-console-table table {
+ min-width: 930px;
+ /* Auto layout lets each column size to its content so long cluster/node
+ names widen the table (scrollable via .worker-table-scroll) instead of
+ overflowing into neighbouring cells and overlapping their content. */
+ table-layout: auto;
+}
+
+.worker-console-table th,
+.worker-console-table td {
+ padding: 12px 13px;
+ white-space: nowrap;
+}
+
+.worker-console-table th {
+ color: #8994a6;
+ font-size: 10px;
+ letter-spacing: 0.025em;
+ text-transform: uppercase;
+}
+
+/* 集群/节点 chip:与 .role-chip / .node-kind-chip 同一风格体系,
+ 可点击变体通过 hover 淡紫底提示可跳转。 */
+.worker-chip {
+ display: inline-flex;
+ align-items: center;
+ max-width: 260px;
+ border-radius: 999px;
+ background: #f0f3f7;
+ color: #667182;
+ padding: 6px 9px;
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.worker-chip-link {
+ border: 0;
+ cursor: pointer;
+ transition:
+ background 0.12s ease,
+ color 0.12s ease;
+}
+
+.worker-chip-link:hover {
+ background: rgba(124, 58, 237, 0.08);
+ color: var(--blue);
+}
+
+.worker-link-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.theme-dark .worker-chip {
+ background: #202b3d;
+ color: #b1bdd0;
+}
+
+.theme-dark .worker-chip-link:hover {
+ background: rgba(167, 139, 250, 0.15);
+ color: #c4b5fd;
+}
+
+.job-action-notice {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 14px;
+ border: 1px solid color-mix(in srgb, #23a36d 35%, var(--line));
+ border-radius: 10px;
+ background: color-mix(in srgb, #23a36d 8%, var(--surface));
+ color: var(--text);
+ padding: 10px 12px;
+ font-size: 13px;
+ font-weight: 650;
+}
+
+.job-action-notice > svg {
+ color: #1c9b64;
+}
+
+.job-action-notice > span {
+ flex: 1;
+}
+
+.job-action-notice > button {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 18px;
+ cursor: pointer;
+}
+
+.app-job-submit-notice {
+ margin: 18px 24px 0;
+}
+
+.node-kind-chip {
+ display: inline-flex;
+ border-radius: 999px;
+ background: #f1f4f8;
+ color: #667489;
+ padding: 4px 7px;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.table-date {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.worker-table-actions {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ justify-content: flex-end;
+}
+
+.worker-sticky-header-col,
+.worker-sticky-actions {
+ position: sticky;
+ right: 0;
+ background: #fff;
+ z-index: 10;
+ box-shadow: -6px 0 8px -6px rgba(15, 23, 42, 0.1);
+ text-align: right;
+}
+
+.worker-sticky-header-col {
+ padding-right: 24px;
+}
+
+.theme-dark .worker-sticky-header-col,
+.theme-dark .worker-sticky-actions {
+ background: #151d2b;
+ box-shadow: -6px 0 8px -6px rgba(0, 0, 0, 0.5);
+}
+
+.action-tooltip {
+ position: relative;
+ display: inline-flex;
+}
+
+.action-tooltip::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ right: 0;
+ bottom: calc(100% + 7px);
+ z-index: 999;
+ width: max-content;
+ max-width: 180px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ background: #202733;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1.3;
+ white-space: nowrap;
+ box-shadow: 0 5px 14px rgba(28, 36, 50, 0.2);
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(3px);
+ transition:
+ opacity 0.12s ease,
+ transform 0.12s ease;
+}
+
+.action-tooltip:hover::after,
+.action-tooltip:focus-within::after {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.worker-table-actions .icon-button {
+ width: 31px;
+ height: 31px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: #fff;
+ color: #667489;
+}
+
+.worker-table-actions .worker-terminal-icon {
+ color: var(--blue);
+ background: #f1f5ff;
+ border-color: #d9e2ff;
+}
+
+.worker-expanded-row td {
+ padding: 0 !important;
+ background: #f8faff;
+}
+
+.worker-expanded-row .worker-detail-drawer {
+ padding: 14px 18px 16px;
+}
+
+.worker-detail-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 11px;
+}
+
+.worker-detail-head > div:first-child {
+ min-width: 0;
+ flex: 0 1 auto;
+}
+
+.worker-detail-head strong {
+ display: block;
+ margin-top: 4px;
+ color: var(--text);
+ font-size: 13px;
+}
+
+.worker-ssh-button {
+ height: 32px;
+ gap: 6px;
+}
+
+.worker-ssh-button svg:last-child {
+ transition: transform 0.15s ease;
+}
+
+.role-runtime-command-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.role-runtime-command-card,
+.role-runtime-selector-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ overflow: hidden;
+}
+
+.role-runtime-command-card {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.role-runtime-command-card .command-code-block {
+ flex: 1;
+}
+
+.role-runtime-command-card > span,
+.role-runtime-selector-card > span {
+ display: grid;
+ place-items: center;
+ min-height: 34px;
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.role-runtime-command-card .command-code-block {
+ border: 0;
+ border-radius: 0;
+}
+
+.role-runtime-selector-card > div {
+ display: grid;
+ gap: 0;
+}
+
+.role-runtime-selector-card p {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) auto minmax(0, 1.1fr);
+ gap: 8px;
+ align-items: center;
+ margin: 0;
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--line);
+ background: #fff;
+}
+
+.role-runtime-selector-card p:last-child {
+ border-bottom: 0;
+}
+
+.role-runtime-selector-card code {
+ min-width: 0;
+ display: block;
+ width: auto;
+ max-width: 100%;
+ border: 1px solid #e6ebf2;
+ border-radius: 7px;
+ background: #f8fafc;
+ padding: 5px 7px;
+ color: #475569;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ overflow-wrap: anywhere;
+}
+
+.role-runtime-selector-card b {
+ display: grid;
+ place-items: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 999px;
+ background: #f1f4f8;
+ color: #7b8797;
+ font-size: 11px;
+}
+
+.role-runtime-selector-card small {
+ display: block;
+ padding: 10px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.role-runtime-table-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ overflow: hidden;
+}
+
+.role-runtime-table-card > span {
+ display: grid;
+ place-items: center;
+ min-height: 34px;
+ padding: 9px 11px;
+ border-bottom: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.role-runtime-table-card table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.role-runtime-table-card th,
+.role-runtime-table-card td {
+ height: 42px;
+ padding: 8px 11px;
+ border-bottom: 1px solid var(--line);
+ text-align: center;
+ vertical-align: middle;
+ font-size: 10px;
+}
+
+.role-runtime-table-card th {
+ color: var(--muted);
+ background: #eef3f9;
+ font-weight: 800;
+}
+
+.role-runtime-table-card td {
+ background: #fbfdff;
+}
+
+.role-runtime-table-card tbody tr:nth-child(even) td {
+ background: #f6f9fd;
+}
+
+.role-runtime-table-card tr:last-child td {
+ border-bottom: 0;
+}
+
+.role-runtime-table-card code {
+ display: inline-flex;
+ max-width: 100%;
+ border-radius: 6px;
+ background: #eef3fb;
+ padding: 4px 7px;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.role-runtime-table-card .empty-cell {
+ color: var(--muted);
+ text-align: center;
+}
+
+.worker-console-table .empty-cell {
+ height: 120px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 13px;
+}
+
+.role-runtime-config-tables {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.role-runtime-env-table th:first-child,
+.role-runtime-env-table td:first-child {
+ width: 32%;
+}
+
+.role-runtime-mount-table th:first-child,
+.role-runtime-mount-table td:first-child {
+ width: 100px;
+}
+
+.role-runtime-mount-table th:nth-child(2),
+.role-runtime-mount-table td:nth-child(2) {
+ width: 38%;
+}
+
+.worker-ssh-inline {
+ min-width: 0;
+ max-width: min(60%, 520px);
+ flex: 1 1 320px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 7px 5px 10px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: #fff;
+}
+
+.worker-ssh-inline code {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: block;
+ overflow: hidden;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-ssh-inline > svg {
+ flex: 0 0 auto;
+}
+
+.worker-ssh-inline .icon-button {
+ width: 26px;
+ height: 26px;
+ flex: 0 0 auto;
+}
+
+.city-label-row > svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.worker-ssh-access {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 11px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ padding: 10px 12px;
+}
+
+.worker-ssh-access > div {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.worker-ssh-access span {
+ display: block;
+ margin-bottom: 5px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+
+.worker-ssh-access code {
+ display: block;
+ color: #39475a;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.worker-ssh-access .secondary-button {
+ flex: 0 0 auto;
+ gap: 6px;
+}
+
+.worker-pagination {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 18px;
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.worker-pagination > div {
+ display: flex;
+ gap: 6px;
+}
+
+.worker-pagination .secondary-button {
+ height: 30px;
+ padding: 0 9px;
+ font-size: 11px;
+}
+
+.log-console-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin: 16px 18px;
+ padding: 12px;
+ background: var(--bg-secondary, #f8f9fa);
+ border-radius: 8px;
+ border: 1px solid var(--line);
+}
+
+.log-search-field {
+ display: flex !important;
+ align-items: center;
+ gap: 7px;
+ flex: 1;
+ min-width: 300px;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fff;
+ padding: 0 12px;
+ color: #8793a6;
+}
+
+.log-search-field input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+ font-size: 13px;
+}
+
+.log-order-filter {
+ display: flex !important;
+ align-items: center;
+ gap: 7px;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fff;
+ padding: 0 12px;
+ color: #8793a6;
+}
+
+.log-order-filter select {
+ border: 0;
+ outline: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 13px;
+ cursor: pointer;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ padding-right: 16px;
+}
+
+.log-order-filter select:focus {
+ outline: none;
+ box-shadow: none;
+}
+
+.log-order-filter::after {
+ content: "▼";
+ font-size: 10px;
+ color: var(--muted);
+ margin-left: -14px;
+ pointer-events: none;
+}
+
+.log-custom-datetime {
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fff;
+ padding: 0 12px;
+ font-size: 13px;
+ color: var(--text);
+ font-family: inherit;
+}
+
+.log-custom-datetime:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
+}
+
+.stream-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #fff;
+ color: #788598;
+ padding: 0 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.stream-toggle i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #a2acba;
+}
+
+.stream-toggle.active {
+ border-color: #bcead9;
+ background: #f0fbf6;
+ color: #16966d;
+}
+
+.stream-toggle.active i {
+ background: #24c994;
+ box-shadow: 0 0 0 3px rgba(36, 201, 148, 0.14);
+}
+
+.log-export-button {
+ height: 36px;
+ padding: 0 12px;
+ font-size: 12px;
+}
+
+.log-list {
+ margin: 0 18px 18px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ overflow: hidden;
+}
+
+.log-terminal {
+ margin: 0 18px 18px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #0d1520;
+ color: #e2e8f0;
+ overflow: hidden;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+}
+
+.log-terminal-fullscreen {
+ position: fixed;
+ inset: 0;
+ margin: 0;
+ border: none;
+ border-radius: 0;
+ /* 高于侧栏(5)、顶栏(4)、tooltip(999),低于弹窗(1200) */
+ z-index: 1100;
+ /* 不透明深色,彻底盖住下层内容 */
+ background: #0d1520;
+ display: flex;
+ flex-direction: column;
+}
+
+.log-terminal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: #1a2332;
+ border-bottom: 1px solid #334155;
+}
+
+.log-terminal-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.log-terminal-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #ef4444;
+}
+
+.log-terminal-dot:nth-child(2) {
+ background: #f59e0b;
+}
+
+.log-terminal-dot:nth-child(3) {
+ background: #10b981;
+}
+
+.log-terminal-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.log-terminal-actions .icon-button {
+ background: transparent;
+ border: none;
+ color: #94a3b8;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+}
+
+.log-terminal-actions .icon-button:hover {
+ background: #334155;
+ color: #e2e8f0;
+}
+
+.log-terminal-content {
+ max-height: 500px;
+ overflow: auto;
+ padding: 12px;
+}
+
+.log-terminal-fullscreen .log-terminal-content {
+ max-height: none;
+ flex: 1;
+ min-height: 0;
+}
+
+.log-terminal-line {
+ display: flex;
+ gap: 12px;
+ padding: 4px 0;
+ line-height: 1.5;
+ /* 让行不被容器宽度压缩,配合 overflow: auto 出现横向滚动条 */
+ min-width: max-content;
+}
+
+.log-terminal-time {
+ color: #64748b;
+ min-width: 140px;
+ flex-shrink: 0;
+}
+
+.log-terminal-worker {
+ color: #3b82f6;
+ min-width: 120px;
+ flex-shrink: 0;
+}
+
+.log-terminal-message {
+ color: #e2e8f0;
+ flex: 1;
+ /* 终端风格:一行就是一行,超出部分通过横向滚动条查看 */
+ white-space: pre;
+ word-break: normal;
+}
+
+.log-terminal-line:hover {
+ background: #1e293b;
+}
+
+.log-terminal-loading-more {
+ padding: 8px 0 4px;
+ color: #64748b;
+ font-size: 11px;
+ text-align: center;
+}
+
+.log-list-head,
+.log-list-row {
+ display: grid;
+ grid-template-columns: 100px minmax(150px, 0.9fr) minmax(260px, 2.4fr) 140px;
+ gap: 12px;
+}
+
+.log-list-head {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--line);
+ background: #f6f7fa;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.log-list-row {
+ align-items: baseline;
+ padding: 10px 12px;
+ border-bottom: 1px solid #edf0f5;
+ font-size: 12px;
+}
+
+.log-list-row:last-child {
+ border-bottom: 0;
+}
+
+.log-role-name {
+ color: var(--muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+}
+.log-worker-name {
+ color: var(--text);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ font-weight: 750;
+}
+.log-list-row p {
+ margin: 0;
+ color: #4b5768;
+ line-height: 1.45;
+}
+
+.log-timestamp {
+ color: var(--muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ font-weight: 500;
+ text-align: right;
+}
+
+.log-load-more {
+ display: flex;
+ justify-content: center;
+ padding: 10px 12px;
+ border-bottom: 0;
+ background: #fafbfc;
+}
+
+.log-load-more .secondary-button {
+ height: 32px;
+ padding: 0 14px;
+ font-size: 12px;
+}
+
+.log-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: #fafbfc;
+}
+
+.log-pagination .secondary-button {
+ height: 32px;
+ padding: 0 14px;
+ font-size: 12px;
+}
+
+.log-page-indicator {
+ font-size: 12px;
+ color: var(--muted);
+ font-family: "JetBrains Mono", monospace;
+}
+
+.metrics-dashboard {
+ margin: 16px 18px 18px;
+}
+
+.metrics-integration-state {
+ display: grid;
+ place-items: center;
+ min-height: 280px;
+ padding: 48px 24px;
+ border: 1px dashed var(--line);
+ border-radius: 12px;
+ background: #fafbfe;
+ text-align: center;
+}
+
+.metrics-integration-icon {
+ display: grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ margin-bottom: 12px;
+ border-radius: 10px;
+ background: #eef1f6;
+ color: var(--muted);
+}
+
+.metrics-integration-state strong {
+ color: var(--text);
+ font-size: 16px;
+}
+
+.metrics-integration-state p {
+ margin: 7px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.metrics-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ flex-wrap: wrap;
+ margin-bottom: 13px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--line);
+}
+
+.metrics-filter-bar label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.metrics-filter-bar label > span {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 850;
+}
+.metrics-filter-bar select {
+ height: 32px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--text);
+ padding: 0 24px 0 9px;
+ font-size: 11px;
+}
+.metrics-filter-bar select:disabled {
+ background: #f3f5f8;
+ color: #9aa4b2;
+ cursor: not-allowed;
+}
+.metrics-scope-toggle {
+ display: inline-flex;
+ padding: 3px;
+ border-radius: 9px;
+ background: #f0f3f8;
+}
+.metrics-scope-toggle button {
+ height: 28px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--muted);
+ padding: 0 10px;
+ font-size: 11px;
+ font-weight: 850;
+}
+.metrics-scope-toggle button.active {
+ background: #fff;
+ color: var(--blue);
+ box-shadow: 0 1px 4px rgba(42, 52, 73, 0.1);
+}
+.metrics-source-label {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.metrics-overview-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.metrics-overview-row strong {
+ color: #209a73;
+ font-size: 11px;
+}
+
+.time-series-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.time-series-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ padding: 13px;
+}
+
+.time-series-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+.time-series-head span {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+.time-series-head strong {
+ display: block;
+ margin-top: 5px;
+ color: var(--text);
+ font-size: 20px;
+}
+.time-series-head strong small {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+.time-series-head i {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-top: 4px;
+}
+.time-series-card svg {
+ display: block;
+ width: 100%;
+ height: 118px;
+ margin-top: 8px;
+ overflow: visible;
+}
+.time-series-card line {
+ stroke: #edf1f7;
+ stroke-width: 0.7;
+ vector-effect: non-scaling-stroke;
+}
+.time-series-card polyline {
+ fill: none;
+ stroke-width: 2.2;
+ vector-effect: non-scaling-stroke;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.time-series-foot {
+ display: flex;
+ justify-content: space-between;
+ color: #a0aabd;
+ font-size: 10px;
+}
+
+.role-worker-panel {
+ margin-bottom: 16px;
+}
+
+.role-worker-groups {
+ display: grid;
+ gap: 14px;
+ padding: 16px 18px 18px;
+}
+
+.role-worker-group {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ overflow: hidden;
+ background: #fbfcff;
+}
+
+.role-worker-group-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: flex-start;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ background: #fff;
+}
+
+.role-worker-title {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin-bottom: 9px;
+}
+
+.role-worker-group-head > div > strong,
+.role-worker-resource-summary strong {
+ display: block;
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.role-worker-group-head small,
+.role-worker-resource-summary span,
+.role-worker-resource-summary small {
+ display: block;
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.role-worker-resource-summary {
+ min-width: 190px;
+ text-align: right;
+}
+
+.worker-access-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
+ gap: 10px;
+ padding: 12px;
+}
+
+.worker-access-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ padding: 13px;
+}
+
+.worker-access-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.worker-access-head > div {
+ min-width: 0;
+ margin-right: auto;
+}
+
+.worker-access-head strong {
+ display: block;
+ color: var(--text);
+ font-size: 13px;
+ line-height: 1.35;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-access-head small {
+ display: block;
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.header-tag {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ border-radius: 999px;
+ padding: 3px 7px;
+ background: #ede9fe;
+ color: #7041d8;
+ font-size: 10px;
+ font-weight: 900;
+ line-height: 1.3;
+}
+
+.worker-access-meta {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin-top: 12px;
+}
+
+.worker-access-meta span {
+ min-width: 0;
+ padding: 7px;
+ border-radius: 9px;
+ background: #f4f7fb;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+ line-height: 1.35;
+}
+
+.worker-access-meta b {
+ display: block;
+ margin-top: 2px;
+ color: var(--text);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-access-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 12px;
+}
+
+.worker-access-actions .terminal-button {
+ flex: 1 1 auto;
+ justify-content: center;
+}
+
+.worker-access-card .worker-detail-drawer {
+ margin-top: 12px;
+ padding: 12px 0 0;
+}
+
+.worker-access-card .pod-subtable {
+ overflow-x: auto;
+}
+
+.worker-access-card .pod-subtable table {
+ min-width: 580px;
+}
+
+.job-config-summary {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 14px;
+ margin: 16px 18px 18px;
+}
+
+.config-section {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: #fbfcff;
+ overflow: hidden;
+}
+
+.config-section-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ padding: 15px 16px;
+ border-bottom: 1px solid var(--line);
+ background: #fff;
+}
+
+.config-section-head h3 {
+ margin: 4px 0 0;
+ color: var(--text);
+ font-size: 16px;
+ line-height: 1.35;
+}
+
+.config-section-head small {
+ max-width: 280px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.5;
+ text-align: right;
+}
+
+.config-access-grid,
+.config-shared-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+ padding: 12px;
+}
+
+.config-kv-card {
+ position: relative;
+ min-width: 0;
+ min-height: 84px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ padding: 12px;
+}
+
+.config-kv-card > span,
+.config-command > span,
+.config-value-list > span {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.config-kv-card strong {
+ margin-top: 6px;
+ color: var(--text);
+ font-size: 14px;
+}
+
+.config-kv-card small {
+ display: block;
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.config-ssh-card code {
+ display: block;
+ margin-top: 7px;
+ color: #334155;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ line-height: 1.45;
+ word-break: break-all;
+}
+
+.config-link-card {
+ text-decoration: none;
+ transition:
+ border-color 0.15s,
+ transform 0.15s;
+}
+
+.config-link-card:hover {
+ border-color: #b7a2f4;
+ transform: translateY(-1px);
+}
+
+.config-link-card svg {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ color: var(--blue);
+}
+
+.config-command {
+ padding: 12px;
+}
+
+.config-command > span {
+ margin-bottom: 8px;
+}
+
+.config-value-list {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ padding: 12px;
+}
+
+.config-value-list > div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 9px;
+}
+
+.config-value-list code {
+ display: inline-block;
+ max-width: 100%;
+ border-radius: 7px;
+ background: #f1f5fb;
+ color: #455166;
+ padding: 5px 7px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ overflow-wrap: anywhere;
+}
+
+.config-value-list small {
+ display: block;
+ margin-top: 9px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.config-value-list.compact {
+ margin-top: 10px;
+ padding: 10px;
+}
+
+.role-config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
+ gap: 12px;
+ padding: 12px;
+}
+
+.role-config-card {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ padding: 13px;
+}
+
+.role-config-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+}
+
+.role-config-head > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.role-config-head strong {
+ color: var(--text);
+ font-size: 12px;
+}
+
+.role-config-facts {
+ display: grid;
+ gap: 9px;
+ margin: 13px 0 0;
+}
+
+.role-config-facts div {
+ display: grid;
+ gap: 3px;
+}
+
+.role-config-facts dt {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.role-config-facts dd {
+ min-width: 0;
+ margin: 0;
+ color: var(--text);
+ font-size: 11px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.role-config-facts code {
+ color: #536176;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+}
+
+.role-prepare-script {
+ margin-top: 10px;
+ border-top: 1px dashed var(--line);
+ padding-top: 10px;
+}
+
+.role-prepare-script summary {
+ cursor: pointer;
+ color: var(--blue);
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.role-prepare-script .code-editor-viewer {
+ margin-top: 10px;
+}
+
+.terminal-button {
+ height: 32px;
+ padding: 0 10px;
+ border-radius: 10px;
+}
+
+.worker-table .row-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.ssh-modal {
+ max-width: 560px;
+}
+
+.ssh-modal .modal-body {
+ padding: 20px 24px;
+}
diff --git a/apps/rlark-ui/src/styles/jobs/tags-and-editing.css b/apps/rlark-ui/src/styles/jobs/tags-and-editing.css
new file mode 100644
index 0000000..6a43ed7
--- /dev/null
+++ b/apps/rlark-ui/src/styles/jobs/tags-and-editing.css
@@ -0,0 +1,590 @@
+/* ============ Jobs 列表页标签列 ============ */
+.job-table-tag-col {
+ min-width: 140px;
+}
+
+.job-table-tag-head {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ font-size: inherit;
+ font-weight: inherit;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+}
+
+.job-table-tag-head svg {
+ opacity: 0.4;
+ transition:
+ opacity 0.15s,
+ color 0.15s;
+}
+
+.job-table-tag-head:hover svg {
+ opacity: 0.8;
+}
+
+.job-table-tag-head.has-filter svg {
+ opacity: 1;
+ color: var(--blue);
+}
+
+.job-table-tag-cell {
+ padding: 10px 12px;
+ vertical-align: top;
+}
+
+.job-tags-cell {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ min-width: 0;
+}
+
+.job-tag-chip {
+ display: inline-block;
+ flex: 0 0 auto;
+ min-width: max-content;
+ padding: 2px 6px;
+ font-size: 11px;
+ font-weight: 500;
+ background: transparent;
+ color: var(--muted);
+ border: 1px solid #d1d5db;
+ border-radius: 3px;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.job-tag-overflow {
+ cursor: pointer;
+}
+
+.job-tag-popover {
+ position: fixed;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ width: max-content;
+ max-width: min(360px, calc(100vw - 24px));
+ max-height: calc(100vh - 24px);
+ padding: 10px;
+ background: var(--card-bg, #fff);
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ box-shadow: 0 8px 20px rgb(0 0 0 / 12%);
+}
+
+.job-tag-popover-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 8px;
+ font-size: 12px;
+}
+
+.job-tag-popover-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s;
+}
+
+.job-tag-popover-close:hover {
+ background: var(--canvas);
+ color: var(--ink);
+}
+
+.job-tag-popover-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.job-no-tag {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+/* ============ JobDetailPage 任务名称编辑 ============ */
+.job-title-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: text;
+ transition: opacity 0.15s;
+}
+
+.job-title-row.is-disabled {
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.job-title-row.is-disabled .job-title-edit-btn {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.job-title-edit-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ color: var(--muted);
+ cursor: pointer;
+ opacity: 1;
+ transition:
+ border-color 0.15s,
+ color 0.15s;
+}
+
+.job-title-edit-btn:hover:not(:disabled) {
+ border-color: var(--line);
+ color: var(--fg);
+}
+
+.job-title-edit-btn:disabled {
+ cursor: not-allowed;
+}
+
+.job-name-edit {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.job-name-edit input {
+ font-size: inherit;
+ font-weight: inherit;
+ padding: 2px 8px;
+ border: 2px solid var(--blue);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--fg);
+ outline: none;
+ /* 宽度需完整容纳最长的 placeholder(英文约 420px) */
+ width: 420px;
+ max-width: 100%;
+}
+
+.job-name-edit input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.job-name-edit input.input-invalid {
+ border-color: var(--red);
+}
+
+.job-name-edit input::placeholder {
+ font-size: 12px;
+}
+
+.job-name-saving {
+ font-size: 14px;
+ color: var(--muted);
+ font-style: italic;
+}
+
+.job-name-edit-error {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--red);
+}
+
+/* ============ JobDetailPage 标签展示/编辑 ============ */
+.public-tags-row {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ align-items: start;
+ gap: 12px;
+ padding: 10px 0;
+ border-top: 1px solid var(--line);
+}
+
+.public-tags-label {
+ align-self: start;
+ padding-top: 4px;
+ font-size: 12px;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.public-tags-content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ justify-items: start;
+ align-items: start;
+ row-gap: 6px;
+ min-width: 0;
+}
+
+.public-tags-content .job-tags-cell,
+.public-tags-content .public-config-empty {
+ grid-column: 1;
+ width: 100%;
+}
+
+.public-tags-content .job-tags-edit-btn {
+ grid-column: 1;
+ grid-row: 2;
+ position: static;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ min-height: 22px;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ color: var(--muted);
+ opacity: 1;
+}
+
+.job-tags-table {
+ display: grid;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.job-tags-table-row {
+ display: grid;
+ grid-template-columns: minmax(96px, 0.3fr) 1fr;
+ border-bottom: 1px solid var(--line);
+}
+
+.job-tags-table-row:last-child {
+ border-bottom: 0;
+}
+
+.job-tags-table-key {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ color: var(--muted);
+ font-size: 12px;
+ background: var(--input-bg, #fafafa);
+ border-right: 1px solid var(--line);
+ overflow-wrap: anywhere;
+}
+
+.job-tags-table-values {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+}
+
+.job-tag-chip-inline {
+ display: inline-block;
+ padding: 3px 10px;
+ font-size: 12px;
+ font-weight: 500;
+ background: var(--blue-bg, #eef2ff);
+ color: var(--blue, #6366f1);
+ border: 1px solid rgba(99, 102, 241, 0.25);
+ border-radius: 5px;
+ line-height: 1.4;
+}
+
+.public-tags-editor-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.job-tags-modal-backdrop {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.job-tags-modal {
+ margin: auto;
+}
+
+.public-config-empty {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.job-tags-edit-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-height: 30px;
+ padding: 0 10px;
+ background: transparent;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.15s;
+ flex-shrink: 0;
+}
+
+.job-tags-edit-btn:hover:not(:disabled) {
+ border-color: var(--blue);
+ color: var(--blue);
+}
+
+.job-tags-edit-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.job-tags-modal {
+ width: min(1120px, calc(100vw - 72px));
+ display: flex;
+ flex-direction: column;
+}
+
+.job-tags-modal-body {
+ min-height: 0;
+ overflow-y: auto;
+ padding: 20px 24px;
+}
+
+.job-tags-modal .tag-editor-rows {
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ overflow: hidden;
+ gap: 0;
+ background: var(--bg);
+}
+
+.job-tags-modal .tag-row {
+ display: grid;
+ grid-template-columns: minmax(240px, 0.3fr) auto minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--line);
+ align-items: start;
+}
+
+.job-tags-modal .tag-input-field {
+ flex: none;
+ width: auto;
+}
+
+.job-tags-modal .tag-input-field > .tag-combobox-input,
+.job-tags-modal .tag-input-field > .tag-combobox > .tag-combobox-input {
+ box-sizing: border-box;
+ height: 37.33px;
+}
+
+.job-tags-modal .tag-input-field:last-of-type {
+ min-width: 0;
+}
+
+.job-tags-modal .tag-row:last-child {
+ border-bottom: 0;
+}
+
+.job-tags-modal .tag-row > .tag-combobox {
+ flex: none;
+ width: auto;
+}
+
+.job-tags-modal .tag-values-editor {
+ width: 100%;
+}
+
+.job-tags-modal .tag-add-btn {
+ align-self: flex-start;
+}
+
+.job-tags-modal-actions {
+ justify-content: flex-end;
+}
+
+@media (max-width: 720px) {
+ .job-tags-modal .tag-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .job-tags-modal .tag-input-field {
+ grid-column: 1 / -1;
+ }
+
+ .job-tags-modal .tag-row .tag-sep {
+ display: none;
+ }
+
+ .job-tags-modal .tag-row .tag-remove-btn {
+ grid-column: 2;
+ grid-row: 1;
+ }
+}
+
+/* ---- Sectioned tag editor (job tags modal) ---- */
+.tag-editor-sectioned {
+ gap: 18px;
+}
+
+.tag-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.tag-section-title {
+ padding-left: 8px;
+ border-left: 3px solid var(--blue);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--fg);
+}
+
+.tag-section-empty {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.tag-editor-sectioned .tag-editor-rows {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ overflow: visible;
+ gap: 10px;
+ background: transparent;
+}
+
+.tag-editor-sectioned .tag-row {
+ display: grid;
+ grid-template-columns:
+ max-content minmax(240px, 0.3fr) max-content minmax(0, 1fr)
+ max-content;
+ gap: 10px;
+ padding: 0;
+ border-bottom: 0;
+ align-items: start;
+}
+
+.tag-editor-sectioned .tag-input-field {
+ flex: none;
+ width: auto;
+}
+
+.tag-editor-sectioned .tag-input-field:last-of-type {
+ min-width: 0;
+}
+
+.tag-row-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 13px;
+ color: var(--fg);
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.tag-row-required {
+ color: var(--danger, #dc2626);
+ font-weight: 600;
+}
+
+.tag-editor-sectioned .tag-row > .tag-combobox {
+ flex: none;
+ width: auto;
+}
+
+.tag-editor-sectioned .tag-values-editor {
+ width: 100%;
+}
+
+.tag-add-link {
+ align-self: flex-start;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--fg);
+ font-size: 13px;
+ cursor: pointer;
+ transition: color 0.15s ease;
+}
+
+.tag-add-link:hover:not(:disabled) {
+ color: var(--blue);
+}
+
+.tag-add-link:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.tag-combobox-chevron {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ color: var(--muted);
+ pointer-events: none;
+}
+
+.tag-combobox-input.has-chevron {
+ padding-right: 28px;
+}
+
+@media (max-width: 720px) {
+ .tag-editor-sectioned .tag-row {
+ grid-template-columns: minmax(0, 1fr) max-content;
+ }
+
+ .tag-editor-sectioned .tag-row-label {
+ grid-column: 1 / -1;
+ }
+
+ .tag-editor-sectioned .tag-input-field {
+ grid-column: 1 / -1;
+ }
+
+ .tag-editor-sectioned .tag-row .tag-sep {
+ display: none;
+ }
+
+ .tag-editor-sectioned .tag-row .tag-remove-btn {
+ grid-column: 2;
+ grid-row: 1;
+ }
+}
+
+.public-tags-error {
+ flex: 1;
+ font-size: 12px;
+ color: var(--danger, #dc2626);
+}
diff --git a/apps/rlark-ui/src/styles/nodes/admin-insight.css b/apps/rlark-ui/src/styles/nodes/admin-insight.css
new file mode 100644
index 0000000..2ad11d3
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/admin-insight.css
@@ -0,0 +1,240 @@
+/* Admin node operations */
+.admin-node-insight {
+ display: grid;
+ gap: 16px;
+}
+
+.admin-node-actionbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 17px 19px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-node-actionbar > div:first-child {
+ min-width: 0;
+}
+
+.admin-node-actionbar strong,
+.admin-node-actionbar small {
+ display: block;
+}
+
+.admin-node-actionbar strong {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.admin-node-actionbar small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.admin-node-actionbar .row-actions {
+ flex: 0 0 auto;
+}
+
+.admin-cordon-button {
+ color: var(--red);
+ border-color: rgba(239, 90, 122, 0.24);
+ background: rgba(239, 90, 122, 0.05);
+}
+
+.admin-cordon-button:hover {
+ background: rgba(239, 90, 122, 0.1);
+}
+
+.admin-node-insight > .node-insight-detail {
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-node-labels {
+ padding: 20px;
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-node-labels .node-insight-section-head > b {
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: var(--canvas);
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.admin-node-labels .label-list {
+ max-width: none;
+ gap: 8px;
+}
+
+.admin-node-labels .label-chip {
+ max-width: min(100%, 360px);
+ padding: 6px 9px;
+ border-radius: 8px;
+}
+
+.admin-label-editor {
+ display: grid;
+ gap: 9px;
+}
+
+.admin-node-managed-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--canvas);
+}
+
+.admin-node-managed-field {
+ display: grid;
+ gap: 7px;
+}
+
+.admin-node-managed-field label {
+ color: var(--soft);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.admin-node-managed-field input {
+ width: 100%;
+ height: 38px;
+ padding: 0 11px;
+ border: 1px solid var(--line-strong);
+ border-radius: 9px;
+ background: var(--panel);
+ color: var(--ink);
+ box-sizing: border-box;
+}
+
+.admin-node-managed-categories {
+ grid-column: 1 / -1;
+}
+
+.admin-node-extension-head {
+ margin-top: 8px;
+}
+
+.admin-node-extension-head strong,
+.admin-node-extension-head small {
+ display: block;
+}
+
+.admin-node-extension-head strong {
+ font-size: 12px;
+}
+
+.admin-node-extension-head small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.admin-label-editor .label-edit-row {
+ grid-template-columns: minmax(180px, 0.8fr) minmax(180px, 1.2fr) 36px;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 11px;
+ background: var(--canvas);
+}
+
+.admin-label-editor .label-edit-row code {
+ overflow: hidden;
+ color: var(--blue);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-label-editor .label-edit-row input,
+.admin-label-editor .label-add-row input {
+ height: 36px;
+ border: 1px solid var(--line-strong);
+ border-radius: 9px;
+ background: var(--panel);
+ color: var(--ink);
+ outline: none;
+}
+
+.admin-label-editor .label-edit-row input:focus,
+.admin-label-editor .label-add-row input:focus {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
+}
+
+.admin-label-editor .label-add-row {
+ grid-template-columns: minmax(180px, 0.8fr) minmax(180px, 1.2fr) auto;
+ padding: 10px;
+ border: 1px dashed var(--line-strong);
+ border-radius: 11px;
+ background: var(--panel);
+}
+
+.admin-label-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 9px;
+ padding-top: 5px;
+}
+
+.admin-label-empty {
+ width: 100%;
+ min-height: 86px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 9px;
+ border: 1px dashed var(--line-strong);
+ border-radius: 12px;
+ background: var(--canvas);
+ color: var(--muted);
+}
+
+.theme-dark .admin-node-actionbar,
+.theme-dark .admin-node-labels {
+ background: #151d2b;
+ border-color: #2b3850;
+}
+
+.theme-dark .admin-label-editor .label-edit-row,
+.theme-dark .admin-label-empty {
+ background: #111a29;
+ border-color: #33425b;
+}
+
+.theme-dark .admin-label-editor .label-edit-row input,
+.theme-dark .admin-label-editor .label-add-row,
+.theme-dark .admin-label-editor .label-add-row input {
+ background: #151d2b;
+ border-color: #33425b;
+ color: #e6edf7;
+}
+
+@media (max-width: 780px) {
+ .admin-node-actionbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .admin-node-actionbar .row-actions {
+ justify-content: flex-end;
+ }
+
+ .admin-label-editor .label-edit-row,
+ .admin-label-editor .label-add-row {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-node-managed-fields {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/nodes/category-table.css b/apps/rlark-ui/src/styles/nodes/category-table.css
new file mode 100644
index 0000000..aa33910
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/category-table.css
@@ -0,0 +1,189 @@
+.node-category-table {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.node-category-table-head,
+.node-category-table .node-row {
+ display: grid;
+ grid-template-columns:
+ minmax(170px, 1.35fr)
+ minmax(100px, 0.7fr)
+ minmax(160px, 1.15fr)
+ minmax(130px, 0.9fr)
+ minmax(110px, 0.8fr)
+ minmax(180px, 1.35fr)
+ 20px;
+ align-items: center;
+ column-gap: 16px;
+ min-width: 1000px;
+}
+
+.node-category-table-head {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--line);
+ background: var(--canvas);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.node-category-table .node-category-list {
+ gap: 0;
+}
+
+.node-category-table .node-row {
+ padding: 11px 12px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+}
+
+.node-category-table .node-row:last-child {
+ border-bottom: 0;
+}
+
+.node-row-primary {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+.node-row-status,
+.node-row-meta,
+.node-row-location,
+.node-row-ip,
+.node-row-task {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-row-meta,
+.node-row-location,
+.node-row-ip,
+.node-row-task {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.node-row-location {
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.node-row-resource {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ overflow: hidden;
+}
+
+.node-row-resource-line {
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ line-height: 1.25;
+}
+
+.node-row-resource-line > i {
+ flex: none;
+ color: var(--soft);
+ font-size: 9px;
+ font-style: normal;
+}
+
+.node-row-resource-line > strong {
+ min-width: 0;
+}
+
+.node-row-resource-line > strong.unlabeled {
+ color: var(--muted);
+ font-weight: 600;
+}
+
+.node-row-resource-line > small {
+ flex: none;
+}
+
+.node-row-resource strong,
+.node-row-resource small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-row-resource strong {
+ color: var(--ink);
+ font-size: 11px;
+}
+
+.node-row-resource small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-row-task {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.embodied-task-dot {
+ width: 7px;
+ height: 7px;
+ flex: none;
+ border-radius: 50%;
+ background: var(--gray);
+}
+
+.embodied-task-dot.active {
+ background: var(--green);
+ box-shadow: 0 0 0 3px rgba(54, 201, 143, 0.1);
+}
+
+.node-row-ip {
+ font-family: "JetBrains Mono", monospace;
+}
+
+.embodied-task-state {
+ width: fit-content;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.embodied-task-state.active {
+ background: rgba(54, 201, 143, 0.12);
+ color: var(--green);
+}
+
+.embodied-task-state.idle {
+ background: rgba(142, 151, 166, 0.1);
+ color: var(--muted);
+}
+
+.theme-dark .node-category-table-head {
+ background: #111a29;
+ border-color: #33425b;
+ color: #9eacc1;
+}
+
+.theme-dark .node-category-table .node-row {
+ border-color: #273348;
+}
+
+.theme-dark .node-row-meta,
+.theme-dark .node-row-location,
+.theme-dark .node-row-ip,
+.theme-dark .node-row-task {
+ color: #b2bfd1;
+}
diff --git a/apps/rlark-ui/src/styles/nodes/detail-panel-overrides.css b/apps/rlark-ui/src/styles/nodes/detail-panel-overrides.css
new file mode 100644
index 0000000..0fc530e
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/detail-panel-overrides.css
@@ -0,0 +1,126 @@
+.node-detail-panel {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.node-resource-detail {
+ position: sticky;
+ top: 88px;
+ scroll-margin-top: 88px;
+}
+
+.node-detail-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.node-detail-header .node-status-ring {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--skyblue-bg);
+ color: var(--blue);
+ flex-shrink: 0;
+}
+
+.node-detail-header h3 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.node-detail-header small {
+ display: block;
+ margin-top: 4px;
+ font-size: 12px;
+ color: #8e97a6;
+}
+
+.node-detail-header .icon-button {
+ margin-left: auto;
+}
+
+.node-detail-body {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ flex: 1;
+ min-height: 0;
+}
+
+.node-detail-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.node-detail-scroll {
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+.node-detail-label {
+ font-size: 12px;
+ color: #8e97a6;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.node-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+}
+
+.node-detail-grid > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.node-detail-grid strong {
+ font-size: 14px;
+}
+
+.node-resource-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.node-resource-table th {
+ text-align: left;
+ padding: 3px 10px;
+ height: auto;
+ font-size: 11px;
+ color: #8e97a6;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ background: var(--panel);
+ z-index: 1;
+}
+
+.node-resource-table td {
+ padding: 2px 10px;
+ height: auto;
+ line-height: 1.4;
+ border-bottom: 1px solid var(--line);
+}
+
+.theme-dark .node-row:hover,
+.theme-dark .node-row.selected {
+ background: rgba(54, 124, 232, 0.12);
+}
+
+.theme-dark .node-detail-grid strong {
+ color: var(--text);
+}
diff --git a/apps/rlark-ui/src/styles/nodes/files-and-selector.css b/apps/rlark-ui/src/styles/nodes/files-and-selector.css
new file mode 100644
index 0000000..b646a59
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/files-and-selector.css
@@ -0,0 +1,434 @@
+.brand-logo-dark {
+ display: none;
+}
+
+.theme-dark .brand-logo-light {
+ display: none;
+}
+
+.theme-dark .brand-logo-dark {
+ display: block;
+}
+
+.files-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 12px 16px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ margin-bottom: 16px;
+ font-size: 13px;
+}
+
+.files-breadcrumb .breadcrumb-label {
+ color: var(--muted);
+ font-weight: 500;
+ margin-right: 8px;
+}
+
+.files-breadcrumb span {
+ color: var(--blue);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 6px;
+ transition: background 0.15s;
+ font-weight: 500;
+}
+
+.files-breadcrumb span:hover {
+ background: var(--hover);
+}
+
+.files-breadcrumb span:not(:last-child)::after {
+ content: "/";
+ margin-left: 4px;
+ color: var(--soft);
+ cursor: default;
+}
+
+.files-breadcrumb span:not(:last-child):hover::after {
+ color: var(--soft);
+}
+
+.files-table {
+ margin-top: 0;
+}
+
+.files-table tbody td {
+ font-size: 14px;
+}
+
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ border-radius: 8px;
+ color: var(--muted);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.btn-icon:hover {
+ background: var(--hover);
+ color: var(--blue);
+}
+
+.btn-icon-danger {
+ color: var(--muted);
+}
+
+.btn-icon-danger:hover {
+ color: var(--red);
+ background: rgba(239, 90, 122, 0.08);
+}
+
+tr.clickable {
+ cursor: pointer;
+}
+
+tr.clickable:hover {
+ background: var(--hover);
+}
+
+@media (prefers-color-scheme: dark) {
+ .files-breadcrumb {
+ background: var(--panel);
+ border-color: var(--line);
+ }
+
+ .files-breadcrumb span:hover {
+ background: var(--hover);
+ }
+}
+
+.theme-dark .label-chip {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .label-chip code {
+ color: #c4b5fd;
+}
+
+.theme-dark .label-chip i {
+ color: #9ca8ba;
+}
+
+.theme-dark .label-toggle {
+ border-color: var(--line-strong);
+ color: #9ca8ba;
+}
+
+.theme-dark .label-toggle:hover {
+ background: rgba(167, 139, 250, 0.12);
+ color: #c4b5fd;
+ border-color: #a78bfa;
+}
+
+.theme-dark .label-edit-row input,
+.theme-dark .label-add-row input {
+ background: #111a29;
+ border-color: var(--line);
+ color: #d8e0ee;
+}
+
+.theme-dark .admin-node-table tr:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.node-selector-picker {
+ position: relative;
+}
+
+.selector-chips-area {
+ min-height: 38px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 6px 30px 6px 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ cursor: pointer;
+ position: relative;
+ background: #fff;
+}
+
+.selector-chips-area:hover {
+ border-color: var(--blue, #7c3aed);
+}
+
+.selector-placeholder {
+ color: var(--muted, #8b95a7);
+ font-size: 13px;
+}
+
+.selector-hint {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--muted, #8b95a7);
+ line-height: 1.5;
+}
+
+.field-hint {
+ display: block;
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--muted, #8b95a7);
+}
+
+.label-row {
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+}
+
+.label-hint {
+ font-size: 11px;
+ color: var(--muted, #8b95a7);
+}
+
+.selector-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #f3eefe;
+ color: #7c3aed;
+ border-radius: 6px;
+ padding: 2px 8px;
+ font-size: 12px;
+ font-family: var(--mono, monospace);
+ cursor: pointer;
+}
+
+.selector-chip:hover {
+ background: #ede9fe;
+}
+
+.selector-chevron {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--muted, #8b95a7);
+}
+
+.selector-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ z-index: 100;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ max-height: 320px;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.selector-loading,
+.selector-empty,
+.selector-no-match {
+ padding: 12px;
+ text-align: center;
+ color: var(--muted, #8b95a7);
+ font-size: 13px;
+}
+
+.selector-group {
+ margin-bottom: 8px;
+}
+
+.selector-group-head {
+ margin-bottom: 4px;
+}
+
+.selector-group-head code {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted, #8b95a7);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.selector-group-values {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.selector-value-chip {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 3px 10px;
+ font-size: 12px;
+ background: #fff;
+ color: #475569;
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.selector-value-chip:hover {
+ border-color: var(--blue, #7c3aed);
+ color: var(--blue, #7c3aed);
+}
+
+.selector-value-chip.active {
+ background: #7c3aed;
+ border-color: #7c3aed;
+ color: #fff;
+}
+
+.selector-matched {
+ margin-top: 8px;
+ border-top: 1px solid var(--line);
+ padding-top: 8px;
+}
+
+.selector-matched-head {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted, #8b95a7);
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.selector-matched-node {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 0;
+}
+
+.selector-matched-node code {
+ font-size: 12px;
+ color: #475569;
+}
+
+.node-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #22c55e;
+ flex-shrink: 0;
+}
+
+.node-dot.offline {
+ background: #ef4444;
+}
+
+.selector-text-input {
+ margin-top: 8px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px 12px;
+ font-size: 13px;
+ width: 100%;
+ background: #fff;
+ color: #202733;
+}
+
+.selector-text-input:focus {
+ outline: none;
+ border-color: var(--blue, #7c3aed);
+}
+
+.theme-dark .selector-chips-area {
+ background: #111a29;
+ border-color: var(--line-strong);
+}
+
+.theme-dark .selector-chips-area:hover {
+ border-color: #a78bfa;
+}
+
+.theme-dark .selector-chip {
+ background: rgba(167, 139, 250, 0.15);
+ color: #c4b5fd;
+}
+
+.theme-dark .selector-chip:hover {
+ background: rgba(167, 139, 250, 0.25);
+}
+
+.theme-dark .selector-dropdown {
+ background: #111a29;
+ border-color: var(--line-strong);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+
+.theme-dark .selector-value-chip {
+ background: #111a29;
+ border-color: var(--line);
+ color: #9ca8ba;
+}
+
+.theme-dark .selector-value-chip:hover {
+ border-color: #a78bfa;
+ color: #c4b5fd;
+}
+
+.theme-dark .selector-value-chip.active {
+ background: #a78bfa;
+ border-color: #a78bfa;
+ color: #fff;
+}
+
+.theme-dark .selector-text-input {
+ background: #111a29;
+ border-color: var(--line-strong);
+ color: #d8e0ee;
+}
+
+.theme-dark .selector-matched-node code {
+ color: #9ca8ba;
+}
+
+.selector-matched-inline {
+ margin-top: 8px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px 10px;
+ background: #f7f9fc;
+}
+
+.selector-matched-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 14px;
+}
+
+.selector-matched-inline .selector-matched-node {
+ padding: 0;
+}
+
+.theme-dark .selector-matched-inline {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.selector-no-match {
+ margin-top: 8px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: rgba(239, 90, 122, 0.08);
+ border: 1px solid rgba(239, 90, 122, 0.25);
+ color: var(--red);
+ font-size: 12px;
+}
+
+.node-admin-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
+ align-items: start;
+ gap: 16px;
+}
diff --git a/apps/rlark-ui/src/styles/nodes/insight.css b/apps/rlark-ui/src/styles/nodes/insight.css
new file mode 100644
index 0000000..6eefa57
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/insight.css
@@ -0,0 +1,830 @@
+/* Node insight detail */
+.node-resource-detail.node-insight-detail {
+ position: static;
+ top: auto;
+ padding: 0;
+ overflow: hidden;
+ border-radius: 24px;
+ background: var(--panel);
+}
+
+.node-insight-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 24px 26px;
+ border-bottom: 1px solid var(--line);
+ background:
+ radial-gradient(circle at 8% 0%, rgba(124, 58, 237, 0.12), transparent 36%),
+ linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--panel) 94%, #ede9fe),
+ var(--panel)
+ );
+}
+
+.node-insight-identity {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.node-insight-icon {
+ width: 48px;
+ height: 48px;
+ flex: 0 0 48px;
+ display: grid;
+ place-items: center;
+ border-radius: 15px;
+ background: #efeafe;
+ color: var(--blue);
+ box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.08);
+}
+
+.node-insight-icon.online {
+ background: var(--green-soft);
+ color: var(--green);
+}
+
+.node-insight-icon.offline {
+ background: rgba(239, 90, 122, 0.1);
+ color: var(--red);
+}
+
+.node-insight-identity h3 {
+ margin: 4px 0 5px;
+ color: var(--ink);
+ font-size: 25px;
+ letter-spacing: -0.65px;
+}
+
+.node-insight-identity p {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.node-insight-identity p span {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--soft);
+}
+
+.node-insight-state {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.schedule-chip {
+ padding: 5px 9px;
+ border: 1px solid rgba(54, 201, 143, 0.2);
+ border-radius: 999px;
+ background: var(--green-soft);
+ color: #168a61;
+ font-size: 10px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.schedule-chip.blocked {
+ border-color: rgba(239, 90, 122, 0.2);
+ background: rgba(239, 90, 122, 0.1);
+ color: var(--red);
+}
+
+.node-health-message {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 18px 24px 0;
+ padding: 10px 12px;
+ border: 1px solid rgba(245, 158, 53, 0.2);
+ border-radius: 11px;
+ background: rgba(245, 158, 53, 0.08);
+ color: #b86f1b;
+ font-size: 12px;
+}
+
+.node-insight-facts {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin: 20px 24px 0;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 15px;
+ background: var(--canvas);
+}
+
+.node-insight-facts > div {
+ min-width: 0;
+ padding: 13px 15px;
+ border-right: 1px solid var(--line);
+}
+
+.node-insight-facts > div:last-child {
+ border-right: 0;
+}
+
+.node-insight-facts small,
+.node-insight-facts strong {
+ display: block;
+}
+
+.node-insight-facts small {
+ margin-bottom: 5px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-insight-facts strong {
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-insight-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.85fr);
+ gap: 16px;
+ padding: 18px 24px 24px;
+}
+
+.node-insight-main,
+.node-insight-side {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ gap: 16px;
+}
+
+.node-insight-main > .node-insight-section {
+ height: 100%;
+}
+
+.node-insight-main {
+ align-content: stretch;
+ grid-template-rows: minmax(0, 1fr);
+}
+
+.node-insight-section {
+ min-width: 0;
+ padding: 17px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--panel);
+}
+
+.node-insight-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 13px;
+}
+
+.node-insight-section-head span,
+.node-insight-section-head small {
+ display: block;
+}
+
+.node-insight-section-head span {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 850;
+}
+
+.node-insight-section-head small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-capacity-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 10px;
+}
+
+.node-capacity-card {
+ min-width: 0;
+ min-height: 142px;
+ padding: 14px 12px;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ background: var(--canvas);
+}
+
+.node-capacity-title {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.node-capacity-title strong {
+ white-space: nowrap;
+}
+
+.node-capacity-title > span {
+ width: 27px;
+ height: 27px;
+ display: grid;
+ place-items: center;
+ border-radius: 8px;
+ background: #efeafe;
+ color: var(--blue);
+}
+
+.node-capacity-title strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.node-capacity-alert {
+ position: relative;
+ width: 20px !important;
+ height: 20px !important;
+ margin-left: auto;
+ border: 1px solid color-mix(in srgb, var(--danger) 38%, transparent) !important;
+ border-radius: 50% !important;
+ background: color-mix(in srgb, var(--danger) 8%, var(--canvas)) !important;
+ color: var(--danger) !important;
+ cursor: help;
+}
+
+.node-capacity-alert-tooltip {
+ position: absolute;
+ right: calc(100% + 10px);
+ bottom: calc(100% + 10px);
+ z-index: 40;
+ width: 230px !important;
+ height: auto !important;
+ display: grid !important;
+ gap: 5px;
+ padding: 10px 12px;
+ border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--line));
+ border-radius: 10px !important;
+ background: var(--panel) !important;
+ box-shadow: 0 14px 32px rgba(28, 39, 58, 0.2);
+ color: var(--ink) !important;
+ line-height: 1.45;
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-4px, 4px);
+ transition:
+ opacity 0.16s ease,
+ transform 0.16s ease;
+}
+
+.node-capacity-alert:hover .node-capacity-alert-tooltip,
+.node-capacity-alert:focus-visible .node-capacity-alert-tooltip {
+ opacity: 1;
+ transform: translate(0, 0);
+}
+
+.node-capacity-alert-tooltip strong,
+.node-capacity-alert-tooltip small {
+ white-space: normal;
+}
+
+.node-capacity-alert-tooltip strong {
+ color: var(--danger);
+ font-size: 11px;
+}
+
+.node-capacity-alert-tooltip small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-capacity-progress {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ margin: 14px 0 12px;
+}
+
+.node-capacity-progress b {
+ min-width: 28px;
+ color: var(--ink);
+ font-size: 12px;
+ text-align: right;
+}
+
+.node-capacity-track {
+ height: 6px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--line-strong);
+}
+
+.node-capacity-track i {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--blue), var(--blue-2));
+}
+
+.node-capacity-card.is-warning {
+ border-color: var(--danger);
+}
+
+.node-capacity-card.is-warning .node-capacity-track i {
+ background: linear-gradient(90deg, var(--danger-strong), var(--danger-soft));
+}
+
+.node-capacity-card.is-warning .node-capacity-progress b {
+ color: var(--danger);
+}
+
+.node-capacity-amounts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 5px 8px;
+}
+
+.node-capacity-amounts > span {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.node-capacity-amounts em,
+.node-capacity-amounts strong {
+ overflow: hidden;
+ font-style: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-capacity-amounts em {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.node-capacity-amounts strong {
+ color: var(--ink);
+ font-size: 11px;
+}
+
+.node-task-callout {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 13px;
+ border: 1px dashed var(--line-strong);
+ border-radius: 13px;
+ background: var(--canvas);
+ color: var(--ink);
+ font: inherit;
+ text-align: left;
+}
+
+.node-task-callout:disabled {
+ cursor: default;
+ opacity: 1;
+}
+
+.node-task-callout.interactive {
+ cursor: pointer;
+ transition:
+ border-color 0.16s ease,
+ background-color 0.16s ease,
+ box-shadow 0.16s ease,
+ transform 0.16s ease;
+}
+
+.node-task-callout.interactive:hover {
+ border-color: rgba(54, 201, 143, 0.55);
+ background: rgba(54, 201, 143, 0.11);
+ box-shadow: 0 8px 18px rgba(35, 156, 112, 0.09);
+ transform: translateY(-1px);
+}
+
+.node-task-callout.interactive:focus-visible {
+ outline: 2px solid var(--green);
+ outline-offset: 2px;
+}
+
+.node-task-callout > span {
+ width: 36px;
+ height: 36px;
+ flex: 0 0 36px;
+ display: grid;
+ place-items: center;
+ border-radius: 11px;
+ background: var(--panel);
+ color: var(--muted);
+}
+
+.node-task-callout > div {
+ min-width: 0;
+}
+
+.node-task-callout strong,
+.node-task-callout small {
+ display: block;
+}
+
+.node-task-callout strong {
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-task-callout small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-task-callout > b {
+ margin-left: auto;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: var(--line);
+ color: var(--muted);
+ font-size: 9px;
+ white-space: nowrap;
+}
+
+.node-task-callout.active {
+ border-style: solid;
+ border-color: rgba(54, 201, 143, 0.24);
+ background: rgba(54, 201, 143, 0.07);
+}
+
+.node-task-callout.active > span {
+ background: var(--green-soft);
+ color: var(--green);
+}
+
+.node-task-callout.active > b {
+ background: var(--green-soft);
+ color: #168a61;
+}
+
+.node-worker-table-wrap {
+ overflow-x: scroll;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ scrollbar-color: #9eabbd #edf1f6;
+ scrollbar-width: auto;
+}
+
+.node-worker-table-wrap::-webkit-scrollbar {
+ height: 12px;
+}
+
+.node-worker-table-wrap::-webkit-scrollbar-track {
+ background: #edf1f6;
+}
+
+.node-worker-table-wrap::-webkit-scrollbar-thumb {
+ min-width: 56px;
+ border: 3px solid #edf1f6;
+ border-radius: 999px;
+ background: #9eabbd;
+}
+
+.node-worker-panel {
+ margin-top: 18px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.node-worker-panel .worker-panel-head {
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--line);
+}
+
+.node-worker-panel .node-worker-table-wrap {
+ border: 0;
+ border-radius: 0;
+}
+
+.node-worker-table-wrap .node-worker-table {
+ width: 100%;
+ min-width: 1280px;
+ border-collapse: collapse;
+}
+
+.node-worker-table th,
+.node-worker-table td {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+}
+
+.node-worker-table th {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.node-worker-table > thead > tr > th:last-child,
+.node-worker-table > tbody > tr > td:last-child {
+ position: sticky;
+ right: 0;
+ z-index: 1;
+ background: var(--panel);
+ box-shadow: -8px 0 12px -12px rgba(15, 23, 42, 0.5);
+}
+
+.node-worker-table > thead > tr > th:last-child {
+ z-index: 2;
+}
+
+.node-worker-table > tbody > tr:last-child > td {
+ border-bottom: 0;
+}
+
+.node-worker-job-link {
+ max-width: 180px;
+ overflow: hidden;
+ color: var(--primary);
+ text-overflow: ellipsis;
+}
+
+.node-worker-empty {
+ padding: 24px;
+ border: 1px dashed var(--line);
+ border-radius: 12px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.node-worker-expanded td {
+ position: static !important;
+ background: var(--surface-2);
+}
+
+.node-worker-expanded .worker-detail-drawer {
+ position: sticky;
+ left: 0;
+ width: min(calc(100vw - 150px), 100%);
+ max-width: calc(100vw - 150px);
+ box-sizing: border-box;
+}
+
+.node-worker-expanded .worker-detail-head {
+ min-width: 0;
+}
+
+.node-worker-expanded .worker-ssh-inline {
+ width: min(52%, 520px);
+ max-width: min(52%, 520px);
+ flex: 0 1 520px;
+}
+
+.node-task-link-icon {
+ flex: none;
+ color: var(--green);
+}
+
+/* Node image pull progress list (NodeDetailReal). */
+.node-pull-progress-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.node-pull-progress-entry {
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--canvas);
+}
+
+.node-pull-progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.node-pull-image {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--blue);
+ font-size: 11px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-pull-status {
+ flex: none;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--line-strong);
+ color: var(--ink);
+ font-size: 10px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.node-pull-status.chip-pulling {
+ background: rgba(54, 201, 143, 0.16);
+ color: var(--green);
+}
+
+.node-pull-status.chip-completed {
+ background: rgba(63, 140, 255, 0.16);
+ color: var(--blue);
+}
+
+.node-pull-status.chip-failed {
+ background: rgba(229, 99, 99, 0.16);
+ color: var(--red, #e56363);
+}
+
+.node-pull-progress-bar {
+ position: relative;
+ display: flex;
+ align-items: center;
+ height: 18px;
+ margin: 9px 0 6px;
+}
+
+.node-pull-progress-bar > i {
+ position: absolute;
+ inset: 6px 0;
+ display: block;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--blue), var(--blue-2));
+ transition: width 0.18s ease;
+}
+
+.node-pull-progress-bar > b {
+ position: relative;
+ z-index: 1;
+ padding-left: 8px;
+ color: var(--ink);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.node-pull-progress-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-info-list {
+ margin: 0;
+}
+
+.node-info-list > div {
+ display: grid;
+ grid-template-columns: 76px minmax(0, 1fr);
+ gap: 10px;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.node-info-list > div:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+.node-info-list dt {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.node-info-list dd {
+ min-width: 0;
+ margin: 0;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 11px;
+ font-weight: 700;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.node-info-list code {
+ color: var(--blue);
+ font-size: 10px;
+}
+
+.node-label-section .label-list {
+ max-width: none;
+ gap: 7px;
+}
+
+.node-label-section .label-chip {
+ max-width: 100%;
+ padding: 5px 7px;
+ background: var(--canvas);
+ white-space: nowrap;
+}
+
+.node-label-section .label-chip code {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.theme-dark .node-insight-hero {
+ background:
+ radial-gradient(
+ circle at 8% 0%,
+ rgba(167, 139, 250, 0.15),
+ transparent 36%
+ ),
+ linear-gradient(135deg, #161f30, #151d2b);
+}
+
+.theme-dark .node-insight-facts,
+.theme-dark .node-capacity-card,
+.theme-dark .node-task-callout,
+.theme-dark .node-label-section .label-chip {
+ background: #111a29;
+ border-color: #2b3850;
+}
+
+.theme-dark .node-insight-section {
+ background: #151d2b;
+ border-color: #2b3850;
+}
+
+.theme-dark .node-task-callout > span {
+ background: #1b2637;
+}
+
+.theme-dark .node-task-callout.active,
+.theme-dark .node-task-callout.active > span,
+.theme-dark .node-task-callout.active > b {
+ background: rgba(54, 201, 143, 0.1);
+}
+
+@media (max-width: 1050px) {
+ .node-insight-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 780px) {
+ .node-insight-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .node-insight-facts,
+ .node-capacity-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .node-insight-facts > div:nth-child(2) {
+ border-right: 0;
+ }
+
+ .node-insight-facts > div:nth-child(-n + 2) {
+ border-bottom: 1px solid var(--line);
+ }
+}
+
+@media (max-width: 520px) {
+ .node-insight-hero,
+ .node-insight-layout {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .node-insight-facts {
+ margin-left: 16px;
+ margin-right: 16px;
+ grid-template-columns: 1fr;
+ }
+
+ .node-insight-facts > div {
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .node-capacity-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/nodes/resource-browser.css b/apps/rlark-ui/src/styles/nodes/resource-browser.css
new file mode 100644
index 0000000..c5e2db4
--- /dev/null
+++ b/apps/rlark-ui/src/styles/nodes/resource-browser.css
@@ -0,0 +1,979 @@
+.node-category-grid {
+ min-width: 0;
+}
+
+.node-category-column {
+ padding: 16px;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+.node-category-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.node-category-header strong {
+ font-size: 14px;
+}
+
+.node-category-header small {
+ display: block;
+ font-size: 11px;
+ color: #8e97a6;
+ margin-top: 2px;
+}
+
+.node-category-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ background: var(--skyblue-bg);
+ color: var(--blue);
+}
+
+.cat-cloud .node-category-icon {
+ background: rgba(54, 124, 232, 0.1);
+ color: #367ce8;
+}
+.cat-edge .node-category-icon {
+ background: rgba(54, 201, 143, 0.1);
+ color: #36c98f;
+}
+.cat-robot .node-category-icon {
+ background: rgba(123, 97, 255, 0.1);
+ color: #7b61ff;
+}
+.cat-unknown .node-category-icon {
+ background: rgba(142, 151, 166, 0.1);
+ color: #8e97a6;
+}
+
+.node-category-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.node-row {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.node-row-chevron {
+ color: var(--soft);
+ flex: none;
+}
+
+.node-row:hover {
+ background: var(--skyblue-bg);
+}
+
+.node-row.selected {
+ background: rgba(54, 124, 232, 0.08);
+ border: 1px solid rgba(54, 124, 232, 0.2);
+}
+
+.node-row .node-status-ring {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ padding: 0;
+ background: var(--gray);
+ flex-shrink: 0;
+}
+
+.node-row .node-status-ring.online {
+ background: var(--green);
+}
+
+.node-row .node-status-ring.offline {
+ background: var(--red);
+}
+
+.node-row-name {
+ flex: 1;
+ font-size: 13px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nodes-resource-section .node-admin-layout {
+ display: block;
+ width: 100%;
+}
+
+.nodes-resource-section .node-category-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 16px;
+ width: 100%;
+}
+
+.nodes-resource-section .node-category-column {
+ width: 100%;
+ max-height: none;
+ overflow: hidden;
+}
+
+.node-resource-browser {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+.node-category-tabs {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ padding: 5px;
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+ scrollbar-width: thin;
+}
+
+.node-category-tab {
+ --tab-accent: #5f6f85;
+ --tab-accent-rgb: 95, 111, 133;
+ min-width: max-content;
+ height: 42px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 13px 0 9px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: 0.16s ease;
+}
+
+.node-category-tab:hover {
+ color: var(--tab-accent);
+ background: rgba(var(--tab-accent-rgb), 0.07);
+}
+
+.node-category-tab.active {
+ color: var(--tab-accent);
+ border-color: rgba(var(--tab-accent-rgb), 0.25);
+ background: rgba(var(--tab-accent-rgb), 0.1);
+ box-shadow: 0 3px 10px rgba(var(--tab-accent-rgb), 0.1);
+}
+
+.node-category-tab:focus-visible {
+ outline: 2px solid rgba(var(--tab-accent-rgb), 0.38);
+ outline-offset: 2px;
+}
+
+.node-category-tab.cat-cloud {
+ --tab-accent: #367ce8;
+ --tab-accent-rgb: 54, 124, 232;
+}
+
+.node-category-tab.cat-edge {
+ --tab-accent: #21a976;
+ --tab-accent-rgb: 33, 169, 118;
+}
+
+.node-category-tab.cat-robot {
+ --tab-accent: #7357e8;
+ --tab-accent-rgb: 115, 87, 232;
+}
+
+.node-category-tab.cat-unknown {
+ --tab-accent: #b77426;
+ --tab-accent-rgb: 183, 116, 38;
+}
+
+.node-category-tab-icon {
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: var(--tab-accent);
+ background: rgba(var(--tab-accent-rgb), 0.09);
+ transition: 0.16s ease;
+}
+
+.node-category-tab b {
+ min-width: 22px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 6px;
+ border-radius: 999px;
+ background: var(--canvas);
+ color: var(--soft);
+ font-size: 10px;
+}
+
+.node-category-tab.active b {
+ background: rgba(var(--tab-accent-rgb), 0.14);
+ color: var(--tab-accent);
+}
+
+.node-category-tab.active .node-category-tab-icon {
+ background: rgba(var(--tab-accent-rgb), 0.16);
+ box-shadow: inset 0 0 0 1px rgba(var(--tab-accent-rgb), 0.08);
+}
+
+.node-resource-table-panel {
+ padding: 0;
+ overflow: hidden;
+ border-radius: 14px;
+}
+
+.node-resource-table-summary {
+ min-height: 62px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.node-resource-table-summary > div {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.node-resource-table-summary strong {
+ font-size: 14px;
+}
+
+.node-resource-table-summary small,
+.node-resource-table-summary > span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.node-resource-table {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.node-resource-table-head,
+.node-resource-row {
+ min-width: 1380px;
+ display: grid;
+ grid-template-columns:
+ minmax(170px, 1.35fr)
+ minmax(110px, 0.75fr)
+ minmax(100px, 0.7fr)
+ minmax(150px, 1fr)
+ minmax(140px, 0.95fr)
+ minmax(130px, 0.85fr)
+ minmax(220px, 1.4fr)
+ minmax(160px, 1.05fr)
+ 20px;
+ align-items: center;
+ column-gap: 14px;
+}
+
+.node-resource-table-head {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--line);
+ background: var(--canvas);
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.node-resource-row {
+ width: 100%;
+ min-height: 58px;
+ padding: 9px 16px;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ cursor: default;
+ transition: background 0.15s ease;
+}
+
+.node-detail-link {
+ min-width: 0;
+ padding: 5px 7px;
+ margin: -5px -7px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.node-detail-link:hover,
+.node-detail-link:focus-visible {
+ background: rgba(124, 58, 237, 0.09);
+ color: var(--blue);
+ outline: none;
+}
+
+.node-resource-row:last-child {
+ border-bottom: 0;
+}
+
+.node-resource-row:hover {
+ background: color-mix(in srgb, var(--skyblue-bg) 72%, var(--panel));
+}
+
+.node-resource-table-head.has-admin-actions,
+.node-resource-row.has-admin-actions {
+ min-width: 1510px;
+ grid-template-columns:
+ minmax(170px, 1.35fr)
+ minmax(110px, 0.75fr)
+ minmax(100px, 0.7fr)
+ minmax(150px, 1fr)
+ minmax(140px, 0.95fr)
+ minmax(130px, 0.85fr)
+ minmax(220px, 1.4fr)
+ minmax(160px, 1.05fr)
+ minmax(190px, 1.15fr);
+}
+
+.node-resource-table-head.has-selection,
+.node-resource-row.has-selection {
+ min-width: 1430px;
+ grid-template-columns:
+ 34px
+ minmax(170px, 1.35fr)
+ minmax(110px, 0.75fr)
+ minmax(100px, 0.7fr)
+ minmax(150px, 1fr)
+ minmax(140px, 0.95fr)
+ minmax(130px, 0.85fr)
+ minmax(220px, 1.4fr)
+ minmax(160px, 1.05fr)
+ 20px;
+}
+
+.node-resource-table-head.has-admin-actions.has-selection,
+.node-resource-row.has-admin-actions.has-selection {
+ min-width: 1560px;
+ grid-template-columns:
+ 34px
+ minmax(170px, 1.35fr)
+ minmax(110px, 0.75fr)
+ minmax(100px, 0.7fr)
+ minmax(150px, 1fr)
+ minmax(140px, 0.95fr)
+ minmax(130px, 0.85fr)
+ minmax(220px, 1.4fr)
+ minmax(160px, 1.05fr)
+ minmax(190px, 1.15fr);
+}
+
+.node-resource-row.selected {
+ background: var(--skyblue-bg);
+}
+
+.node-batch-checkbox {
+ display: grid;
+ place-items: center;
+}
+
+.node-batch-checkbox input,
+.node-batch-select-all input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--blue);
+}
+
+.node-batch-select-all {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.node-batch-selection-actions {
+ min-height: 32px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ align-self: center;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ padding: 3px;
+ background: var(--panel-soft);
+}
+
+.node-resource-table-summary > .node-batch-selection-actions {
+ align-items: center;
+ gap: 4px;
+}
+
+.node-batch-selection-actions .node-batch-select-all {
+ min-height: 26px;
+ align-items: center;
+ border-radius: 6px;
+ padding: 0 7px;
+ color: var(--ink-soft);
+ background: var(--panel);
+ line-height: 1;
+}
+
+.node-batch-selection-actions .node-batch-select-all input {
+ flex: 0 0 auto;
+ margin: 0;
+}
+
+.node-batch-selection-actions .plain-button {
+ min-height: 26px;
+ display: inline-flex;
+ align-items: center;
+ border-radius: 6px;
+ padding: 0 8px;
+ color: var(--blue);
+ line-height: 1;
+ font-size: 11px;
+}
+
+.node-batch-selection-actions .plain-button:hover:not(:disabled) {
+ background: rgba(73, 93, 230, 0.09);
+}
+
+.node-batch-selection-actions .plain-button:disabled {
+ color: var(--muted);
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.admin-node-list-actions,
+.admin-node-batch-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.admin-node-list-actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.admin-node-management-page {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.admin-node-page-heading {
+ gap: 24px;
+}
+
+.admin-node-page-heading .admin-node-list-actions {
+ flex: 0 0 auto;
+ max-width: 540px;
+ padding-bottom: 1px;
+}
+
+.admin-node-page-heading .admin-node-list-actions button {
+ min-height: 34px;
+}
+
+.admin-node-overview {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.admin-node-overview > div {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ align-items: center;
+ column-gap: 10px;
+ padding: 13px 16px;
+ border-right: 1px solid var(--line);
+}
+
+.admin-node-overview > div:last-child {
+ border-right: 0;
+}
+
+.admin-node-overview-icon {
+ grid-row: 1 / 3;
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ color: #495de6;
+ background: rgba(73, 93, 230, 0.1);
+}
+
+.admin-node-overview-icon.online,
+.admin-node-overview-icon.ready {
+ color: #14875e;
+ background: rgba(20, 135, 94, 0.1);
+}
+
+.admin-node-overview-icon.clusters {
+ color: #7c3aed;
+ background: rgba(124, 58, 237, 0.1);
+}
+
+.admin-node-overview small {
+ align-self: end;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-node-overview strong {
+ grid-column: 3;
+ grid-row: 1 / 3;
+ font-size: 22px;
+ line-height: 1;
+}
+
+.admin-node-batch-panel {
+ margin-bottom: 16px;
+ padding: 16px;
+}
+
+.admin-node-batch-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.admin-node-batch-head strong,
+.admin-node-batch-head small {
+ display: block;
+}
+
+.admin-node-batch-head strong {
+ margin-top: 4px;
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.admin-node-batch-head small {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.admin-node-batch-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 14px;
+}
+
+.admin-node-batch-field {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+ transition:
+ border-color 0.16s ease,
+ background-color 0.16s ease;
+}
+
+.admin-node-batch-field.active {
+ border-color: rgba(79, 70, 229, 0.5);
+ background: rgba(79, 70, 229, 0.035);
+}
+
+.admin-node-batch-field-toggle {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 9px;
+ border: 0;
+ padding: 10px;
+ color: var(--ink);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.admin-node-batch-field-toggle:hover {
+ background: var(--panel-soft);
+}
+
+.admin-node-batch-field.active .admin-node-batch-field-toggle {
+ background: transparent;
+}
+
+.admin-node-batch-field-icon {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+ color: var(--blue);
+ background: rgba(73, 93, 230, 0.09);
+}
+
+.admin-node-batch-field-toggle strong,
+.admin-node-batch-field-toggle small {
+ display: block;
+}
+
+.admin-node-batch-field-toggle strong {
+ font-size: 12px;
+}
+
+.admin-node-batch-field-toggle small {
+ overflow: hidden;
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-node-batch-field-toggle i {
+ border-radius: 999px;
+ padding: 4px 7px;
+ color: var(--muted);
+ background: var(--panel-soft);
+ font-size: 9px;
+ font-style: normal;
+ font-weight: 700;
+}
+
+.admin-node-batch-field.active .admin-node-batch-field-toggle i {
+ border: 1px solid rgba(79, 70, 229, 0.28);
+ color: #4f46e5;
+ background: rgba(79, 70, 229, 0.08);
+}
+
+.admin-node-batch-field.active .admin-node-batch-field-icon {
+ color: #4f46e5;
+ background: rgba(79, 70, 229, 0.12);
+}
+
+.admin-node-batch-field > input {
+ width: 100%;
+ height: 36px;
+ box-sizing: border-box;
+ border: 0;
+ border-top: 1px solid var(--line);
+ padding: 0 10px;
+ color: var(--ink);
+ background: rgba(255, 255, 255, 0.78);
+ outline: 0;
+}
+
+.admin-node-batch-field > input:focus {
+ background: var(--panel-soft);
+}
+
+.admin-node-batch-examples {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px;
+ border-top: 1px solid var(--line);
+ padding: 7px 10px 9px;
+ background: var(--panel);
+}
+
+.admin-node-batch-examples > span {
+ margin-right: 2px;
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 700;
+}
+
+.admin-node-batch-examples button {
+ border: 1px solid rgba(79, 70, 229, 0.18);
+ border-radius: 999px;
+ padding: 3px 7px;
+ color: #4f46e5;
+ background: rgba(79, 70, 229, 0.06);
+ cursor: pointer;
+ font-size: 9px;
+}
+
+.admin-node-batch-examples button:hover {
+ border-color: rgba(79, 70, 229, 0.38);
+ background: rgba(79, 70, 229, 0.12);
+}
+
+.admin-node-category-chips {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ border-top: 1px solid var(--line);
+ padding: 8px 10px;
+ background: var(--panel);
+}
+
+.admin-node-category-chips button {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 5px 10px;
+ color: var(--muted);
+ background: var(--panel-soft);
+ cursor: pointer;
+ font-size: 10px;
+}
+
+.admin-node-category-chips button.selected {
+ border-color: rgba(73, 93, 230, 0.4);
+ color: var(--blue);
+ background: rgba(73, 93, 230, 0.1);
+}
+
+.admin-node-batch-hint {
+ display: block;
+ border-top: 1px solid var(--line);
+ padding: 7px 10px 9px;
+ color: var(--muted);
+ background: var(--panel);
+ font-size: 9px;
+ line-height: 1.5;
+}
+
+.node-type-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.admin-node-batch-actions {
+ justify-content: flex-end;
+ margin-top: 14px;
+}
+
+.admin-node-batch-actions > span {
+ margin-right: auto;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+@media (max-width: 760px) {
+ .admin-node-batch-fields {
+ grid-template-columns: 1fr;
+ }
+}
+
+.node-scheduling-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.node-scheduling-state {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 7px;
+ border-radius: 999px;
+ color: #14875e;
+ background: rgba(54, 201, 143, 0.11);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.node-scheduling-state.cordoned {
+ color: #c2410c;
+ background: rgba(249, 115, 22, 0.12);
+}
+
+.node-scheduling-button {
+ min-width: 68px;
+ min-height: 30px;
+ padding: 5px 9px;
+ font-size: 11px;
+}
+
+.node-scheduling-button.danger {
+ color: #c2410c;
+ border-color: rgba(234, 88, 12, 0.25);
+ background: rgba(255, 247, 237, 0.9);
+}
+
+.node-resource-row:focus-visible {
+ outline: 2px solid var(--purple);
+ outline-offset: -2px;
+}
+
+.node-resource-row .node-status-ring {
+ width: 8px;
+ height: 8px;
+ flex: none;
+ border-radius: 50%;
+ background: var(--gray);
+}
+
+.node-resource-row .node-status-ring.online {
+ background: var(--green);
+ box-shadow: 0 0 0 3px rgba(54, 201, 143, 0.1);
+}
+
+.node-resource-row .node-status-ring.offline {
+ background: var(--red);
+}
+
+.node-type-cell {
+ width: fit-content;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ border-radius: 7px;
+ background: var(--canvas);
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.node-type-cell.cat-cloud {
+ color: #367ce8;
+ background: rgba(54, 124, 232, 0.09);
+}
+.node-type-cell.cat-edge {
+ color: #21a976;
+ background: rgba(54, 201, 143, 0.1);
+}
+.node-type-cell.cat-robot {
+ color: #7b61ff;
+ background: rgba(123, 97, 255, 0.1);
+}
+.node-type-cell.cat-unknown {
+ color: var(--muted);
+ background: rgba(142, 151, 166, 0.1);
+}
+
+.node-resource-empty {
+ min-height: 190px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ color: var(--soft);
+}
+
+.node-resource-empty strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.node-resource-empty small {
+ color: var(--muted);
+}
+
+.theme-dark .node-category-tabs,
+.theme-dark .node-resource-table-panel {
+ border-color: #2b3950;
+ background: #151f2f;
+}
+
+.theme-dark .node-category-tab:hover {
+ background: rgba(var(--tab-accent-rgb), 0.11);
+}
+
+.theme-dark .node-category-tab b,
+.theme-dark .node-resource-table-head {
+ background: #101827;
+}
+
+.theme-dark .node-category-tab.active {
+ background: rgba(var(--tab-accent-rgb), 0.18);
+ border-color: rgba(var(--tab-accent-rgb), 0.42);
+ color: color-mix(in srgb, var(--tab-accent) 72%, white);
+}
+
+.theme-dark .node-category-tab.active b {
+ background: rgba(var(--tab-accent-rgb), 0.24);
+ color: color-mix(in srgb, var(--tab-accent) 68%, white);
+}
+
+.theme-dark .node-category-tab.active .node-category-tab-icon {
+ background: rgba(var(--tab-accent-rgb), 0.25);
+ color: color-mix(in srgb, var(--tab-accent) 68%, white);
+}
+
+.theme-dark .node-resource-table-head,
+.theme-dark .node-resource-row,
+.theme-dark .node-resource-table-summary {
+ border-color: #2a374c;
+}
+
+.theme-dark .node-resource-row:hover {
+ background: #19263a;
+}
+
+.theme-dark .node-batch-selection-actions {
+ background: #111a29;
+}
+
+.theme-dark .node-batch-selection-actions .node-batch-select-all {
+ background: #182337;
+}
+
+.theme-dark .admin-node-batch-field.active {
+ border-color: rgba(167, 139, 250, 0.5);
+ background: rgba(139, 92, 246, 0.08);
+}
+
+.theme-dark .admin-node-batch-field > input {
+ background: rgba(17, 26, 41, 0.74);
+}
+
+.theme-dark .admin-node-batch-field.active .admin-node-batch-field-toggle i {
+ border-color: rgba(167, 139, 250, 0.32);
+ color: #c4b5fd;
+ background: rgba(139, 92, 246, 0.12);
+}
+
+.theme-dark .admin-node-batch-field.active .admin-node-batch-field-icon {
+ color: #c4b5fd;
+ background: rgba(139, 92, 246, 0.14);
+}
diff --git a/apps/rlark-ui/src/styles/overrides/global-responsive.css b/apps/rlark-ui/src/styles/overrides/global-responsive.css
new file mode 100644
index 0000000..f54e9dd
--- /dev/null
+++ b/apps/rlark-ui/src/styles/overrides/global-responsive.css
@@ -0,0 +1,316 @@
+/* Late responsive overrides for resource tables defined after the base breakpoints. */
+@media (max-width: 1300px) {
+ .cluster-management-table-head,
+ .cluster-management-row {
+ min-width: 760px;
+ grid-template-columns:
+ minmax(170px, 1.35fr)
+ minmax(66px, 0.55fr)
+ minmax(48px, 0.42fr)
+ minmax(48px, 0.42fr)
+ minmax(48px, 0.42fr)
+ minmax(104px, 0.8fr)
+ minmax(82px, 0.65fr)
+ 16px;
+ column-gap: 8px;
+ }
+
+ .cluster-management-empty {
+ min-width: 760px;
+ }
+
+ .node-resource-table-head,
+ .node-resource-row {
+ min-width: 1120px;
+ grid-template-columns:
+ minmax(130px, 1.25fr)
+ minmax(76px, 0.65fr)
+ minmax(80px, 0.68fr)
+ minmax(100px, 0.85fr)
+ minmax(104px, 0.85fr)
+ minmax(98px, 0.8fr)
+ minmax(170px, 1.25fr)
+ minmax(120px, 0.95fr)
+ 16px;
+ column-gap: 7px;
+ }
+
+ .node-resource-table-head.has-admin-actions,
+ .node-resource-row.has-admin-actions {
+ min-width: 1250px;
+ grid-template-columns:
+ minmax(130px, 1.25fr)
+ minmax(76px, 0.65fr)
+ minmax(80px, 0.68fr)
+ minmax(100px, 0.85fr)
+ minmax(104px, 0.85fr)
+ minmax(98px, 0.8fr)
+ minmax(170px, 1.25fr)
+ minmax(120px, 0.95fr)
+ minmax(170px, 1fr);
+ }
+
+ .storage-class-table-panel table,
+ .storage-class-table-panel .storage-table-heading {
+ min-width: 780px;
+ }
+
+ .storage-files-table-panel table,
+ .storage-files-table-panel .storage-table-heading {
+ min-width: 740px;
+ }
+
+ .jobs-table-panel table {
+ min-width: 820px;
+ }
+}
+
+/* Keep content usable on tablet-sized windows without requiring a manual
+ sidebar toggle. This final rule wins over the broader 1050px breakpoint. */
+@media (max-width: 780px) {
+ .app-shell,
+ .app-shell.sidebar-collapsed {
+ grid-template-columns: 72px minmax(0, 1fr);
+ }
+
+ .sidebar {
+ padding: 14px 10px;
+ }
+
+ .sidebar .brand {
+ height: 46px;
+ padding: 0 5px;
+ margin-bottom: 16px;
+ }
+
+ .sidebar .brand-logo {
+ width: 44px;
+ height: 44px;
+ object-fit: cover;
+ object-position: left center;
+ }
+
+ .sidebar nav button span,
+ .sidebar nav button em,
+ .sidebar .nav-label,
+ .sidebar .environment-card div,
+ .sidebar .environment-card i,
+ .sidebar .sidebar-bottom > button span {
+ display: none;
+ }
+
+ .sidebar nav button,
+ .sidebar .sidebar-bottom > button {
+ justify-content: center;
+ padding-inline: 0;
+ }
+
+ .sidebar .nav-children {
+ padding-left: 0;
+ }
+
+ .sidebar .environment-card {
+ grid-template-columns: 1fr;
+ min-height: 54px;
+ padding: 8px;
+ }
+
+ .sidebar .environment-card > span {
+ margin: auto;
+ }
+}
+
+@media (min-width: 621px) and (max-width: 780px) {
+ .job-detail-page {
+ gap: 10px;
+ }
+
+ .job-detail-summary-card {
+ gap: 12px;
+ padding: 14px;
+ }
+
+ .job-detail-summary-head {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ padding-bottom: 10px;
+ }
+
+ .job-detail-summary-head h2 {
+ margin: 5px 0;
+ font-size: 22px;
+ }
+
+ .job-detail-summary-head p {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .job-detail-summary-status {
+ justify-items: end;
+ }
+
+ .job-detail-summary-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .task-summary-metric {
+ padding: 8px 12px;
+ }
+
+ .task-summary-metric strong {
+ margin: 4px 0 2px;
+ font-size: 15px;
+ }
+
+ .job-detail-summary-columns {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ align-items: start;
+ }
+
+ .job-detail-summary-section {
+ padding: 9px 12px;
+ }
+
+ .public-card-head h3 {
+ font-size: 14px;
+ }
+
+ .header-worker-identity {
+ margin-top: 10px;
+ }
+
+ .copyable-code-block {
+ margin-top: 9px;
+ }
+
+ .copyable-code-block code {
+ padding: 8px 10px;
+ line-height: 1.45;
+ }
+
+ .config-value-list > div {
+ display: grid;
+ gap: 5px;
+ margin-top: 7px;
+ }
+
+ .config-value-list code {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow-wrap: normal;
+ }
+
+ .sub-tabs {
+ height: 42px;
+ }
+
+ .sub-tabs button {
+ height: 34px;
+ }
+}
+
+@media (max-width: 620px) {
+ .job-detail-summary-head,
+ .job-detail-summary-grid,
+ .job-detail-summary-columns {
+ grid-template-columns: 1fr;
+ }
+
+ .job-detail-summary-status {
+ justify-items: start;
+ }
+
+ .public-runtime-card,
+ .public-command-card {
+ grid-column: auto;
+ grid-row: auto;
+ }
+
+ .public-runtime-topology,
+ .public-runtime-tables,
+ .role-runtime-config-tables {
+ grid-template-columns: 1fr;
+ }
+
+ .role-runtime-config {
+ padding: 12px;
+ }
+
+ .role-runtime-meta {
+ min-width: 0;
+ flex-wrap: wrap;
+ }
+
+ .role-runtime-details {
+ grid-template-columns: 1fr;
+ }
+
+ .worker-detail-head,
+ .worker-ssh-access {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .worker-ssh-inline {
+ width: 100%;
+ max-width: 100%;
+ flex-basis: auto;
+ box-sizing: border-box;
+ }
+
+ .worker-detail-head .worker-ssh-button,
+ .worker-ssh-access .secondary-button {
+ width: fit-content;
+ }
+}
+
+@media (max-width: 1080px) {
+ .admin-node-page-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .admin-node-page-heading .admin-node-list-actions {
+ max-width: none;
+ justify-content: flex-start;
+ }
+
+ .admin-node-overview {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .admin-node-overview > div:nth-child(2) {
+ border-right: 0;
+ }
+
+ .admin-node-overview > div:nth-child(-n + 2) {
+ border-bottom: 1px solid var(--line);
+ }
+}
+
+@media (max-width: 640px) {
+ .admin-node-page-heading .admin-node-list-actions {
+ width: 100%;
+ }
+
+ .admin-node-page-heading .admin-node-list-actions button {
+ flex: 1 1 calc(50% - 8px);
+ }
+
+ .admin-node-overview > div {
+ grid-template-columns: 30px minmax(0, 1fr) auto;
+ padding: 11px;
+ }
+
+ .admin-node-overview-icon {
+ width: 30px;
+ height: 30px;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/overrides/resource-responsive.css b/apps/rlark-ui/src/styles/overrides/resource-responsive.css
new file mode 100644
index 0000000..c11bfc6
--- /dev/null
+++ b/apps/rlark-ui/src/styles/overrides/resource-responsive.css
@@ -0,0 +1,143 @@
+/* Final responsive safeguards for resource views. These rules intentionally
+ live after the resource components so later component styles cannot undo
+ the compact layout. */
+@media (max-width: 1250px) {
+ .node-admin-layout {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .node-resource-detail {
+ position: static;
+ width: 100%;
+ }
+}
+
+@media (max-width: 780px) {
+ .job-worker-overview,
+ .job-detail-summary-head,
+ .job-detail-summary-grid,
+ .job-detail-summary-columns,
+ .worker-detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .job-detail-summary-card {
+ padding: 16px;
+ }
+
+ .job-detail-summary-status {
+ justify-items: start;
+ }
+
+ .role-worker-group-head,
+ .config-section-head {
+ flex-direction: column;
+ }
+
+ .role-worker-resource-summary,
+ .config-section-head small {
+ min-width: 0;
+ max-width: none;
+ text-align: left;
+ }
+
+ .worker-access-grid,
+ .role-config-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .worker-access-meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .worker-access-meta span:last-child {
+ grid-column: 1 / -1;
+ }
+
+ .job-detail-summary-grid,
+ .job-detail-summary-columns,
+ .time-series-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .metrics-source-label {
+ width: 100%;
+ margin-left: 0;
+ }
+
+ .log-list-row {
+ grid-template-columns: 1fr;
+ gap: 4px;
+ }
+
+ .log-list-head {
+ display: none;
+ }
+
+ .worker-role-strip,
+ .worker-metrics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .worker-panel-head,
+ .observe-panel-head {
+ flex-direction: column;
+ }
+
+ .role-runtime-tabs {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .role-runtime-summary {
+ display: flex;
+ align-items: stretch;
+ flex-wrap: wrap;
+ }
+
+ .role-runtime-image {
+ flex-basis: 100%;
+ }
+
+ .role-runtime-details {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .worker-panel-head small {
+ max-width: none;
+ text-align: left;
+ }
+
+ .worker-primary-panel .worker-table {
+ overflow-x: auto;
+ }
+
+ .worker-primary-panel .worker-table table {
+ min-width: 820px;
+ }
+
+ .node-category-grid {
+ grid-template-columns: 1fr !important;
+ width: 100%;
+ }
+
+ .node-category-column {
+ max-height: none;
+ overflow: visible;
+ }
+
+ .node-detail-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .node-detail-panel {
+ padding: 16px;
+ }
+}
+
+@media (max-width: 520px) {
+ .node-detail-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/overview.css b/apps/rlark-ui/src/styles/overview.css
new file mode 100644
index 0000000..2d55429
--- /dev/null
+++ b/apps/rlark-ui/src/styles/overview.css
@@ -0,0 +1,681 @@
+.node-detail-page {
+ display: flex;
+ flex-direction: column;
+ overscroll-behavior: contain;
+ scrollbar-color: #aeb8c8 #e9edf3;
+ scrollbar-width: auto;
+}
+
+.node-detail-page > * {
+ flex-shrink: 0;
+}
+
+.node-detail-page::-webkit-scrollbar {
+ width: 10px;
+}
+
+.node-detail-page::-webkit-scrollbar-track {
+ background: #e9edf3;
+}
+
+.node-detail-page::-webkit-scrollbar-thumb {
+ border: 2px solid #e9edf3;
+ border-radius: 999px;
+ background: #aeb8c8;
+}
+
+.overview-page {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.overview-china-panel {
+ flex: 0 0 auto;
+ gap: 14px;
+ padding: 16px;
+ overflow: hidden;
+}
+
+.overview-china-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.overview-china-heading > div:first-child {
+ min-width: 0;
+}
+
+.overview-china-heading h3 {
+ margin: 5px 0 3px;
+ font-size: 17px;
+}
+
+.overview-china-heading p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.overview-demo-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ color: #7357e8;
+ background: rgba(115, 87, 232, 0.1);
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.overview-china-legend {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 7px;
+ flex-wrap: wrap;
+}
+
+.overview-china-legend span {
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.overview-china-legend .cloud {
+ color: #367ce8;
+ background: rgba(54, 124, 232, 0.07);
+}
+
+.overview-china-legend .edge {
+ color: #21a976;
+ background: rgba(33, 169, 118, 0.07);
+}
+
+.overview-china-legend .robot {
+ color: #7357e8;
+ background: rgba(115, 87, 232, 0.07);
+}
+
+.overview-china-layout {
+ min-height: 360px;
+ display: grid;
+ grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.75fr);
+ gap: 14px;
+}
+
+.overview-china-map {
+ position: relative;
+ min-width: 0;
+ overflow: hidden;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background:
+ radial-gradient(
+ circle at 72% 68%,
+ rgba(54, 201, 143, 0.13),
+ transparent 31%
+ ),
+ radial-gradient(
+ circle at 28% 32%,
+ rgba(123, 97, 255, 0.14),
+ transparent 33%
+ ),
+ linear-gradient(145deg, #f7f9ff, #f2fbf7);
+}
+
+.overview-china-map.is-dragging {
+ cursor: grabbing;
+}
+
+.overview-china-map > svg {
+ position: absolute;
+ inset: 8px 16px 5px;
+ width: calc(100% - 32px);
+ height: calc(100% - 13px);
+ overflow: visible;
+}
+
+.overview-map-controls {
+ position: absolute;
+ z-index: 4;
+ top: 12px;
+ right: 12px;
+ display: flex;
+ align-items: center;
+ padding: 4px;
+ border: 1px solid rgba(115, 87, 232, 0.16);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.9);
+ box-shadow: 0 6px 18px rgba(31, 43, 65, 0.08);
+ backdrop-filter: blur(8px);
+ cursor: default;
+}
+
+.overview-map-controls button {
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ border-radius: 7px;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+}
+
+.overview-map-controls button:hover:not(:disabled) {
+ color: #7357e8;
+ background: rgba(115, 87, 232, 0.1);
+}
+
+.overview-map-controls button:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.overview-map-controls span {
+ min-width: 38px;
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 700;
+ text-align: center;
+}
+
+.china-provinces path {
+ fill: rgba(235, 242, 252, 0.9);
+ fill-rule: evenodd;
+ stroke: rgba(138, 159, 190, 0.48);
+ stroke-width: 0.8;
+ vector-effect: non-scaling-stroke;
+ transition: fill 0.18s ease;
+}
+
+.china-provinces path:hover {
+ fill: rgba(220, 231, 251, 0.98);
+}
+
+.china-city-links .city-link-line {
+ fill: none;
+ stroke: rgba(115, 87, 232, 0.15);
+ stroke-width: 0.85;
+ stroke-dasharray: 3 6;
+ vector-effect: non-scaling-stroke;
+}
+
+.china-city-links .link-particle {
+ fill: #7357e8;
+ filter: drop-shadow(0 0 3px rgba(115, 87, 232, 0.75));
+}
+
+.china-city-links .link-particle.particle-1 {
+ fill: #367ce8;
+ filter: drop-shadow(0 0 3px rgba(54, 124, 232, 0.75));
+}
+
+.china-city-links .link-particle.particle-2 {
+ fill: #21a976;
+ filter: drop-shadow(0 0 3px rgba(33, 169, 118, 0.75));
+}
+
+.china-city-pin {
+ cursor: pointer;
+ outline: none;
+}
+
+.china-city-pin .pin-halo {
+ fill: rgba(115, 87, 232, 0.14);
+ stroke: rgba(115, 87, 232, 0.18);
+ stroke-width: 5;
+}
+
+.china-city-pin .pin-core {
+ fill: #7357e8;
+ stroke: white;
+ stroke-width: 2;
+}
+
+.china-city-pin.pin-cloud .pin-core {
+ fill: #367ce8;
+}
+
+.china-city-pin.pin-edge .pin-core {
+ fill: #21a976;
+}
+
+.china-city-pin.pin-robot .pin-core {
+ fill: #8b5cf6;
+}
+
+.china-city-pin .pin-badge {
+ fill: #14a86c;
+ stroke: white;
+ stroke-width: 1.5;
+}
+
+.china-city-pin .pin-value {
+ fill: white;
+ font-size: 8px;
+ font-weight: 800;
+ pointer-events: none;
+}
+
+.china-city-pin .pin-label {
+ fill: var(--ink);
+ font-size: 9px;
+ font-weight: 750;
+ paint-order: stroke;
+ stroke: rgba(255, 255, 255, 0.9);
+ stroke-width: 3px;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+
+.china-city-pin:hover .pin-halo,
+.china-city-pin:focus-visible .pin-halo {
+ fill: rgba(115, 87, 232, 0.24);
+ stroke: rgba(115, 87, 232, 0.28);
+}
+
+.china-city-tooltip {
+ display: none;
+}
+
+.china-city-pin:hover .china-city-tooltip,
+.china-city-pin:focus-visible .china-city-tooltip {
+ display: block;
+}
+
+.china-city-tooltip rect {
+ fill: rgba(255, 255, 255, 0.96);
+ stroke: rgba(115, 87, 232, 0.22);
+ stroke-width: 1;
+ filter: drop-shadow(0 5px 10px rgba(31, 43, 65, 0.18));
+}
+
+.china-city-tooltip .tooltip-city {
+ fill: var(--ink);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.china-city-tooltip .tooltip-detail {
+ fill: var(--muted);
+ font-size: 9px;
+ font-weight: 650;
+}
+
+.overview-map-loading {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.overview-map-caption {
+ position: absolute;
+ left: 14px;
+ bottom: 14px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 9px 11px;
+ border: 1px solid rgba(115, 87, 232, 0.16);
+ border-radius: 11px;
+ color: #7357e8;
+ background: rgba(255, 255, 255, 0.9);
+ box-shadow: 0 8px 20px rgba(31, 43, 65, 0.09);
+ backdrop-filter: blur(8px);
+ z-index: 3;
+}
+
+.overview-map-caption span,
+.overview-map-caption strong,
+.overview-map-caption small {
+ display: block;
+}
+
+.overview-map-caption strong {
+ color: var(--ink);
+ font-size: 10px;
+}
+
+.overview-map-caption small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.overview-china-aside {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.overview-map-stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.overview-map-stats button {
+ min-width: 0;
+ min-height: 70px;
+ padding: 10px;
+ text-align: left;
+ border: 1px solid var(--line);
+ border-radius: 11px;
+ color: var(--ink);
+ background: var(--panel);
+ cursor: pointer;
+ transition: 0.16s ease;
+}
+
+.overview-map-stats button:hover {
+ border-color: rgba(115, 87, 232, 0.25);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-soft);
+}
+
+.overview-map-stats small,
+.overview-map-stats strong {
+ display: block;
+}
+
+.overview-map-stats small {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 9px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.overview-map-stats strong {
+ margin-top: 7px;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.overview-map-stats .cloud strong {
+ color: #367ce8;
+}
+.overview-map-stats .edge strong {
+ color: #21a976;
+}
+.overview-map-stats .robot strong {
+ color: #7357e8;
+}
+
+.overview-cross-region {
+ min-height: 82px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 5px;
+ padding: 12px;
+ border: 1px dashed rgba(115, 87, 232, 0.25);
+ border-radius: 12px;
+ color: var(--ink);
+ background: rgba(115, 87, 232, 0.045);
+ cursor: pointer;
+}
+
+.overview-cross-region span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: #7357e8;
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.overview-cross-region strong {
+ font-size: 11px;
+}
+
+.overview-cross-region small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.overview-city-summary {
+ min-height: 0;
+ display: grid;
+ gap: 7px;
+}
+
+.overview-city-summary button {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 10px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ color: var(--muted);
+ background: var(--panel);
+ text-align: left;
+ cursor: pointer;
+}
+
+.overview-city-summary button:hover {
+ color: #7357e8;
+ border-color: rgba(115, 87, 232, 0.24);
+}
+
+.overview-city-summary span,
+.overview-city-summary strong,
+.overview-city-summary small {
+ min-width: 0;
+ display: block;
+}
+
+.overview-city-summary strong {
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 10px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.overview-city-summary small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.overview-city-summary b {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.theme-dark .overview-china-map {
+ border-color: #2a3950;
+ background:
+ radial-gradient(
+ circle at 72% 68%,
+ rgba(54, 201, 143, 0.12),
+ transparent 31%
+ ),
+ radial-gradient(
+ circle at 28% 32%,
+ rgba(123, 97, 255, 0.16),
+ transparent 33%
+ ),
+ linear-gradient(145deg, #101a2a, #101d25);
+}
+
+.theme-dark .china-provinces path {
+ fill: rgba(29, 45, 66, 0.92);
+ stroke: rgba(112, 137, 171, 0.48);
+}
+
+.theme-dark .china-provinces path:hover {
+ fill: rgba(39, 58, 84, 0.98);
+}
+
+.theme-dark .china-city-pin .pin-core,
+.theme-dark .china-city-pin .pin-badge {
+ stroke: #111b2a;
+}
+
+.theme-dark .china-city-pin .pin-label {
+ stroke: rgba(16, 25, 40, 0.95);
+}
+
+.theme-dark .overview-map-caption {
+ border-color: rgba(151, 123, 255, 0.25);
+ background: rgba(19, 30, 47, 0.9);
+}
+
+.theme-dark .overview-map-controls {
+ border-color: rgba(151, 123, 255, 0.25);
+ background: rgba(19, 30, 47, 0.9);
+}
+
+.theme-dark .china-city-tooltip rect {
+ fill: rgba(19, 30, 47, 0.97);
+ stroke: rgba(151, 123, 255, 0.34);
+}
+
+.theme-dark .overview-map-stats button,
+.theme-dark .overview-city-summary button {
+ border-color: #2b3950;
+ background: #151f2f;
+}
+
+.theme-dark .overview-cross-region {
+ border-color: rgba(151, 123, 255, 0.3);
+ background: rgba(123, 97, 255, 0.09);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .china-city-links .link-particle {
+ display: none;
+ }
+}
+
+.overview-page .hero-strip {
+ flex-shrink: 0;
+ margin-bottom: 0;
+}
+
+.overview-page .metric-grid {
+ flex-shrink: 0;
+ margin: 0;
+}
+.overview-page .metric-grid .metric-card {
+ min-height: 0;
+ padding: 10px 14px 9px;
+}
+.overview-page .metric-card .metric-head {
+ margin-bottom: 8px;
+}
+.overview-page .metric-card .metric-value-row strong {
+ font-size: 22px;
+}
+.overview-page .metric-card > small {
+ font-size: 10px;
+}
+
+.overview-page .dashboard-grid {
+ flex: 0 0 auto;
+ min-height: auto;
+ margin-bottom: 0;
+}
+
+.overview-page .bottom-grid {
+ flex: 0 0 auto;
+ min-height: auto;
+}
+
+.overview-page .panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.overview-page .panel-title {
+ flex-shrink: 0;
+}
+
+.overview-page .robot-state-list {
+ overflow-y: auto;
+ flex: 0 0 auto;
+ min-height: auto;
+}
+
+.overview-page .workflow-list {
+ overflow-y: auto;
+ flex: 0 0 auto;
+ min-height: auto;
+}
+
+.overview-page .activity-list {
+ overflow-y: auto;
+ flex: 0 0 auto;
+ min-height: auto;
+}
+
+.overview-page .resource-chart {
+ flex-shrink: 0;
+}
+
+.overview-page .resource-summary {
+ flex-shrink: 0;
+}
+
+.overview-page .mini-bars {
+ flex-shrink: 0;
+}
+
+.overview-page .phase-summary {
+ flex-shrink: 0;
+}
+
+.overview-page .resource-split {
+ margin: 0;
+ flex: 0 0 auto;
+ min-height: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding-top: 4px;
+}
+.overview-page .resource-split .resource-row {
+ flex: 0 0 auto;
+ min-height: 62px;
+ grid-template-rows: auto;
+ align-items: center;
+ padding: 12px 20px;
+}
+.overview-page .resource-row > span {
+ height: 12px;
+}
+
+.overview-page .chart-panel {
+ gap: 12px;
+}
+
+.overview-page .workload-panel {
+ gap: 12px;
+}
diff --git a/apps/rlark-ui/src/styles/shared/content.css b/apps/rlark-ui/src/styles/shared/content.css
new file mode 100644
index 0000000..a0cc8dc
--- /dev/null
+++ b/apps/rlark-ui/src/styles/shared/content.css
@@ -0,0 +1,2309 @@
+.section-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+ margin-bottom: 20px;
+}
+
+.section-heading h2 {
+ margin-top: 7px;
+}
+
+.view-switch {
+ display: flex;
+ padding: 4px;
+ border: 1px solid var(--line);
+ background: #fff;
+ border-radius: 13px;
+}
+
+.view-switch button {
+ border: 0;
+ width: 34px;
+ height: 32px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ background: transparent;
+ color: #8c96a5;
+}
+
+.view-switch button.active {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.page-toolbar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.page-toolbar > small {
+ margin-left: auto;
+ font-size: 11px;
+ color: #8d96a5;
+ white-space: nowrap;
+}
+
+.search-field {
+ height: 40px;
+ min-width: min(320px, 100%);
+ flex: 1 1 320px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 0 14px;
+ color: #8d97a6;
+}
+
+.toolbar-filter {
+ height: 40px;
+ min-width: 132px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+ color: var(--muted);
+ box-shadow: 0 4px 14px rgba(28, 39, 58, 0.035);
+}
+
+.toolbar-filter select {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.search-field input {
+ border: 0;
+ outline: 0;
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ background: transparent;
+ color: #29313e;
+}
+
+.search-field kbd {
+ font-size: 10px;
+ border: 1px solid #dfe5ed;
+ background: #f6f8fb;
+ padding: 2px 6px;
+ border-radius: 8px;
+ color: #9aa4b2;
+}
+
+.secondary-button span {
+ background: #e7f8f1;
+ color: #179467;
+ padding: 2px 6px;
+ border-radius: 999px;
+ font-size: 10px;
+}
+
+.master-detail {
+ min-height: 590px;
+ display: grid;
+ grid-template-columns: 305px 1fr;
+ overflow: hidden;
+}
+
+.master-list {
+ border-right: 1px solid var(--line);
+ padding: 12px;
+ background: #fbfcfe;
+}
+
+.master-list button {
+ width: 100%;
+ min-height: 76px;
+ display: grid;
+ grid-template-columns: 40px 1fr auto 14px;
+ gap: 11px;
+ align-items: center;
+ border: 1px solid transparent;
+ background: transparent;
+ border-radius: 16px;
+ text-align: left;
+ padding: 11px;
+ color: #7f8998;
+}
+
+.master-list button:hover {
+ background: #fff;
+}
+
+.master-list button.selected {
+ background: #fff;
+ border-color: var(--line);
+ box-shadow: var(--shadow-soft);
+}
+
+.detail-area {
+ padding: 24px;
+ min-width: 0;
+}
+
+.detail-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.detail-header h3 {
+ font-size: 21px;
+ margin: 6px 0 4px;
+ letter-spacing: -0.55px;
+}
+
+.detail-header p {
+ margin: 0;
+ color: #8e98a6;
+ font-size: 12px;
+}
+
+.detail-header > div:last-child {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.detail-stats {
+ display: grid;
+ grid-template-columns: 1.2fr repeat(3, 1fr);
+ margin: 24px 0 16px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ overflow: hidden;
+ background: #fff;
+}
+
+.detail-stats > div {
+ padding: 16px;
+ border-right: 1px solid var(--line);
+ position: relative;
+}
+
+.detail-stats > div:last-child {
+ border: 0;
+}
+
+.detail-stats span {
+ display: block;
+ color: #8792a1;
+ font-size: 11px;
+}
+
+.detail-stats strong {
+ display: block;
+ font-size: 18px;
+ margin-top: 5px;
+}
+
+.detail-stats small {
+ display: block;
+ color: #9aa4b2;
+ font-size: 10px;
+ margin-top: 3px;
+}
+
+.detail-stats i {
+ height: 4px;
+ position: absolute;
+ bottom: 0;
+ left: 16px;
+ right: 16px;
+ background: #e8edf4;
+ border-radius: 999px 999px 0 0;
+}
+
+.detail-stats i b {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, var(--blue), #c4b5fd);
+}
+
+.sub-tabs {
+ height: 46px;
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ padding: 4px;
+ border: 0;
+ border-radius: 12px;
+ background: #f0f3f8;
+}
+
+.sub-tabs button {
+ flex: 0 0 auto;
+ height: 38px;
+ border: 0;
+ border-radius: 9px;
+ background: transparent;
+ padding: 0 16px;
+ font-size: 12px;
+ color: #8a94a3;
+ font-weight: 800;
+}
+
+.sub-tabs button.active {
+ color: var(--blue);
+ background: #fff;
+ box-shadow: 0 2px 8px rgba(42, 52, 73, 0.09);
+}
+
+/* Job 详情页:Tab 栏与下方内容面板融合为一个整体卡片 */
+.job-tab-panel {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+ overflow: hidden;
+}
+
+.job-tab-panel > .sub-tabs {
+ border-radius: 0;
+ border-bottom: 1px solid var(--line);
+ margin: 0;
+ padding: 6px 12px;
+}
+
+/* 融合卡片内的子面板去掉自身边框/圆角/阴影,由外层统一承载 */
+.job-tab-panel .worker-primary-panel,
+.job-tab-panel .job-observe-panel,
+.job-tab-panel .job-detail-summary-card,
+.job-detail-page .job-tab-panel .role-runtime-config.job-detail-summary-card {
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ margin: 0;
+}
+
+/* 融合卡片内的三个区块之间用浅色背景带分隔,替代生硬的分界线 */
+.job-tab-panel .role-runtime-config.job-detail-summary-card,
+.job-tab-panel > .job-detail-summary-card {
+ padding-bottom: 20px;
+ margin-bottom: 4px;
+}
+
+.job-tab-panel .worker-primary-panel {
+ padding-top: 4px;
+}
+
+/* 每个区块之间用浅色背景条做视觉缓冲 */
+.job-tab-panel > section + section,
+.job-tab-panel > .worker-primary-panel {
+ position: relative;
+}
+
+.job-tab-panel > section + section::before,
+.job-tab-panel > .worker-primary-panel::before {
+ content: "";
+ display: block;
+ height: 6px;
+ background: #f7f8fb;
+ margin: 0 -16px;
+ border-top: 1px solid #eef0f4;
+ border-bottom: 1px solid #eef0f4;
+}
+
+.dag-canvas {
+ height: 365px;
+ margin-top: 18px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ position: relative;
+ overflow: hidden;
+ background: #fbfcff;
+}
+
+.canvas-grid {
+ position: absolute;
+ inset: 0;
+ opacity: 0.52;
+ background-image: radial-gradient(#cdd5df 1px, transparent 1px);
+ background-size: 20px 20px;
+}
+
+.dag-lines,
+.node-map > svg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.dag-lines path,
+.node-map > svg path {
+ fill: none;
+ stroke: #b8c4d2;
+ stroke-width: 1.8;
+ stroke-dasharray: 5 4;
+ vector-effect: non-scaling-stroke;
+}
+
+.dag-node {
+ position: absolute;
+ width: 184px;
+ min-height: 70px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.96);
+ border-radius: 16px;
+ padding: 12px;
+ display: flex;
+ gap: 10px;
+ box-shadow: var(--shadow-soft);
+}
+
+.dag-node small {
+ display: block;
+ color: #9ba5b3;
+ font-size: 9px;
+ letter-spacing: 0.7px;
+}
+
+.dag-node strong {
+ display: block;
+ font-size: 12px;
+ margin: 3px 0;
+}
+
+.dag-node em {
+ display: block;
+ font-style: normal;
+ color: #8792a1;
+ font-size: 10px;
+}
+
+.node-icon {
+ width: 31px;
+ height: 31px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ flex: none;
+}
+
+.node-icon.success {
+ background: #e5f8f0;
+ color: #18a170;
+}
+
+.node-icon.running {
+ background: #e9f1ff;
+ color: var(--blue);
+}
+
+.node-icon.pending {
+ background: #fff2df;
+ color: #cf7c20;
+}
+
+.node-start {
+ left: 5%;
+ top: 42%;
+}
+
+.node-top {
+ left: 38%;
+ top: 18%;
+}
+
+.node-bottom {
+ left: 38%;
+ top: 64%;
+}
+
+.node-end {
+ right: 5%;
+ top: 42%;
+}
+
+.dag-actions {
+ position: absolute;
+ z-index: 2;
+ right: 14px;
+ top: 14px;
+ display: flex;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: var(--shadow-soft);
+}
+
+.dag-actions button {
+ border: 0;
+ border-right: 1px solid var(--line);
+ background: #fff;
+ width: 34px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ color: #6f7a89;
+}
+
+.dag-actions button:last-child {
+ border: 0;
+}
+
+.live-chip,
+.map-live {
+ position: absolute;
+ bottom: 14px;
+ left: 14px;
+ padding: 8px 11px;
+ background: #fff;
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow-soft);
+ border-radius: 999px;
+ font-size: 11px;
+ color: #677282;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.live-chip span,
+.map-live i {
+ width: 7px;
+ height: 7px;
+ background: var(--green);
+ border-radius: 50%;
+}
+
+.table-panel {
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+.domains-table-panel table {
+ min-width: 760px;
+ table-layout: fixed;
+}
+
+.domains-table-panel th:first-child {
+ width: 48%;
+}
+
+.domains-table-panel th:nth-child(2) {
+ width: 15%;
+}
+
+.domains-table-panel th:nth-child(3) {
+ width: 13%;
+}
+
+.domains-table-panel th:nth-child(4) {
+ width: 19%;
+}
+
+.domains-table-panel th:last-child {
+ width: 5%;
+}
+
+.domain-name-cell {
+ min-width: 0;
+}
+
+.domain-name-cell strong,
+.domain-detail-title,
+.network-domain-summary,
+.public-config-truncated-value {
+ display: block;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.jobs-table-panel table {
+ min-width: 920px;
+}
+
+.selected-cluster-panel .table-panel table {
+ min-width: 560px;
+}
+
+.table-empty-state {
+ min-height: 220px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 28px;
+ text-align: center;
+}
+
+.table-empty-state > span {
+ width: 46px;
+ height: 46px;
+ display: grid;
+ place-items: center;
+ border-radius: 14px;
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.table-empty-state strong {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.table-empty-state small {
+ max-width: 420px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+th {
+ height: 48px;
+ background: #fbfcfe;
+ color: #8993a3;
+ text-align: left;
+ font-size: 11px;
+ letter-spacing: 0.35px;
+ font-weight: 800;
+ padding: 0 18px;
+ border-bottom: 1px solid var(--line);
+ white-space: nowrap;
+}
+
+td {
+ height: 66px;
+ padding: 0 18px;
+ color: #667182;
+ border-bottom: 1px dashed var(--line);
+}
+
+tbody tr:last-child td {
+ border: 0;
+}
+
+tbody tr:hover {
+ background: #fbfdff;
+}
+
+.table-primary {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+}
+
+.table-primary strong {
+ color: #262d39;
+ font-size: 12px;
+}
+
+.worker-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.table-primary small {
+ display: block;
+ color: #98a1af;
+ font-size: 10px;
+ margin-top: 3px;
+}
+
+.row-icon {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ background: #f3eefe;
+ color: var(--blue);
+ border-radius: 11px;
+}
+
+.inline-progress {
+ display: grid;
+ grid-template-columns: 82px auto;
+ align-items: center;
+ gap: 10px;
+ font-size: 11px;
+}
+
+.role-chip {
+ background: #f0f3f7;
+ color: #667182;
+ padding: 6px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.node-layout {
+ min-height: 560px;
+ display: grid;
+ grid-template-columns: 305px 1fr;
+ overflow: hidden;
+}
+
+.node-list {
+ border-right: 1px solid var(--line);
+ background: #fbfcfe;
+ padding: 12px;
+}
+
+.node-list button {
+ width: 100%;
+ height: 76px;
+ display: grid;
+ grid-template-columns: 40px 1fr auto 14px;
+ align-items: center;
+ gap: 11px;
+ border: 1px solid transparent;
+ border-radius: 16px;
+ background: transparent;
+ text-align: left;
+ color: #7f8998;
+ padding: 11px;
+}
+
+.node-list button.selected {
+ background: #fff;
+ border-color: var(--line);
+ box-shadow: var(--shadow-soft);
+}
+
+.node-list strong {
+ display: block;
+ font-size: 13px;
+ color: #272d38;
+}
+
+.node-list small {
+ display: block;
+ font-size: 10px;
+ color: #98a1af;
+ margin-top: 4px;
+}
+
+.node-status-ring {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ background: #e7f8f1;
+ color: #1f9c70;
+}
+
+.node-status-ring.offline {
+ background: #ffe9ee;
+ color: #d94767;
+}
+
+.node-detail {
+ padding: 24px;
+}
+
+.node-health {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1.25fr 1.25fr;
+ gap: 12px;
+ margin: 24px 0 16px;
+}
+
+.gauge,
+.gpu-card {
+ min-height: 112px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 15px;
+ background: #fff;
+}
+
+.gauge {
+ display: grid;
+ grid-template-columns: 62px minmax(0, 1fr);
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+}
+
+.gauge > div {
+ width: 62px;
+ height: 62px;
+ min-width: 62px;
+ aspect-ratio: 1 / 1;
+ flex: 0 0 62px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.gauge > div::after {
+ content: "";
+ position: absolute;
+ inset: 7px;
+ background: white;
+ border-radius: 50%;
+}
+
+.gauge span {
+ position: relative;
+ z-index: 1;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.gauge small {
+ color: #778292;
+ font-size: 11px;
+ font-weight: 700;
+ min-width: 0;
+ line-height: 1.35;
+}
+
+.gpu-card span {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border-radius: 11px;
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.gpu-card strong {
+ display: block;
+ font-size: 21px;
+ margin-top: 12px;
+}
+
+.gpu-card small {
+ display: block;
+ color: #8d96a5;
+ font-size: 11px;
+}
+
+.node-map {
+ height: 305px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ position: relative;
+ overflow: hidden;
+ background: #fbfcff;
+}
+
+.machine {
+ position: absolute;
+ z-index: 2;
+ width: 178px;
+ min-height: 84px;
+ border: 1px solid var(--line);
+ background: #fff;
+ border-radius: 18px;
+ padding: 14px;
+ display: grid;
+ grid-template-columns: 38px 1fr;
+ column-gap: 11px;
+ box-shadow: var(--shadow-soft);
+}
+
+.machine span {
+ grid-row: 1 / 3;
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.machine strong {
+ font-size: 12px;
+ align-self: end;
+}
+
+.machine small {
+ font-size: 10px;
+ color: #8d96a5;
+}
+
+.machine-main {
+ left: 10%;
+ top: 37%;
+}
+
+.machine-gpu {
+ right: 12%;
+ top: 16%;
+}
+
+.machine-storage {
+ right: 12%;
+ bottom: 16%;
+}
+
+.api-reference-page {
+ display: grid;
+ align-content: start;
+ gap: 18px;
+}
+
+.api-page-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 74px;
+}
+
+.api-page-meta {
+ min-width: 150px;
+ display: grid;
+ justify-items: end;
+ gap: 3px;
+}
+
+.api-page-meta span,
+.api-page-meta small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.api-page-meta span {
+ font-weight: 750;
+}
+
+.api-page-meta strong {
+ color: var(--ink);
+ font-size: 22px;
+ line-height: 1;
+}
+
+.api-category-bar {
+ position: sticky;
+ z-index: 10;
+ top: -28px;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.9);
+ box-shadow: 0 10px 30px rgba(35, 42, 60, 0.07);
+ backdrop-filter: blur(14px);
+}
+
+.api-category-tabs {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.api-category-tabs::-webkit-scrollbar {
+ display: none;
+}
+
+.api-category-tabs button {
+ height: 36px;
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 11px;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ color: #697588;
+ font-size: 11px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.api-category-tabs button:hover {
+ color: var(--ink);
+ background: #f4f6fa;
+}
+
+.api-category-tabs button.active {
+ color: #7042d8;
+ background: #eee8ff;
+}
+
+.api-category-tabs small {
+ min-width: 18px;
+ padding: 2px 5px;
+ border-radius: 999px;
+ background: rgba(106, 117, 136, 0.1);
+ color: inherit;
+ font-size: 8px;
+ text-align: center;
+}
+
+.api-category-bar .search-field {
+ width: min(260px, 28vw);
+ min-width: 190px;
+ height: 36px;
+ flex: none;
+ border-radius: 10px;
+ box-shadow: none;
+}
+
+.api-content {
+ min-width: 0;
+ padding: 28px 30px 32px;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.api-section-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.api-section-heading > div {
+ min-width: 0;
+}
+
+.api-section-heading h2 {
+ margin: 7px 0 6px;
+ color: var(--ink);
+ font-size: 25px;
+ letter-spacing: -0.55px;
+}
+
+.api-section-heading p {
+ max-width: 700px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.api-endpoint-count {
+ flex: none;
+ padding: 6px 9px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--muted);
+ background: #fafbfd;
+ font-size: 9px;
+ font-weight: 750;
+}
+
+.api-endpoint-stack {
+ display: grid;
+ gap: 10px;
+ margin-top: 24px;
+}
+
+.api-endpoint-stack article {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fbfcfe;
+ transition:
+ border-color 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+.api-endpoint-stack article.expanded {
+ border-color: rgba(124, 58, 237, 0.25);
+ box-shadow: 0 12px 30px rgba(59, 38, 102, 0.07);
+}
+
+.api-endpoint-summary {
+ width: 100%;
+ min-height: 68px;
+ display: grid;
+ grid-template-columns: 54px minmax(200px, 1.4fr) minmax(150px, 0.8fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ border: 0;
+ color: inherit;
+ background: transparent;
+ text-align: left;
+ transition: background 0.16s ease;
+}
+
+.api-endpoint-summary:hover {
+ background: #faf8ff;
+}
+
+.api-endpoint-stack article.expanded .api-endpoint-summary {
+ background: #f8f5ff;
+}
+
+.api-endpoint-summary code {
+ overflow-wrap: anywhere;
+ color: #29313e;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+}
+
+.api-endpoint-summary p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.api-expand-label {
+ color: #7042d8;
+ font-size: 9px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.api-endpoint-detail {
+ padding: 0 16px 16px;
+ border-top: 1px solid var(--line);
+ background: #fff;
+}
+
+.api-detail-meta {
+ min-height: 55px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.api-detail-meta > div {
+ min-width: 0;
+}
+
+.api-detail-meta span {
+ display: block;
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 750;
+}
+
+.api-detail-meta code {
+ display: block;
+ margin-top: 4px;
+ overflow-wrap: anywhere;
+ color: var(--ink);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+}
+
+.api-empty-state {
+ padding: 28px 16px;
+ color: var(--muted);
+ font-size: 12px;
+ text-align: center;
+}
+
+.api-load-state {
+ min-height: 260px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.api-guide-card {
+ margin-top: 24px;
+ padding: 22px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: #fbfcfe;
+ display: grid;
+ gap: 10px;
+}
+
+.api-guide-card strong {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.api-guide-card p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+.api-guide-card code {
+ width: fit-content;
+ max-width: 100%;
+ padding: 8px 10px;
+ border-radius: 9px;
+ background: #eef1f6;
+ color: #374151;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+
+.api-overview {
+ display: grid;
+ gap: 16px;
+ margin-top: 24px;
+}
+
+.api-overview-hero {
+ position: relative;
+ min-height: 132px;
+ display: grid;
+ grid-template-columns: 46px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 16px;
+ padding: 22px;
+ overflow: hidden;
+ border: 1px solid rgba(124, 58, 237, 0.16);
+ border-radius: 18px;
+ background:
+ radial-gradient(
+ circle at 88% 15%,
+ rgba(124, 58, 237, 0.2),
+ transparent 34%
+ ),
+ linear-gradient(135deg, #fff 20%, #f6f2ff 100%);
+}
+
+.api-overview-hero::after {
+ content: "";
+ position: absolute;
+ right: 8%;
+ top: -82px;
+ width: 150px;
+ height: 150px;
+ border: 24px solid rgba(124, 58, 237, 0.04);
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+.api-overview-hero > * {
+ position: relative;
+ z-index: 1;
+}
+
+.api-overview-hero > span,
+.api-overview-facts > div > span {
+ display: grid;
+ place-items: center;
+ border-radius: 13px;
+ background: #ece6ff;
+ color: #7042d8;
+}
+
+.api-overview-hero > span {
+ width: 46px;
+ height: 46px;
+}
+
+.api-overview-hero strong,
+.api-overview-hero p {
+ display: block;
+}
+
+.api-overview-hero strong {
+ color: var(--ink);
+ font-size: 17px;
+}
+
+.api-overview-hero p {
+ max-width: 480px;
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.api-overview-hero code {
+ padding: 9px 12px;
+ border: 1px solid rgba(124, 58, 237, 0.13);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.78);
+ color: #6240c4;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+}
+
+.api-overview-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.api-overview-facts > div {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr);
+ gap: 3px 12px;
+ padding: 17px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: var(--panel);
+}
+
+.api-overview-facts > div > span {
+ width: 38px;
+ height: 38px;
+ grid-row: 1 / 4;
+}
+
+.api-overview-facts > div > span.mint {
+ background: #e4f8f0;
+ color: #168a61;
+}
+
+.api-overview-facts small,
+.api-overview-facts strong,
+.api-overview-facts p {
+ min-width: 0;
+}
+
+.api-overview-facts small {
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 750;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.api-overview-facts strong {
+ color: var(--ink);
+ font-size: 16px;
+}
+
+.api-overview-facts p {
+ margin: 1px 0 0;
+ color: var(--muted);
+ font-size: 10px;
+ line-height: 1.45;
+}
+
+.api-overview-resources,
+.api-overview-flow {
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: var(--panel);
+}
+
+.api-overview-title {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 14px;
+}
+
+.api-overview-title span,
+.api-overview-title strong {
+ display: block;
+}
+
+.api-overview-title span,
+.api-overview-title small,
+.api-overview-flow > span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.api-overview-title strong {
+ margin-top: 3px;
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.api-overview-resource-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.api-overview-resource-grid button {
+ min-width: 0;
+ min-height: 64px;
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr) 16px;
+ align-items: center;
+ gap: 9px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fbfcfe;
+ color: var(--muted);
+ text-align: left;
+ transition: 0.16s ease;
+}
+
+.api-overview-resource-grid button:hover {
+ color: var(--blue);
+ border-color: rgba(124, 58, 237, 0.25);
+ background: #faf8ff;
+ transform: translateY(-1px);
+}
+
+.api-overview-resource-grid button > span {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+ background: #f0ebff;
+ color: #7042d8;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.api-overview-resource-grid strong,
+.api-overview-resource-grid small {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.api-overview-resource-grid strong {
+ color: var(--ink);
+ font-size: 11px;
+}
+
+.api-overview-resource-grid small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.api-overview-flow > span {
+ font-weight: 750;
+ text-transform: uppercase;
+ letter-spacing: 0.55px;
+}
+
+.api-overview-flow ol {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin: 14px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.api-overview-flow li {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 30px minmax(0, 1fr);
+ align-items: center;
+ gap: 9px;
+}
+
+.api-overview-flow b {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #171f2e;
+ color: #fff;
+ font-size: 9px;
+}
+
+.api-overview-flow strong,
+.api-overview-flow small {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.api-overview-flow strong {
+ color: var(--ink);
+ font-size: 10px;
+}
+
+.api-overview-flow small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-family: "JetBrains Mono", monospace;
+ font-size: 8px;
+}
+
+.method {
+ width: 46px;
+ padding: 5px 0;
+ text-align: center;
+ border-radius: 9px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.method.get {
+ background: #e7f8f1;
+ color: #168a61;
+}
+
+.method.post {
+ background: #f3eefe;
+ color: var(--blue);
+}
+
+.method.patch {
+ background: #fff2df;
+ color: #bd7424;
+}
+
+.method.put {
+ background: #e8f1ff;
+ color: #2d6cc0;
+}
+
+.method.delete {
+ background: #ffe9e9;
+ color: #c24141;
+}
+
+.code-block {
+ margin: 0;
+ border-radius: 12px;
+ overflow: hidden;
+ background: #101827;
+ color: #c8d3e4;
+}
+
+.code-block > div {
+ height: 42px;
+ border-bottom: 1px solid #243048;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ font-size: 12px;
+}
+
+.code-block button {
+ border: 0;
+ background: transparent;
+ color: #8ea7d6;
+ font-size: 11px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.code-block button:hover {
+ color: #dbeafe;
+}
+
+.code-block pre {
+ min-height: 0;
+ overflow-x: auto;
+ overflow-y: visible;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 11px;
+ line-height: 1.8;
+ margin: 0;
+ padding: 18px;
+ color: #d7e4f6;
+}
+
+@media (max-width: 780px) {
+ .api-page-heading {
+ align-items: flex-start;
+ }
+
+ .api-page-meta {
+ min-width: auto;
+ }
+
+ .api-category-bar {
+ top: -18px;
+ align-items: stretch;
+ flex-direction: column;
+ padding: 8px;
+ }
+
+ .api-category-bar .search-field {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .api-content {
+ padding: 24px 18px;
+ }
+
+ .api-section-heading {
+ align-items: flex-start;
+ }
+
+ .api-endpoint-summary {
+ grid-template-columns: 52px minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ }
+
+ .api-endpoint-summary .method {
+ grid-row: 1 / 3;
+ }
+
+ .api-endpoint-summary p {
+ grid-column: 2;
+ grid-row: 2;
+ }
+
+ .api-expand-label {
+ grid-column: 3;
+ grid-row: 1 / 3;
+ }
+
+ .api-overview-hero {
+ grid-template-columns: 42px minmax(0, 1fr);
+ padding: 18px;
+ }
+
+ .api-overview-hero code {
+ grid-column: 1 / -1;
+ width: 100%;
+ }
+
+ .api-overview-facts,
+ .api-overview-flow ol {
+ grid-template-columns: 1fr;
+ }
+
+ .api-overview-resource-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .api-detail-meta {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 0;
+ }
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ /* 必须高于 .action-dropdown(50),否则打开弹窗时下拉菜单会浮在遮罩之上 */
+ z-index: 60;
+ display: grid;
+ place-items: center;
+ background: rgba(16, 22, 32, 0.46);
+ backdrop-filter: blur(4px);
+}
+
+.modal {
+ width: 580px;
+ max-height: 86vh;
+ background: #fff;
+ border-radius: 24px;
+ box-shadow: 0 28px 90px rgba(13, 22, 38, 0.28);
+ overflow: hidden;
+}
+
+.modal-head {
+ padding: 24px 24px 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ border-bottom: 1px solid var(--line);
+}
+
+.modal-head h2 {
+ margin: 6px 0 0;
+ font-size: 22px;
+}
+
+.stepper {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ padding: 16px 24px;
+ background: #fbfcfe;
+ border-bottom: 1px solid var(--line);
+}
+
+.stepper > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ position: relative;
+}
+
+.stepper > div:not(:last-child)::after {
+ content: "";
+ position: absolute;
+ left: 72%;
+ right: 5%;
+ height: 1px;
+ background: #dce4ee;
+}
+
+.stepper span {
+ width: 25px;
+ height: 25px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #e8edf4;
+ color: #8692a3;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.stepper .active span {
+ background: var(--blue);
+ color: #fff;
+}
+
+.stepper small {
+ font-size: 11px;
+ color: #727d8d;
+ font-weight: 700;
+}
+
+.modal-body {
+ min-height: 340px;
+ padding: 22px 24px;
+}
+
+.modal-body label {
+ display: grid;
+ gap: 7px;
+ margin-bottom: 14px;
+ color: #596374;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.modal-body input,
+.modal-body textarea,
+.modal-body select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ padding: 11px 12px;
+ color: #27303d;
+ outline: 0;
+ font-size: 12px;
+ background: #fff;
+}
+
+.modal-body input:focus,
+.modal-body textarea:focus,
+.modal-body select:focus {
+ border-color: #c4b5fd;
+ box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.1);
+}
+
+.modal-body textarea {
+ height: 78px;
+ resize: none;
+}
+
+.modal-body input.input-invalid {
+ border-color: var(--red);
+}
+
+.modal-body input.input-invalid:focus {
+ border-color: var(--red);
+ box-shadow: 0 0 0 4px rgba(239, 90, 122, 0.12);
+}
+
+.field-error {
+ margin-top: 2px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--red);
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.job-builder {
+ height: 290px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.job-builder > div {
+ width: 188px;
+ min-height: 98px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 16px;
+ box-shadow: var(--shadow-soft);
+}
+
+.job-builder strong,
+.job-builder small {
+ display: block;
+ margin-top: 8px;
+ font-size: 12px;
+}
+
+.job-builder small {
+ color: #8792a1;
+ font-size: 10px;
+}
+
+.builder-line {
+ width: 48px;
+ height: 1px;
+ border-top: 1px dashed #aeb9c8;
+}
+
+.review-state {
+ height: 290px;
+ display: grid;
+ place-content: center;
+ text-align: center;
+ justify-items: center;
+}
+
+.review-state > span {
+ width: 66px;
+ height: 66px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #e5f8f0;
+ color: #19a371;
+}
+
+.review-state h3 {
+ margin: 16px 0 6px;
+}
+
+.review-state p {
+ color: #7e8998;
+ font-size: 12px;
+}
+
+.modal-footer {
+ height: 72px;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 10px;
+ padding: 0 24px;
+ border-top: 1px solid var(--line);
+ background: #fbfcfe;
+}
+
+.sidebar-collapsed {
+ grid-template-columns: 72px minmax(0, 1fr);
+}
+
+.sidebar-collapsed .brand > div:last-child,
+.sidebar-collapsed nav button span,
+.sidebar-collapsed nav button em,
+.sidebar-collapsed .nav-label,
+.sidebar-collapsed .environment-card div,
+.sidebar-collapsed .environment-card i,
+.sidebar-collapsed .sidebar-bottom > button span {
+ display: none;
+}
+
+.sidebar-collapsed .sidebar {
+ padding-left: 12px;
+ padding-right: 12px;
+}
+
+.sidebar-collapsed .brand {
+ height: 44px;
+ padding: 0 6px;
+ margin-bottom: 22px;
+}
+
+.sidebar-collapsed .brand-logo {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+}
+
+.sidebar-collapsed nav button,
+.sidebar-collapsed .sidebar-bottom > button {
+ justify-content: center;
+ padding: 0;
+}
+
+.sidebar-collapsed .environment-card {
+ grid-template-columns: 1fr;
+ min-height: 56px;
+ padding: 9px;
+}
+
+.sidebar-collapsed .environment-card > span {
+ margin: auto;
+}
+
+.theme-dark {
+ --ink: #f5f7fb;
+ --muted: #9ca8ba;
+ --soft: #748196;
+ --line: #273348;
+ --line-strong: #33425b;
+ --panel: #151d2b;
+ --canvas: #0f1623;
+ --blue: #a78bfa;
+ --blue-2: #c4b5fd;
+ --green: #48d8a2;
+ --green-soft: #12352d;
+ --shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
+ --shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.22);
+ --app-bg: #0c111b;
+ --hover: rgba(167, 139, 250, 0.08);
+ color: #f5f7fb;
+ background:
+ radial-gradient(
+ circle at 82% 8%,
+ rgba(167, 139, 250, 0.14),
+ transparent 28%
+ ),
+ linear-gradient(180deg, #101827 0%, #0c111b 100%);
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.theme-dark .brand-logo-light {
+ display: none;
+}
+
+.theme-dark .brand-logo-dark {
+ display: block;
+}
+
+.theme-dark .main-area,
+.theme-dark .master-list,
+.theme-dark .node-list,
+.theme-dark .stepper,
+.theme-dark .modal-footer,
+.theme-dark th {
+ background: #101827;
+}
+
+.theme-dark .sidebar,
+.theme-dark .topbar {
+ background: rgba(20, 29, 44, 0.9);
+ border-color: var(--line);
+}
+
+.theme-dark .brand strong,
+.theme-dark .topbar h1,
+.theme-dark .hero-strip h2,
+.theme-dark .section-heading h2,
+.theme-dark .panel-title h3,
+.theme-dark .metric-value-row strong,
+.theme-dark .workflow-info strong,
+.theme-dark .master-list strong,
+.theme-dark .node-list strong,
+.theme-dark .detail-header h3,
+.theme-dark .detail-stats strong,
+.theme-dark .table-primary strong,
+.theme-dark .dag-node strong,
+.theme-dark .machine strong,
+.theme-dark .gpu-card strong,
+.theme-dark .api-section-heading h2,
+.theme-dark .activity-list strong,
+.theme-dark .modal-head h2,
+.theme-dark .review-state h3 {
+ color: #f5f7fb;
+}
+
+.theme-dark .brand small,
+.theme-dark .nav-label,
+.theme-dark .hero-strip p,
+.theme-dark .section-heading p,
+.theme-dark .metric-card > small,
+.theme-dark .workflow-info small,
+.theme-dark .master-list small,
+.theme-dark .node-list small,
+.theme-dark .detail-header p,
+.theme-dark .detail-stats span,
+.theme-dark .detail-stats small,
+.theme-dark .activity-list small,
+.theme-dark .activity-list time,
+.theme-dark .api-section-heading p,
+.theme-dark .chart-labels,
+.theme-dark .gauge small,
+.theme-dark .gpu-card small,
+.theme-dark .machine small,
+.theme-dark .dag-node em,
+.theme-dark .modal-body label,
+.theme-dark .review-state p {
+ color: #9ca8ba;
+}
+
+.theme-dark .sidebar nav button,
+.theme-dark .sidebar-bottom > button {
+ color: #a7b2c5;
+}
+
+.theme-dark .sidebar nav button:hover {
+ background: #1b2637;
+ color: #fff;
+}
+
+.theme-dark .sidebar nav button.active,
+.theme-dark .view-switch button.active,
+.theme-dark .segmented-control button.active {
+ background: rgba(167, 139, 250, 0.16);
+ color: #c4b5fd;
+}
+
+.theme-dark .api-overview-hero {
+ border-color: rgba(167, 139, 250, 0.24);
+ background:
+ radial-gradient(
+ circle at 88% 15%,
+ rgba(124, 58, 237, 0.22),
+ transparent 34%
+ ),
+ linear-gradient(135deg, #151d2b 20%, #1b1730 100%);
+}
+
+.theme-dark .api-page-meta strong,
+.theme-dark .api-section-heading h2,
+.theme-dark .api-endpoint-summary code,
+.theme-dark .api-detail-meta code {
+ color: #f5f7fb;
+}
+
+.theme-dark .api-category-bar {
+ border-color: var(--line);
+ background: rgba(21, 29, 43, 0.92);
+}
+
+.theme-dark .api-category-tabs button:hover {
+ color: #f5f7fb;
+ background: #202b3d;
+}
+
+.theme-dark .api-category-tabs button.active {
+ background: rgba(167, 139, 250, 0.16);
+ color: #c4b5fd;
+}
+
+.theme-dark .api-content,
+.theme-dark .api-endpoint-stack article {
+ border-color: var(--line);
+ background: #151d2b;
+}
+
+.theme-dark .api-endpoint-count {
+ border-color: var(--line);
+ background: #101827;
+ color: #b8c3d3;
+}
+
+.theme-dark .api-endpoint-summary:hover,
+.theme-dark .api-endpoint-stack article.expanded .api-endpoint-summary {
+ background: rgba(167, 139, 250, 0.09);
+}
+
+.theme-dark .api-endpoint-detail {
+ border-color: var(--line);
+ background: #111a29;
+}
+
+.theme-dark .api-overview-hero code {
+ border-color: rgba(167, 139, 250, 0.2);
+ background: rgba(15, 23, 42, 0.64);
+ color: #c4b5fd;
+}
+
+.theme-dark .api-overview-facts > div,
+.theme-dark .api-overview-resources,
+.theme-dark .api-overview-flow {
+ border-color: var(--line);
+ background: #151d2b;
+}
+
+.theme-dark .api-overview-resource-grid button {
+ border-color: var(--line);
+ background: #101827;
+}
+
+.theme-dark .api-overview-resource-grid button:hover {
+ border-color: rgba(167, 139, 250, 0.3);
+ background: rgba(167, 139, 250, 0.08);
+}
+
+.theme-dark .api-overview-hero strong,
+.theme-dark .api-overview-facts strong,
+.theme-dark .api-overview-title strong,
+.theme-dark .api-overview-resource-grid strong,
+.theme-dark .api-overview-flow strong {
+ color: #f5f7fb;
+}
+
+.theme-dark .metric-card,
+.theme-dark .panel,
+.theme-dark .table-panel,
+.theme-dark .master-detail,
+.theme-dark .node-layout,
+.theme-dark .hero-health,
+.theme-dark .modal,
+.theme-dark .gauge,
+.theme-dark .gpu-card,
+.theme-dark .node-map,
+.theme-dark .dag-canvas,
+.theme-dark .detail-stats,
+.theme-dark .dag-node,
+.theme-dark .machine {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .topbar h1::before,
+.theme-dark .cluster-picker,
+.theme-dark .secondary-button,
+.theme-dark .icon-button,
+.theme-dark .segmented-control,
+.theme-dark .search-field,
+.theme-dark .view-switch,
+.theme-dark .dag-actions,
+.theme-dark .dag-actions button,
+.theme-dark .live-chip,
+.theme-dark .map-live,
+.theme-dark .modal-body input,
+.theme-dark .modal-body textarea,
+.theme-dark .modal-body select {
+ background: #111a29;
+ border-color: var(--line);
+ color: #d8e0ee;
+}
+
+.theme-dark .topbar > div:first-child:has(h1)::before {
+ border-color: #687895;
+}
+
+.theme-dark .topbar > div:first-child:has(h1)::after,
+.theme-dark .search-field kbd {
+ color: #8796ad;
+ border-color: #2e3a51;
+ background: #101827;
+}
+
+.theme-dark .toolbar-filter,
+.theme-dark .toolbar-filter select {
+ background: #151d2b;
+ color: #d8e0ee;
+ border-color: var(--line);
+}
+
+.theme-dark .metric-card::after {
+ background: rgba(167, 139, 250, 0.08);
+}
+
+.theme-dark .grid-lines line {
+ stroke: #263248;
+}
+
+.theme-dark .resource-summary,
+.theme-dark .mini-bars,
+.theme-dark .workflow-list > button,
+.theme-dark .activity-list > div,
+.theme-dark .sub-tabs,
+.theme-dark .modal-head,
+.theme-dark .modal-footer,
+.theme-dark .stepper,
+.theme-dark td,
+.theme-dark th {
+ border-color: var(--line);
+}
+
+/* Cluster overview tables need dedicated dark surfaces because their light
+ styles intentionally use opaque header and selected-row backgrounds. */
+.theme-dark .selected-cluster-panel .cluster-detail-header {
+ background: #151d2b;
+ border-color: #2b3850;
+}
+
+.theme-dark .selected-cluster-panel .cluster-detail-title {
+ color: #dbe4f2;
+}
+
+.theme-dark .selected-cluster-panel .cluster-detail-title strong,
+.theme-dark .cluster-list-table .cluster-list-name strong {
+ color: #f5f7fb;
+}
+
+.theme-dark .selected-cluster-panel .cluster-detail-meta {
+ color: #9eacc1;
+}
+
+.theme-dark .selected-cluster-panel .cluster-detail-meta .dot {
+ background: #53627a;
+}
+
+.theme-dark .cluster-node-table thead th,
+.theme-dark .cluster-list-table thead th {
+ background: #1b2637;
+ color: #aeb9cc;
+ border-color: #33425b;
+}
+
+.theme-dark .cluster-node-table tbody tr,
+.theme-dark .cluster-list-table tbody tr {
+ background: transparent;
+ border-color: #273348;
+}
+
+.theme-dark .cluster-node-table tbody tr:hover,
+.theme-dark .cluster-list-table tbody tr:hover {
+ background: rgba(167, 139, 250, 0.07);
+}
+
+.theme-dark .cluster-node-table td,
+.theme-dark .cluster-list-table td {
+ color: #c5d0e0;
+ border-color: #273348;
+}
+
+.theme-dark .cluster-node-table td strong {
+ color: #eef3fa;
+}
+
+.theme-dark .cluster-node-table td small,
+.theme-dark .cluster-list-rate small {
+ color: #91a0b6;
+}
+
+.theme-dark .cluster-list-table tbody tr.selected {
+ background: rgba(167, 139, 250, 0.14);
+}
+
+.theme-dark .cluster-list-table tbody tr.selected td {
+ color: #d9ccff;
+}
+
+.theme-dark .cluster-list-table tbody tr.selected .cluster-list-name strong {
+ color: #ffffff;
+}
+
+.theme-dark .cluster-list-rate i {
+ background: #2b3850;
+}
+
+.theme-dark .cluster-node-table-wrap,
+.theme-dark .cluster-list-scroll {
+ color-scheme: dark;
+ scrollbar-color: #53627a #151d2b;
+}
+
+.theme-dark .mini-bars i,
+.theme-dark .progress-cell > i,
+.theme-dark .inline-progress > i,
+.theme-dark .detail-stats i,
+.theme-dark .resource-row > span {
+ background: #273348;
+}
+
+.theme-dark .workflow-list > button:hover,
+.theme-dark tbody tr:hover,
+.theme-dark .master-list button:hover {
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.theme-dark .master-list button.selected,
+.theme-dark .node-list button.selected {
+ background: #182235;
+ border-color: #33425b;
+}
+
+.theme-dark .canvas-grid {
+ background-image: radial-gradient(#33425b 1px, transparent 1px);
+}
+
+.theme-dark .gauge > div::after {
+ background: #151d2b;
+}
+
+.theme-dark .code-block {
+ background: #070b12;
+ border: 1px solid #263248;
+}
+
+.theme-dark .modal-backdrop {
+ background: rgba(2, 6, 14, 0.62);
+}
+
+.theme-dark .status-running,
+.theme-dark .status-online {
+ background: rgba(72, 216, 162, 0.13);
+ color: #70e0b5;
+}
+
+.theme-dark .status-succeeded {
+ background: rgba(59, 130, 246, 0.16);
+ color: #8bb9ff;
+}
+
+.theme-dark .status-failed,
+.theme-dark .status-offline {
+ background: rgba(239, 90, 122, 0.14);
+ color: #ff8ca4;
+}
+
+.theme-dark .status-pending {
+ background: rgba(245, 158, 53, 0.14);
+ color: #ffbd6b;
+}
+
+.theme-dark .row-icon,
+.theme-dark .gpu-card span,
+.theme-dark .machine span,
+.theme-dark .node-status-ring,
+.theme-dark .workflow-symbol.running {
+ background: rgba(167, 139, 250, 0.15);
+ color: #c4b5fd;
+}
+
+.theme-dark .role-chip {
+ background: #202b3d;
+ color: #b1bdd0;
+}
diff --git a/apps/rlark-ui/src/styles/shared/table-actions-and-controls.css b/apps/rlark-ui/src/styles/shared/table-actions-and-controls.css
new file mode 100644
index 0000000..ab40ba3
--- /dev/null
+++ b/apps/rlark-ui/src/styles/shared/table-actions-and-controls.css
@@ -0,0 +1,537 @@
+.theme-dark .cluster-detail-stats strong,
+.theme-dark .cluster-card > strong,
+.theme-dark .node-resource-card strong,
+.theme-dark .robot-endpoints strong,
+.theme-dark .robot-endpoints code,
+.theme-dark .map-pin strong {
+ color: #f5f7fb;
+}
+
+.theme-dark .cluster-detail-stats span,
+.theme-dark .cluster-detail-stats small,
+.theme-dark .cluster-card > small,
+.theme-dark .cluster-card-foot span,
+.theme-dark .node-resource-card small,
+.theme-dark .node-resource-card em,
+.theme-dark .robot-endpoints span,
+.theme-dark .map-pin small {
+ color: #9ca8ba;
+}
+
+.cert-panel {
+ max-width: 720px;
+ padding: 28px;
+}
+
+.cert-form {
+ display: flex;
+ gap: 14px;
+ align-items: flex-end;
+}
+
+.cert-form label {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.cert-form label span {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.cert-form input {
+ padding: 10px 14px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.cert-form input:focus {
+ border-color: var(--blue);
+}
+
+.cert-error {
+ margin-top: 16px;
+ padding: 12px 16px;
+ background: rgba(239, 90, 122, 0.08);
+ border: 1px solid rgba(239, 90, 122, 0.25);
+ border-radius: 10px;
+ color: var(--red);
+ font-size: 12px;
+ font-weight: 600;
+ word-break: break-all;
+}
+
+.cert-result {
+ margin-top: 24px;
+}
+
+.cert-result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--line);
+}
+
+.cert-result-header div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.cert-result-header div svg {
+ color: var(--green);
+}
+
+.cert-result-header small {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.row-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.table-panel .table-actions-col {
+ width: 1%;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.table-panel .table-actions-col .row-actions,
+.table-panel td:last-child > .row-actions {
+ justify-content: flex-end;
+ white-space: nowrap;
+}
+
+.table-panel .row-actions .icon-button {
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+ box-shadow: none;
+}
+
+.table-action-button {
+ min-height: 30px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: var(--surface, #fff);
+ color: var(--blue);
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.table-action-button:hover {
+ border-color: var(--blue);
+ background: rgba(77, 118, 255, 0.06);
+}
+
+.table-action-button.danger {
+ color: var(--red);
+ border-color: rgba(239, 90, 122, 0.25);
+}
+
+.table-action-button.danger:hover {
+ border-color: var(--red);
+ background: rgba(239, 90, 122, 0.08);
+}
+
+.jobs-list-page .row-actions {
+ justify-content: flex-end;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.jobs-list-page .action-tooltip::after {
+ right: 50%;
+ transform: translate(50%, 3px);
+}
+
+.jobs-list-page .action-tooltip:hover::after,
+.jobs-list-page .action-tooltip:focus-within::after {
+ transform: translate(50%, 0);
+}
+
+.job-row-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ min-height: 32px;
+ padding: 0 9px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.job-row-action:hover:not(:disabled) {
+ border-color: rgba(124, 77, 255, 0.35);
+ color: var(--purple);
+}
+
+.job-row-action:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.job-quick-lifecycle.start {
+ border-color: rgba(5, 150, 105, 0.24);
+ background: rgba(236, 253, 245, 0.9);
+ color: #059669;
+}
+
+.job-quick-lifecycle.stop {
+ border-color: rgba(217, 119, 6, 0.22);
+ background: rgba(255, 251, 235, 0.92);
+ color: #b45309;
+}
+
+.job-quick-lifecycle.start:hover:not(:disabled) {
+ border-color: rgba(5, 150, 105, 0.42);
+ background: #d1fae5;
+}
+
+.job-quick-lifecycle.stop:hover:not(:disabled) {
+ border-color: rgba(217, 119, 6, 0.4);
+ background: #fef3c7;
+}
+
+.theme-dark .job-quick-lifecycle.start {
+ border-color: rgba(52, 211, 153, 0.25);
+ background: rgba(16, 185, 129, 0.12);
+ color: #6ee7b7;
+}
+
+.theme-dark .job-quick-lifecycle.stop {
+ border-color: rgba(251, 191, 36, 0.24);
+ background: rgba(245, 158, 11, 0.12);
+ color: #fcd34d;
+}
+
+.action-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ z-index: 50;
+ min-width: 140px;
+ padding: 4px;
+ border-radius: 10px;
+ background: var(--surface, #fff);
+ border: 1px solid var(--border, #e5e5ec);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.action-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border: none;
+ background: transparent;
+ border-radius: 6px;
+ font-size: 13px;
+ color: var(--text, #1a1a2e);
+ cursor: pointer;
+ transition: background 0.12s;
+}
+
+.action-dropdown-item:hover:not(:disabled) {
+ background: var(--hover, #f5f5fa);
+}
+
+.action-dropdown-item:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.action-dropdown-item.danger {
+ color: var(--red, #cf3f61);
+}
+
+.clickable-row {
+ cursor: pointer;
+}
+
+.clickable-row:hover {
+ background: var(--surface-hover, rgba(0, 0, 0, 0.03));
+}
+
+.pagination-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: flex-end;
+ margin-top: 12px;
+ padding-top: 8px;
+ min-height: 40px;
+ color: var(--muted);
+}
+
+.pagination-summary {
+ margin-right: auto;
+}
+
+.pagination-size {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+}
+
+.pagination-size select {
+ width: auto;
+ min-width: 66px;
+ height: 32px;
+ padding: 0 26px 0 10px;
+}
+
+.pagination-bar .icon-button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.files-breadcrumb button {
+ border: 0;
+ background: transparent;
+ color: var(--blue);
+ padding: 2px 4px;
+ border-radius: 5px;
+ cursor: pointer;
+ font: inherit;
+}
+
+.files-breadcrumb button:hover,
+.files-breadcrumb button:focus-visible {
+ background: var(--hover);
+ outline: none;
+}
+
+.selector-chips-area:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* Unified native form controls */
+select {
+ appearance: none;
+ -webkit-appearance: none;
+ min-height: 38px;
+ padding-right: 36px !important;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background-color: var(--panel);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%237c8492' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ background-size: 14px;
+ color: var(--ink);
+ cursor: pointer;
+ outline: none;
+ transition:
+ border-color 0.16s ease,
+ box-shadow 0.16s ease,
+ background-color 0.16s ease;
+}
+
+select:hover {
+ border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
+}
+
+select:focus-visible {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
+}
+
+select:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+select option {
+ background: var(--panel);
+ color: var(--ink);
+}
+
+.toolbar-filter {
+ border-radius: 999px;
+ padding-left: 12px;
+ padding-right: 8px;
+ transition:
+ border-color 0.16s ease,
+ box-shadow 0.16s ease,
+ background-color 0.16s ease;
+}
+
+.toolbar-filter:hover {
+ border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
+}
+
+.toolbar-filter:focus-within {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
+}
+
+.toolbar-filter select {
+ min-height: 36px;
+ padding-left: 2px;
+ padding-right: 30px !important;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%237c8492' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 7px center;
+ box-shadow: none;
+}
+
+.pagination-size {
+ min-height: 34px;
+ padding-left: 11px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--panel);
+ color: var(--muted);
+ transition:
+ border-color 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+.pagination-size:hover {
+ border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
+}
+
+.pagination-size:focus-within {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
+}
+
+.pagination-size select {
+ min-width: 60px;
+ min-height: 32px;
+ height: 32px;
+ padding-left: 7px;
+ padding-right: 27px !important;
+ border: 0;
+ border-radius: 999px;
+ background-color: transparent;
+ background-position: right 7px center;
+ box-shadow: none;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+input[type="checkbox"],
+input[type="radio"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 17px;
+ height: 17px;
+ min-width: 17px;
+ margin: 0;
+ border: 1.5px solid var(--line-strong);
+ background-color: var(--panel);
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+input[type="checkbox"] {
+ border-radius: 5px;
+}
+
+input[type="radio"] {
+ border-radius: 50%;
+}
+
+input[type="checkbox"]:hover,
+input[type="radio"]:hover {
+ border-color: var(--blue);
+}
+
+input[type="checkbox"]:checked {
+ border-color: var(--blue);
+ background-color: var(--blue);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 4 4L19 6'/%3E%3C/svg%3E");
+}
+
+input[type="radio"]:checked {
+ border: 5px solid var(--blue);
+ background-color: var(--panel);
+}
+
+input[type="checkbox"]:focus-visible,
+input[type="radio"]:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.16);
+}
+
+input[type="checkbox"]:disabled,
+input[type="radio"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.theme-dark select,
+.theme-dark select option,
+.theme-dark .pagination-size,
+.theme-dark input[type="checkbox"],
+.theme-dark input[type="radio"] {
+ background-color: #151d2b;
+ border-color: #33425b;
+ color: #e6edf7;
+}
+
+.theme-dark select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca8ba' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+}
+
+.theme-dark .pagination-size select,
+.theme-dark .toolbar-filter select {
+ background-color: transparent;
+}
+
+.theme-dark .toolbar-filter select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca8ba' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 7px center;
+ background-size: 14px 14px;
+}
+
+.theme-dark input[type="checkbox"]:checked {
+ background-color: var(--blue);
+ border-color: var(--blue);
+}
+
+.theme-dark input[type="radio"]:checked {
+ background-color: #151d2b;
+ border-color: var(--blue);
+}
diff --git a/apps/rlark-ui/src/styles/storage/create-dialog.css b/apps/rlark-ui/src/styles/storage/create-dialog.css
new file mode 100644
index 0000000..30d0f89
--- /dev/null
+++ b/apps/rlark-ui/src/styles/storage/create-dialog.css
@@ -0,0 +1,509 @@
+/* Storage class creation dialog */
+.storage-create-backdrop {
+ padding: 24px;
+ align-items: center;
+ justify-content: center;
+}
+
+.storage-create-modal {
+ width: min(920px, calc(100vw - 48px));
+ max-height: min(860px, calc(100vh - 48px));
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: 20px;
+}
+
+.storage-create-head {
+ padding: 22px 26px;
+ flex: none;
+}
+
+.storage-create-head > div:first-child {
+ min-width: 0;
+}
+
+.storage-create-head h2 {
+ margin: 6px 0 4px;
+ font-size: 22px;
+}
+
+.storage-create-head p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.storage-create-close {
+ flex: none;
+ font-size: 0;
+ width: 38px;
+ height: 38px;
+ border-radius: 999px;
+}
+
+.storage-create-form {
+ min-height: 0;
+ overflow-y: auto;
+ padding: 22px 26px 0;
+ display: grid;
+ gap: 16px;
+ background: var(--canvas);
+}
+
+.storage-create-form .form-section {
+ margin: 0;
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.storage-create-form .form-section > strong {
+ display: block;
+ margin-bottom: 14px;
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.storage-create-form .form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.storage-create-form label {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ color: var(--ink);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.storage-create-form input:not([type="checkbox"]),
+.storage-create-form select,
+.storage-create-form textarea {
+ width: 100%;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--panel);
+ color: var(--ink);
+ outline: none;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+
+.storage-create-form input:not([type="checkbox"]),
+.storage-create-form select {
+ height: 42px;
+ padding: 0 12px;
+}
+
+.storage-create-form textarea {
+ min-height: 92px;
+ padding: 11px 12px;
+ resize: vertical;
+ line-height: 1.55;
+}
+
+.storage-cluster-picker {
+ position: relative;
+ display: grid;
+ gap: 0;
+ width: 100%;
+}
+
+.storage-cluster-select {
+ width: 100%;
+ min-height: 42px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 7px 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--panel);
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+
+.storage-cluster-select.open,
+.storage-cluster-select:focus-visible {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
+ outline: 0;
+}
+
+.storage-cluster-select span {
+ min-width: 0;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-cluster-select strong {
+ min-width: 0;
+ margin-top: 2px;
+ display: block;
+ color: var(--ink);
+ font-size: 11px;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-cluster-select svg {
+ color: var(--muted);
+ transition: transform 0.15s ease;
+}
+
+.storage-cluster-select.open svg {
+ transform: rotate(90deg);
+}
+
+.storage-cluster-dropdown {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ display: grid;
+ gap: 8px;
+ border: 1px solid var(--line-strong);
+ border-radius: 12px;
+ background: var(--panel);
+ box-shadow: 0 18px 50px rgba(13, 22, 38, 0.16);
+ padding: 10px;
+}
+
+.storage-cluster-picker-search {
+ position: relative;
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ align-items: center;
+ height: 38px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--panel);
+}
+
+.storage-cluster-picker-search svg {
+ justify-self: center;
+ color: var(--muted);
+}
+
+.storage-create-form
+ .storage-cluster-picker-search
+ input:not([type="checkbox"]) {
+ height: 36px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ padding: 0 8px 0 0;
+ box-shadow: none;
+}
+
+.storage-cluster-picker-search small {
+ padding: 0 10px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.storage-cluster-picker-options {
+ display: grid;
+ gap: 2px;
+ max-height: 188px;
+ overflow-y: auto;
+ padding-right: 4px;
+ scrollbar-width: thin;
+}
+
+.storage-cluster-select-all,
+.storage-cluster-picker-options label {
+ min-width: 0;
+ display: grid;
+ align-items: center;
+ gap: 8px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ padding: 8px;
+ color: var(--ink);
+ text-align: left;
+ cursor: pointer;
+}
+
+.storage-cluster-select-all {
+ grid-template-columns: 18px minmax(0, 1fr) auto;
+ width: 100%;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+}
+
+.storage-cluster-picker-options label {
+ grid-template-columns: 18px minmax(0, 1fr);
+}
+
+.storage-cluster-select-all:hover,
+.storage-cluster-picker-options label:hover,
+.storage-cluster-picker-options label.active {
+ background: #f4f7fb;
+}
+
+.storage-cluster-select-all input,
+.storage-cluster-picker-options input {
+ width: 15px;
+ height: 15px;
+ margin: 0;
+ accent-color: var(--blue);
+}
+
+.storage-cluster-picker-options label > span {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.storage-cluster-picker-options strong {
+ min-width: 0;
+ overflow: hidden;
+ color: inherit;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-cluster-picker-options small {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-cluster-picker-options p {
+ margin: 0;
+ padding: 10px;
+ color: var(--muted);
+ font-size: 12px;
+ text-align: center;
+}
+
+.storage-cluster-select-all em {
+ color: var(--muted);
+ font-size: 10px;
+ font-style: normal;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.storage-create-form input:focus,
+.storage-create-form select:focus,
+.storage-create-form textarea:focus {
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
+}
+
+.storage-create-form .form-grid > label:has(> input[type="checkbox"]) {
+ min-height: 42px;
+ margin-top: 18px;
+ padding: 0 12px;
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--canvas);
+}
+
+.storage-create-form input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ accent-color: var(--blue);
+}
+
+.registry-cluster-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.storage-create-form .registry-cluster-options label {
+ min-width: 0;
+ min-height: 42px;
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ align-items: center;
+ gap: 9px;
+ padding: 9px 11px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--canvas);
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ background 0.15s,
+ box-shadow 0.15s;
+}
+
+.storage-create-form .registry-cluster-options label:hover,
+.storage-create-form .registry-cluster-options label.active {
+ border-color: var(--blue);
+ background: rgba(124, 58, 237, 0.06);
+}
+
+.storage-create-form .registry-cluster-options label:focus-within {
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
+}
+
+.registry-cluster-options label > span {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.registry-cluster-options strong,
+.registry-cluster-options small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.registry-cluster-options strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.registry-cluster-options small,
+.registry-cluster-options p {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.registry-cluster-options p {
+ grid-column: 1 / -1;
+ margin: 0;
+ padding: 10px 0;
+}
+
+.storage-create-form .error-text {
+ margin: 0;
+ padding: 10px 12px;
+ border: 1px solid rgba(239, 90, 122, 0.25);
+ border-radius: 10px;
+ background: rgba(239, 90, 122, 0.08);
+ color: var(--red);
+ font-size: 12px;
+}
+
+.storage-create-form .form-actions {
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
+ margin: 0 -26px;
+ padding: 16px 26px;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ border-top: 1px solid var(--line);
+ background: color-mix(in srgb, var(--panel) 94%, transparent);
+ backdrop-filter: blur(12px);
+}
+
+.theme-dark .storage-create-form {
+ background: #0f1623;
+}
+
+.theme-dark .storage-create-form .form-section,
+.theme-dark .storage-create-form input:not([type="checkbox"]),
+.theme-dark .storage-create-form select,
+.theme-dark .storage-create-form textarea {
+ background: #151d2b;
+ border-color: #33425b;
+ color: #e6edf7;
+}
+
+.theme-dark
+ .storage-create-form
+ .form-grid
+ > label:has(> input[type="checkbox"]) {
+ background: #111a29;
+ border-color: #33425b;
+}
+
+.theme-dark .storage-cluster-select,
+.theme-dark .storage-cluster-dropdown,
+.theme-dark .storage-cluster-picker-search,
+.theme-dark .storage-cluster-select-all,
+.theme-dark .storage-cluster-picker-options label {
+ background: #111a29;
+ border-color: #33425b;
+ color: #e6edf7;
+}
+
+.theme-dark
+ .storage-create-form
+ .storage-cluster-picker-search
+ input:not([type="checkbox"]) {
+ background: transparent;
+ border-color: transparent;
+ color: #e6edf7;
+}
+
+.theme-dark .storage-cluster-select-all:hover,
+.theme-dark .storage-cluster-picker-options label:hover,
+.theme-dark .storage-cluster-picker-options label.active {
+ background: rgba(139, 92, 246, 0.16);
+}
+
+@media (max-width: 760px) {
+ .storage-create-backdrop {
+ padding: 12px;
+ }
+
+ .storage-create-modal {
+ width: calc(100vw - 24px);
+ max-height: calc(100vh - 24px);
+ }
+
+ .storage-create-head,
+ .storage-create-form {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ .storage-create-form .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .storage-cluster-dropdown {
+ position: static;
+ margin-top: 6px;
+ }
+
+ .storage-create-form .form-actions {
+ margin-left: -16px;
+ margin-right: -16px;
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+}
diff --git a/apps/rlark-ui/src/styles/storage/index.css b/apps/rlark-ui/src/styles/storage/index.css
new file mode 100644
index 0000000..d267110
--- /dev/null
+++ b/apps/rlark-ui/src/styles/storage/index.css
@@ -0,0 +1,749 @@
+/* Storage resources */
+/* Keep storage detail sections aligned after the shared node-detail rules,
+ which are declared later in this stylesheet. */
+.node-detail-body.storage-detail-layout {
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: stretch;
+}
+
+.storage-overview-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 18px;
+}
+
+.storage-overview-card {
+ min-width: 0;
+ min-height: 112px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.storage-overview-card > span {
+ width: 42px;
+ height: 42px;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ border-radius: 14px;
+}
+
+.storage-overview-card.purple > span {
+ color: #7c3aed;
+ background: #f1eafe;
+}
+
+.storage-overview-card.green > span {
+ color: #159b6b;
+ background: #e5f8f1;
+}
+
+.storage-overview-card.orange > span {
+ color: #e47a14;
+ background: #fff1df;
+}
+
+.storage-overview-card div {
+ min-width: 0;
+}
+
+.storage-overview-card small,
+.storage-overview-card strong,
+.storage-overview-card em {
+ display: block;
+}
+
+.storage-overview-card small {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.storage-overview-card strong {
+ margin-top: 3px;
+ color: var(--ink);
+ font-size: 24px;
+ line-height: 1.15;
+}
+
+.storage-overview-card em {
+ max-width: 100%;
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 10px;
+ font-style: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-class-table-panel {
+ overflow-x: auto;
+}
+
+.storage-class-table-panel table {
+ min-width: 900px;
+}
+
+/* 操作列右对齐,让表头文字与行内图标对齐 */
+.storage-class-table-panel .storage-actions-col {
+ text-align: right;
+}
+
+.storage-class-table-panel .storage-actions-col .row-actions {
+ justify-content: flex-end;
+}
+
+.storage-table-heading {
+ min-width: 900px;
+ min-height: 68px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.storage-table-heading strong,
+.storage-table-heading small {
+ display: block;
+}
+
+.storage-table-heading strong {
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.storage-table-heading small,
+.storage-table-heading > span {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.storage-table-heading > span {
+ flex: 0 0 auto;
+ margin: 0;
+ padding: 6px 9px;
+ border-radius: 999px;
+ background: #f2f4f8;
+ font-weight: 700;
+}
+
+.storage-class-table-panel tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.storage-empty-cell {
+ height: 180px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.storage-name-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+}
+
+.storage-name-cell > span:first-child {
+ width: 34px;
+ height: 34px;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ border-radius: 11px;
+ background: #f1eafe;
+ color: var(--blue);
+}
+
+.storage-name-cell strong,
+.storage-name-cell small {
+ display: block;
+}
+
+.storage-name-cell strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.storage-name-cell small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.storage-provider-chip,
+.storage-cluster-list span {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: #f2f4f8;
+ color: #596579;
+ font-size: 10px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.storage-provider-chip {
+ background: #e8f8f2;
+ color: #168764;
+}
+
+.storage-cluster-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.storage-description {
+ display: -webkit-box;
+ max-width: 320px;
+ overflow: hidden;
+ line-height: 1.5;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.storage-class-table-panel .row-actions {
+ justify-content: flex-end;
+}
+
+.storage-files-hero {
+ align-items: flex-end;
+}
+
+.storage-files-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.storage-files-summary > div {
+ min-width: 0;
+ min-height: 80px;
+ display: grid;
+ grid-template-columns: 36px 1fr;
+ grid-template-rows: auto auto;
+ align-content: center;
+ column-gap: 11px;
+ padding: 13px 15px;
+ border: 1px solid var(--line);
+ border-radius: 17px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.storage-files-summary > div > span {
+ grid-row: 1 / 3;
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border-radius: 11px;
+ background: #f1eafe;
+ color: var(--blue);
+}
+
+.storage-files-summary > div:nth-child(2) > span {
+ background: #e5f8f1;
+ color: #159b6b;
+}
+
+.storage-files-summary > div:nth-child(3) > span {
+ background: #fff1df;
+ color: #e47a14;
+}
+
+.storage-files-summary small {
+ align-self: end;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.storage-files-summary strong {
+ min-width: 0;
+ align-self: start;
+ margin-top: 3px;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-files-summary strong em {
+ color: var(--muted);
+ font-size: 10px;
+ font-style: normal;
+ font-weight: 600;
+}
+
+.storage-files-page .files-breadcrumb {
+ min-height: 46px;
+ gap: 6px;
+ margin-bottom: 12px;
+ padding: 10px 14px;
+ border-radius: 14px;
+ box-shadow: 0 5px 16px rgba(28, 39, 58, 0.035);
+}
+
+.storage-files-page .files-breadcrumb > svg {
+ flex: 0 0 auto;
+ color: var(--blue);
+}
+
+.storage-files-page .files-breadcrumb .breadcrumb-label {
+ margin-right: 2px;
+ font-size: 11px;
+}
+
+.storage-files-page .files-breadcrumb button,
+.storage-files-page .files-breadcrumb > span:not(.breadcrumb-label) {
+ min-height: 25px;
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 8px;
+ border-radius: 8px;
+ background: rgba(124, 58, 237, 0.07);
+ color: var(--blue);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.storage-files-toolbar {
+ margin-bottom: 12px;
+ padding: 0;
+}
+
+.storage-files-toolbar .search-field {
+ min-width: min(360px, 100%);
+}
+
+.storage-files-table-panel {
+ overflow-x: auto;
+}
+
+.storage-files-table-panel .storage-table-heading,
+.storage-files-table-panel table {
+ min-width: 820px;
+}
+
+.storage-files-table-panel th:nth-child(1) {
+ width: 44%;
+}
+
+.storage-files-table-panel th:nth-child(2) {
+ width: 15%;
+}
+
+.storage-files-table-panel th:nth-child(3) {
+ width: 25%;
+}
+
+.storage-files-table-panel th:last-child {
+ width: 120px;
+ text-align: right;
+}
+
+.storage-files-table-panel td:last-child {
+ text-align: right;
+}
+
+.storage-files-table-panel tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.storage-file-name {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.storage-file-name > i {
+ width: 34px;
+ height: 34px;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ background: #f1eafe;
+ color: var(--blue);
+ font-style: normal;
+}
+
+.storage-file-name.file > i {
+ background: #eef2f7;
+ color: #718096;
+}
+
+.storage-file-name > span {
+ min-width: 0;
+}
+
+.storage-file-name strong,
+.storage-file-name small {
+ display: block;
+}
+
+.storage-file-name strong {
+ max-width: 480px;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storage-file-name small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 600;
+}
+
+.storage-files-table-panel .row-actions {
+ justify-content: flex-end;
+}
+
+.storage-files-error {
+ display: grid;
+ grid-template-columns: 34px 1fr auto;
+ align-items: center;
+ gap: 11px;
+ margin-bottom: 12px;
+ padding: 11px 12px;
+ border: 1px solid rgba(239, 90, 122, 0.2);
+ border-radius: 14px;
+ background: rgba(239, 90, 122, 0.06);
+ color: var(--red);
+}
+
+.storage-files-error > svg {
+ justify-self: center;
+}
+
+.storage-files-error strong,
+.storage-files-error span {
+ display: block;
+}
+
+.storage-files-error strong {
+ font-size: 11px;
+}
+
+.storage-files-error span {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.storage-files-error .secondary-button {
+ min-height: 34px;
+ height: 34px;
+}
+
+.storage-go-up-row td {
+ color: var(--blue);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.storage-go-up-row td svg {
+ margin-right: 7px;
+ vertical-align: middle;
+}
+
+.storage-files-state {
+ min-height: 170px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.storage-files-state > span {
+ width: 44px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ border-radius: 14px;
+ background: #f1eafe;
+ color: var(--blue);
+}
+
+.storage-files-state strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.storage-files-state small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.storage-files-spinner svg {
+ animation: storage-files-spin 0.9s linear infinite;
+}
+
+@keyframes storage-files-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.storage-detail-hero {
+ align-items: flex-end;
+}
+
+/* 返回按钮挪到左上之后,与 eyebrow 保持一点间距 */
+.storage-detail-hero .back-button {
+ margin-bottom: 4px;
+}
+
+.storage-detail-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 13px;
+}
+
+.storage-detail-badges span {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 4px 9px;
+ border: 1px solid rgba(124, 58, 237, 0.12);
+ border-radius: 999px;
+ background: rgba(124, 58, 237, 0.08);
+ color: #6d3fd3;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.storage-detail-layout {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+ gap: 16px;
+}
+
+.storage-detail-panel {
+ min-width: 0;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: var(--panel);
+ box-shadow: var(--shadow-soft);
+}
+
+.storage-detail-panel .node-detail-label {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 800;
+ text-transform: none;
+}
+
+.storage-detail-panel .node-detail-label svg {
+ color: var(--blue);
+}
+
+.storage-detail-section-copy {
+ margin: -3px 0 8px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.storage-detail-info-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.storage-detail-info-grid > div {
+ min-width: 0;
+ min-height: 82px;
+ justify-content: center;
+ padding: 13px 14px;
+ border: 1px solid var(--line);
+ border-radius: 15px;
+ background: #f8f9fc;
+}
+
+.storage-detail-info-grid .muted {
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.storage-detail-info-grid strong {
+ margin-top: 5px;
+ overflow-wrap: anywhere;
+ color: var(--ink);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.theme-dark .storage-overview-card {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .storage-files-hero {
+ background:
+ radial-gradient(
+ circle at 78% 15%,
+ rgba(124, 58, 237, 0.18),
+ transparent 25%
+ ),
+ linear-gradient(135deg, #151d2b 35%, #1a2030 100%);
+ border-color: var(--line);
+}
+
+.theme-dark .storage-files-summary > div {
+ background: #151d2b;
+ border-color: var(--line);
+}
+
+.theme-dark .storage-files-summary > div > span,
+.theme-dark .storage-file-name.folder > i,
+.theme-dark .storage-files-state > span {
+ background: rgba(124, 58, 237, 0.18);
+ color: #b79af8;
+}
+
+.theme-dark .storage-files-summary > div:nth-child(2) > span {
+ background: rgba(54, 201, 143, 0.14);
+ color: #62dbaf;
+}
+
+.theme-dark .storage-files-summary > div:nth-child(3) > span {
+ background: rgba(244, 143, 46, 0.15);
+ color: #f5a75d;
+}
+
+.theme-dark .storage-file-name.file > i {
+ background: #1e293a;
+ color: #aebbd0;
+}
+
+.theme-dark .storage-files-page .files-breadcrumb button,
+.theme-dark
+ .storage-files-page
+ .files-breadcrumb
+ > span:not(.breadcrumb-label) {
+ background: rgba(124, 58, 237, 0.18);
+ color: #c4b5fd;
+}
+
+.theme-dark .storage-detail-hero {
+ background:
+ radial-gradient(
+ circle at 10% 30%,
+ rgba(124, 58, 237, 0.2),
+ transparent 28%
+ ),
+ linear-gradient(135deg, #151d2b 30%, #1a2030 100%);
+ border-color: var(--line);
+}
+
+.theme-dark .storage-detail-badges span {
+ border-color: rgba(183, 154, 248, 0.18);
+ background: rgba(124, 58, 237, 0.18);
+ color: #c4b5fd;
+}
+
+.theme-dark .storage-detail-panel,
+.theme-dark .storage-detail-info-grid > div {
+ border-color: var(--line);
+ background: #151d2b;
+}
+
+.theme-dark .storage-detail-info-grid > div {
+ background: #111a29;
+}
+
+.theme-dark .storage-overview-card.purple > span,
+.theme-dark .storage-name-cell > span {
+ background: rgba(124, 58, 237, 0.18);
+ color: #b79af8;
+}
+
+.theme-dark .storage-overview-card.green > span {
+ background: rgba(54, 201, 143, 0.14);
+ color: #62dbaf;
+}
+
+.theme-dark .storage-overview-card.orange > span {
+ background: rgba(244, 143, 46, 0.15);
+ color: #f5a75d;
+}
+
+.theme-dark .storage-table-heading > span,
+.theme-dark .storage-cluster-list span {
+ background: #1e293a;
+ color: #b4c0d1;
+}
+
+.theme-dark .storage-provider-chip {
+ background: rgba(54, 201, 143, 0.14);
+ color: #62dbaf;
+}
+
+.theme-dark .storage-class-page .toolbar-filter select {
+ background-position: right 8px center !important;
+ background-repeat: no-repeat !important;
+ background-size: 14px 14px !important;
+}
+
+.theme-dark .storage-class-table-panel .inline-code {
+ color: #c5d1e2;
+}
+
+@media (max-width: 900px) {
+ .storage-overview-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .storage-files-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .storage-detail-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 620px) {
+ .storage-detail-hero {
+ align-items: flex-start;
+ padding: 20px;
+ }
+
+ .storage-detail-info-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/apps/rlark-ui/src/types.ts b/apps/rlark-ui/src/types.ts
index ba5f525..0f5fcbc 100644
--- a/apps/rlark-ui/src/types.ts
+++ b/apps/rlark-ui/src/types.ts
@@ -48,7 +48,9 @@ export interface ClusterSummary {
export interface CRDWorkload {
kind: string;
replicas: number;
+ /** @deprecated Use template.spec.volumes[].ephemeral.volumeClaimTemplate. */
pvcStorageMap?: Record;
+ /** @deprecated Use template.spec.volumes[].ephemeral.volumeClaimTemplate. */
pvcSizeGbMap?: Record;
template: {
spec: {
@@ -66,6 +68,17 @@ export interface CRDWorkload {
name: string;
hostPath?: { path: string };
persistentVolumeClaim?: { claimName: string };
+ ephemeral?: {
+ volumeClaimTemplate: {
+ spec: {
+ accessModes?: string[];
+ storageClassName?: string;
+ resources?: {
+ requests?: Record;
+ };
+ };
+ };
+ };
}>;
};
};
@@ -89,6 +102,7 @@ export interface CRDTask {
metadata: {
name: string;
namespace?: string;
+ annotations?: Record;
};
spec?: {
kubernetes?: {
@@ -97,6 +111,26 @@ export interface CRDTask {
};
status?: {
observedNodes?: string[];
+ tensorBoardProxy?: string;
+ pullProgress?: PullProgressEntry[];
+ events?: NodeEventEntry[];
+ };
+}
+
+export interface CRDPod {
+ metadata?: { name?: string; namespace?: string };
+ spec?: {
+ taskName?: string;
+ taskNamespace?: string;
+ podName?: string;
+ podNamespace?: string;
+ domain?: string;
+ };
+ status?: {
+ phase?: string;
+ node?: string;
+ ip?: string;
+ message?: string;
};
}
@@ -106,6 +140,7 @@ export interface CRDJob {
metadata: {
name: string;
creationTimestamp?: string;
+ deletionTimestamp?: string;
labels?: Record;
annotations?: Record;
};
@@ -114,6 +149,12 @@ export interface CRDJob {
stopped?: boolean;
sshPublicKey?: string;
tasks: CRDJobTask[];
+ // 后端可能仍返回旧格式 {key, value};新格式是 {key, values[]}
+ tags?: Array<{
+ key: string;
+ values?: string[];
+ value?: string;
+ }>;
};
status?: {
phase: string;
@@ -152,8 +193,12 @@ export interface CRDWorkflowJobTemplate {
export interface CRDWorkflow {
apiVersion: string;
kind: string;
- metadata: { name: string; creationTimestamp?: string };
- spec: { jobTemplates: CRDWorkflowJobTemplate[] };
+ metadata: {
+ name: string;
+ creationTimestamp?: string;
+ deletionTimestamp?: string;
+ };
+ spec: { jobTemplates: CRDWorkflowJobTemplate[]; stopped?: boolean };
status?: {
phase: string;
jobs?: Array<{ name: string; phase: string; message: string }>;
@@ -191,10 +236,8 @@ export interface RoleResource {
objectStorage: string;
mountPath: string;
hostPath: string;
- pvcSizeGb: number;
+ pvcSizeGb: number | "";
}>;
- pvcStorageMap?: Record;
- pvcSizeGbMap?: Record;
}
export interface WorkflowJobDef {
@@ -283,6 +326,11 @@ export interface CRDNode {
};
addresses?: Array<{ type: string; address: string }>;
diskPressure?: boolean;
+ storage?: {
+ capacityBytes?: number;
+ usedBytes?: number;
+ availableBytes?: number;
+ };
allocatable?: Record;
capacity?: Record;
used?: Record;
diff --git a/apps/rlark-ui/src/utils/crd.ts b/apps/rlark-ui/src/utils/crd.ts
index c6937f6..31e294a 100644
--- a/apps/rlark-ui/src/utils/crd.ts
+++ b/apps/rlark-ui/src/utils/crd.ts
@@ -1,11 +1,33 @@
-import type { Job, JobType, Phase } from "../data";
+import type { Job, JobTag, JobType, Phase } from "../data";
import type { CRDJob, CRDJobTask, CRDWorkflow } from "../types";
+// 生成稳定的 tag id:用 key:value 组合哈希,保证同一 CRD 多次转换得到相同 id。
+function tagId(key: string, value: string): string {
+ const s = `${key}|${value}`;
+ let h = 0x811c9dc5;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return `tag-${key.replace(/[^a-zA-Z0-9]/g, "_")}-${(h >>> 0)
+ .toString(16)
+ .padStart(8, "0")}`;
+}
+
+function storageGi(quantity?: string): number {
+ const match = quantity?.match(/^(\d+)(?:Gi)?$/);
+ return match ? Number(match[1]) : 10;
+}
+
export function crdToJob(crd: CRDJob): Job {
const tasks = crd.spec.tasks ?? [];
const container =
tasks[0]?.kubernetes?.workload?.template.spec.containers?.[0];
- const phase = (crd.status?.phase ?? "Pending") as Phase;
+ const phase = (
+ crd.metadata.deletionTimestamp
+ ? "Deleting"
+ : (crd.status?.phase ?? "Pending")
+ ) as Phase;
const allTaskStatuses = crd.status?.tasks ?? [];
const runningTasks = allTaskStatuses.filter(
(t) => t.phase === "Running",
@@ -44,6 +66,16 @@ export function crdToJob(crd: CRDJob): Job {
const vol = t.kubernetes?.workload?.template.spec.volumes?.find(
(v) => v.name === vm.name,
);
+ if (vol?.ephemeral) {
+ const spec = vol.ephemeral.volumeClaimTemplate.spec;
+ return {
+ type: "storage" as const,
+ objectStorage: spec.storageClassName ?? "",
+ mountPath: vm.mountPath,
+ hostPath: "",
+ pvcSizeGb: storageGi(spec.resources?.requests?.storage),
+ };
+ }
if (vol?.persistentVolumeClaim) {
const claimName = vol.persistentVolumeClaim.claimName;
const storageClass =
@@ -78,7 +110,6 @@ export function crdToJob(crd: CRDJob): Job {
prepareScript: t.prepareScript ?? "",
env: taskEnv,
mounts: taskMounts,
- pvcStorageMap: t.kubernetes?.workload?.pvcStorageMap,
};
});
const env =
@@ -92,6 +123,16 @@ export function crdToJob(crd: CRDJob): Job {
const vol = tasks[0]?.kubernetes?.workload?.template.spec.volumes?.find(
(v) => v.name === vm.name,
);
+ if (vol?.ephemeral) {
+ const spec = vol.ephemeral.volumeClaimTemplate.spec;
+ return {
+ type: "storage" as const,
+ objectStorage: spec.storageClassName ?? "",
+ mountPath: vm.mountPath,
+ hostPath: "",
+ pvcSizeGb: storageGi(spec.resources?.requests?.storage),
+ };
+ }
if (vol?.persistentVolumeClaim) {
const claimName = vol.persistentVolumeClaim.claimName;
const storageClass =
@@ -149,6 +190,19 @@ export function crdToJob(crd: CRDJob): Job {
stopped: crd.spec.stopped ?? false,
domain: crd.spec.domain ?? "",
sshPublicKey: crd.spec.sshPublicKey ?? "",
+ tags:
+ crd.spec.tags && crd.spec.tags.length > 0
+ ? crd.spec.tags.flatMap((tag) => {
+ // 兼容旧格式 {key, value} 与新格式 {key, values[]}
+ const values =
+ tag.values ?? (tag.value !== undefined ? [tag.value] : []);
+ return values.map((value): JobTag => ({
+ id: tagId(tag.key, value),
+ key: tag.key,
+ value,
+ }));
+ })
+ : [],
resources,
taskStatuses: allTaskStatuses,
};
@@ -169,7 +223,12 @@ export function mapRoleToJobType(tasks: CRDJobTask[]): JobType {
export function crdToWorkflow(crd: CRDWorkflow) {
const jobs = crd.status?.jobs ?? [];
const running = jobs.filter((j) => j.phase === "Running").length;
- const phase = crd.status?.phase ?? "Pending";
+ const phase = crd.metadata.deletionTimestamp
+ ? "Deleting"
+ : crd.spec.stopped &&
+ (crd.status?.phase === "Pending" || crd.status?.phase === "Running")
+ ? "Stopping"
+ : (crd.status?.phase ?? "Pending");
return {
name: crd.metadata.name,
phase: phase as Phase,
@@ -178,5 +237,6 @@ export function crdToWorkflow(crd: CRDWorkflow) {
created: crd.metadata.creationTimestamp ?? "—",
templates: crd.spec.jobTemplates,
jobStatuses: jobs,
+ stopped: crd.spec.stopped ?? false,
};
}
diff --git a/apps/rlark-ui/src/utils/dag.ts b/apps/rlark-ui/src/utils/dag.ts
index f7bba7e..6c4fca8 100644
--- a/apps/rlark-ui/src/utils/dag.ts
+++ b/apps/rlark-ui/src/utils/dag.ts
@@ -1,12 +1,10 @@
import type { JobType } from "../data";
import type { DAGNode, DAGEdge, RoleResource } from "../types";
-import { computePvcStorageMap } from "./job";
export function makeDefaultRoleResources(
type: JobType,
roles: string[],
clusterDisplayNames: string[] = [],
- jobName?: string,
): Record {
const rr: Record = {};
roles.forEach((role, index) => {
@@ -32,7 +30,6 @@ export function makeDefaultRoleResources(
prepareScript: "",
envs: [{ key: "RLARK_TASK_ROLE", value: role }],
mounts,
- pvcStorageMap: computePvcStorageMap(role, mounts, jobName),
};
});
return rr;
diff --git a/apps/rlark-ui/src/utils/deployYaml.ts b/apps/rlark-ui/src/utils/deployYaml.ts
new file mode 100644
index 0000000..af9182b
--- /dev/null
+++ b/apps/rlark-ui/src/utils/deployYaml.ts
@@ -0,0 +1,75 @@
+import type { DeploymentConfig } from "../backend.js";
+import type { SignAgentCertResponse } from "../types.js";
+
+export const defaultDeploymentConfig: DeploymentConfig = {
+ controlPlaneAddress: "",
+ sshAddress: "",
+ insecureSkipTlsVerify: false,
+ kubernetes: {
+ kubeconfig: "~/.kube/config",
+ agentImage: "rlark:latest",
+ image: "rlark:latest",
+ imagePullPolicy: "Always",
+ imagePullSecrets: [],
+ containerdSocket: "/run/containerd/containerd.sock",
+ },
+};
+
+export function resolveDeploymentConfig(
+ config: DeploymentConfig = {},
+): DeploymentConfig {
+ return {
+ ...defaultDeploymentConfig,
+ ...config,
+ kubernetes: {
+ ...defaultDeploymentConfig.kubernetes,
+ ...config.kubernetes,
+ },
+ };
+}
+
+const indentPEM = (value: string) =>
+ value
+ .split("\n")
+ .map((line) => ` ${line}`)
+ .join("\n");
+
+export function buildDeployYaml(
+ result: SignAgentCertResponse,
+ config: DeploymentConfig = {},
+) {
+ const resolved = resolveDeploymentConfig(config);
+ const kubernetes = [
+ resolved.kubernetes?.kubeconfig
+ ? ` kubeconfig: ${resolved.kubernetes.kubeconfig}`
+ : "",
+ ` agent-image: ${resolved.kubernetes?.agentImage}`,
+ resolved.kubernetes?.image ? ` image: ${resolved.kubernetes.image}` : "",
+ resolved.kubernetes?.imagePullPolicy
+ ? ` image-pull-policy: ${resolved.kubernetes.imagePullPolicy}`
+ : "",
+ resolved.kubernetes?.imagePullSecrets?.length
+ ? ` image-pull-secrets: [${resolved.kubernetes.imagePullSecrets.join(", ")}]`
+ : "",
+ resolved.kubernetes?.containerdSocket
+ ? ` containerd-socket: ${resolved.kubernetes.containerdSocket}`
+ : "",
+ ].filter(Boolean);
+
+ return `apiVersion: rlark.io/v1alpha1
+kind: DeployConfig
+plane: data
+control-plane-address: ${resolved.controlPlaneAddress || result.server_addr}
+${resolved.sshAddress ? `ssh-address: ${resolved.sshAddress}\n` : ""}${resolved.insecureSkipTlsVerify ? "insecure-skip-tls-verify: true\n" : ""}
+cert:
+ ca-cert: |
+${indentPEM(result.ca_cert)}
+ agent-cert: |
+${indentPEM(result.agent_cert)}
+ agent-key: |
+${indentPEM(result.agent_key)}
+
+kubernetes:
+${kubernetes.join("\n")}
+`;
+}
diff --git a/apps/rlark-ui/src/utils/job.ts b/apps/rlark-ui/src/utils/job.ts
index 16a758d..91d5d6c 100644
--- a/apps/rlark-ui/src/utils/job.ts
+++ b/apps/rlark-ui/src/utils/job.ts
@@ -1,6 +1,22 @@
-import type { JobType } from "../data";
+import type { JobTag, JobType } from "../data";
import type { RoleResource } from "../types";
+// 任务名称限制:1-64 个字符,支持中英文、数字、中划线(-)、下划线(_)和英文句号(.)
+export const JOB_DISPLAY_NAME_MAX_LENGTH = 64;
+export const JOB_DISPLAY_NAME_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5._-]{1,64}$/;
+
+export function isValidJobDisplayName(name: string): boolean {
+ return JOB_DISPLAY_NAME_PATTERN.test(name);
+}
+
+// 角色名称与任务名称使用相同的字符与长度限制
+export const ROLE_NAME_MAX_LENGTH = JOB_DISPLAY_NAME_MAX_LENGTH;
+export const ROLE_NAME_PATTERN = JOB_DISPLAY_NAME_PATTERN;
+
+export function isValidRoleName(name: string): boolean {
+ return ROLE_NAME_PATTERN.test(name);
+}
+
const TASK_ROLE_MAP: Record = {
Actor: "Actor",
Learner: "Actor",
@@ -143,57 +159,6 @@ export function toVolumeName(mountPath: string): string {
return name;
}
-export function computePvcStorageMap(
- role: string,
- mounts: Array<{
- type: "host" | "storage";
- objectStorage: string;
- mountPath: string;
- hostPath: string;
- pvcSizeGb: number;
- }>,
- jobName?: string,
-): Record | undefined {
- const roleSlug = toResourceName(role);
- const storageMounts = mounts.filter((m) => m.type === "storage");
- if (storageMounts.length === 0) return undefined;
- const map: Record = {};
- const jobSlug = jobName ? toResourceName(jobName) : "";
- storageMounts.forEach((m) => {
- const volName = toVolumeName(m.mountPath);
- const claimName = jobSlug
- ? `pvc-${jobSlug}-${roleSlug}-${volName}`
- : `pvc-${roleSlug}-${volName}`;
- map[claimName] = m.objectStorage ?? "";
- });
- return map;
-}
-
-export function computePvcSizeGbMap(
- role: string,
- mounts: Array<{
- type: "host" | "storage";
- mountPath: string;
- pvcSizeGb: number;
- }>,
- jobName?: string,
-): Record | undefined {
- const roleSlug = toResourceName(role);
- const storageMounts = mounts.filter((m) => m.type === "storage");
- if (storageMounts.length === 0) return undefined;
- const map: Record = {};
- const jobSlug = jobName ? toResourceName(jobName) : "";
- storageMounts.forEach((m) => {
- const volName =
- m.mountPath.replace(/\//g, "-").replace(/^-|-$/g, "") || "vol";
- const claimName = jobSlug
- ? `pvc-${jobSlug}-${roleSlug}-${volName}`
- : `pvc-${roleSlug}-${volName}`;
- map[claimName] = Math.min(200, Math.max(1, m.pvcSizeGb));
- });
- return map;
-}
-
export function automaticNetworkDomain(domains: Array<{ name: string }>) {
return (
[...domains].sort((left, right) => left.name.localeCompare(right.name))[0]
@@ -212,6 +177,7 @@ export function generateJobCRD(opts: {
domain: string;
tensorBoardDir?: string;
sshPublicKey?: string;
+ tags?: JobTag[];
}) {
const tasks = opts.roles
.map((role) => {
@@ -227,8 +193,6 @@ export function generateJobCRD(opts: {
{ name: "RLARK_TASK_ROLE", value: role },
];
const taskName = toResourceName(role);
- const jobSlug = toResourceName(opts.name);
-
const hostMounts = roleMounts.filter((m) => m.type === "host");
const storageMounts = roleMounts.filter((m) => m.type === "storage");
@@ -239,22 +203,26 @@ export function generateJobCRD(opts: {
const storageVolumes = storageMounts.map((m) => {
const volName = toVolumeName(m.mountPath);
- const claimName = `pvc-${jobSlug}-${taskName}-${volName}`;
+ const pvcSizeGb =
+ m.pvcSizeGb === "" ? 10 : Math.min(200, Math.max(1, m.pvcSizeGb));
return {
name: volName,
- persistentVolumeClaim: {
- claimName,
+ ephemeral: {
+ volumeClaimTemplate: {
+ spec: {
+ accessModes: ["ReadWriteOnce"],
+ ...(m.objectStorage
+ ? { storageClassName: m.objectStorage }
+ : {}),
+ resources: {
+ requests: { storage: `${pvcSizeGb}Gi` },
+ },
+ },
+ },
},
};
});
- const pvcStorageMap = computePvcStorageMap(
- taskName,
- roleMounts,
- opts.name,
- );
- const pvcSizeGbMap = computePvcSizeGbMap(taskName, roleMounts, opts.name);
-
const allVolumeMounts = roleMounts.map((m) => ({
name: toVolumeName(m.mountPath),
mountPath: m.mountPath,
@@ -275,8 +243,6 @@ export function generateJobCRD(opts: {
workload: {
kind: "StatefulSet",
replicas: res ? Number(res.replicas) : 1,
- ...(pvcStorageMap ? { pvcStorageMap } : {}),
- ...(pvcSizeGbMap ? { pvcSizeGbMap } : {}),
template: {
spec: {
containers: [
@@ -345,6 +311,31 @@ export function generateJobCRD(opts: {
tasks,
...(opts.domain ? { domain: opts.domain } : {}),
...(opts.sshPublicKey ? { sshPublicKey: opts.sshPublicKey } : {}),
+ ...(opts.tags && opts.tags.length > 0
+ ? {
+ tags: [
+ ...new Map(
+ opts.tags
+ .filter((t) => t.key.trim() && t.value.trim())
+ .map((t) => [
+ t.key.trim(),
+ {
+ key: t.key.trim(),
+ values: opts
+ .tags!.filter(
+ (item) => item.key.trim() === t.key.trim(),
+ )
+ .map((item) => item.value.trim())
+ .filter(
+ (value, index, values) =>
+ value && values.indexOf(value) === index,
+ ),
+ },
+ ]),
+ ).values(),
+ ],
+ }
+ : {}),
},
};
}
diff --git a/apps/rlark-ui/src/utils/jobPhase.ts b/apps/rlark-ui/src/utils/jobPhase.ts
index dd1d3ee..b373d49 100644
--- a/apps/rlark-ui/src/utils/jobPhase.ts
+++ b/apps/rlark-ui/src/utils/jobPhase.ts
@@ -5,6 +5,9 @@ export type JobDisplayPhase = Phase | "Stopping";
// 任务状态统一以 Job 自身的 phase 为准。
// 当 job.stopped 已置位但 phase 尚未推进到 Stopped 时,展示过渡状态 Stopping。
export function effectiveJobPhase(job: Job): JobDisplayPhase {
+ if (job.phase === "Deleting") {
+ return "Deleting";
+ }
if (job.stopped && job.phase !== "Stopped") {
return "Stopping";
}
diff --git a/apps/rlark-ui/src/utils/nodeBatchMetadata.ts b/apps/rlark-ui/src/utils/nodeBatchMetadata.ts
index b8292c4..e00f5a9 100644
--- a/apps/rlark-ui/src/utils/nodeBatchMetadata.ts
+++ b/apps/rlark-ui/src/utils/nodeBatchMetadata.ts
@@ -50,7 +50,10 @@ export function updateNodeCategoryLabels(
});
(["cloud", "edge", "robot"] as const).forEach((category) => {
if (categories.includes(category)) {
- next[`rlark.io/node-category-${category}`] = "true";
+ const key = `rlark.io/node-category-${category}`;
+ next[key] = "true";
+ // 写回新值的 key 不能再标记为 removed,否则调用方会在 patch 里把它置 null 覆盖掉
+ removedKeys.delete(key);
}
});
return { labels: next, removedKeys };
diff --git a/apps/rlark-ui/src/utils/nodeResources.ts b/apps/rlark-ui/src/utils/nodeResources.ts
index be676d5..bb7fa5d 100644
--- a/apps/rlark-ui/src/utils/nodeResources.ts
+++ b/apps/rlark-ui/src/utils/nodeResources.ts
@@ -105,13 +105,79 @@ export function formatResourceQuantity(key: string, raw?: string): string {
const value = parseResourceQuantity(key, raw);
if (value === null) return "—";
if (key === "memory" || key === "ephemeral-storage") {
- const gb = value / 1000 ** 3;
- return `${gb >= 100 ? gb.toFixed(0) : gb.toFixed(1)} GB`;
+ const gib = value / 1024 ** 3;
+ return `${gib >= 100 ? gib.toFixed(0) : gib.toFixed(1)} GiB`;
}
if (key === "cpu") return `${formatResourceNumber(value)} 核`;
return formatResourceNumber(value);
}
+export function getResourceUsagePercent(
+ key: string,
+ used?: string,
+ total?: string,
+): number | null {
+ const totalValue = parseResourceQuantity(key, total);
+ if (totalValue === null || totalValue <= 0) return null;
+ if (!used) return 0;
+ if (used.endsWith("%")) {
+ const percent = Number.parseFloat(used);
+ return Number.isFinite(percent)
+ ? Math.min(100, Math.max(0, Math.round(percent)))
+ : null;
+ }
+ const usedValue = parseResourceQuantity(key, used);
+ return usedValue === null
+ ? null
+ : Math.min(100, Math.max(0, Math.round((usedValue / totalValue) * 100)));
+}
+
+export function getNodeDiskUsage(node: CRDNode): {
+ capacityBytes: number;
+ usedBytes: number;
+ availableBytes: number;
+ percent: number;
+} | null {
+ const storage = node.status?.storage;
+ const capacityBytes = storage?.capacityBytes;
+ const availableBytes = storage?.availableBytes;
+ if (
+ capacityBytes === undefined ||
+ availableBytes === undefined ||
+ capacityBytes <= 0
+ ) {
+ return null;
+ }
+ const usedBytes = Math.min(
+ capacityBytes,
+ Math.max(0, storage?.usedBytes ?? capacityBytes - availableBytes),
+ );
+ const normalizedAvailableBytes = Math.min(
+ capacityBytes,
+ Math.max(0, availableBytes),
+ );
+ const rawPercent =
+ ((capacityBytes - normalizedAvailableBytes) / capacityBytes) * 100;
+ return {
+ capacityBytes,
+ usedBytes,
+ availableBytes: normalizedAvailableBytes,
+ percent:
+ normalizedAvailableBytes > 0
+ ? Math.min(99, Math.max(0, Math.floor(rawPercent)))
+ : 100,
+ };
+}
+
+export function isDiskUsageWarning(node: CRDNode): boolean {
+ const usage = getNodeDiskUsage(node);
+ if (node.status?.diskPressure === true) return true;
+ if (!usage) return false;
+ return (
+ usage.capacityBytes - usage.availableBytes >= usage.capacityBytes * 0.9
+ );
+}
+
export function getNodeResourceSummary(
node: CRDNode,
zh: boolean,
diff --git a/apps/rlark-ui/src/utils/nodes.ts b/apps/rlark-ui/src/utils/nodes.ts
index 1ee8fee..d95460f 100644
--- a/apps/rlark-ui/src/utils/nodes.ts
+++ b/apps/rlark-ui/src/utils/nodes.ts
@@ -19,7 +19,10 @@ export {
export {
formatResourceQuantity,
getGPUResourceKey,
+ getNodeDiskUsage,
getNodeResourceSummary,
+ getResourceUsagePercent,
+ isDiskUsageWarning,
parseResourceQuantity,
selectDeviceResourceKey,
} from "./nodeResources";
@@ -72,6 +75,9 @@ export function buildMockCRDNodes(): CRDNode[] {
};
return mockNodes.map((node) => {
+ const diskPercent = node.id === "gpu-cloud-01" ? 90 : 40;
+ const diskCapacityBytes = 120 * 1024 ** 3;
+ const diskUsedBytes = (diskCapacityBytes * diskPercent) / 100;
const city = node.cluster.includes("上海")
? "上海市"
: node.cluster.includes("杭州")
@@ -123,6 +129,7 @@ export function buildMockCRDNodes(): CRDNode[] {
allocatable: {
cpu: "16",
memory: "64Gi",
+ "ephemeral-storage": "100Gi",
"nvidia.com/gpu": node.gpu.split(" / ")[1] ?? "0",
...(node.kind !== "CloudCompute"
? {
@@ -134,6 +141,7 @@ export function buildMockCRDNodes(): CRDNode[] {
capacity: {
cpu: "16",
memory: "64Gi",
+ "ephemeral-storage": "120Gi",
"nvidia.com/gpu": node.gpu.split(" / ")[1] ?? "0",
...(node.kind !== "CloudCompute"
? {
@@ -142,9 +150,15 @@ export function buildMockCRDNodes(): CRDNode[] {
}
: {}),
},
+ storage: {
+ capacityBytes: diskCapacityBytes,
+ usedBytes: diskUsedBytes,
+ availableBytes: diskCapacityBytes - diskUsedBytes,
+ },
used: {
cpu: `${node.cpu}%`,
memory: `${node.memory}%`,
+ "ephemeral-storage": `${diskPercent}Gi`,
"nvidia.com/gpu": node.gpu.split(" / ")[0] ?? "0",
...(node.kind !== "CloudCompute"
? {
@@ -163,11 +177,9 @@ export function useNodeLabels() {
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
- fetch("/api/v1/rlinf.io/v1alpha1/nodes")
- .then((r) =>
- r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
- )
- .then((data) => setNodes(data.items ?? []))
+ nodesApi
+ .list()
+ .then(setNodes)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
@@ -179,3 +191,4 @@ export function useNodeLabels() {
const clusterDisplayNames = clusterNames;
return { nodes, loading, clusterNames, clusterDisplayNames };
}
+import { nodesApi } from "../backend";
diff --git a/apps/rlark-ui/src/utils/route.ts b/apps/rlark-ui/src/utils/route.ts
index 230f04d..fab2d15 100644
--- a/apps/rlark-ui/src/utils/route.ts
+++ b/apps/rlark-ui/src/utils/route.ts
@@ -1,14 +1,69 @@
import { useEffect, useState } from "react";
import type { Page } from "../types";
+export type AdminRoute = { page: string; sub: string };
+
+const adminPages = new Set([
+ "dashboard",
+ "clusters-list",
+ "create-cluster",
+ "clusters-nodes",
+ "addons",
+ "jobs",
+ "domains",
+ "api",
+ "config",
+ "storageClass",
+ "files",
+ "image-registries",
+ "ssh-keys",
+]);
+
+export function isAdminPath(pathname: string) {
+ return pathname === "/admin" || pathname.startsWith("/admin/");
+}
+
+export function parseAdminRoute(
+ pathname = window.location.pathname,
+): AdminRoute {
+ const parts = pathname
+ .replace(/^\/admin\/?/, "")
+ .replace(/\/+$/, "")
+ .split("/")
+ .filter(Boolean);
+ const page = adminPages.has(parts[0])
+ ? parts[0]
+ : parts.length > 0
+ ? "clusters-nodes"
+ : "dashboard";
+ const subParts = adminPages.has(parts[0]) ? parts.slice(1) : parts;
+ return {
+ page,
+ sub: subParts.length > 0 ? decodeURIComponent(subParts.join("/")) : "",
+ };
+}
+
+export function filesPath(
+ cluster: string,
+ storageClass: string,
+ admin = false,
+) {
+ const prefix = admin ? "/admin/files" : "/files";
+ return `${prefix}/${encodeURIComponent(cluster)}/${encodeURIComponent(storageClass)}`;
+}
+
+export function hasTerminalSession(storage: Pick) {
+ return Boolean(storage.getItem("rlark-auth-token"));
+}
+
export function useIsAdminPath() {
const [isAdmin, setIsAdmin] = useState(() => {
if (typeof window === "undefined") return false;
- return window.location.pathname.startsWith("/admin");
+ return isAdminPath(window.location.pathname);
});
useEffect(() => {
const onPop = () => {
- setIsAdmin(window.location.pathname.startsWith("/admin"));
+ setIsAdmin(isAdminPath(window.location.pathname));
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
diff --git a/apps/rlark-ui/src/utils/sshKeys.ts b/apps/rlark-ui/src/utils/sshKeys.ts
new file mode 100644
index 0000000..f7a45e4
--- /dev/null
+++ b/apps/rlark-ui/src/utils/sshKeys.ts
@@ -0,0 +1,76 @@
+export type SSHUserKey = {
+ index: number;
+ user: string;
+ public_key: string;
+ added_at: string;
+};
+
+export type ResolvedSSHKey = {
+ publicKey: string;
+ owners: Array<{ user: string; index: number }>;
+};
+
+export type SelectableSSHKey = ResolvedSSHKey;
+
+export type SSHKeyDuplicate = "name" | "publicKey" | null;
+
+export function findSSHKeyDuplicate(
+ name: string,
+ publicKey: string,
+ knownKeys: SSHUserKey[],
+): SSHKeyDuplicate {
+ const normalizedName = name.trim();
+ const normalizedKey = publicKey.trim();
+
+ if (knownKeys.some((key) => key.user.trim() === normalizedName))
+ return "name";
+ if (knownKeys.some((key) => key.public_key.trim() === normalizedKey))
+ return "publicKey";
+ return null;
+}
+
+export function groupSSHUserKeys(knownKeys: SSHUserKey[]): SelectableSSHKey[] {
+ const keysByValue = new Map();
+
+ for (const key of knownKeys) {
+ const publicKey = key.public_key.trim();
+ if (!publicKey) continue;
+ const existing = keysByValue.get(publicKey);
+ if (existing) {
+ existing.owners.push({ user: key.user, index: key.index });
+ } else {
+ keysByValue.set(publicKey, {
+ publicKey,
+ owners: [{ user: key.user, index: key.index }],
+ });
+ }
+ }
+
+ return [...keysByValue.values()];
+}
+
+export function splitSSHPublicKeys(value?: string): string[] {
+ if (!value) return [];
+
+ return value
+ .split(/\r?\n/)
+ .map((key) => key.trim())
+ .filter(Boolean);
+}
+
+export function resolveSSHKeyOwners(
+ value: string | undefined,
+ knownKeys: SSHUserKey[],
+): ResolvedSSHKey[] {
+ const ownersByKey = new Map(
+ groupSSHUserKeys(knownKeys).map(({ publicKey, owners }) => [
+ publicKey,
+ owners,
+ ]),
+ );
+
+ return splitSSHPublicKeys(value).map((publicKey) => ({
+ publicKey,
+ owners: ownersByKey.get(publicKey) ?? [],
+ }));
+}
diff --git a/apps/rlark-ui/tests/api.test.mjs b/apps/rlark-ui/tests/api.test.mjs
new file mode 100644
index 0000000..d9c5f05
--- /dev/null
+++ b/apps/rlark-ui/tests/api.test.mjs
@@ -0,0 +1,131 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ AUTH_ROLE_KEY,
+ AUTH_TOKEN_KEY,
+ clearAuthSession,
+ hasAuthSession,
+ storeAuthSession,
+ UNAUTHORIZED_EVENT,
+ request,
+} from "../dist/test/api.js";
+import {
+ apiReferenceApi,
+ domainsApi,
+ nodesApi,
+ systemConfigApi,
+} from "../dist/test/backend.js";
+
+function storage() {
+ const values = new Map();
+ return {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, String(value)),
+ removeItem: (key) => values.delete(key),
+ };
+}
+
+test("request attaches the JWT and preserves headers", async () => {
+ globalThis.sessionStorage = storage();
+ globalThis.window = { dispatchEvent() {} };
+ storeAuthSession("jwt-token", "admin");
+ globalThis.fetch = async (_input, init) => {
+ assert.equal(init.headers.get("Authorization"), "Bearer jwt-token");
+ assert.equal(init.headers.get("Content-Type"), "application/json");
+ return new Response(null, { status: 200 });
+ };
+
+ const response = await request("/api/v1/clusters", {
+ headers: { "Content-Type": "application/json" },
+ });
+ assert.equal(response.status, 200);
+ assert.equal(hasAuthSession("admin"), true);
+});
+
+test("request clears the session after a 401", async () => {
+ globalThis.sessionStorage = storage();
+ let eventType = "";
+ globalThis.window = { dispatchEvent: (event) => (eventType = event.type) };
+ sessionStorage.setItem(AUTH_TOKEN_KEY, "expired");
+ sessionStorage.setItem(AUTH_ROLE_KEY, "user");
+ globalThis.fetch = async () => new Response(null, { status: 401 });
+
+ await assert.rejects(() => request("/api/v1/clusters"));
+ assert.equal(sessionStorage.getItem(AUTH_TOKEN_KEY), null);
+ assert.equal(eventType, UNAUTHORIZED_EVENT);
+ clearAuthSession();
+});
+
+test("resource APIs encode paths, query values, and JSON bodies", async () => {
+ globalThis.sessionStorage = storage();
+ globalThis.window = { dispatchEvent() {} };
+ const calls = [];
+ globalThis.fetch = async (input, init) => {
+ calls.push({ input: String(input), init });
+ return new Response(JSON.stringify({ items: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ await nodesApi.list({ labelSelector: "rlark.io/cluster-id=a/b" });
+ await domainsApi.create({ metadata: { name: "domain-a" } });
+
+ assert.equal(
+ calls[0].input,
+ "/api/v1/rlinf.io/v1alpha1/nodes?labelSelector=rlark.io%2Fcluster-id%3Da%2Fb",
+ );
+ assert.equal(calls[1].init.method, "POST");
+ assert.equal(calls[1].init.headers.get("Content-Type"), "application/json");
+ assert.equal(calls[1].init.body, '{"metadata":{"name":"domain-a"}}');
+});
+
+test("system config API shares reads and refreshes its cache after update", async () => {
+ globalThis.sessionStorage = storage();
+ globalThis.window = { dispatchEvent() {} };
+ let calls = 0;
+ globalThis.fetch = async (_input, init) => {
+ calls += 1;
+ const config =
+ init?.method === "PUT"
+ ? { ssh: { jumpHost: "new.example.com", jumpPort: "22" } }
+ : { ssh: { jumpHost: "old.example.com", jumpPort: "22" } };
+ return new Response(JSON.stringify(config), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ const [first, second] = await Promise.all([
+ systemConfigApi.get({ refresh: true }),
+ systemConfigApi.get(),
+ ]);
+ assert.equal(calls, 1);
+ assert.equal(first.ssh.jumpHost, "old.example.com");
+ assert.equal(second.ssh.jumpHost, "old.example.com");
+
+ await systemConfigApi.update({ ssh: { jumpHost: "new.example.com" } });
+ const cached = await systemConfigApi.get();
+ assert.equal(calls, 2);
+ assert.equal(cached.ssh.jumpHost, "new.example.com");
+});
+
+test("API reference is loaded from Gateway", async () => {
+ globalThis.sessionStorage = storage();
+ globalThis.window = { dispatchEvent() {} };
+ globalThis.fetch = async (input) => {
+ assert.equal(String(input), "/api/v1/api-reference");
+ return new Response(
+ JSON.stringify({
+ title: { zh: "接口参考", en: "API Reference" },
+ description: { zh: "接口", en: "APIs" },
+ sections: [],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ };
+
+ const reference = await apiReferenceApi.get();
+ assert.equal(reference.title.en, "API Reference");
+});
diff --git a/apps/rlark-ui/tests/deleting-phase.test.mjs b/apps/rlark-ui/tests/deleting-phase.test.mjs
new file mode 100644
index 0000000..84586c7
--- /dev/null
+++ b/apps/rlark-ui/tests/deleting-phase.test.mjs
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { crdToJob, crdToWorkflow } from "../dist/test/utils/crd.js";
+
+test("maps terminating jobs to Deleting", () => {
+ const job = crdToJob({
+ apiVersion: "rlinf.io/v1alpha1",
+ kind: "Job",
+ metadata: {
+ name: "job",
+ deletionTimestamp: "2026-09-18T00:00:00Z",
+ },
+ spec: { tasks: [] },
+ status: { phase: "Running" },
+ });
+
+ assert.equal(job.phase, "Deleting");
+});
+
+test("maps terminating workflows to Deleting", () => {
+ const workflow = crdToWorkflow({
+ apiVersion: "rlinf.io/v1alpha1",
+ kind: "Workflow",
+ metadata: {
+ name: "workflow",
+ deletionTimestamp: "2026-09-18T00:00:00Z",
+ },
+ spec: { jobTemplates: [] },
+ status: { phase: "Running" },
+ });
+
+ assert.equal(workflow.phase, "Deleting");
+});
diff --git a/apps/rlark-ui/tests/deploy-yaml.test.mjs b/apps/rlark-ui/tests/deploy-yaml.test.mjs
new file mode 100644
index 0000000..763ff38
--- /dev/null
+++ b/apps/rlark-ui/tests/deploy-yaml.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildDeployYaml } from "../dist/test/utils/deployYaml.js";
+
+const certificate = {
+ cluster_id: "cluster-a",
+ server_addr: "https://signed.example.com:8443",
+ ca_cert: "CA-LINE-1\nCA-LINE-2",
+ agent_cert: "CERT",
+ agent_key: "KEY",
+};
+
+test("deploy YAML uses system deployment defaults", () => {
+ const yaml = buildDeployYaml(certificate, {
+ controlPlaneAddress: "https://configured.example.com:8443",
+ sshAddress: "client@configured.example.com:2222",
+ kubernetes: {
+ kubeconfig: "/etc/kubernetes/admin.conf",
+ agentImage: "registry.example.com/rlark-agent:v1",
+ image: "registry.example.com/rlark:v1",
+ imagePullPolicy: "IfNotPresent",
+ imagePullSecrets: ["registry-secret"],
+ containerdSocket: "/run/k3s/containerd/containerd.sock",
+ },
+ });
+
+ assert.match(
+ yaml,
+ /control-plane-address: https:\/\/configured\.example\.com:8443/,
+ );
+ assert.match(yaml, /kubeconfig: \/etc\/kubernetes\/admin\.conf/);
+ assert.match(yaml, /agent-image: registry\.example\.com\/rlark-agent:v1/);
+ assert.match(yaml, /image: registry\.example\.com\/rlark:v1/);
+ assert.match(yaml, /image-pull-policy: IfNotPresent/);
+ assert.match(yaml, /image-pull-secrets: \[registry-secret\]/);
+ assert.match(
+ yaml,
+ /containerd-socket: \/run\/k3s\/containerd\/containerd\.sock/,
+ );
+ assert.match(yaml, / CA-LINE-1\n CA-LINE-2/);
+});
+
+test("deploy YAML keeps existing defaults when deployment config is absent", () => {
+ const yaml = buildDeployYaml(certificate);
+ assert.match(
+ yaml,
+ /control-plane-address: https:\/\/signed\.example\.com:8443/,
+ );
+ assert.match(yaml, /kubeconfig: ~\/\.kube\/config/);
+ assert.match(yaml, /agent-image: rlark:latest/);
+ assert.match(yaml, /image: rlark:latest/);
+ assert.match(yaml, /image-pull-policy: Always/);
+ assert.match(yaml, /containerd-socket: \/run\/containerd\/containerd\.sock/);
+});
diff --git a/apps/rlark-ui/tests/domain-display.test.mjs b/apps/rlark-ui/tests/domain-display.test.mjs
new file mode 100644
index 0000000..c686992
--- /dev/null
+++ b/apps/rlark-ui/tests/domain-display.test.mjs
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { readStyles } from "./read-styles.mjs";
+
+const domainsSource = await readFile(
+ new URL("../src/pages/Domains.tsx", import.meta.url),
+ "utf8",
+);
+const createJobSource = await readFile(
+ new URL("../src/pages/CreateJob.tsx", import.meta.url),
+ "utf8",
+);
+const jobsSource = await readFile(
+ new URL("../src/pages/Jobs.tsx", import.meta.url),
+ "utf8",
+);
+const styles = await readStyles();
+
+test("long domain names are truncated with their full value available on hover", () => {
+ assert.match(domainsSource, /domains-table-panel/);
+ assert.match(domainsSource, //);
+ assert.match(domainsSource, /className="domain-detail-title"/);
+ assert.match(
+ createJobSource,
+ /className="field-hint network-domain-summary"/,
+ );
+ assert.match(createJobSource, /title=\{automaticDomain \|\| undefined\}/);
+ assert.match(
+ jobsSource,
+ /className: job\.domain \? "public-config-truncated-value"/,
+ );
+ assert.match(
+ styles,
+ /\.domains-table-panel table[\s\S]*?table-layout: fixed/,
+ );
+ assert.match(styles, /\.domain-detail-title[\s\S]*?text-overflow: ellipsis/);
+});
+
+test("job details render every injected SSH key with its owner", () => {
+ assert.match(jobsSource, /resolveSSHKeyOwners\(job\.sshPublicKey, sshKeys\)/);
+ assert.match(jobsSource, /resolvedSSHKeys\.map/);
+ assert.match(
+ jobsSource,
+ /owners\.map\(\(\{ user \}\) => user\)\.join\(", "\)/,
+ );
+ assert.match(styles, /\.job-ssh-key-list/);
+});
diff --git a/apps/rlark-ui/tests/image-registries.test.mjs b/apps/rlark-ui/tests/image-registries.test.mjs
new file mode 100644
index 0000000..14fa78e
--- /dev/null
+++ b/apps/rlark-ui/tests/image-registries.test.mjs
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const page = readFileSync("src/pages/ImageRegistries.tsx", "utf8");
+const admin = readFileSync("src/admin/AdminApp.tsx", "utf8");
+const mock = readFileSync("src/mockBackend.ts", "utf8");
+const backend = readFileSync("src/backend.ts", "utf8");
+
+test("image registries use immutable IDs for routes and mutations", () => {
+ assert.match(page, /key=\{item\.id\}/);
+ assert.match(page, /onSelect\?\.\(item\.id\)/);
+ assert.match(page, /imageRegistriesApi\.remove\(item\.id\)/);
+ assert.match(backend, /image-registries\/\$\{encodeURIComponent\(id\)\}/);
+ assert.match(admin, /selectedID=\{adminSub \|\| undefined\}/);
+});
+
+test("image registry forms submit explicit distribution scope", () => {
+ assert.match(page, /"None" \| "Selected" \| "All"/);
+ assert.match(page, /clusterSelection: \{ mode: "All", clusters: \[\] \}/);
+ assert.match(page, /clusterSelection: form\.clusterSelection/);
+ assert.match(page, /form\.password \? \{ password: form\.password \} : \{\}/);
+});
+
+test("mock backend implements ID-based image registry CRUD", () => {
+ assert.match(mock, /path === "\/api\/v1\/image-registries"/);
+ assert.match(mock, /item\.id === id/);
+ assert.match(mock, /return json\(\{ ok: true \}, 202\)/);
+});
diff --git a/apps/rlark-ui/tests/job-actions.test.mjs b/apps/rlark-ui/tests/job-actions.test.mjs
index f598bf2..a8bcf4e 100644
--- a/apps/rlark-ui/tests/job-actions.test.mjs
+++ b/apps/rlark-ui/tests/job-actions.test.mjs
@@ -1,19 +1,25 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
+import { readStyles } from "./read-styles.mjs";
const jobsSource = await readFile(
new URL("../src/pages/Jobs.tsx", import.meta.url),
"utf8",
);
-const jobsStyles = await readFile(
- new URL("../src/styles.css", import.meta.url),
+const jobsStyles = await readStyles();
+const mockBackendSource = await readFile(
+ new URL("../src/mockBackend.ts", import.meta.url),
"utf8",
);
const createJobSource = await readFile(
new URL("../src/pages/CreateJob.tsx", import.meta.url),
"utf8",
);
+const backendSource = await readFile(
+ new URL("../src/backend.ts", import.meta.url),
+ "utf8",
+);
const sharedSource = await readFile(
new URL("../src/components/shared.tsx", import.meta.url),
"utf8",
@@ -22,6 +28,14 @@ const clustersSource = await readFile(
new URL("../src/pages/Clusters.tsx", import.meta.url),
"utf8",
);
+const nodesSource = await readFile(
+ new URL("../src/utils/nodes.ts", import.meta.url),
+ "utf8",
+);
+const nodeResourceBrowserSource = await readFile(
+ new URL("../src/components/NodeResourceBrowser.tsx", import.meta.url),
+ "utf8",
+);
const appSource = await readFile(
new URL("../src/App.tsx", import.meta.url),
"utf8",
@@ -78,7 +92,7 @@ test("failed jobs clean residual workers before starting", () => {
assert.match(jobsSource, /job\.phase === "Failed"/);
assert.match(
jobsSource,
- /body: JSON\.stringify\(\{ spec: \{ stopped: true \} \}\)/,
+ /jobsApi\.setStopped\(job\.name, true\)/,
);
assert.match(jobsSource, /await waitForFailedJobCleanup\(job\)/);
assert.match(
@@ -87,11 +101,11 @@ test("failed jobs clean residual workers before starting", () => {
);
assert.match(
jobsSource,
- /body: JSON\.stringify\(\{ spec: \{ stopped: false \} \}\)/,
+ /jobsApi\.setStopped\(job\.name, false\)/,
);
assert.match(
jobsSource,
- /isStartable \? : /,
+ /isStartable && !isFailed \? : /,
);
});
@@ -111,11 +125,192 @@ test("job lifecycle actions use the shared in-app confirmation dialog", () => {
assert.match(jobsSource, /清理后启动任务?/);
});
-test("worker event tooltip stays compact and shows only recent events", () => {
+test("worker node shows the shared info tooltip in the warning color", () => {
+ assert.match(jobsSource, /isDiskUsageWarning\(n\)/);
+ assert.match(
+ jobsSource,
+ /\.\.\.observedNodes.*nodesApi\.get\(nodeName, \{ namespace \}\)/s,
+ );
+ assert.match(jobsSource, /setDetailNodeDiskWarningMap\(diskWarningMap\)/);
+ assert.match(
+ jobsSource,
+ /detailNodeDiskWarningMap\[worker\.node\].*nodeDiskWarningMap\[worker\.node\]/s,
+ );
+ assert.match(jobsSource, /variant="danger"/);
+ assert.match(jobsSource, /className=\{`status-info\$\{variant/);
+ assert.match(jobsSource, /onMouseEnter=\{show\}/);
+ assert.match(jobsSource, /onFocus=\{show\}/);
+ assert.match(jobsSource, /磁盘即将用满,请及时清理空间/);
+ assert.match(jobsSource, /statusTitle=\{\s*zh \? "健康与容量告警"/);
+ assert.match(
+ jobsStyles,
+ /\.status-info\.status-info-danger \{\s*color: var\(--danger\);\s*\}/,
+ );
+ assert.doesNotMatch(
+ jobsStyles,
+ /\.status-info\.status-info-danger \{[^}]*border-color:/s,
+ );
+ assert.doesNotMatch(
+ jobsStyles,
+ /\.status-info\.status-info-danger \{[^}]*background:/s,
+ );
+ assert.doesNotMatch(jobsStyles, /\.worker-disk-warning-popover/);
+ assert.doesNotMatch(jobsStyles, /\.worker-node-with-warning\.is-warning/);
+ assert.match(jobsStyles, /--danger: #e05270/);
+});
+
+test("mock topology exposes a real disk warning on gpu-cloud-01", () => {
+ assert.match(nodesSource, /node\.id === "gpu-cloud-01" \? 90 : 40/);
+ assert.match(nodesSource, /storage: \{/);
+ assert.match(nodesSource, /usedBytes: diskUsedBytes/);
+});
+
+test("node detail shows real disk usage with the requested card layout", () => {
+ assert.match(clustersSource, /label: zh \? "磁盘" : "Storage"/);
+ assert.match(clustersSource, /getNodeDiskUsage\(node\)/);
+ assert.match(clustersSource, /node\.status\?\.diskPressure === true/);
+ assert.match(clustersSource, /磁盘使用率已达到/);
+ assert.match(clustersSource, / /);
+ assert.match(clustersSource, /className="node-capacity-alert"/);
+ assert.match(clustersSource, /className="node-capacity-alert-tooltip"/);
+ assert.match(clustersSource, /健康与容量告警/);
+ assert.match(
+ jobsStyles,
+ /\.node-capacity-alert:hover \.node-capacity-alert-tooltip/,
+ );
+ assert.match(clustersSource, /used\[key\]\?\.endsWith\("%"\)/);
+ assert.match(clustersSource, /total - \(requested \?\? 0\)/);
+ assert.match(clustersSource, /className="node-capacity-progress"/);
+ assert.match(clustersSource, /\{zh \? "剩余量" : "Available"\}<\/em>/);
+ assert.match(
+ jobsStyles,
+ /grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/,
+ );
+ assert.match(jobsStyles, /\.node-capacity-card\.is-warning \{/);
+ assert.match(
+ jobsStyles,
+ /\.node-capacity-card\.is-warning \.node-capacity-track i \{\s*background: linear-gradient\([\s\S]*var\(--danger-strong\),[\s\S]*var\(--danger-soft\)/,
+ );
+ assert.match(
+ jobsStyles,
+ /\.node-capacity-card\.is-warning \.node-capacity-progress b \{/,
+ );
+ assert.match(
+ jobsStyles,
+ /\.node-capacity-alert-tooltip \{[\s\S]*right: calc\(100% \+ 10px\);[\s\S]*bottom: calc\(100% \+ 10px\)/,
+ );
+ assert.match(jobsStyles, /background: var\(--panel\) !important/);
+ assert.doesNotMatch(
+ jobsStyles,
+ /\.node-capacity-card\.is-warning \{[^}]*background:/s,
+ );
+ assert.doesNotMatch(
+ jobsStyles,
+ /\.node-capacity-card\.is-warning \.node-capacity-title > span/,
+ );
+});
+
+test("worker pending tooltip shows readable reasons without raw Kubernetes messages", () => {
+ assert.match(jobsSource, /FailedScheduling: \{ zh: "调度失败"/);
+ assert.match(jobsSource, /NodeNotReady: \{ zh: "节点不可用"/);
+ assert.match(jobsSource, /ErrImagePull: \{ zh: "镜像拉取失败"/);
+ assert.match(jobsSource, /FailedMount: \{ zh: "存储挂载失败"/);
+ assert.match(jobsSource, /FailedBinding: \{ zh: "存储卷绑定失败"/);
+ assert.match(jobsSource, /CrashLoopBackOff: \{/);
+ assert.match(jobsSource, /PIDPressure: \{ zh: "节点进程资源不足"/);
+ assert.match(jobsSource, /Evicted: \{ zh: "Worker 已被节点驱逐"/);
+ assert.match(jobsSource, /en: "Volume binding failed"/);
+ assert.match(jobsSource, /en: "Container repeatedly failed to start"/);
+ assert.match(jobsSource, /en: "Node PID pressure"/);
+ assert.match(jobsSource, /workerEventReasonLabel\(ev\.reason, zh, failed\)/);
+ assert.doesNotMatch(jobsSource, /className="event-message">\{ev\.message\}/);
+ assert.match(jobsStyles, /width: min\(360px, calc\(100vw - 24px\)\)/);
+});
+
+test("worker pending tooltip groups duplicate reasons and shows the latest four", () => {
assert.match(jobsSource, /\.sort\(\(left, right\) =>/);
+ assert.match(jobsSource, /new Map\(/);
+ assert.match(
+ jobsSource,
+ /workerEventReasonLabel\(event\.reason, zh, failed\)/,
+ );
assert.match(jobsSource, /\.slice\(0, 4\)/);
assert.match(jobsSource, /recentEvents\.map/);
- assert.match(jobsStyles, /-webkit-line-clamp: 2/);
+});
+
+test("worker event colors follow the worker phase", () => {
+ assert.match(
+ jobsSource,
+ /ev\.type === "Normal"[\s\S]*?"event-normal"[\s\S]*?failed[\s\S]*?"event-failed"[\s\S]*?"event-pending"/,
+ );
+ assert.match(jobsStyles, /\.event-chip\.event-pending[\s\S]*?#fff4df/);
+ assert.match(jobsStyles, /\.event-chip\.event-failed[\s\S]*?#fff0f1/);
+});
+
+test("worker failed tooltip reuses readable Kubernetes event reasons", () => {
+ assert.match(
+ jobsSource,
+ /phase === "Pending" \|\| phase === "Failed".*podEventsMap\[pod\.name\]/s,
+ );
+ assert.match(jobsSource, /failed=\{worker\.phase === "Failed"\}/);
+ assert.match(jobsSource, /failed[\s\S]*?"失败原因"[\s\S]*?"Failure Reasons"/);
+ assert.match(jobsSource, /workerEventReasonLabel\(ev\.reason, zh, failed\)/);
+ assert.match(jobsSource, /if \(failed\) return zh \? "Worker 运行失败"/);
+ assert.match(
+ jobsSource,
+ /statusMessage: workerStatusSummary\([\s\S]*?phase === "Failed"/,
+ );
+ assert.match(
+ jobsSource,
+ /return failed \? jobFailureMessage\(message, zh\) : message/,
+ );
+});
+
+test("job list and detail translate raw Kubernetes failure messages", () => {
+ assert.match(jobsSource, /function jobFailureMessage/);
+ assert.match(
+ jobsSource,
+ /back-off .*restarting failed container.*workerEventReasonLabel\("CrashLoopBackOff", zh, true\)/s,
+ );
+ assert.match(jobsSource, /workerEventReasonLabel\("OOMKilled", zh, true\)/);
+ assert.match(
+ jobsSource,
+ /workerEventReasonLabel\("ErrImagePull", zh, true\)/,
+ );
+ assert.match(
+ jobsSource,
+ /\.map\(\(ts\) => jobFailureMessage\(ts\.message, zh\)\)/,
+ );
+ assert.match(
+ jobsSource,
+ /worker\.phase === "Failed"[\s\S]*?jobFailureMessage\(worker\.statusMessage, zh\)/,
+ );
+});
+
+test("mock pending workers expose representative reasons through pod events", () => {
+ for (const reason of [
+ "FailedScheduling",
+ "ImagePullBackOff",
+ "FailedMount",
+ "NodeNotReady",
+ "FailedCreatePodSandBox",
+ ]) {
+ assert.match(mockBackendSource, new RegExp(`reason: "${reason}"`));
+ }
+ assert.match(
+ mockBackendSource,
+ /path\.startsWith\("\/api\/v1\/rlinf\.io\/v1alpha1\/pods\/"\)/,
+ );
+ assert.match(mockBackendSource, /path\.endsWith\("\/events"\)/);
+ assert.match(
+ mockBackendSource,
+ /return json\(\{ events: pendingWorkerEventMap\[podName\] \?\? \[\] \}\)/,
+ );
+ assert.match(
+ jobsSource,
+ /podsApi\.events<\{ events\?: NodeEventEntry\[\] \}>\([\s\S]*?podName/,
+ );
+ assert.match(jobsSource, /events=\{worker\.events \?\? \[\]\}/);
});
test("job IDs remain readable and can be copied from list and detail", () => {
@@ -133,23 +328,27 @@ test("job IDs remain readable and can be copied from list and detail", () => {
test("switching worker roles scrolls back to the configuration header", () => {
assert.match(createJobSource, /const roleConfigTopRef = useRef/);
assert.match(createJobSource, /modalBody\.scrollTop = 0/);
- assert.match(createJobSource, /selectRole\(roles\[idx \+ 1\], true\)/);
+ assert.match(
+ createJobSource,
+ /selectRole\(roles\[idx \+ 1\]\?\.id \?\? "", true\)/,
+ );
});
-test("job deletion waits for worker cleanup before deleting", () => {
- assert.match(jobsSource, /await waitForJobWorkersStopped\(job\)/);
- assert.match(jobsSource, /task\.status\?\.phase === "Stopped"/);
- assert.match(
- jobsSource,
- /await waitForJobWorkersStopped\(job\);[\s\S]*?method: "DELETE"/,
+test("job deletion is delegated to the controller and remains visible", () => {
+ const deleteHandler = jobsSource.slice(
+ jobsSource.indexOf("const handleDelete"),
+ jobsSource.indexOf("const handleSetStopped"),
);
+ assert.doesNotMatch(deleteHandler, /waitForJobWorkersStopped/);
+ assert.match(deleteHandler, /jobsApi\.remove\(job\.name\)/);
+ assert.match(deleteHandler, /phase: "Deleting" as Phase/);
});
test("job actions report success and keep the selected job detail", () => {
assert.match(jobsSource, /className="job-action-notice" role="status"/);
assert.match(
jobsSource,
- /setActionNotice\(zh \? "任务已删除" : "Job deleted"\)/,
+ /setActionNotice\(zh \? "任务正在删除" : "Job deletion started"\)/,
);
assert.match(jobsSource, /if \(selectedName\) onSelect\(job\.name\)/);
assert.match(jobsSource, /if \(succeeded\) onSelect\(job\.name\)/);
@@ -161,7 +360,9 @@ test("job submission reports success and opens the saved job detail", () => {
createJobSource,
/onSuccess: \(message: string, jobName: string\) => void/,
);
- assert.match(createJobSource, /const savedJob = await resp\.json\(\)/);
+ assert.match(createJobSource, /const savedJob = isEdit/);
+ assert.match(createJobSource, /jobsApi\.replace/);
+ assert.match(createJobSource, /jobsApi\.create/);
assert.match(createJobSource, /savedJob\.metadata\?\.name/);
assert.match(createJobSource, /\? "任务提交成功"/);
assert.match(appSource, /setJobSubmitNotice\(message\)/);
@@ -188,21 +389,67 @@ test("job and worker refresh actions show progress", () => {
);
});
-test("stopping a job immediately uses the latest server status", () => {
- const waitForStoppedSource = jobsSource.slice(
- jobsSource.indexOf("const waitForJobWorkersStopped"),
- jobsSource.indexOf("const handleSetStopped"),
+test("job, worker, and node refreshes mask only their data regions", () => {
+ assert.match(jobsSource, /jobs-table-panel refreshable-region/);
+ assert.match(jobsSource, /visible=\{listRefreshing\}/);
+ assert.match(jobsSource, /worker-table-scroll refreshable-region/);
+ assert.match(jobsSource, /visible=\{workerRefreshing\}/);
+ assert.match(
+ nodeResourceBrowserSource,
+ /node-resource-table-panel refreshable-region/,
+ );
+ assert.match(nodeResourceBrowserSource, /visible=\{!!refreshing\}/);
+ assert.match(sharedSource, /export function RefreshOverlay/);
+ assert.match(sharedSource, /className="refreshable-region-overlay"/);
+ assert.match(sharedSource, /role="status"/);
+ assert.match(
+ jobsStyles,
+ /\.refreshable-region\.is-refreshing > :not\(\.refreshable-region-overlay\)/,
+ );
+ assert.match(jobsStyles, /pointer-events: none/);
+ assert.match(jobsStyles, /\.refreshable-region-spinner/);
+});
+
+test("stopping a job returns after setting the stop marker", () => {
+ assert.doesNotMatch(jobsSource, /waitForJobWorkersStopped/);
+ assert.match(
+ backendSource,
+ /setStopped\(name: string, stopped: boolean\)[\s\S]*?body: \{ spec: \{ stopped \} \}/,
+ );
+ assert.match(jobsSource, /\.\.\.j,[\s\S]*?stopped,/);
+ assert.match(
+ jobsSource,
+ /setActionNotice\([\s\S]*?"任务已提交停止"[\s\S]*?"Job stop submitted"/,
+ );
+});
+
+test("stopping jobs disable conflicting lifecycle actions", () => {
+ assert.match(
+ jobsSource,
+ /const isStopping = job\.stopped && job\.phase !== "Stopped"/,
+ );
+ assert.match(
+ jobsSource,
+ /disabled=\{pending \|\| isDeleting \|\| isStopping\}/,
+ );
+ assert.match(
+ jobsSource,
+ /disabled=\{\s*lifecycleActions\.pending !== null \|\| isDeleting \|\| isStopping\s*\}/,
+ );
+});
+
+test("deleting jobs disable metadata editing", () => {
+ assert.match(
+ jobsSource,
+ /const isUpdating = lifecycleActions\.pending !== null \|\| isDeleting/,
);
- assert.match(waitForStoppedSource, /return current;/);
- assert.doesNotMatch(waitForStoppedSource, /return;\s*\n\s*}/);
assert.match(
jobsSource,
- /const stoppedJob = stopped \? await waitForJobWorkersStopped\(job\) : null/,
+ /const isUpdating =\s*lifecycleActions\.pending !== null \|\| job\.phase === "Deleting"/,
);
- assert.match(jobsSource, /stoppedJob \?\?/);
});
-test("job storage mappings are derived from the generated resource ID", () => {
+test("job creation does not compute legacy PVC storage mappings", () => {
assert.doesNotMatch(createJobSource, /computePvcStorageMap/);
assert.match(createJobSource, /name: jobResourceName/);
});
diff --git a/apps/rlark-ui/tests/job-name.test.mjs b/apps/rlark-ui/tests/job-name.test.mjs
new file mode 100644
index 0000000..319443f
--- /dev/null
+++ b/apps/rlark-ui/tests/job-name.test.mjs
@@ -0,0 +1,64 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isValidJobDisplayName,
+ isValidRoleName,
+ JOB_DISPLAY_NAME_MAX_LENGTH,
+ ROLE_NAME_MAX_LENGTH,
+} from "../dist/test/utils/job.js";
+
+test("accepts valid job display names", () => {
+ for (const value of [
+ "任务一",
+ "job-1",
+ "train_llm_v2",
+ "model.eval.01",
+ "RL-训练_任务.001",
+ "a".repeat(JOB_DISPLAY_NAME_MAX_LENGTH),
+ ]) {
+ assert.equal(isValidJobDisplayName(value), true, value);
+ }
+});
+
+test("rejects empty or overlong job display names", () => {
+ assert.equal(isValidJobDisplayName(""), false);
+ assert.equal(
+ isValidJobDisplayName("a".repeat(JOB_DISPLAY_NAME_MAX_LENGTH + 1)),
+ false,
+ );
+});
+
+test("rejects unsupported characters in job display names", () => {
+ for (const value of [
+ "任务 名称",
+ "job:name",
+ "a/b",
+ "邮箱@test",
+ "emoji🚀",
+ "逗号,名称",
+ ]) {
+ assert.equal(isValidJobDisplayName(value), false, value);
+ }
+});
+
+test("accepts valid role names with the same rules", () => {
+ assert.equal(ROLE_NAME_MAX_LENGTH, 64);
+ for (const value of [
+ "Actor",
+ "rollout-1",
+ "env_worker",
+ "模型.评估",
+ "a".repeat(ROLE_NAME_MAX_LENGTH),
+ ]) {
+ assert.equal(isValidRoleName(value), true, value);
+ }
+});
+
+test("rejects invalid role names", () => {
+ assert.equal(isValidRoleName(""), false);
+ assert.equal(isValidRoleName("a".repeat(ROLE_NAME_MAX_LENGTH + 1)), false);
+ for (const value of ["Actor 2", "role:name", "a/b", "邮箱@test"]) {
+ assert.equal(isValidRoleName(value), false, value);
+ }
+});
diff --git a/apps/rlark-ui/tests/job-phase.test.mjs b/apps/rlark-ui/tests/job-phase.test.mjs
index c909030..1a2c18c 100644
--- a/apps/rlark-ui/tests/job-phase.test.mjs
+++ b/apps/rlark-ui/tests/job-phase.test.mjs
@@ -42,6 +42,10 @@ test("distinguishes stopping from normal Pending states", () => {
assert.equal(effectiveJobPhase(job("Pending", false, [])), "Pending");
});
+test("deleting takes precedence over stopping", () => {
+ assert.equal(effectiveJobPhase(job("Deleting", true, ["Stopped"])), "Deleting");
+});
+
test("does not infer terminal states from task phases", () => {
assert.equal(
effectiveJobPhase(job("Pending", false, ["Pending", "Failed"])),
diff --git a/apps/rlark-ui/tests/job-ssh-keys.test.mjs b/apps/rlark-ui/tests/job-ssh-keys.test.mjs
new file mode 100644
index 0000000..a421486
--- /dev/null
+++ b/apps/rlark-ui/tests/job-ssh-keys.test.mjs
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ findSSHKeyDuplicate,
+ groupSSHUserKeys,
+ resolveSSHKeyOwners,
+ splitSSHPublicKeys,
+} from "../dist/test/utils/sshKeys.js";
+import { generateJobCRD } from "../dist/test/utils/job.js";
+
+const aliceKey = "ssh-ed25519 AAAA-alice";
+const bobKey = "ssh-rsa AAAA-bob";
+
+test("splits all injected SSH public keys from a Job", () => {
+ assert.deepEqual(splitSSHPublicKeys(` ${aliceKey}\r\n\n${bobKey} `), [
+ aliceKey,
+ bobKey,
+ ]);
+});
+
+test("rejects duplicate SSH key names and contents", () => {
+ const knownKeys = [
+ { index: 0, user: "alice", public_key: aliceKey, added_at: "" },
+ ];
+
+ assert.equal(findSSHKeyDuplicate(" alice ", bobKey, knownKeys), "name");
+ assert.equal(
+ findSSHKeyDuplicate("bob", ` ${aliceKey} `, knownKeys),
+ "publicKey",
+ );
+ assert.equal(findSSHKeyDuplicate("bob", bobKey, knownKeys), null);
+});
+
+test("groups duplicate public key records into one selectable option", () => {
+ assert.deepEqual(
+ groupSSHUserKeys([
+ { index: 0, user: "alice", public_key: aliceKey, added_at: "" },
+ { index: 0, user: "bob", public_key: bobKey, added_at: "" },
+ { index: 1, user: "carol", public_key: aliceKey, added_at: "" },
+ { index: 2, user: "empty", public_key: " ", added_at: "" },
+ ]),
+ [
+ {
+ publicKey: aliceKey,
+ owners: [
+ { user: "alice", index: 0 },
+ { user: "carol", index: 1 },
+ ],
+ },
+ { publicKey: bobKey, owners: [{ user: "bob", index: 0 }] },
+ ],
+ );
+});
+
+test("resolves every owner for each injected key", () => {
+ assert.deepEqual(
+ resolveSSHKeyOwners(`${aliceKey}\n${bobKey}`, [
+ { index: 1, user: "alice", public_key: aliceKey, added_at: "" },
+ { index: 0, user: "bob", public_key: bobKey, added_at: "" },
+ ]),
+ [
+ { publicKey: aliceKey, owners: [{ user: "alice", index: 1 }] },
+ { publicKey: bobKey, owners: [{ user: "bob", index: 0 }] },
+ ],
+ );
+});
+
+test("keeps injected keys visible after their registered owner is removed", () => {
+ assert.deepEqual(resolveSSHKeyOwners(aliceKey, []), [
+ { publicKey: aliceKey, owners: [] },
+ ]);
+});
+
+test("preserves every selected SSH public key in the submitted Job", () => {
+ const sshPublicKey = `${aliceKey}\n${bobKey}`;
+ const roleResources = {
+ actor: {
+ role: "actor",
+ cluster: "cluster-a",
+ nodeSelector: "",
+ replicas: 1,
+ cpu: "1",
+ memory: "1Gi",
+ gpu: "0",
+ devices: [],
+ image: "busybox:latest",
+ prepareScript: "",
+ envs: [],
+ mounts: [],
+ },
+ };
+
+ const job = generateJobCRD({
+ name: "jo-ssh-keys",
+ type: "Custom",
+ headerRole: "actor",
+ roles: ["actor"],
+ roleResources,
+ runScript: "echo ready",
+ domain: "",
+ sshPublicKey,
+ });
+
+ assert.equal(job.spec.sshPublicKey, sshPublicKey);
+});
diff --git a/apps/rlark-ui/tests/job-storage.test.mjs b/apps/rlark-ui/tests/job-storage.test.mjs
index 56e1467..b590701 100644
--- a/apps/rlark-ui/tests/job-storage.test.mjs
+++ b/apps/rlark-ui/tests/job-storage.test.mjs
@@ -4,6 +4,7 @@ import {
generateJobCRD,
generateJobResourceName,
} from "../dist/test/utils/job.js";
+import { crdToJob } from "../dist/test/utils/crd.js";
function storageResource(mounts) {
return {
@@ -49,12 +50,12 @@ test("caps generated PVC storage size at 200 Gi", () => {
});
const workload = crd.spec.tasks[0].kubernetes.workload;
- const claimName =
- workload.template.spec.volumes[0].persistentVolumeClaim.claimName;
- assert.equal(workload.pvcSizeGbMap[claimName], 200);
+ const claimSpec =
+ workload.template.spec.volumes[0].ephemeral.volumeClaimTemplate.spec;
+ assert.equal(claimSpec.resources.requests.storage, "200Gi");
});
-test("maps a selected storage class to the generated PVC claim name", () => {
+test("generates an ephemeral volume claim for selected storage", () => {
const resourceName = "jo-0123456789abcdef";
const mounts = [
{
@@ -77,10 +78,49 @@ test("maps a selected storage class to the generated PVC claim name", () => {
});
const workload = crd.spec.tasks[0].kubernetes.workload;
- const claimName =
- workload.template.spec.volumes[0].persistentVolumeClaim.claimName;
+ const volume = workload.template.spec.volumes[0];
+ const claimSpec = volume.ephemeral.volumeClaimTemplate.spec;
- assert.equal(claimName, "pvc-jo-0123456789abcdef-actor-data");
- assert.equal(workload.pvcStorageMap[claimName], "fast-storage");
- assert.equal(workload.pvcSizeGbMap[claimName], 20);
+ assert.equal(volume.name, "data");
+ assert.equal(volume.persistentVolumeClaim, undefined);
+ assert.deepEqual(claimSpec.accessModes, ["ReadWriteOnce"]);
+ assert.equal(claimSpec.storageClassName, "fast-storage");
+ assert.equal(claimSpec.resources.requests.storage, "20Gi");
+ assert.equal(workload.pvcStorageMap, undefined);
+ assert.equal(workload.pvcSizeGbMap, undefined);
+ assert.equal(
+ workload.template.spec.containers[0].volumeMounts[0].name,
+ volume.name,
+ );
+});
+
+test("reads ephemeral volume storage settings from a job CRD", () => {
+ const crd = generateJobCRD({
+ name: "jo-0123456789abcdef",
+ type: "Custom",
+ headerRole: "actor",
+ roles: ["actor"],
+ roleResources: {
+ actor: storageResource([
+ {
+ type: "storage",
+ objectStorage: "fast-storage",
+ mountPath: "/data",
+ hostPath: "",
+ pvcSizeGb: 20,
+ },
+ ]),
+ },
+ runScript: "echo ready",
+ domain: "",
+ });
+
+ const job = crdToJob(crd);
+ assert.deepEqual(job.mounts[0], {
+ type: "storage",
+ objectStorage: "fast-storage",
+ mountPath: "/data",
+ hostPath: "",
+ pvcSizeGb: 20,
+ });
});
diff --git a/apps/rlark-ui/tests/node-batch-metadata.test.mjs b/apps/rlark-ui/tests/node-batch-metadata.test.mjs
index c478191..4f6ae40 100644
--- a/apps/rlark-ui/tests/node-batch-metadata.test.mjs
+++ b/apps/rlark-ui/tests/node-batch-metadata.test.mjs
@@ -60,6 +60,18 @@ test("removes deselected categories from a multi-category node", () => {
assert.equal(result.removedKeys.has("rlark.io/node-category-robot"), true);
});
+test("selected category must NOT appear in removedKeys (regression: patch would null out the new value)", () => {
+ const result = updateNodeCategoryLabels({}, ["cloud"]);
+ assert.equal(result.labels["rlark.io/node-category-cloud"], "true");
+ // 关键断言:新写入的 key 不能同时出现在 removedKeys 里,
+ // 否则调用方在 merge-patch 里会把它置 null 把刚写入的 "true" 覆盖掉。
+ assert.equal(result.removedKeys.has("rlark.io/node-category-cloud"), false);
+ // 未选中的仍应被标记删除
+ assert.equal(result.removedKeys.has("rlark.io/node-category-edge"), true);
+ assert.equal(result.removedKeys.has("rlark.io/node-category-robot"), true);
+ assert.equal(result.removedKeys.has("rlark.io/node-category"), true);
+});
+
test("detects labels removed in the detail editor", () => {
assert.deepEqual(
getRemovedLabelKeys({ zone: "a", team: "robot" }, { zone: "a" }),
diff --git a/apps/rlark-ui/tests/node-resources.test.mjs b/apps/rlark-ui/tests/node-resources.test.mjs
index d84298c..986756c 100644
--- a/apps/rlark-ui/tests/node-resources.test.mjs
+++ b/apps/rlark-ui/tests/node-resources.test.mjs
@@ -1,10 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
+ formatResourceQuantity,
+ getNodeDiskUsage,
getNodeResourceSummary,
+ getResourceUsagePercent,
+ isDiskUsageWarning,
selectDeviceResourceKey,
} from "../dist/test/utils/nodeResources.js";
+test("formats Kubernetes binary quantities without inflating them", () => {
+ assert.equal(
+ formatResourceQuantity("ephemeral-storage", "3748906852Ki"),
+ "3575 GiB",
+ );
+ assert.equal(formatResourceQuantity("memory", "1Gi"), "1.0 GiB");
+ assert.equal(formatResourceQuantity("memory", "1000M"), "0.9 GiB");
+});
+
+test("calculates disk usage against allocatable capacity", () => {
+ assert.equal(
+ getResourceUsagePercent("ephemeral-storage", "90Gi", "100Gi"),
+ 90,
+ );
+ assert.equal(
+ getResourceUsagePercent("ephemeral-storage", "89%", "100Gi"),
+ 89,
+ );
+});
+
+test("warns when real disk usage reaches ninety percent", () => {
+ const node = {
+ metadata: { name: "disk-warning" },
+ spec: {},
+ status: {
+ storage: {
+ capacityBytes: 1000,
+ usedBytes: 900,
+ availableBytes: 100,
+ },
+ },
+ };
+ assert.equal(isDiskUsageWarning(node), true);
+ node.status.storage.usedBytes = 899;
+ node.status.storage.availableBytes = 100;
+ assert.equal(isDiskUsageWarning(node), true);
+ node.status.storage.usedBytes = 894;
+ node.status.storage.availableBytes = 106;
+ assert.equal(isDiskUsageWarning(node), false);
+});
+
+test("keeps disk usage below one hundred percent while space remains", () => {
+ const usage = getNodeDiskUsage({
+ metadata: { name: "nearly-full" },
+ spec: {},
+ status: {
+ storage: {
+ capacityBytes: 3838880616448,
+ usedBytes: 3827780616448,
+ availableBytes: 11100000000,
+ },
+ },
+ });
+ assert.equal(usage?.percent, 99);
+});
+
+test("uses available bytes as the source of truth for disk percentage", () => {
+ const usage = getNodeDiskUsage({
+ metadata: { name: "inconsistent-stats" },
+ spec: {},
+ status: {
+ storage: {
+ capacityBytes: 1000,
+ usedBytes: 1000,
+ availableBytes: 100,
+ },
+ },
+ });
+ assert.equal(usage?.percent, 90);
+});
+
+test("shows one hundred percent only when no disk space remains", () => {
+ const usage = getNodeDiskUsage({
+ metadata: { name: "full" },
+ spec: {},
+ status: {
+ storage: {
+ capacityBytes: 1000,
+ usedBytes: 1000,
+ availableBytes: 0,
+ },
+ },
+ });
+ assert.equal(usage?.percent, 100);
+});
+
+test("warns whenever kubelet reports disk pressure", () => {
+ const node = {
+ metadata: { name: "disk-pressure" },
+ spec: {},
+ status: {
+ diskPressure: true,
+ storage: {
+ capacityBytes: 1000,
+ usedBytes: 100,
+ availableBytes: 900,
+ },
+ },
+ };
+ assert.equal(isDiskUsageWarning(node), true);
+});
+
test("prefers a positive modeled device resource over zero-capacity keys", () => {
const capacity = {
"rlinf.io/device": "0",
diff --git a/apps/rlark-ui/tests/page-layout.test.mjs b/apps/rlark-ui/tests/page-layout.test.mjs
new file mode 100644
index 0000000..4f71af0
--- /dev/null
+++ b/apps/rlark-ui/tests/page-layout.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { readStyles } from "./read-styles.mjs";
+
+const readSource = (path) =>
+ readFile(new URL(`../src/${path}`, import.meta.url), "utf8");
+
+const [styles, overview, addons] = await Promise.all([
+ readStyles(),
+ readSource("pages/Overview.tsx"),
+ readSource("admin/Addons.tsx"),
+]);
+
+test("header-adjacent pages use the shared page shell", () => {
+ assert.match(overview, /page-content resource-page overview-page/);
+ assert.equal(
+ addons.match(/className="page-content resource-page addon-page"/g)?.length,
+ 3,
+ );
+});
+
+test("page-specific classes do not override shared outer spacing", () => {
+ assert.match(
+ styles,
+ /\.page-content \{[\s\S]*?padding: 28px 28px 38px;[\s\S]*?width: 100%;[\s\S]*?overflow-y: auto;[\s\S]*?overflow-x: hidden;/,
+ );
+
+ const adminNodeRule = styles.match(
+ /\.admin-node-management-page \{([\s\S]*?)\}/,
+ )?.[1];
+ assert.ok(adminNodeRule);
+ assert.doesNotMatch(adminNodeRule, /padding|margin/);
+
+ const adminNodeHeadingRule = styles.match(
+ /\.admin-node-page-heading \{([\s\S]*?)\}/,
+ )?.[1];
+ assert.ok(adminNodeHeadingRule);
+ assert.doesNotMatch(
+ adminNodeHeadingRule,
+ /padding|margin|min-height|border|font-size/,
+ );
+
+ for (const pageClass of [
+ "node-detail-page",
+ "overview-page",
+ "cluster-overview-page",
+ "files-page",
+ "cluster-detail-page",
+ "storage-class-page",
+ "storage-files-page",
+ "storage-detail-page",
+ ]) {
+ const rule = styles.match(
+ new RegExp(`\\.${pageClass} \\{([\\s\\S]*?)\\}`),
+ )?.[1];
+ if (!rule) continue;
+ assert.doesNotMatch(
+ rule,
+ /(?:^|\s)(?:padding|margin|width|height|min-height|overflow(?:-x|-y)?):/,
+ );
+ }
+
+ for (const headingClass of ["storage-files-hero", "storage-detail-hero"]) {
+ const rule = styles.match(
+ new RegExp(`\\.${headingClass} \\{([\\s\\S]*?)\\}`),
+ )?.[1];
+ assert.ok(rule);
+ assert.doesNotMatch(
+ rule,
+ /padding|margin|min-height|border|font-size|background|box-shadow/,
+ );
+ }
+});
diff --git a/apps/rlark-ui/tests/read-styles.mjs b/apps/rlark-ui/tests/read-styles.mjs
new file mode 100644
index 0000000..c20dc7c
--- /dev/null
+++ b/apps/rlark-ui/tests/read-styles.mjs
@@ -0,0 +1,11 @@
+import { readFile } from "node:fs/promises";
+
+export async function readStyles() {
+ const entryUrl = new URL("../src/styles.css", import.meta.url);
+ const entry = await readFile(entryUrl, "utf8");
+ const imports = [...entry.matchAll(/@import\s+["'](.+?)["'];/g)];
+
+ return Promise.all(
+ imports.map((match) => readFile(new URL(match[1], entryUrl), "utf8")),
+ ).then((parts) => parts.join(""));
+}
diff --git a/apps/rlark-ui/tests/refresh-feedback.test.mjs b/apps/rlark-ui/tests/refresh-feedback.test.mjs
new file mode 100644
index 0000000..f39e5c4
--- /dev/null
+++ b/apps/rlark-ui/tests/refresh-feedback.test.mjs
@@ -0,0 +1,114 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { readStyles } from "./read-styles.mjs";
+
+const readSource = (path) =>
+ readFile(new URL(`../src/${path}`, import.meta.url), "utf8");
+
+const [
+ domainsSource,
+ workflowsSource,
+ clusterManagementSource,
+ clustersSource,
+ overviewSource,
+ adminDashboardSource,
+ adminPageSource,
+ sshKeysSource,
+ imageRegistriesSource,
+ storageSource,
+ systemConfigSource,
+ stylesSource,
+] = await Promise.all([
+ readSource("pages/Domains.tsx"),
+ readSource("pages/Workflows.tsx"),
+ readSource("pages/ClusterManagement.tsx"),
+ readSource("pages/Clusters.tsx"),
+ readSource("pages/Overview.tsx"),
+ readSource("admin/AdminDashboard.tsx"),
+ readSource("admin/AdminPage.tsx"),
+ readSource("pages/SSHKeys.tsx"),
+ readSource("pages/ImageRegistries.tsx"),
+ readSource("pages/Storage.tsx"),
+ readSource("pages/SystemConfig.tsx"),
+ readStyles(),
+]);
+
+test("resource lists expose controlled refresh state and mask their tables", () => {
+ for (const source of [
+ domainsSource,
+ workflowsSource,
+ clusterManagementSource,
+ ]) {
+ assert.match(
+ source,
+ /const \[refreshing, setRefreshing\] = useState\(false\)/,
+ );
+ assert.match(source, /refreshing=\{refreshing\}/);
+ assert.match(source, /refreshable-region/);
+ assert.match(source, /aria-busy=\{refreshing\}/);
+ assert.match(source, / {
+ for (const source of [
+ clustersSource,
+ overviewSource,
+ adminDashboardSource,
+ adminPageSource,
+ systemConfigSource,
+ ]) {
+ assert.match(source, /page-refresh-region/);
+ assert.match(source, / \.section-heading/,
+ );
+ assert.match(stylesSource, /z-index: 21/);
+ assert.match(
+ stylesSource,
+ /\.refreshable-region\.is-refreshing\.page-refresh-region/,
+ );
+});
+
+test("system configuration separates categories with horizontal navigation", () => {
+ assert.match(
+ systemConfigSource,
+ /className="api-category-bar system-config-category-bar"/,
+ );
+ assert.match(systemConfigSource, /activeCategory === "ssh"/);
+ assert.match(systemConfigSource, /activeCategory === "deployment"/);
+ assert.match(systemConfigSource, /activeCategory === "log"/);
+ assert.match(systemConfigSource, /SSH 跳板配置/);
+ assert.match(systemConfigSource, /部署配置默认值/);
+ assert.match(systemConfigSource, /日志后端配置/);
+ assert.match(systemConfigSource, /system-config-overview/);
+ assert.match(systemConfigSource, /system-config-panel/);
+});
+
+test("credential lists keep existing rows visible during refresh", () => {
+ assert.match(sshKeysSource, /\{filteredKeys\.length === 0 \? \(/);
+ assert.match(imageRegistriesSource, /\{filteredItems\.length === 0 \? \(/);
+
+ for (const source of [sshKeysSource, imageRegistriesSource]) {
+ assert.match(source, /refreshable-region/);
+ assert.match(source, / {
+ assert.match(storageSource, /storage-class-table-panel refreshable-region/);
+ assert.match(storageSource, /storage-files-table-panel refreshable-region/);
+ assert.match(storageSource, /label=\{zh \? "正在刷新存储类列表"/);
+ assert.match(storageSource, /label=\{zh \? "正在刷新目录内容"/);
+
+ const fileTableSource = storageSource.slice(
+ storageSource.indexOf("storage-files-table-panel refreshable-region"),
+ );
+ assert.doesNotMatch(fileTableSource, /\{loading && \(/);
+ assert.match(fileTableSource, /\{filteredPrefixes\.map/);
+ assert.match(fileTableSource, /\{pagedObjects\.map/);
+});
diff --git a/apps/rlark-ui/tests/route.test.mjs b/apps/rlark-ui/tests/route.test.mjs
new file mode 100644
index 0000000..d1c0c27
--- /dev/null
+++ b/apps/rlark-ui/tests/route.test.mjs
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ filesPath,
+ hasTerminalSession,
+ isAdminPath,
+ parseAdminRoute,
+} from "../dist/test/utils/route.js";
+
+test("admin routes preserve shared file browser context", () => {
+ assert.equal(filesPath("cluster a", "shared/data"), "/files/cluster%20a/shared%2Fdata");
+ assert.equal(
+ filesPath("cluster a", "shared/data", true),
+ "/admin/files/cluster%20a/shared%2Fdata",
+ );
+ assert.deepEqual(parseAdminRoute("/admin/files/cluster%20a/shared%2Fdata"), {
+ page: "files",
+ sub: "cluster a/shared/data",
+ });
+});
+
+test("admin path matching observes a route boundary", () => {
+ assert.equal(isAdminPath("/admin"), true);
+ assert.equal(isAdminPath("/admin/jobs"), true);
+ assert.equal(isAdminPath("/administrator"), false);
+});
+
+test("terminal requires an access token", () => {
+ const storage = (values) => ({ getItem: (key) => values[key] ?? null });
+ assert.equal(hasTerminalSession(storage({ "rlark-auth-token": "token" })), true);
+ assert.equal(hasTerminalSession(storage({ "rlark-user-auth": "1" })), false);
+ assert.equal(hasTerminalSession(storage({})), false);
+});
diff --git a/apps/rlark-ui/tests/table-actions.test.mjs b/apps/rlark-ui/tests/table-actions.test.mjs
new file mode 100644
index 0000000..5b2b122
--- /dev/null
+++ b/apps/rlark-ui/tests/table-actions.test.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { readStyles } from "./read-styles.mjs";
+
+const readSource = (path) =>
+ readFile(new URL(`../src/${path}`, import.meta.url), "utf8");
+
+const [styles, domains, workflows, registries, sshKeys, addons] =
+ await Promise.all([
+ readStyles(),
+ readSource("pages/Domains.tsx"),
+ readSource("pages/Workflows.tsx"),
+ readSource("pages/ImageRegistries.tsx"),
+ readSource("pages/SSHKeys.tsx"),
+ readSource("admin/Addons.tsx"),
+ ]);
+
+test("resource table action columns use the shared compact layout", () => {
+ for (const source of [domains, workflows, registries, sshKeys]) {
+ assert.match(source, /className="table-actions-col"/);
+ assert.match(source, /className="row-actions"/);
+ assert.match(
+ source,
+ //,
+ );
+ }
+
+ assert.match(styles, /\.table-panel \.row-actions \.icon-button/);
+ assert.match(styles, /width: 30px;/);
+ assert.match(styles, /border-radius: 10px;/);
+});
+
+test("addon text actions use the shared table action button", () => {
+ assert.match(addons, /className="table-action-button"/);
+ assert.match(addons, /className="table-action-button danger"/);
+ assert.doesNotMatch(addons, /padding: "4px 10px"/);
+});
diff --git a/apps/rlark-ui/tests/workflow-actions.test.mjs b/apps/rlark-ui/tests/workflow-actions.test.mjs
new file mode 100644
index 0000000..87042d8
--- /dev/null
+++ b/apps/rlark-ui/tests/workflow-actions.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import { crdToWorkflow } from "../dist/test/utils/crd.js";
+
+const workflowSource = await readFile(
+ new URL("../src/pages/Workflows.tsx", import.meta.url),
+ "utf8",
+);
+const backendSource = await readFile(
+ new URL("../src/backend.ts", import.meta.url),
+ "utf8",
+);
+const mockSource = await readFile(
+ new URL("../src/mockBackend.ts", import.meta.url),
+ "utf8",
+);
+const dataSource = await readFile(new URL("../src/data.ts", import.meta.url), "utf8");
+
+test("stopped workflow requests display Stopping until observed", () => {
+ const workflow = crdToWorkflow({
+ apiVersion: "rlinf.io/v1alpha1",
+ kind: "Workflow",
+ metadata: { name: "workflow" },
+ spec: { stopped: true, jobTemplates: [] },
+ status: { phase: "Running" },
+ });
+ assert.equal(workflow.phase, "Stopping");
+ assert.equal(workflow.stopped, true);
+});
+
+test("stopping intent does not hide terminal workflow phases", () => {
+ for (const phase of ["Succeeded", "Failed"]) {
+ const workflow = crdToWorkflow({
+ apiVersion: "rlinf.io/v1alpha1",
+ kind: "Workflow",
+ metadata: { name: "workflow" },
+ spec: { stopped: true, jobTemplates: [] },
+ status: { phase },
+ });
+ assert.equal(workflow.phase, phase);
+ }
+});
+
+test("workflow list and detail expose stop and resume PATCH actions", () => {
+ assert.match(workflowSource, /workflowsApi\.setStopped\(name, stopped\)/);
+ assert.match(
+ backendSource,
+ /setStopped\(name: string, stopped: boolean\)[\s\S]*?body: \{ spec: \{ stopped \} \}/,
+ );
+ assert.match(workflowSource, /onSetStopped/);
+ assert.match(workflowSource, / {
+ assert.match(dataSource, /\| "Stopping"/);
+ assert.match(dataSource, /\| "Unknown"/);
+});
diff --git a/apps/rlark-ui/tsconfig.test.json b/apps/rlark-ui/tsconfig.test.json
new file mode 100644
index 0000000..ef7fdb0
--- /dev/null
+++ b/apps/rlark-ui/tsconfig.test.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM"],
+ "module": "ES2022",
+ "moduleResolution": "Bundler",
+ "rootDir": "src",
+ "outDir": "dist/test",
+ "sourceMap": true,
+ "jsx": "react-jsx",
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/api.ts",
+ "src/backend.ts",
+ "src/utils/crd.ts",
+ "src/utils/deployYaml.ts",
+ "src/utils/imageReference.ts",
+ "src/utils/job.ts",
+ "src/utils/jobPhase.ts",
+ "src/utils/nodeBatchMetadata.ts",
+ "src/utils/nodeResources.ts",
+ "src/utils/nodeVisibility.ts",
+ "src/utils/resourceAvailability.ts",
+ "src/utils/route.ts",
+ "src/utils/sshKeys.ts",
+ "src/utils/terminalKeyboard.ts"
+ ]
+}
diff --git a/apps/rlark/Dockerfile b/apps/rlark/Dockerfile
index 03b2756..aa38c71 100644
--- a/apps/rlark/Dockerfile
+++ b/apps/rlark/Dockerfile
@@ -23,7 +23,7 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
CGO_ENABLED=0 GOOS=linux go build -o /agent ./cmd/agent/... && \
CGO_ENABLED=0 GOOS=linux go build -o /network-sidecar ./cmd/network-sidecar/... && \
CGO_ENABLED=0 GOOS=linux go build -o /rlarkadm ./cmd/rlarkadm && \
- CGO_ENABLED=0 GOOS=linux go build -o /rlark-sshd ./cmd/sshd
+ CGO_ENABLED=0 GOOS=linux go build -o /rlark-tools ./cmd/rlark-tools
# -- Runtime stage ---------------------------------------------------------
FROM ${RUNTIME_BASE_IMAGE}
@@ -36,4 +36,4 @@ COPY --from=builder /gateway /usr/local/bin/gateway
COPY --from=builder /agent /usr/local/bin/agent
COPY --from=builder /network-sidecar /usr/local/bin/network-sidecar
COPY --from=builder /rlarkadm /usr/local/bin/rlarkadm
-COPY --from=builder /rlark-sshd /usr/local/bin/rlark-sshd
+COPY --from=builder /rlark-tools /usr/local/bin/rlark-tools
diff --git a/apps/rlark/Makefile b/apps/rlark/Makefile
index 31b2202..ac826cd 100644
--- a/apps/rlark/Makefile
+++ b/apps/rlark/Makefile
@@ -24,7 +24,7 @@ GATEWAY_BIN := gateway
AGENT_BIN := agent
NETWORK_SIDECAR_BIN := network-sidecar
RLARKADM_BIN := rlarkadm
-SSHD_BIN := rlark-sshd
+RLARK_TOOLS_BIN := rlark-tools
##@ Lint & format
@@ -43,7 +43,7 @@ fmt: ## Format Go (gofmt)
.PHONY: all build
all: build ## Build all binaries (default)
-build: $(SERVER_BIN) $(CONTROLLER_MANAGER_BIN) $(GATEWAY_BIN) $(AGENT_BIN) $(NETWORK_SIDECAR_BIN) $(RLARKADM_BIN) $(SSHD_BIN) ## Build all binaries
+build: $(SERVER_BIN) $(CONTROLLER_MANAGER_BIN) $(GATEWAY_BIN) $(AGENT_BIN) $(NETWORK_SIDECAR_BIN) $(RLARKADM_BIN) $(RLARK_TOOLS_BIN) ## Build all binaries
.PHONY: $(SERVER_BIN)
$(SERVER_BIN): ## Build server binary
@@ -69,9 +69,9 @@ $(NETWORK_SIDECAR_BIN): ## Build network-sidecar binary
$(RLARKADM_BIN): ## Build rlarkadm binary
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags="$(LDFLAGS)" -o bin/$@ ./cmd/$@
-.PHONY: $(SSHD_BIN)
-$(SSHD_BIN): ## Build rlark-sshd binary
- CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags="$(LDFLAGS)" -o bin/$@ ./cmd/sshd
+.PHONY: $(RLARK_TOOLS_BIN)
+$(RLARK_TOOLS_BIN): ## Build rlark-tools binary
+ CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags="$(LDFLAGS)" -o bin/$@ ./cmd/rlark-tools
.PHONY: vet test clean
vet: ## Run go vet
diff --git a/apps/rlark/README.md b/apps/rlark/README.md
index f6edc8c..5f46294 100644
--- a/apps/rlark/README.md
+++ b/apps/rlark/README.md
@@ -19,7 +19,7 @@ apps/rlark/
│ ├── server/ # Server: tunnel, cert, SSH, peer, k8s proxy
│ ├── gateway/ # Gateway: CRD CRUD, cert, auth, storage
│ ├── agent/ # Agent: pull/push controllers, container adapters
-│ ├── controllermanager/ # Controllers: job, domain, task, node, workflow
+│ ├── controllermanager/ # Controllers: job, domain, task, node
│ ├── network/ # Network: sidecar, nodeserver, SSH dialer
│ ├── addons/ # Addon catalog and management
│ ├── auth/ # Authentication
@@ -31,7 +31,7 @@ apps/rlark/
│ └── utils/ # Shared utilities
└── docs/ # RLark core documentation
├── architecture.md # Technical architecture
- ├── concepts.md # Core concepts (Domain, Job, Task, Workflow)
+ ├── concepts.md # Core concepts (Domain, Job, Task)
├── quickstart.md # Local development setup
├── deployment.md # Production deployment guide
├── storage-api.md # Storage API documentation
@@ -42,7 +42,7 @@ apps/rlark/
## Documentation
- [Architecture](docs/architecture.md) — complete technical architecture
-- [Core Concepts](docs/concepts.md) — Domain, Job, Task, Workflow, etc.
+- [Core Concepts](docs/concepts.md) — Domain, Job, Task, etc.
- [Quick Start](docs/quickstart.md) — local development setup
- [Deployment Guide](docs/deployment.md) — production deployment
- [API Reference](docs/api/reference.md) — REST API reference
diff --git a/apps/rlark/cmd/agent/main.go b/apps/rlark/cmd/agent/main.go
index ade6dc0..3452158 100644
--- a/apps/rlark/cmd/agent/main.go
+++ b/apps/rlark/cmd/agent/main.go
@@ -1,7 +1,10 @@
package main
import (
+ "context"
"os"
+ "os/signal"
+ "syscall"
"github.com/spf13/cobra"
@@ -22,7 +25,9 @@ func main() {
}
config.SetupFlags(cmd.Flags())
- if err := cmd.Execute(); err != nil {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ if err := cmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}
diff --git a/apps/rlark/cmd/crd-api-docgen/main.go b/apps/rlark/cmd/crd-api-docgen/main.go
index a0fa32f..a213cd9 100644
--- a/apps/rlark/cmd/crd-api-docgen/main.go
+++ b/apps/rlark/cmd/crd-api-docgen/main.go
@@ -107,9 +107,10 @@ func main() {
var out bytes.Buffer
_, _ = fmt.Fprintf(&out, "# CRD Schema Reference\n\n")
- _, _ = fmt.Fprintf(&out, "Kubernetes resource operations and schemas generated from the current CRD manifests. This is not the RLark Gateway HTTP API reference.\n\n")
+ _, _ = fmt.Fprintf(&out, "> **Generated file:** This page is generated from `api/config/crd/bases` by `apps/rlark/cmd/crd-api-docgen`. Do not edit it manually; run `make generate-crd-schema-docs` instead.\n\n")
+ _, _ = fmt.Fprintf(&out, "Kubernetes resource operations and schemas generated from the current CRD manifests. This is not the RLark Gateway HTTP API reference. Descriptions are copied from source schemas, may retain their original language, and are shortened for readability.\n\n")
for _, doc := range docs {
- if doc.Kind != "CustomResourceDefinition" {
+ if doc.Kind != "CustomResourceDefinition" || doc.Spec.Names.Plural == "workflows" {
continue
}
version, ok := storageVersion(doc.Spec.Versions)
diff --git a/apps/rlark/cmd/rlark-tools/main.go b/apps/rlark/cmd/rlark-tools/main.go
new file mode 100644
index 0000000..0245664
--- /dev/null
+++ b/apps/rlark/cmd/rlark-tools/main.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/sshd"
+ "github.com/rlinf/rlark/apps/rlark/pkg/version"
+ "github.com/spf13/cobra"
+)
+
+func main() {
+ cmd := &cobra.Command{
+ Use: "rlark-tools",
+ Short: "Utilities for RLark workloads",
+ Version: version.String(),
+ }
+ cmd.AddCommand(newSSHDCommand())
+
+ if err := cmd.Execute(); err != nil {
+ os.Exit(2)
+ }
+}
+
+func newSSHDCommand() *cobra.Command {
+ var port string
+ var shell string
+ cmd := &cobra.Command{
+ Use: "sshd",
+ Short: "Start the in-pod SSH server",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ srv := &sshd.Server{Port: port, Shell: shell}
+ go func() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ <-sigCh
+ os.Exit(0)
+ }()
+ return srv.ListenAndServe()
+ },
+ }
+ cmd.Flags().StringVar(&port, "port", "22", "SSH listen port")
+ cmd.Flags().StringVar(&shell, "shell", "", "Shell binary path (default: /bin/bash)")
+ return cmd
+}
diff --git a/apps/rlark/cmd/sshd/main.go b/apps/rlark/cmd/sshd/main.go
deleted file mode 100644
index e9ceb7d..0000000
--- a/apps/rlark/cmd/sshd/main.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "os"
- "os/signal"
- "syscall"
-
- "github.com/rlinf/rlark/apps/rlark/pkg/sshd"
-)
-
-func main() {
- port := flag.String("port", "22", "SSH listen port")
- shell := flag.String("shell", "", "Shell binary path (default: /bin/bash)")
- flag.Parse()
-
- srv := &sshd.Server{
- Port: *port,
- Shell: *shell,
- }
-
- go func() {
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
- <-sigCh
- os.Exit(0)
- }()
-
- if err := srv.ListenAndServe(); err != nil {
- fmt.Fprintf(os.Stderr, "sshd: %v\n", err)
- os.Exit(1)
- }
-}
diff --git a/apps/rlark/docs/README.md b/apps/rlark/docs/README.md
index a95c1cc..c58962f 100644
--- a/apps/rlark/docs/README.md
+++ b/apps/rlark/docs/README.md
@@ -4,7 +4,7 @@ RLark maintains English and Chinese documentation with the same audience-oriente
1. **Overview** — product capabilities, concepts, and architecture.
2. **Quick Start** — deploy the control plane, onboard a Kubernetes data plane, and run the first Job.
-3. **Platform User Guide** — day-to-day use of resources, Jobs, Workers, Workflows, storage, and SSH keys.
+3. **Platform User Guide** — day-to-day use of resources, Jobs, Workers, storage, and SSH keys.
4. **Administrator Guide** — production deployment, cluster onboarding, security, devices, and operations.
5. **Developer Guide** — source development, architecture, debugging, device integration, and contribution.
6. **Reference** — CLI, API, CRD, configuration, and release information.
diff --git a/apps/rlark/docs/admin-guide/agent.md b/apps/rlark/docs/admin-guide/agent.md
index 11d5430..3680362 100644
--- a/apps/rlark/docs/admin-guide/agent.md
+++ b/apps/rlark/docs/admin-guide/agent.md
@@ -43,6 +43,8 @@ rlarkadm install -f deploy-data-plane.yaml
Do not combine cluster and node modes in one Deployment for a multi-node Kubernetes data plane. `rlarkadm` creates the correct Deployment and DaemonSet, certificate Secret, RBAC, socket mounts, and container-runtime mount.
+`rlarkadm` assigns the node Agent a dedicated `rlark-agent-node` ServiceAccount. Its local-cluster RBAC is limited to reading the current Node and listing/watching Node events; it does not inherit the cluster Agent's resource-management permissions.
+
### Verification
```bash
diff --git a/apps/rlark/docs/admin-guide/control-plane.md b/apps/rlark/docs/admin-guide/control-plane.md
index 02dc3e5..0c29c3f 100644
--- a/apps/rlark/docs/admin-guide/control-plane.md
+++ b/apps/rlark/docs/admin-guide/control-plane.md
@@ -10,7 +10,7 @@ The production control plane consists of the following components:
| PostgreSQL | Optional persistent storage when the top-level `db` block is configured | 5432 |
| rlark-server | Certificate management, Agent tunnels, SSH, health, and metrics | 8443 (HTTPS/WSS), 2222 (SSH), 8888 (internal HTTP) |
| rlark-gateway | REST API gateway for the console and CLI | 8090 |
-| rlark-controller-manager | Job/Workflow/Domain reconciliation | 8080 (metrics), 8081 (health) |
+| rlark-controller-manager | Job/Domain reconciliation | 8080 (metrics), 8081 (health) |
| rlark-ui | Web management console and `/api/` reverse proxy | 80 |
The standalone Gateway binary defaults to `:8080`; `rlarkadm` overrides it to `:8090`.
diff --git a/apps/rlark/docs/admin-guide/data-plane.md b/apps/rlark/docs/admin-guide/data-plane.md
index 33d00d3..a743f24 100644
--- a/apps/rlark/docs/admin-guide/data-plane.md
+++ b/apps/rlark/docs/admin-guide/data-plane.md
@@ -13,10 +13,14 @@ Onboarding a Kubernetes data plane requires an Agent certificate and a `DeployCo
3. Enter only the cluster name, for example `my-cluster-01`.
4. Choose **Sign Certificate**.
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

After signing, the page displays the cluster name, Server address, and a complete deploy YAML. It also adds the name to **Signed Clusters**, where the YAML can be opened and copied again.
+Administrators can set the generated YAML defaults under **System Configuration > Deployment**. These settings follow the `rlarkadm` `DeployConfig` structure. The configured control-plane and SSH addresses override generated values; TLS verification, kubeconfig, Agent image, shared RLark image, image pull policy, image pull Secrets, and containerd socket are also applied when present. Existing signed-cluster entries use the current defaults whenever their YAML is opened.
+
!!! warning "Protect the YAML"
The displayed `agent-key` is a private key. Store the copied YAML securely and never reuse it for another cluster.
@@ -45,8 +49,8 @@ cert:
-----END PRIVATE KEY-----
kubernetes:
- kubeconfig: /path/to/kubeconfig.yaml
- agent-image: rlark-agent:latest
+ kubeconfig: ~/.kube/config
+ agent-image: rlark:latest
```
Replace `kubernetes.kubeconfig` with a kubeconfig that can deploy to the target cluster and set an available Agent image. Add the optional `kubernetes.image` only when enabling components that require the shared RLark image. See [Configuration Reference](../reference/configuration.md) for all accepted keys.
diff --git a/apps/rlark/docs/admin-guide/embodied-runtime.md b/apps/rlark/docs/admin-guide/embodied-runtime.md
index 2b837ab..9d7cdbf 100644
--- a/apps/rlark/docs/admin-guide/embodied-runtime.md
+++ b/apps/rlark/docs/admin-guide/embodied-runtime.md
@@ -26,7 +26,7 @@ The Embodied Runtime has three layers:
|-------|-----------|-------------|
| Device Plugin | `device-plugin` | Registers device resources (`rlinf.io/device-*`) with Kubernetes |
| Controllers | `ros-controller`, `ros2-controller`, `camera-controller` | gRPC services that manage device lifecycle |
-| Webhook | Mutating Webhook | Automatically injects `devinit` sidecar for macvlan networking |
+| Webhook | Mutating Webhook | Optionally injects a `devinit` init container for macvlan networking |
### How It Works
@@ -46,9 +46,12 @@ The Embodied Runtime has three layers:
### Helm (Recommended)
+Direct deployment with the Embodied Runtime Helm chart supports ROS 2 through `config.ros2`. The RLark addon catalog currently does not expose ROS 2 configuration, so use the chart directly when ROS 2 is required.
+
```bash
-helm install embodied-runtime ./charts/embodied-runtime \
+helm install embodied-runtime ./apps/embodied-runtime/charts/embodied-runtime \
--namespace rlark-system \
+ --create-namespace \
--set config.ros.enabled=true \
--set config.camera.enabled=true
```
@@ -59,18 +62,18 @@ Configure the device plugin with the devices available on your nodes:
```yaml
# device-plugin-config.yaml
+device_count: 1
+
host_devices:
- - name: rlinf.io/device-webcam
- count: 2
- devices:
- - /dev/video0
- - /dev/video1
+ - host_path: /dev/video0
+ - host_path: /dev/ttyUSB0
+ permissions: rw
host_macvlans:
- - name: rlinf.io/device-franka
- count: 1
- parent_interface: eth0
- robot_ip: 192.168.1.100
+ - host_nic: eno1
+ name: macvlan0
+ ip: 172.16.0.0/24
+ # gateway: 172.16.0.1
camera:
enabled: true
@@ -91,10 +94,9 @@ For simple devices like USB cameras, use `host_devices` to pass through device f
```yaml
host_devices:
- - name: rlinf.io/device-webcam
- count: 1
- devices:
- - /dev/video0
+ - host_path: /dev/video0
+ # container_path: /dev/video0
+ # permissions: rwm
```
### Macvlan for Network Robots
@@ -103,13 +105,13 @@ For robots with fixed IP addresses on the network, use `host_macvlans`:
```yaml
host_macvlans:
- - name: rlinf.io/device-franka
- count: 1
- parent_interface: eth0
- robot_ip: 192.168.1.100
+ - host_nic: eno1
+ name: macvlan0
+ ip: 172.16.0.0/24
+ # gateway: 172.16.0.1
```
-The mutating webhook automatically injects a `devinit` sidecar that creates the macvlan interface in the Worker container.
+The webhook is disabled by default. Set `webhook.enabled=true` and provide a non-empty `config.hostMacvlans` list to render it. It then injects a `devinit` init container that creates the macvlan interface in the Worker pod's network namespace.
### Controller Pods
@@ -121,7 +123,7 @@ After deployment, verify the Embodied Runtime is working:
```bash
# 1. Check device plugin is running
-kubectl get pods -n rlark-system -l app=device-plugin
+kubectl get pods -n rlark-system -l app.kubernetes.io/name=embodied-runtime,app.kubernetes.io/component=device-plugin
# 2. Verify device resources are registered
kubectl describe node | grep rlinf.io/device
diff --git a/apps/rlark/docs/admin-guide/network-security.md b/apps/rlark/docs/admin-guide/network-security.md
index b1935e2..db5ed19 100644
--- a/apps/rlark/docs/admin-guide/network-security.md
+++ b/apps/rlark/docs/admin-guide/network-security.md
@@ -6,6 +6,8 @@ A Domain groups virtual addresses and scopes cross-cluster forwarding. Each Doma
### Creating a Domain
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

- Administrator Console → Domain Management → Create Domain
diff --git a/apps/rlark/docs/admin-guide/nodes.md b/apps/rlark/docs/admin-guide/nodes.md
index aba20c5..5bad51d 100644
--- a/apps/rlark/docs/admin-guide/nodes.md
+++ b/apps/rlark/docs/admin-guide/nodes.md
@@ -34,13 +34,16 @@ Administrators can control whether a node accepts new workload by toggling its s
Open the administrator console → Nodes to view and manage nodes:
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

Node details include:
- Scheduling status (schedulable / cordoned)
- Node type, access mode, OS, architecture
- Agent version
-- Resource usage: CPU, memory, GPU
+- Resource usage: CPU, memory, disk, GPU, and embodied-device resources
+- Disk warnings when usage reaches 90% or kubelet reports `DiskPressure=True`
- Associated jobs running on the node
## Using the UI
diff --git a/apps/rlark/docs/api/examples.md b/apps/rlark/docs/api/examples.md
index 92a7b0b..1d9dcca 100644
--- a/apps/rlark/docs/api/examples.md
+++ b/apps/rlark/docs/api/examples.md
@@ -2,15 +2,14 @@
This page provides end-to-end RLark Gateway HTTP API examples, focused on the **Kubernetes runtime** (`agentType=Kubernetes`). For resource operations and schemas, see [API Reference](reference.md). The machine-readable contract is available as the [OpenAPI specification](swagger.yaml).
-!!! warning "Authentication limitation"
- The login endpoint only validates the built-in Web UI credentials. It does not return a Bearer token or session cookie for subsequent requests, and the other Gateway APIs do not authorize requests based on that result. Run these commands only on a trusted network or through an ingress that enforces authentication and authorization.
+Authenticate first and use the returned `token` as `Authorization: Bearer ` for subsequent Gateway API requests. Tokens expire after 8 hours by default.
## Conventions
- The standalone Gateway listens on `http://localhost:8080` by default. An `rlarkadm` deployment exposes it internally on port `8090` and routes browser traffic through the UI service.
- CRD API root: `/api/v1/rlinf.io/v1alpha1`
- Namespaced resources such as `nodes` and `tasks` require `namespace=` in the query string.
-- Cluster-scoped resources such as `jobs` and `workflows` do not use a namespace query parameter.
+- Cluster-scoped resources such as `jobs` do not use a namespace query parameter.
- `spec.agentType` accepts `Kubernetes`, `Docker`, or `Raw`. Only the Kubernetes runtime is currently implemented; Docker and Raw are planned.
- `spec.role` is required and accepts `Actor`, `Rollout`, or `Env`.
- `kubernetes.workload.template` is a Kubernetes `corev1.PodTemplateSpec`.
@@ -93,6 +92,41 @@ echo "$JOB_ID" # jo-<16 hexadecimal characters>
The image, command, environment, resources, and volumes belong under `kubernetes.workload.template.spec.containers`; they are not top-level Task fields.
+### Access an unsupported device through HostNetwork
+
+If embodied-runtime does not yet support a network device but the data-plane node can reach it directly, enable host networking explicitly in the Task PodTemplate:
+
+```json
+{
+ "nodeSelector": {"kubernetes.io/hostname": "worker-1"},
+ "kubernetes": {
+ "workload": {
+ "kind": "Deployment",
+ "replicas": 1,
+ "template": {
+ "metadata": {"labels": {"app": "vendor-device-client"}},
+ "spec": {
+ "hostNetwork": true,
+ "dnsPolicy": "ClusterFirstWithHostNet",
+ "containers": [
+ {
+ "name": "app",
+ "image": "registry.example.com/vendor/device-sdk:latest",
+ "command": ["sh", "-c"],
+ "args": ["./device-client --address 192.168.10.20"]
+ }
+ ]
+ }
+ }
+ }
+ }
+}
+```
+
+This path does not provide embodied-runtime device discovery, resource isolation, controllers, CLIs, or SDK injection. The workload image is responsible for device drivers and lifecycle management. `hostNetwork` reduces network isolation and can cause port conflicts, so use it only on trusted data planes and dedicated device nodes. Do not enable `RLARK_ENABLE_UNSAFE_TASK_PRIVILEGES` for this purpose; that variable globally applies the legacy privileged/hostNetwork mode to multiple tasks.
+
+For the native and compatibility paths, see the "Unsupported devices" section in the embodied-runtime documentation at `apps/embodied-runtime/docs/examples.md`.
+
```bash
# List Jobs by label.
curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/jobs?labelSelector=framework=ppo"
@@ -124,80 +158,7 @@ curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/tasks?namespace=default&labelSelec
curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/tasks/ppo-cartpole-actor-head?namespace=default"
```
-## 4. Create a Workflow
-
-A Workflow contains Job templates linked by dependencies. Each `jobTemplates[].spec` is a complete Job spec.
-
-```bash
-curl -X POST "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/workflows" \
- -H "Content-Type: application/json" \
- -d '{
- "apiVersion": "rlinf.io/v1alpha1",
- "kind": "Workflow",
- "metadata": {"name": "training-pipeline"},
- "spec": {
- "jobTemplates": [
- {
- "name": "prepare",
- "dependencies": [],
- "spec": {
- "tasks": [
- {
- "name": "prepare-data",
- "role": "Env",
- "agentType": "Kubernetes",
- "kubernetes": {
- "workload": {
- "kind": "Deployment",
- "replicas": 1,
- "template": {
- "spec": {
- "containers": [
- {"name": "prepare", "image": "registry.example.com/rl/prepare:v1"}
- ]
- }
- }
- }
- }
- }
- ]
- }
- },
- {
- "name": "train",
- "dependencies": ["prepare"],
- "spec": {
- "tasks": [
- {
- "name": "trainer",
- "head": true,
- "role": "Actor",
- "agentType": "Kubernetes",
- "kubernetes": {
- "workload": {
- "kind": "Deployment",
- "replicas": 1,
- "template": {
- "spec": {
- "containers": [
- {"name": "trainer", "image": "registry.example.com/rl/train:v1"}
- ]
- }
- }
- }
- }
- }
- ]
- }
- }
- ]
- }
- }'
-
-curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/workflows/training-pipeline"
-```
-
-## 5. UI credential check
+## 4. UI credential check
Only the built-in usernames `admin` and `user` are accepted. A successful response is `{"ok":true,"role":"admin"}` or `{"ok":true,"role":"user"}`.
diff --git a/apps/rlark/docs/api/reference.md b/apps/rlark/docs/api/reference.md
index 22c67e6..5ba7ea4 100644
--- a/apps/rlark/docs/api/reference.md
+++ b/apps/rlark/docs/api/reference.md
@@ -2,8 +2,9 @@
This page lists the HTTP routes registered by the Gateway in [`pkg/gateway/router.go`](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/gateway/router.go). For runnable requests, see [API Examples](examples.md). The machine-readable subset is available as the [OpenAPI specification](swagger.yaml).
-!!! warning "Authentication limitation"
- `POST /api/v1/auth/login` validates the built-in UI credentials and returns a role, but it does not create a server-side session or issue a token. The Gateway currently does not enforce that login result on the API routes documented below. Do not expose the Gateway directly to untrusted networks; place it behind an authenticated reverse proxy or another trusted access-control layer.
+Except for `POST /api/v1/auth/login` and `/metrics`, Gateway routes require the JWT returned by login in the `Authorization: Bearer ` header. Tokens expire after 8 hours by default; configure the lifetime with `--jwt-token-ttl`. A missing or invalid token returns `401`; an authenticated `user` calling an admin-only route returns `403`.
+
+Admin-only operations are Node and Domain mutations, all certificate, image registry, and Addon routes, system configuration updates, plus StorageClass provider and mutation routes. System configuration reads and all SSH key operations accept either authenticated role. Authorization is role-based and does not yet isolate resources, including SSH keys, by individual user.
Path parameters are written as `{name}` below; Gin uses the equivalent `:name` syntax in the router. Namespaced CRD routes require the `namespace` query parameter.
@@ -12,8 +13,7 @@ Path parameters are written as `{name}` below; Gin uses the equivalent `:name` s
| Resource | Scope | Routes |
|----------|-------|--------|
| `nodes` | Namespaced | `GET, POST /api/v1/rlinf.io/v1alpha1/nodes`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/nodes/{name}` |
-| `workflows` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/workflows`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/workflows/{name}` |
-| `jobs` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/jobs`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/jobs/{name}`; `GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs`; `GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/metrics` |
+| `jobs` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/jobs`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/jobs/{name}`; `GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs`; `GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs/label-values`; `GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/metrics` |
| `tasks` | Namespaced | `GET, POST /api/v1/rlinf.io/v1alpha1/tasks`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/tasks/{name}`; all methods on `/api/v1/rlinf.io/v1alpha1/tasks/{name}/tensorboard/{path}` |
| `pods` | Namespaced | `GET /api/v1/rlinf.io/v1alpha1/pods`; `GET, PATCH /api/v1/rlinf.io/v1alpha1/pods/{name}`; `GET /api/v1/rlinf.io/v1alpha1/pods/{name}/events`; `GET /api/v1/rlinf.io/v1alpha1/pods/{name}/terminal` |
| `domains` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/domains`; `GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/domains/{name}` |
@@ -22,6 +22,10 @@ When creating a Job, the Gateway stores the submitted `metadata.name` as its dis
The Gateway router does not expose CRD status subresource routes. Status is returned as part of the normal resource representation.
+The Job `logs` route is implemented and accepts `from`, `to`, `task`, `pod`, `query`, `cursor`, and `order`. `from` and `to` are RFC 3339 timestamps; `order=asc` requests ascending backend results, while any other value uses descending order. When `from` is supplied and a log backend is configured, a successful response contains `source: "backend"`, `entries`, `hasMore`, and `nextCursor`. Otherwise—including backend errors—it falls back to Pod logs and returns `source: "pod"` plus a `pods` array containing `taskName`, `podName`, `phase`, `node`, and `logs`.
+
+`logs/label-values` accepts `label` (default `pod`), `from`, `to`, `task`, and `pod`, and returns `{ "values": [...] }`; without a configured backend the array is empty. Job `metrics` is registered but currently only returns HTTP `501 Not Implemented`.
+
## Clusters and certificates
| Method | Path |
@@ -41,16 +45,45 @@ The Gateway router does not expose CRD status subresource routes. Status is retu
| `POST` | `/api/v1/auth/login` |
| `GET` | `/api/v1/ssh-user-keys` |
| `POST` | `/api/v1/ssh-user-keys` |
-| `DELETE` | `/api/v1/ssh-user-keys/{id}` |
+| `DELETE` | `/api/v1/ssh-user-keys/{index}?user={user}` |
+
+## API reference metadata
+
+| Method | Path |
+|--------|------|
+| `GET` | `/api/v1/api-reference` |
+
+The authenticated API reference metadata endpoint returns localized section titles, endpoint methods and paths, descriptions, and response examples. The Web UI uses this endpoint as its API reference data source instead of maintaining a separate endpoint list.
+
+## Gateway observability
-## Image registries and system configuration
+| Method | Path |
+|--------|------|
+| `GET` | `/metrics` |
+
+`GET /metrics` exposes Gateway metrics in Prometheus text format.
+
+## Images, image registries, and system configuration
| Method | Path |
|--------|------|
+| `GET` | `/api/v1/images` |
| `GET, POST` | `/api/v1/image-registries` |
-| `GET, PUT, DELETE` | `/api/v1/image-registries/{name}` |
+| `GET, PUT, DELETE` | `/api/v1/image-registries/{id}` |
| `GET, PUT` | `/api/v1/system-config` |
+System configuration is split into `ssh` and `log` categories. `ssh.jumpHost` must be a hostname or IP address without a scheme, user, path, or port; `ssh.jumpPort`, when set, must be an integer from 1 to 65535. Set `log.backend` to `none` to disable historical log queries, or to `sls` with the required SLS connection fields. Sensitive SLS credentials are returned as `****`; sending that placeholder back preserves the stored value. A successful `PUT` returns the effective masked configuration.
+
+The optional `deployment` category follows the relevant subset of the `rlarkadm` `DeployConfig` structure and controls defaults in the deployment YAML shown after signing a data-plane Agent cluster. It accepts only `controlPlaneAddress`, `sshAddress`, `insecureSkipTlsVerify`, and the Kubernetes Agent fields `kubeconfig`, `agentImage`, `image`, `imagePullPolicy`, `imagePullSecrets`, and `containerdSocket`. Control-plane components, database, certificate, Docker, and Raw deployment fields are rejected. If `controlPlaneAddress` is empty, the signing API's Server address is used. Image pull policy may be `Always`, `IfNotPresent`, or `Never`.
+
+Image registry credentials use an immutable `ir-<16 lowercase hexadecimal characters>` ID; the display `name` may be duplicated or changed. Create requests require `name`, `registry`, `username`, `password`, and `clusterSelection`:
+
+```json
+{"name":"Production Harbor","registry":"harbor.example.com","username":"robot","password":"secret","clusterSelection":{"mode":"Selected","clusters":["cluster-a"]}}
+```
+
+`clusterSelection.mode` is `None` (store only), `Selected` (one or more logical cluster names), or `All` (current and future clusters). `clusters` must be empty for `None` and `All`. Responses omit the password and include `id`. `POST` returns `201 Created`. On `PUT`, omitting `password` preserves it; an explicitly empty password is invalid. `DELETE` returns `202 Accepted` because Replication and Delivery remove distributed Secrets asynchronously.
+
## Storage
| Method | Path |
diff --git a/apps/rlark/docs/api/swagger.yaml b/apps/rlark/docs/api/swagger.yaml
index fef62ef..6ba7bb1 100644
--- a/apps/rlark/docs/api/swagger.yaml
+++ b/apps/rlark/docs/api/swagger.yaml
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
- title: RLark Gateway CRD API
+ title: RLark Gateway CRD API Subset
description: >-
- Kubernetes-style API for rlinf.io resources. The Gateway login endpoint
+ A maintained subset of routes registered by the RLark Gateway, focused on CRD operations and selected supporting endpoints; this is not a complete Gateway specification. The Gateway login endpoint
does not issue a token or establish a session, and these routes currently
do not enforce the UI login result. Deploy the Gateway only on a trusted
network or behind an authenticated access-control layer. Domain resources
@@ -27,24 +27,40 @@ tags:
description: Namespaced Node resources
- name: Task
description: Namespaced Task resources
- - name: Workflow
- description: Cluster-scoped Workflow resources
+ - name: Image
+ description: Images referenced by Jobs and Tasks
+ - name: Observability
+ description: Gateway Prometheus metrics
paths:
/api/v1/images:
get:
tags: [Image]
- summary: List recently used images
- description: Returns up to ten images used by the most recent jobs, ordered by last use time.
+ summary: List used images
+ description: Returns images referenced by current Jobs and Tasks, with usage counts and last-used timestamps.
operationId: listImages
responses:
'200':
- description: OK
+ description: Image usage records
content:
application/json:
schema:
$ref: '#/components/schemas/ImageUsageList'
+ /metrics:
+ get:
+ tags: [Observability]
+ summary: Get Gateway metrics
+ description: Returns Gateway metrics in the Prometheus text exposition format.
+ operationId: readGatewayMetrics
+ responses:
+ '200':
+ description: Prometheus metrics
+ content:
+ text/plain:
+ schema:
+ type: string
+
# ─── Job Collection ───────────────────────────────────────────────
/api/v1/rlinf.io/v1alpha1/jobs:
get:
@@ -202,39 +218,81 @@ paths:
'404':
description: Not Found
- # ─── Job Logs (not yet implemented) ──────────────────────────────
+ # ─── Job Logs ────────────────────────────────────────────────────
/api/v1/rlinf.io/v1alpha1/jobs/{name}/logs:
get:
tags: [Job]
summary: Get Job logs
- description: Get logs for a Job resource. Not yet implemented.
+ description: >-
+ Queries the configured backend when from is supplied. If no backend is
+ configured, no from value is supplied, or the backend query fails, the
+ handler falls back to current Pod logs.
operationId: readJobLogs
parameters:
- $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
+ - $ref: '#/components/parameters/logFrom'
+ - $ref: '#/components/parameters/logTo'
+ - $ref: '#/components/parameters/logTask'
+ - $ref: '#/components/parameters/logPod'
+ - name: query
+ in: query
+ description: Raw backend-specific log query.
+ schema: {type: string}
+ - name: cursor
+ in: query
+ description: Backend pagination cursor returned as nextCursor.
+ schema: {type: string}
+ - name: order
+ in: query
+ description: Use asc for ascending backend results; all other values are descending.
+ schema: {type: string, default: desc}
responses:
'200':
- description: OK
- '404':
- description: Not Found
- '501':
- description: Not Implemented
+ description: Backend log entries or Pod-log fallback
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: '#/components/schemas/BackendLogResponse'
+ - $ref: '#/components/schemas/PodLogResponse'
+ '500':
+ description: Failed to list Tasks for the Pod fallback
+
+ /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs/label-values:
+ get:
+ tags: [Job]
+ summary: List Job log label values
+ description: Returns unique values for a backend log label; label defaults to pod. Returns an empty values array when no backend is configured.
+ operationId: listJobLogLabelValues
+ parameters:
+ - $ref: '#/components/parameters/name'
+ - name: label
+ in: query
+ schema: {type: string, default: pod}
+ - $ref: '#/components/parameters/logFrom'
+ - $ref: '#/components/parameters/logTo'
+ - $ref: '#/components/parameters/logTask'
+ - $ref: '#/components/parameters/logPod'
+ responses:
+ '200':
+ description: Unique label values
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogLabelValuesResponse'
+ '500':
+ description: Log backend query failed
# ─── Job Metrics (not yet implemented) ───────────────────────────
/api/v1/rlinf.io/v1alpha1/jobs/{name}/metrics:
get:
tags: [Job]
summary: Get Job metrics
- description: Get metrics for a Job resource. Not yet implemented.
+ description: This registered route is not implemented and always returns HTTP 501.
operationId: readJobMetrics
parameters:
- $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
responses:
- '200':
- description: OK
- '404':
- description: Not Found
'501':
description: Not Implemented
@@ -564,163 +622,6 @@ paths:
'404':
description: Not Found
- # ─── Workflow Collection ─────────────────────────────────────────
- /api/v1/rlinf.io/v1alpha1/workflows:
- get:
- tags: [Workflow]
- summary: List workflows
- description: List workflows resources.
- operationId: listWorkflow
- parameters:
- - $ref: '#/components/parameters/pretty'
- - $ref: '#/components/parameters/continue'
- - $ref: '#/components/parameters/limit'
- - $ref: '#/components/parameters/fieldSelector'
- - $ref: '#/components/parameters/labelSelector'
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/WorkflowList'
-
- post:
- tags: [Workflow]
- summary: Create a Workflow
- description: Create a Workflow resource.
- operationId: createWorkflow
- parameters:
- - $ref: '#/components/parameters/pretty'
- - $ref: '#/components/parameters/dryRun'
- - $ref: '#/components/parameters/fieldManager'
- - $ref: '#/components/parameters/fieldValidation'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- responses:
- '201':
- description: Created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '202':
- description: Accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
-
- # ─── Workflow Named ──────────────────────────────────────────────
- /api/v1/rlinf.io/v1alpha1/workflows/{name}:
- get:
- tags: [Workflow]
- summary: Get a Workflow
- description: Get a Workflow resource.
- operationId: readWorkflow
- parameters:
- - $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '404':
- description: Not Found
-
- put:
- tags: [Workflow]
- summary: Replace a Workflow
- description: Replace a Workflow resource.
- operationId: replaceWorkflow
- parameters:
- - $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
- - $ref: '#/components/parameters/fieldManager'
- - $ref: '#/components/parameters/fieldValidation'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '404':
- description: Not Found
-
- patch:
- tags: [Workflow]
- summary: Patch a Workflow
- description: Patch a Workflow resource.
- operationId: patchWorkflow
- parameters:
- - $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
- - $ref: '#/components/parameters/fieldManager'
- - $ref: '#/components/parameters/fieldValidation'
- - $ref: '#/components/parameters/force'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- application/strategic-merge-patch+json:
- schema:
- $ref: '#/components/schemas/Workflow'
- application/merge-patch+json:
- schema:
- $ref: '#/components/schemas/Workflow'
- application/json-patch+json:
- schema:
- $ref: '#/components/schemas/JSONPatch'
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '404':
- description: Not Found
-
- delete:
- tags: [Workflow]
- summary: Delete a Workflow
- description: Delete a Workflow resource.
- operationId: deleteWorkflow
- parameters:
- - $ref: '#/components/parameters/name'
- - $ref: '#/components/parameters/pretty'
- responses:
- '200':
- description: OK
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Status'
- '202':
- description: Accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Status'
- '404':
- description: Not Found
-
components:
parameters:
name:
@@ -808,7 +709,94 @@ components:
schema:
type: boolean
+ logFrom:
+ name: from
+ in: query
+ description: RFC 3339 start time. Its presence enables a configured backend query.
+ schema:
+ type: string
+ format: date-time
+
+ logTo:
+ name: to
+ in: query
+ description: RFC 3339 end time; the backend query defaults to the current time when omitted.
+ schema:
+ type: string
+ format: date-time
+
+ logTask:
+ name: task
+ in: query
+ description: Filter backend logs or label values by task label.
+ schema:
+ type: string
+
+ logPod:
+ name: pod
+ in: query
+ description: Filter backend logs or label values by pod label.
+ schema:
+ type: string
+
schemas:
+ LogEntry:
+ type: object
+ additionalProperties: true
+ description: A backend-specific structured log entry.
+
+ BackendLogResponse:
+ type: object
+ required: [source, entries, hasMore, nextCursor]
+ properties:
+ source:
+ type: string
+ enum: [backend]
+ entries:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogEntry'
+ hasMore:
+ type: boolean
+ nextCursor:
+ type: string
+
+ PodLogInfo:
+ type: object
+ required: [taskName, podName, phase, node, logs]
+ properties:
+ taskName:
+ type: string
+ podName:
+ type: string
+ phase:
+ type: string
+ node:
+ type: string
+ logs:
+ type: string
+
+ PodLogResponse:
+ type: object
+ required: [source, pods]
+ properties:
+ source:
+ type: string
+ enum: [pod]
+ pods:
+ type: array
+ items:
+ $ref: '#/components/schemas/PodLogInfo'
+
+ LogLabelValuesResponse:
+ type: object
+ required: [values]
+ properties:
+ values:
+ type: array
+ items:
+ type: string
+
ImageUsage:
type: object
required: [image, useCount, lastUsedAt]
@@ -1383,96 +1371,3 @@ components:
type: array
items:
$ref: '#/components/schemas/Task'
-
- # ─── Workflow Schemas ──────────────────────────────────────────
- Workflow:
- type: object
- required:
- - apiVersion
- - kind
- properties:
- apiVersion:
- type: string
- enum:
- - rlinf.io/v1alpha1
- kind:
- type: string
- enum:
- - Workflow
- metadata:
- $ref: '#/components/schemas/ObjectMeta'
- spec:
- $ref: '#/components/schemas/WorkflowSpec'
- status:
- $ref: '#/components/schemas/WorkflowStatus'
-
- WorkflowSpec:
- type: object
- properties:
- jobTemplates:
- type: array
- items:
- $ref: '#/components/schemas/WorkflowJobTemplate'
-
- WorkflowJobTemplate:
- type: object
- properties:
- dependencies:
- type: array
- items:
- type: string
- name:
- type: string
- spec:
- $ref: '#/components/schemas/JobSpec'
-
- WorkflowStatus:
- type: object
- properties:
- conditions:
- type: array
- items:
- $ref: '#/components/schemas/Condition'
- endTime:
- type: string
- format: date-time
- jobs:
- type: array
- items:
- $ref: '#/components/schemas/WorkflowJobStatus'
- phase:
- type: string
- startTime:
- type: string
- format: date-time
-
- WorkflowJobStatus:
- type: object
- properties:
- message:
- type: string
- name:
- type: string
- phase:
- type: string
-
- WorkflowList:
- type: object
- required:
- - apiVersion
- - kind
- properties:
- apiVersion:
- type: string
- enum:
- - rlinf.io/v1alpha1
- kind:
- type: string
- enum:
- - WorkflowList
- metadata:
- $ref: '#/components/schemas/ListMeta'
- items:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
diff --git a/apps/rlark/docs/architecture.md b/apps/rlark/docs/architecture.md
index 79414b7..fadf8fd 100644
--- a/apps/rlark/docs/architecture.md
+++ b/apps/rlark/docs/architecture.md
@@ -74,7 +74,6 @@ Controller-Manager runs in the control plane, coordinating the lifecycle of high
| Domain Controller | Manage Domain CRD, allocate IP subnets, sign DomainPeer certificates | [domain/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/domain/) |
| Task Controller | Watch Task status, sync to corresponding Job | [task/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/task/) |
| Node Controller | Watch Node registration/offline events | [node/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/node/) |
-| Workflow Controller | DAG orchestration, schedule Jobs in dependency order | [workflow/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/workflow/) |
**Job State Machine**:
@@ -196,11 +195,12 @@ func (a *containerNetworkAdapter) GetContainerNetworkDial(...) (utils.Dial, erro
Per-Domain SSH connection pool. Design highlights:
-- At most one SSH connection per Domain (ssh.Client multiplexing)
+- Each Domain starts with one SSH connection and grows up to four connections when all existing connections have active channels
+- New channels use the least-loaded connection; idle physical connections are reclaimed by background GC
- Auto-reconnect on disconnect; concurrent requests wait during reconnection instead of creating separate connections
- Exponential backoff on reconnection failure (1s → 2s → 4s → ... → 30s)
-- Background GC closes idle connections (default 10 min timeout)
-- Thread-safe; read lock on normal path, no blocking
+- Background GC closes idle connections (default 24 hour timeout)
+- Data-path activity timestamps are updated atomically and rate-limited to avoid a mutex on every read and write
### 4.6 Embodied Runtime
@@ -315,7 +315,6 @@ sequenceDiagram
```mermaid
flowchart LR
- wf["WorkflowCluster "]
job["JobCluster "]
task["TaskNamespaced "]
node["NodeNamespaced "]
@@ -351,4 +350,3 @@ iptables/CNI solutions require modifying node network configuration with high pr
- Runs in userspace; creating TUN devices still requires privileged access
- gVisor netstack supports the required network protocol handling
- Can be injected as a Sidecar container, decoupled from business containers
-
diff --git a/apps/rlark/docs/concepts.md b/apps/rlark/docs/concepts.md
index bd3f71b..43d2e46 100644
--- a/apps/rlark/docs/concepts.md
+++ b/apps/rlark/docs/concepts.md
@@ -5,7 +5,6 @@
RLark uses a multi-layer resource abstraction, from underlying infrastructure to top-level embodied AI workloads:
```
-Workflow ──── Workflow (DAG orchestration of multiple Jobs)
│
└── Job ──── Training Job (a complete embodied AI task)
│
@@ -146,7 +145,7 @@ These business fields are stored on the KCP Node CR and are preserved when the A
Workspace node totals and cluster-detail node lists include usable Workers carrying an RLark category label. Legacy `rlark.io/node-category` values and unlabeled Nodes that explicitly advertise GPU or embodied-device resources remain supported. Nodes carrying a Kubernetes `master` or `control-plane` role label remain visible only in the administration workspace and are not counted or shown as workload Workers in the business workspace.
-CPU, memory, and GPU usage in Node details is aggregated from the Kubernetes `resources.requests` of running Workers. It represents scheduler-reserved resources, not real-time hardware utilization from metrics-server. The detail page also lists every Worker on the Node with its Job, role, IP, resource requests, and runtime phase.
+CPU, memory, and GPU usage in Node details is aggregated from the Kubernetes `resources.requests` of running Workers. It represents scheduler-reserved resources, not real-time hardware utilization from metrics-server. Disk is reported separately from kubelet Stats Summary: the Agent combines nodefs with a dedicated imagefs, while avoiding double-counting when both refer to the same filesystem. The UI shows real used, total, and available storage and warns at 90% usage or when kubelet reports `DiskPressure=True`. The detail page also lists every Worker on the Node with its Job, role, IP, resource requests, and runtime phase.
## 5. Node Cordon/Uncordon
@@ -228,12 +227,17 @@ status:
```
(empty) ──init──▶ Pending ──tasks-running──▶ Running
- │ │
- │ any-task-failed │ all-tasks-succeeded
- ▼ ▼
- Failed Succeeded
+ │ │ │
+ │ stop │ stop │ all-tasks-succeeded
+ ▼ ▼ ▼
+ Stopping ──cleanup-complete──▶ Stopped Succeeded
+ ▲
+ │ failed-run stop/restart cleanup
+ Failed
```
+`Stopping` is transitional: RLark deletes the child Tasks and waits for their Workers and task PVCs to be cleaned up. Stopping and then starting a terminal Job performs a clean rerun after removing the previous Tasks, Workers, and task PVCs.
+
### Relationship with Task
After Job Controller reconciliation:
@@ -247,19 +251,23 @@ Jobs can be stopped and restarted via the `stopped` field in the Job spec, provi
### Concept
-Setting `spec.stopped: true` on a Job causes the Job controller to stop all associated workloads (Pods, Deployments, StatefulSets) without deleting the Job resource. Setting it back to `false` (or removing the field) restarts the workloads.
+Setting `spec.stopped: true` starts an orderly stop without deleting the Job resource. The Job is shown as `Stopping` while RLark waits for the Job and all child Tasks and Workers to stop, and then becomes `Stopped`. Clearing the field starts a stopped Job again.
### How it works
-1. **Stop**: When `spec.stopped` is set to `true`, the Job controller detects the change and deletes the underlying Kubernetes workloads (Deployments/StatefulSets) while keeping the Job CR.
-2. **Restart**: When `spec.stopped` is removed or set to `false`, the Job controller recreates the workloads from the Task templates.
-3. **State preservation**: The Job's phase and status fields are preserved during stop/restart cycles.
+1. **Stop**: RLark deletes the child Task CRs, waits for their underlying workloads and PVCs to be cleaned up, and retains the Job CR and configuration.
+2. **Start**: Clearing `spec.stopped` on a `Stopped` Job recreates Tasks, workloads, and empty task PVCs from the templates.
+3. **Restart**: Restart cleans up the current Tasks, Workers, and task PVCs before recreating them. `Succeeded` and `Failed` Jobs both start a new run from their templates.
+4. **Terminal result**: Stopping a terminal Job preserves completed Task results in status until the new run starts.
+5. **Delete**: Deleting a Job first performs the stop-and-wait sequence, then removes the Job and child Tasks. Task PVCs are deleted; hostPath data is not.
### Key Features
-- **Non-destructive**: Stopping a Job does not delete the Job CR or its Tasks
-- **Persistent state**: PVCs and other persistent resources are not affected by stopping
-- **Web UI integration**: The Web UI provides one-click Stop/Start buttons in the Job list
+- **Configuration preservation**: Stop keeps the Job CR and configuration, but does not preserve task PVC data
+- **Clean recreation**: Start and restart create empty task PVCs; copy required output elsewhere first
+- **Web UI integration**: The Web UI provides Stop, Start, Restart, and Delete actions with lifecycle-aware availability
+
+When a Workflow is stopped, RLark deletes all Jobs from the current run, including completed Jobs. Resuming the Workflow creates a new run from the beginning of the DAG; completion state from the previous run is not reused.
## 8. Task
@@ -339,60 +347,7 @@ graph LR
Rollout <-->|"obs"| Camera
```
-## 9. Workflow
-
-Workflow is **DAG orchestration of multiple Jobs**, supporting training pipelines with dependencies.
-
-### Concept
-
-A Workflow contains multiple Job templates, each declaring upstream dependencies via `dependencies`. The Workflow Controller schedules Jobs in topological order: upstream Jobs must succeed before dependent Jobs can start.
-
-### Key Properties
-
-```yaml
-apiVersion: rlinf.io/v1alpha1
-kind: Workflow
-metadata:
- name: training-pipeline-v1
-spec:
- jobTemplates:
- - name: prepare-data
- dependencies: [] # No dependencies, start immediately
- spec:
- tasks:
- - name: prep
- role: Env
- agentType: Kubernetes
- kubernetes: ...
- - name: train
- dependencies: ["prepare-data"] # Start after prepare-data succeeds
- spec:
- tasks:
- - name: actor-head
- head: true
- role: Actor
- agentType: Kubernetes
- kubernetes: ...
- - name: evaluate
- dependencies: ["train"]
- spec:
- tasks: ...
-```
-
-### Typical Pipeline
-
-```
-Data Preparation ──▶ Model Training ──▶ Model Evaluation
- prepare train evaluate
-```
-
-### State Machine
-
-Similar to Job, Workflow state is determined by the aggregate of its Jobs' states:
-- All Jobs succeed → Workflow Succeeded
-- Any Job fails → Workflow Failed
-
-## 10. Pod
+## 9. Pod
Pod CR is the **control plane mirror** of data plane Pods, reported by the Agent's Push controller.
@@ -406,11 +361,10 @@ When a Pod is created in the data plane cluster, the Agent's Pod Push controller
- **SSH Lookup**: Server's PodCache quickly locates Pod's Agent based on Pod CR
- **Log Queries**: Gateway finds Pod's Agent via Pod CR and forwards log requests
-## 11. Resource Relationship Summary
+## 9. Resource Relationship Summary
```mermaid
graph TD
- wf["Workflow (Cluster scoped) DAG Pipeline"] -->|"1:N"| job["Job (Cluster scoped) Training Job"]
job -->|"1:N"| task["Task (Namespaced: agent-{id}) Exec Unit"]
task -->|"1:1 (K8s workload)"| workload["Deployment / DaemonSet / StatefulSet (Local k8s cluster)"]
workload -->|"1:N"| pod["Pod + Sidecar Agent Push reports → Pod CR"]
@@ -418,7 +372,7 @@ graph TD
node["Node (Namespaced) Compute Node"]
```
-## 12. Naming Conventions
+## 9. Naming Conventions
| Namespace Prefix | Meaning | Example |
|-----------------|---------|---------|
@@ -427,7 +381,7 @@ graph TD
| Label `rlinf.io/job` | Pod/Task's owning Job | `rlinf.io/job=ppo-cartpole-v1` |
| Annotation `rlinf.io/ray-role` | Ray cluster role | `head` / `worker` |
-## 13. Ray Cluster Integration
+## 9. Ray Cluster Integration
RLark supports declarative Ray cluster creation via Task annotations:
@@ -449,38 +403,52 @@ annotations:
## 14. Object Storage & PVCs
-RLark supports mounting persistent volumes to training tasks via `pvcStorageMap` in the Task specification.
+RLark mounts remote storage to training tasks with Kubernetes generic ephemeral volumes.
### Concept
-When a Task specifies `pvcStorageMap`, the Agent's Pull controller automatically creates PVCs with the specified StorageClass before creating the workload, and cleans them up when the task is deleted.
+Each Pod gets a PVC created from its volume's `ephemeral.volumeClaimTemplate`. Kubernetes owns this PVC and removes it with the Pod.
### Configuration
```yaml
kubernetes:
workload:
- pvcStorageMap:
- my-data-pvc: "ceph-rbd" # PVC name → StorageClass name
+ template:
+ spec:
+ volumes:
+ - name: data
+ ephemeral:
+ volumeClaimTemplate:
+ spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: ceph-rbd
+ resources:
+ requests:
+ storage: 10Gi
```
### How it works
-1. Agent queries StorageClasses via `GET /api/v1/storage/storageclass?clusters=`
-2. When creating a workload, Agent calls `ensurePVCs` to create PVCs with the specified StorageClass
-3. PVCs are created in the target namespace, scoped to the task
-4. On task deletion, PVCs are cleaned up automatically
+1. The frontend queries StorageClasses via `GET /api/v1/storage/storageclass?clusters=`
+2. The selected class and requested size are written into `volumeClaimTemplate`
+3. Kubernetes creates one PVC for each Pod and binds it through the selected StorageClass
+4. Kubernetes removes the PVC when its Pod is deleted
+
+`pvcStorageMap` and `pvcSizeGbMap` are deprecated and retained only for compatibility with existing Tasks. New Tasks should use `ephemeral.volumeClaimTemplate`.
## 15. User Authentication
-RLark provides login and role-based navigation for the Web UI. The current `admin` and `user` distinction is a **frontend gate only**: it selects the admin or platform console, but the Gateway does not enforce these roles as API authorization. Do not treat the UI role as a security boundary or expose the Gateway to untrusted clients on that basis.
+RLark authenticates Web UI and API requests with short-lived JWT access tokens. The verified `admin` and `user` roles enforce coarse-grained API authorization: platform operations are available to both roles, while control-plane configuration and credential management require `admin`.
### Authentication Flow
-1. During deployment, `rlarkadm` generates random passwords and stores them in a KCP Secret (`rlark-ui-auth`)
+1. During deployment, `rlarkadm` generates random passwords and a JWT signing key and stores them in a KCP Secret (`rlark-ui-auth`)
2. Web UI sends `POST /api/v1/auth/login` with username and password
-3. Gateway validates against the KCP Secret and returns the role
-4. Frontend stores the login result in `sessionStorage` and uses the selected console route as the role gate
+3. Gateway validates against the Secret and returns an HS256 JWT containing the subject, role, issue time, and expiration time
+4. The frontend stores the token in `sessionStorage` and sends it as `Authorization: Bearer ` on API requests; the Gateway verifies it before dispatching protected routes
+
+`user` can manage Jobs, Workflows, Tasks, Pods, terminal sessions, SSH keys, and storage objects, and read cluster, node, Domain, image, StorageClass, and system configuration data. Node and Domain mutations, certificates, image registries, system configuration updates, StorageClass management, and Addon management require `admin`. This is role-level authorization; per-user resource ownership, including SSH key ownership, is not yet enforced.
## 16. Addon (Component Management)
diff --git a/apps/rlark/docs/deployment.md b/apps/rlark/docs/deployment.md
index 0233101..427266a 100644
--- a/apps/rlark/docs/deployment.md
+++ b/apps/rlark/docs/deployment.md
@@ -71,6 +71,17 @@ kubernetes:
## 3. Kubernetes Deployment
+By default, `rlarkadm` deploys kcp as the management API. To use the target Kubernetes cluster itself, set:
+
+```yaml
+kubernetes:
+ management-api: kubernetes
+```
+
+In this mode, `rlarkadm` installs the RLark CRDs directly into the target cluster and does not deploy kcp or etcd. Server, Gateway, and Controller Manager use dedicated ServiceAccounts, ClusterRoles, and in-cluster credentials. Normal uninstall keeps the cluster-scoped CRDs, RLark custom resources, and management Secrets; removing them is a separate destructive operation.
+
+With kcp, `rlarkadm` stores the Controller Manager election Lease in the management API's `default` namespace. When the target Kubernetes cluster is the management API, it stores the Lease in `rlark-system`, alongside the deployed control-plane workloads.
+
### 3.1 Control Plane Deployment
```bash
@@ -170,7 +181,7 @@ volumes:
|-----------|---------|-------------|
| `--kubeconfig` | `$KUBECONFIG` | Control plane kubeconfig |
| `--server-address` | `https://rlark-server.rlark-system.svc:8443` | Server address |
-| `--leader-elect` | `true` | Enable leader election |
+| `--leader-election` | `true` | Enable leader election |
| `--metrics-bind-address` | `:8080` | Metrics bind address |
| `--health-probe-bind-address` | `:8081` | `/healthz` and `/readyz` bind address |
@@ -185,6 +196,8 @@ volumes:
### 5.4 Agent
+Ready-to-use data-plane manifests: [agent-rbac.yaml](examples/agent-rbac.yaml) and [agent-deploy.yaml](examples/agent-deploy.yaml).
+
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--kubeconfig` | `$KUBECONFIG` | Data plane kubeconfig |
@@ -240,13 +253,20 @@ pkg/addons/catalog/
│ ├── configmap-template.yaml # ConfigMap template (camera/ROS controller configs)
│ ├── headless-services.yaml # Headless Services for camera/ROS controllers
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding
-└── csi-driver-rclone/
- ├── addon.yaml # Addon metadata (name, version, category: storage)
+├── csi-driver-rclone/
+│ ├── addon.yaml # Addon metadata (name, version, category: storage)
+│ └── manifests/
+│ ├── controller.yaml # CSI Controller Deployment
+│ ├── node.yaml # CSI Node DaemonSet
+│ ├── configmap.yaml # RClone configuration
+│ ├── csidriver.yaml # CSIDriver resource
+│ └── rbac.yaml # RBAC permissions
+└── fluent-bit/
+ ├── addon.yaml # Addon metadata and logging backend configuration
└── manifests/
- ├── controller.yaml # CSI Controller Deployment
- ├── node.yaml # CSI Node DaemonSet
- ├── configmap.yaml # RClone configuration
- ├── csidriver.yaml # CSIDriver resource
+ ├── daemonset.yaml # Fluent Bit log collector DaemonSet
+ ├── configmap.yaml # Input, filter, and output configuration
+ ├── secret.yaml # Backend credentials
└── rbac.yaml # RBAC permissions
```
@@ -254,12 +274,16 @@ Key configurable parameters for `embodied-runtime-device-plugin`:
| Parameter | Description | Default |
|-----------|-------------|---------|
-| `image` | Device plugin container image | `rlark/embodied-device-plugin:0.1.0` |
+| `deviceCount` | Number of devices exposed per node | `"1"` |
+| `robots` | Global robot configuration in YAML | `""` |
+| `macvlans` | Global MacVlan configuration in YAML | `""` |
+| `nodeOverrides` | Per-node YAML overrides keyed by node name | `""` |
+| `image` | Device plugin container image | `rlinf/embodied-runtime:v0.1.0-b4b4d6f8` |
| `rendererImage` | Node-level config renderer initContainer image (yq) | `yq:4.53.2` |
-| `cameraImage` | Camera controller container image | — |
-| `rosImage` | ROS controller container image | — |
-| `nodeSelector` | Node selector for DaemonSet scheduling | `nvidia.com/gpu=true` |
-| `robotTolerationKey` | Toleration key for robot nodes | — |
+| `cameraImage` | Camera controller container image | `rlinf/camera-base:v0.1.0-946787a0` |
+| `rosImage` | ROS controller container image | `rlinf/serl_franka_controllers:v0.1.0-libfranka-0.19.0-frankaros-0.10.2` |
+| `nodeSelector` | Node selector for DaemonSet scheduling | `""` |
+| `robotTolerationKey` | Toleration key for robot nodes | `rlinf.io/robot` |
The addon also deploys two headless Services (`camera-controller-headless` and `ros-controller-headless`) for stable DNS-based discovery of camera and ROS controllers within the cluster.
@@ -267,17 +291,35 @@ Key configurable parameters for `csi-driver-rclone`:
| Parameter | Description | Default |
|-----------|-------------|---------|
-| `rcloneImage` | RClone CSI driver container image | `csi-driver-rclone:v0.2.0` |
-| `csiProvisionerImage` | CSI provisioner sidecar image | `csi-provisioner:v6.2.0` |
-| `livenessProbeImage` | Liveness probe sidecar image | `livenessprobe:v2.18.0` |
-| `nodeDriverRegistrarImage` | Node driver registrar sidecar image | `csi-node-driver-registrar:v2.16.0` |
+| `rcloneImage` | RClone CSI driver container image | `rlinf/csi-rclone/csi-driver-rclone:v0.2.0` |
+| `csiProvisionerImage` | CSI provisioner sidecar image | `rlinf/csi-rclone/csi-provisioner:v6.2.0` |
+| `livenessProbeImage` | Liveness probe sidecar image | `rlinf/csi-rclone/livenessprobe:v2.18.0` |
+| `nodeDriverRegistrarImage` | Node driver registrar sidecar image | `rlinf/csi-rclone/csi-node-driver-registrar:v2.16.0` |
| `driverName` | CSI driver registration name | `rclone.csi.veloxpack.io` |
+| `nodeSelector` | Node DaemonSet selector in `label=value` format | `""` |
| `controllerReplicas` | Controller Deployment replicas | `1` |
| `controllerLogLevel` | Controller log level (0-10) | `5` |
| `nodeLogLevel` | Node DaemonSet log level (0-10) | `5` |
The RClone CSI driver enables dynamic provisioning of PersistentVolumes backed by remote storage (S3, GCS, Azure Blob, etc.) via RClone.
+Key configurable parameters for `fluent-bit`:
+
+| Parameter | Description | Default |
+|-----------|-------------|---------|
+| `backend` | Logging backend; currently only `sls` is supported | `sls` |
+| `endpoint` | Backend endpoint; for SLS, the Kafka endpoint | `""` |
+| `project` | SLS project or backend tenant/organization | `""` |
+| `logstore` | SLS Logstore or backend index | `""` |
+| `accessKeyId` | Backend authentication ID | `""` |
+| `accessKeySecret` | Backend authentication secret | `""` |
+| `clusterId` | Value attached to logs as the `cluster_id` label | `""` |
+| `image` | Fluent Bit container image | `fluent/fluent-bit:3.1.8` |
+| `cpuLimit` | Fluent Bit CPU limit | `200m` |
+| `memoryLimit` | Fluent Bit memory limit | `256Mi` |
+
+For SLS, set `endpoint` to `.:` (public port `10012`, private port `10011`), `project` to the SLS project, `logstore` to the Kafka topic, and provide an AccessKey with SLS write permission. The DaemonSet collects Pod stdout/stderr and adds labels including `cluster_id`, `job`, `task`, `pod`, and `namespace`.
+
### 6.2 Installing an Addon
```bash
@@ -286,9 +328,9 @@ curl -X POST "http://localhost:8080/api/v1/clusters/agent-beijing/addons" \
-H "Content-Type: application/json" \
-d '{
"addonName": "embodied-runtime-device-plugin",
- "version": "0.1.0",
+ "version": "v0.1.0",
"values": {
- "image": "rlark/embodied-device-plugin:0.1.0"
+ "image": "rlinf/embodied-runtime:v0.1.0-b4b4d6f8"
}
}'
```
@@ -306,19 +348,19 @@ curl "http://localhost:8080/api/v1/installed-addons"
curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons"
# Get addon details
-curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin"
+curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin"
# Update addon configuration
-curl -X PUT "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin" \
+curl -X PUT "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin" \
-H "Content-Type: application/json" \
-d '{
"values": {
- "image": "rlark/embodied-device-plugin:0.2.0"
+ "image": "rlinf/embodied-runtime:v0.1.0-b4b4d6f8"
}
}'
# Uninstall an addon
-curl -X DELETE "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin"
+curl -X DELETE "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin"
```
## 7. Storage Configuration
@@ -393,20 +435,21 @@ Returns certificate and private key for deployment to the data plane Agent.
### 9.3 UI Authentication
-During deployment, `rlarkadm` automatically creates a `rlark-ui-auth` Secret in the kcp cluster's `default` namespace, containing randomly generated passwords for admin and user roles:
+During deployment, `rlarkadm` automatically creates a `rlark-ui-auth` Secret containing randomly generated passwords and the JWT signing key. It is stored in the kcp `default` namespace when kcp is used, or in `rlark-system` when the target Kubernetes cluster is the management API:
| Key | Purpose |
|-----|---------|
| `admin-password` | Admin role password (16 random characters) |
| `user-password` | User role password (16 random characters) |
+| `jwt-signing-key` | 32-byte HS256 signing key; existing Secrets are upgraded without rotating an existing key |
-Passwords are displayed in the install summary. The web UI uses `POST /api/v1/auth/login` to authenticate.
+Passwords are displayed in the install summary; the signing key is not. The web UI uses `POST /api/v1/auth/login` to authenticate and obtain an expiring JWT.
## 10. Production Deployment and High Availability
### 10.1 Current `rlarkadm` Scope
-The maintained `rlarkadm` example deploys one replica of each enabled control-plane component. Although the configuration accepts global and component-level `replicas`, RLark does not currently document or validate a production HA topology for Gateway, Server, kcp, etcd, or PostgreSQL. Increasing replica counts alone must not be assumed to provide high availability.
+The maintained `rlarkadm` example deploys one replica of each enabled control-plane component. kcp is currently limited to one replica; an explicit `kubernetes.kcp.replicas` value greater than one is rejected, and global `replicas` does not scale kcp. RLark does not currently document or validate a production HA topology for the other components, so increasing replica counts alone must not be assumed to provide high availability.
For production, keep the maintained single-replica topology unless you have independently designed and tested the component topology, shared state, traffic routing, failure recovery, and storage behavior. Use externally managed highly available data services where required; `rlarkadm` does not configure PostgreSQL primary/standby replication.
@@ -490,16 +533,20 @@ kubectl get pods -n rlark-system
1. Check if Agent certificate is valid (not expired, signed by correct CA)
2. Check network connectivity: `curl -k https://:8443`
-3. Check Server logs: `kubectl logs -n rlark-system deployment/server`
+3. Check Server logs: `kubectl logs -n rlark-system deployment/rlark-server`
### Training Job Cannot Start
-1. Check if Node has sufficient resources: `kubectl get nodes -n rlark-system`
+1. Check if Node has sufficient resources: `kubectl describe node `
2. Check Task status: query the corresponding Task CR
-3. Check Agent logs: `kubectl logs -n rlark-system daemonset/agent`
+3. Check cluster Agent logs: `kubectl logs -n rlark-system deployment/rlark-agent`
### Cross-Cluster Network Not Working
1. Check if DomainPeer has been created
2. Check if Domain certificate was signed successfully
-3. Check if network-sidecar is injected into the Pod
\ No newline at end of file
+3. Check if network-sidecar is injected into the Pod
+
+## 14. Physical Device Onboarding
+
+For the complete workflow for onboarding GPU nodes, robots, cameras, and other physical devices, configuring the Embodied Runtime, and submitting device workloads, see [Embodied Device Onboarding](admin-guide/embodied-runtime.md).
diff --git a/apps/rlark/docs/developer-guide/local-development.md b/apps/rlark/docs/developer-guide/local-development.md
index 4d598c9..806555c 100644
--- a/apps/rlark/docs/developer-guide/local-development.md
+++ b/apps/rlark/docs/developer-guide/local-development.md
@@ -66,7 +66,7 @@ rlark-gateway \
rlark-controller-manager \
--server-address=https://localhost:8443 \
--db-config=apps/rlark/docs/examples/db-config.yaml \
- --leader-elect=false \
+ --leader-election=false \
--metrics-bind-address=:0 \
--health-probe-bind-address=:0
```
@@ -212,4 +212,4 @@ Recommended VS Code extensions:
- Go
- ESLint
- Prettier
-- YAML
\ No newline at end of file
+- YAML
diff --git a/apps/rlark/docs/examples/agent-rbac.yaml b/apps/rlark/docs/examples/agent-rbac.yaml
index d808d6b..d31c726 100644
--- a/apps/rlark/docs/examples/agent-rbac.yaml
+++ b/apps/rlark/docs/examples/agent-rbac.yaml
@@ -12,7 +12,7 @@ metadata:
name: rlark-agent
rules:
- apiGroups: [""]
- resources: ["nodes", "pods", "services", "configmaps", "secrets", "namespaces", "persistentvolumeclaims", "events"]
+ resources: ["nodes", "pods", "pods/log", "services", "configmaps", "secrets", "namespaces", "persistentvolumeclaims", "events"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
diff --git a/apps/rlark/docs/examples/deploy-control-plane.yaml b/apps/rlark/docs/examples/deploy-control-plane.yaml
index 0aa085e..0b765f4 100644
--- a/apps/rlark/docs/examples/deploy-control-plane.yaml
+++ b/apps/rlark/docs/examples/deploy-control-plane.yaml
@@ -16,6 +16,8 @@ plane: control
kubernetes:
kubeconfig: ~/.kube/config # kubeconfig for the target cluster
+ # 使用目标 Kubernetes API 存储 RLark 资源且不部署 kcp/etcd:
+ # management-api: kubernetes
gateway-image: rlark:latest
controller-manager-image: rlark:latest
server-image: rlark:latest
@@ -24,6 +26,9 @@ kubernetes:
postgresql-image: postgres:15
ui-image: rlark-ui:latest
+ # 组件镜像拉取策略:Always | IfNotPresent | Never,默认 Always
+ #image-pull-policy: IfNotPresent
+
# 全局副本数会应用到所有组件;维护中的拓扑使用 1。
# 多副本不等同于经过验证的 HA,请勿直接用于生产 HA 设计。
#replicas: 1
@@ -68,6 +73,8 @@ kubernetes:
# etcd-image: etcd:3.5
# postgresql-image: postgres:15
# ui-image: rlark-ui:latest
+# # 组件镜像拉取策略:Always | IfNotPresent | Never,默认 Always
+# image-pull-policy: IfNotPresent
#raw:
# gateway-artifact: https://github.com/rlinf/rlark/releases/download/v0.1.0/rlark-gateway
@@ -75,4 +82,4 @@ kubernetes:
# server-artifact: https://github.com/rlinf/rlark/releases/download/v0.1.0/rlark-server
# kcp-artifact: https://github.com/kcp-dev/kcp/releases/download/v0.30.0/kcp
# etcd-artifact: https://github.com/etcd-io/etcd/releases/download/v3.5.0/etcd
-# postgresql-artifact: https://github.com/rlinf/rlark/releases/download/v0.1.0/rlark-postgresql
\ No newline at end of file
+# postgresql-artifact: https://github.com/rlinf/rlark/releases/download/v0.1.0/rlark-postgresql
diff --git a/apps/rlark/docs/examples/deploy-data-plane.yaml b/apps/rlark/docs/examples/deploy-data-plane.yaml
index e5c0146..a35b908 100644
--- a/apps/rlark/docs/examples/deploy-data-plane.yaml
+++ b/apps/rlark/docs/examples/deploy-data-plane.yaml
@@ -9,6 +9,7 @@ apiVersion: rlark.io/v1alpha1
kind: DeployConfig
plane: data
control-plane-address: https://rlark-server.rlark-system.svc:8443
+# ssh-address: client@rlark-server.rlark-system.svc:2222 # 可选,跨集群网络的 Server SSH 地址 user@host:port;留空则从 control-plane-address 自动推导
cert:
ca-cert: |
@@ -31,6 +32,7 @@ kubernetes:
agent-image: rlark:latest
image: rlark:latest # 可选,启用跨集群 Pod 网络互通和 SSH server
# containerd-socket: /run/k3s/containerd/containerd.sock # 可选,k3s 等非标准 containerd 路径,启用 node-agent 镜像拉取进度监控
+ # image-pull-policy: IfNotPresent # 可选,组件镜像拉取策略 Always | IfNotPresent | Never,默认 Always
#docker:
# agent-image: rlark-agent:latest
diff --git a/apps/rlark/docs/examples/docker-compose.yml b/apps/rlark/docs/examples/docker-compose.yml
index cb59919..fa2348b 100644
--- a/apps/rlark/docs/examples/docker-compose.yml
+++ b/apps/rlark/docs/examples/docker-compose.yml
@@ -93,7 +93,7 @@ services:
- "/rlark-controller-manager"
- "--kubeconfig=/etc/rlark/kubeconfig.yaml"
- "--server-address=https://rlark-server:8443"
- - "--leader-elect=false"
+ - "--leader-election=false"
- "--metrics-bind-address=:0"
- "--health-probe-bind-address=:0"
- "--db-config=/etc/rlark/db-config.yaml"
@@ -111,4 +111,4 @@ volumes:
networks:
rlark-network:
- driver: bridge
\ No newline at end of file
+ driver: bridge
diff --git a/apps/rlark/docs/images/architecture-zh.png b/apps/rlark/docs/images/architecture-zh.png
new file mode 100644
index 0000000..11ad82f
Binary files /dev/null and b/apps/rlark/docs/images/architecture-zh.png differ
diff --git a/apps/rlark/docs/images/architecture-zh.svg b/apps/rlark/docs/images/architecture-zh.svg
new file mode 100644
index 0000000..e076ef1
--- /dev/null
+++ b/apps/rlark/docs/images/architecture-zh.svg
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 用户层
+
+
+
+ Web 界面
+ API 客户端
+ RLinf/框架集
+
+
+
+
+
+
+
+ 控制面
+
+
+
+
+ API 网关服务
+ 跨集群通信服务
+ 任务编排服务
+ 运维监控服务
+
+
+
+
+
+
+ 数据面
+ 云端 GPU 集群
+
+
+
+
+ 集群代理
+ 节点代理
+ 算力设备插件
+ 算力节点
+
+ Kubernetes
+
+ 算力硬件
+
+
+
+
+
+
+
+
+
+
+
+ 跨集群网络
+
+
+
+
+ 数据面
+ 边缘设备集群
+
+
+
+
+
+ 集群代理
+ 节点代理
+ 具身设备插件
+ 统一运行时
+ 机器人工作节点
+
+ Kubernetes
+
+ 机械臂
+ 传感器
+ 边缘算力
+ 其他
+
diff --git a/apps/rlark/docs/images/architecture.png b/apps/rlark/docs/images/architecture.png
index 3ab3192..4a6e2ed 100644
Binary files a/apps/rlark/docs/images/architecture.png and b/apps/rlark/docs/images/architecture.png differ
diff --git a/apps/rlark/docs/images/architecture.svg b/apps/rlark/docs/images/architecture.svg
index b7019c5..74cd3e2 100644
--- a/apps/rlark/docs/images/architecture.svg
+++ b/apps/rlark/docs/images/architecture.svg
@@ -1,229 +1,104 @@
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
- Client Layer
-
-
-
- Web UI
-
-
-
-
- API Clients
-
-
-
-
-
-
-
-
- Control Plane
-
-
-
- UI Server
- Nginx
-
-
-
-
- Gateway
- REST API
-
-
-
-
- Server
- HTTPS + SSH
-
-
-
-
- Controller
- Workload · Domain
-
-
-
-
- KCP + ETCD* + PostgreSQL*
- CRD Storage
-
-
-
-
-
-
- GPU Cluster
-
- Kubernetes
-
- Docker†
-
- Raw†
-
-
-
-
- Cluster Agent
-
-
-
-
- Node Agent
-
-
-
-
-
-
-
-
-
-
-
- Pod
-
- main
-
- sidecar
-
- GPU
-
-
-
- Pod
-
- main
-
- sidecar
-
- GPU
-
+
+
+
+
+
+ Access Layer
+
+
+
+ Web UI
+ API Client
+ RLinf/Frameworks
+
+
+
+
+
+
+
+ Control Plane
+
+
+
+
+ API Gateway
+ Cross-cluster
+ Communication
+ Task Orchestration
+ Operations &
+ Observability
+
+
+
+
+
+
+ Data Plane
+ Cloud GPU Cluster
+
+
+
+
+ Cluster Agent
+ Node Agent
+ GPU Device
+ Plugin
+ Compute Nodes
+
+ Kubernetes
+
+ GPU Hardware
+
+
+
+
+
+
+
-
-
-
- Edge Device Cluster
-
- Kubernetes
-
- Docker†
-
- Raw†
-
-
-
-
- Cluster
- Agent
-
-
-
-
- Node
- Agent
-
-
-
-
- Device
- Plugin
-
-
-
- Embodied Runtime
-
-
-
-
- Controller
- ros / ros2
- camera
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Pod
-
- main
-
- sidecar
-
- Robot · Camera
-
- /var/run/rlark/...sock
-
-
-
- Pod
-
- main
-
- sidecar
-
- Robot · Camera
-
- /var/run/rlark/...sock
-
-
-
-
-
-
-
-
-
-
-
-
-
- WebSocket — Resource Sync
-
- SSH Tunnel via Unix Socket — Network Routing
-
- gRPC via Unix Socket — Device Control
- * Marked components are optional
- † Marked runtimes are planned
-
-
\ No newline at end of file
+
+
+ Cross-cluster
+ Networking
+
+
+
+
+ Data Plane
+ Edge Device Cluster
+
+
+
+
+
+ Cluster Agent
+ Node
+ Agent
+ Embodied Device
+ Plugin
+ Unified Runtime
+ Robot Worker Nodes
+
+ Kubernetes
+
+ Robot Arm
+ Sensors
+ Edge Compute
+ Others
+
diff --git a/apps/rlark/docs/images/logo-en.png b/apps/rlark/docs/images/logo-en.png
index 7e3c966..ab1642b 100644
Binary files a/apps/rlark/docs/images/logo-en.png and b/apps/rlark/docs/images/logo-en.png differ
diff --git a/apps/rlark/docs/images/logo-zh.png b/apps/rlark/docs/images/logo-zh.png
index bdb0a67..6e3980e 100644
Binary files a/apps/rlark/docs/images/logo-zh.png and b/apps/rlark/docs/images/logo-zh.png differ
diff --git a/apps/rlark/docs/images/ui/admin-clusters-nodes.jpg b/apps/rlark/docs/images/ui/admin-clusters-nodes.jpg
index 19df9a3..674ca39 100644
Binary files a/apps/rlark/docs/images/ui/admin-clusters-nodes.jpg and b/apps/rlark/docs/images/ui/admin-clusters-nodes.jpg differ
diff --git a/apps/rlark/docs/images/ui/admin-create-cluster.jpg b/apps/rlark/docs/images/ui/admin-create-cluster.jpg
index aa1199c..621843a 100644
Binary files a/apps/rlark/docs/images/ui/admin-create-cluster.jpg and b/apps/rlark/docs/images/ui/admin-create-cluster.jpg differ
diff --git a/apps/rlark/docs/images/ui/console-overview.png b/apps/rlark/docs/images/ui/console-overview.png
index 2c16e21..9d76d7c 100644
Binary files a/apps/rlark/docs/images/ui/console-overview.png and b/apps/rlark/docs/images/ui/console-overview.png differ
diff --git a/apps/rlark/docs/images/ui/create-job-worker-configuration.png b/apps/rlark/docs/images/ui/create-job-worker-configuration.png
index 7274a37..547bc3d 100644
Binary files a/apps/rlark/docs/images/ui/create-job-worker-configuration.png and b/apps/rlark/docs/images/ui/create-job-worker-configuration.png differ
diff --git a/apps/rlark/docs/images/ui/create-job.jpg b/apps/rlark/docs/images/ui/create-job.jpg
deleted file mode 100644
index bfcc167..0000000
Binary files a/apps/rlark/docs/images/ui/create-job.jpg and /dev/null differ
diff --git a/apps/rlark/docs/images/ui/domain-ui.png b/apps/rlark/docs/images/ui/domain-ui.png
index fb50cf7..c57de5c 100644
Binary files a/apps/rlark/docs/images/ui/domain-ui.png and b/apps/rlark/docs/images/ui/domain-ui.png differ
diff --git a/apps/rlark/docs/images/ui/first-login-cluster-detail.png b/apps/rlark/docs/images/ui/first-login-cluster-detail.png
index 348ab45..58a049f 100644
Binary files a/apps/rlark/docs/images/ui/first-login-cluster-detail.png and b/apps/rlark/docs/images/ui/first-login-cluster-detail.png differ
diff --git a/apps/rlark/docs/images/ui/first-login-cluster-list.png b/apps/rlark/docs/images/ui/first-login-cluster-list.png
index 8cdc1e1..a87c239 100644
Binary files a/apps/rlark/docs/images/ui/first-login-cluster-list.png and b/apps/rlark/docs/images/ui/first-login-cluster-list.png differ
diff --git a/apps/rlark/docs/images/ui/first-login-job-logs.png b/apps/rlark/docs/images/ui/first-login-job-logs.png
index 4ab7724..1502547 100644
Binary files a/apps/rlark/docs/images/ui/first-login-job-logs.png and b/apps/rlark/docs/images/ui/first-login-job-logs.png differ
diff --git a/apps/rlark/docs/images/ui/first-login-job-worker.png b/apps/rlark/docs/images/ui/first-login-job-worker.png
index 7c1a110..e8cbf09 100644
Binary files a/apps/rlark/docs/images/ui/first-login-job-worker.png and b/apps/rlark/docs/images/ui/first-login-job-worker.png differ
diff --git a/apps/rlark/docs/images/ui/first-login-node-detail.png b/apps/rlark/docs/images/ui/first-login-node-detail.png
index ea2491d..656ddde 100644
Binary files a/apps/rlark/docs/images/ui/first-login-node-detail.png and b/apps/rlark/docs/images/ui/first-login-node-detail.png differ
diff --git a/apps/rlark/docs/images/ui/job-details-worker-and-pod.png b/apps/rlark/docs/images/ui/job-details-worker-and-pod.png
index 2f13a43..825f08a 100644
Binary files a/apps/rlark/docs/images/ui/job-details-worker-and-pod.png and b/apps/rlark/docs/images/ui/job-details-worker-and-pod.png differ
diff --git a/apps/rlark/docs/images/ui/ssh-key-ui.png b/apps/rlark/docs/images/ui/ssh-key-ui.png
index 30ef890..78cf946 100644
Binary files a/apps/rlark/docs/images/ui/ssh-key-ui.png and b/apps/rlark/docs/images/ui/ssh-key-ui.png differ
diff --git a/apps/rlark/docs/images/ui/storage-file-browser.png b/apps/rlark/docs/images/ui/storage-file-browser.png
index 61369ea..ccbb096 100644
Binary files a/apps/rlark/docs/images/ui/storage-file-browser.png and b/apps/rlark/docs/images/ui/storage-file-browser.png differ
diff --git a/apps/rlark/docs/index.md b/apps/rlark/docs/index.md
index b056dbe..0737b19 100644
--- a/apps/rlark/docs/index.md
+++ b/apps/rlark/docs/index.md
@@ -13,10 +13,6 @@ hide:
-
-
-
-
RLark — Cross-Cluster Embodied Intelligence Cloud-Native Platform
@@ -30,18 +26,20 @@ Manage cross-cluster embodied intelligence workloads natively with Kubernetes, f
## Key Capabilities
-- **Embodied AI Workload Orchestration**: From cloud GPU training (RL/LLM) to edge deployment, unified declarative Job/Workflow/Task abstraction across the full pipeline
+- **Embodied AI Workload Orchestration**: From cloud GPU training (RL/LLM) to edge deployment, 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 and Node CRDs, with the control plane running on kcp
-- **Declarative Training Jobs**: Multi-layer abstraction with DAG-based training pipelines and declarative Ray cluster definition
+- **Declarative Training Jobs**: Job/Task-based multi-role orchestration with declarative Ray cluster definitions
- **Cross-Cluster Pod Networking**: Virtual network based on TUN devices + gVisor netstack + SSH tunnels, enabling Pod-to-Pod communication without NAT traversal
- **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
## Architecture Overview
+This is a simplified view of the main control-plane and data-plane components.
+
-
+
## Quick Start
diff --git a/apps/rlark/docs/overview/capabilities.md b/apps/rlark/docs/overview/capabilities.md
index 45e3780..e9ea504 100644
--- a/apps/rlark/docs/overview/capabilities.md
+++ b/apps/rlark/docs/overview/capabilities.md
@@ -5,7 +5,7 @@ RLark provides a unified control plane for heterogeneous embodied-intelligence i
- **Multi-cluster resource management** — onboard multiple runtimes and view usable compute and embodied devices consistently.
- **Kubernetes Runtime — Preview:** implemented and suitable for evaluation; production stability is not yet guaranteed.
- **Docker / Raw Runtime — Planned:** API and controller scaffolding only; workloads cannot run on these runtimes yet.
-- **Job and workflow orchestration** — describe distributed jobs as Tasks and compose repeatable pipelines as Workflows.
+- **Job orchestration** — describe distributed jobs as coordinated Tasks across available compute resources.
- **Cross-cluster networking** — connect workloads through TUN, gVisor netstack, and SSH tunnels without requiring direct inbound connectivity.
- **Interactive development** — inspect Workers, copy SSH commands, and open WebTerminal sessions from the console.
- **Embodied Runtime** — expose robots and cameras as schedulable resources through device plugins and runtime controllers.
diff --git a/apps/rlark/docs/quickstart.md b/apps/rlark/docs/quickstart.md
index a97e970..db407f6 100644
--- a/apps/rlark/docs/quickstart.md
+++ b/apps/rlark/docs/quickstart.md
@@ -75,6 +75,24 @@ The script completes these steps:
| 11 | Create cross-cluster test resources (Workspace, Domain, Job) |
| 12 | Verify cross-cluster network connectivity |
+#### Advanced options
+
+The one-click script accepts environment variables rather than command-line flags:
+
+```bash
+# Create three data plane clusters instead of the default two
+CLUSTER_COUNT=3 bash apps/rlark/docs/examples/quickstart.sh
+
+# Use a different kind node image (default: kindest/node:v1.31.0)
+KIND_IMAGE=kindest/node:v1.32.0 bash apps/rlark/docs/examples/quickstart.sh
+
+# Combine both settings
+CLUSTER_COUNT=3 KIND_IMAGE=kindest/node:v1.32.0 \
+ bash apps/rlark/docs/examples/quickstart.sh
+```
+
+`CLUSTER_COUNT` controls how many `rlark-data-N` clusters are created. `KIND_IMAGE` must name a kind-compatible node image; the script first checks the local Docker image cache, then tries Docker Hub and its configured mirror.
+
### 2. Sign in to the UI
After the script completes, start the UI locally:
@@ -90,7 +108,7 @@ Open `http://localhost:5173/admin`. Use the credentials from the script output:
| Service | URL | Purpose |
|---------|-----|---------|
| Admin Console | `http://localhost:5173/admin` | Cluster onboarding, nodes, certificates |
-| Platform | `http://localhost:5173` | Jobs, Workers, Workflows, storage |
+| Platform | `http://localhost:5173` | Jobs, Workers, storage |
| Gateway API | `http://localhost:9000` | Automation |
### 3. Clean Up
@@ -124,6 +142,12 @@ This script:
!!! tip "Keep the terminal open"
The UI dev server runs in the foreground. Keep this terminal open while you use the UI. Press `Ctrl+C` to stop the UI when done.
+To start only the control plane without installing Node.js or running the UI dev server, pass `--no-ui`:
+
+```bash
+bash apps/rlark/docs/examples/quickstart-cp.sh --no-ui
+```
+
The output shows:
```
@@ -180,6 +204,22 @@ bash apps/rlark/docs/examples/quickstart-dp.sh \
--cluster-id my-cluster-2
```
+The script derives the number of kind clusters from the number of repeated `--cluster-id` options. Use `--cluster-name` to change the kind cluster name prefix (the default is `rlark-data`); an index is always appended, even for one cluster:
+
+```bash
+# Creates edge-1 and edge-2 for the two cluster IDs
+bash apps/rlark/docs/examples/quickstart-dp.sh \
+ --cluster-id my-cluster-1 \
+ --cluster-id my-cluster-2 \
+ --cluster-name edge
+
+# Override the kind node image (default: kindest/node:v1.31.0)
+KIND_IMAGE=kindest/node:v1.32.0 \
+ bash apps/rlark/docs/examples/quickstart-dp.sh --cluster-id my-cluster
+```
+
+`CLUSTER_COUNT` is not an input for `quickstart-dp.sh`; it is calculated internally from the supplied cluster IDs. Set `CLUSTER_COUNT` only when running the one-click `quickstart.sh` script.
+
### 4. Verify the Cluster and Nodes
**Using the UI:** Admin Console → Clusters and Nodes. Verify both clusters are online and their nodes are synchronized.
@@ -203,6 +243,8 @@ curl -s "http://localhost:9000/api/v1/rlinf.io/v1alpha1/nodes" | \
- **Image**: `rayproject/ray:2.9.0-py310`
- **Run Script**: `echo hello from RLark; sleep 3600`
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

6. Review the YAML preview and click **Submit**
@@ -255,7 +297,7 @@ kubectl --kubeconfig /tmp/kind-kubeconfig-2 exec -n rlark-system \
Expected output: `200`
-See [Networking and Security](admin-guide/network-security.md) for details.
+See [Networking and Security](admin-guide/network-security.md) for details. Reusable manifests: [domain.yaml](examples/domain.yaml) and [cross-cluster-ping.yaml](examples/cross-cluster-ping.yaml).
### 7. Clean Up
diff --git a/apps/rlark/docs/reference/configuration.md b/apps/rlark/docs/reference/configuration.md
index 6d936f7..2e71a02 100644
--- a/apps/rlark/docs/reference/configuration.md
+++ b/apps/rlark/docs/reference/configuration.md
@@ -99,17 +99,26 @@ rlark-gateway \
## rlark-controller-manager
-Controller manager. Reconciles Jobs, Workflows, and Domain resources.
+Controller manager. Reconciles Jobs and Domain resources.
| Flag | Type | Default | Description |
| ------ | ------ | --------- | ------------- |
| `--server-address` | string | `https://rlark-server.rlark-system.svc:8443` | RLark server address |
| `--db-config` | string | `""` | Database configuration file path |
-| `--leader-elect` | bool | `true` | Enable leader election for HA |
-| `--leader-election-id` | string | `rlark-controller-manager` | Leader election identity |
+| `--leader-election` | bool | `true` | Enable leader election for HA |
+| `--leader-election-key` | string | `rlark-controller-manager` | Leader election lock key (`name` or `namespace/name`) |
+| `--leader-election-id` | string | `""` | Reserved participant identity; controller-runtime currently generates its own identity |
| `--metrics-bind-address` | string | `:8080` | Metrics endpoint bind address |
| `--health-probe-bind-address` | string | `:8081` | Health probe endpoint bind address |
-| `--sync-workers` | int | `5` | Number of concurrent sync workers |
+| `--job-controller-workers` | int | `8` | Maximum concurrent Job reconciles |
+| `--task-controller-workers` | int | `8` | Maximum concurrent Task reconciles |
+| `--workflow-controller-workers` | int | `8` | Maximum concurrent Workflow reconciles |
+| `--node-controller-workers` | int | `8` | Maximum concurrent Node reconciles |
+| `--domain-controller-workers` | int | `8` | Maximum concurrent Domain reconciles |
+| `--job-sync-controller-workers` | int | `8` | Maximum concurrent Job database sync reconciles |
+| `--task-sync-controller-workers` | int | `8` | Maximum concurrent Task database sync reconciles |
+| `--workflow-sync-controller-workers` | int | `8` | Maximum concurrent Workflow database sync reconciles |
+| `--node-sync-controller-workers` | int | `8` | Maximum concurrent Node database sync reconciles |
| `--kubeconfig` | string | `$KUBECONFIG` | kubeconfig file path |
| `--master` | string | `""` | Kubernetes API server address |
| `--in-cluster` | bool | `false` | Use in-cluster Kubernetes config |
@@ -119,7 +128,7 @@ Controller manager. Reconciles Jobs, Workflows, and Domain resources.
| `--kube-timeout` | duration | `0` | Kubernetes client request timeout |
!!! note "Single-instance deployment"
- Set `--leader-elect=false` for single-instance deployments to avoid unnecessary election overhead.
+ Set `--leader-election=false` for single-instance deployments to avoid unnecessary election overhead.
**Example:**
@@ -127,7 +136,7 @@ Controller manager. Reconciles Jobs, Workflows, and Domain resources.
rlark-controller-manager \
--server-address=https://rlark-server:8443 \
--db-config=/etc/rlark/db-config.yaml \
- --leader-elect=false \
+ --leader-election=false \
--metrics-bind-address=:8080 \
--health-probe-bind-address=:8081
```
@@ -150,8 +159,21 @@ Data plane agent. Deployed on each cluster or node. Manages node registration, T
| `--leader-election-key` | string | `default/rlark-agent` | Leader election key (namespace/name) |
| `--leader-election-id` | string | `hostname-pid` | Leader election identity |
| `--metrics-bind-address` | string | `:8081` | Metrics endpoint bind address |
+| `--task-pull-controller-workers` | int | `8` | Maximum concurrent Task pull reconciles |
+| `--addon-pull-controller-workers` | int | `8` | Maximum concurrent Addon pull reconciles |
+| `--task-deployment-push-controller-workers` | int | `8` | Maximum concurrent Task Deployment push reconciles |
+| `--task-daemonset-push-controller-workers` | int | `8` | Maximum concurrent Task DaemonSet push reconciles |
+| `--task-statefulset-push-controller-workers` | int | `8` | Maximum concurrent Task StatefulSet push reconciles |
+| `--node-push-controller-workers` | int | `8` | Maximum concurrent Node push reconciles |
+| `--pod-push-controller-workers` | int | `8` | Maximum concurrent Pod push reconciles |
+| `--pod-orphan-sweep-interval` | duration | `5m` | Interval between agent-scoped management Pod orphan sweeps |
+| `--pod-orphan-sweep-page-size` | int | `200` | Management Pods processed per orphan sweep page |
+| `--pod-stale-ttl` | duration | `15m` | Time a missing local Pod is retained as `Unknown`/stale before its management Pod is deleted |
+
+The Pod orphan sweep is a fallback for missed local delete events. It deletes only agent-scoped mirrors whose local Pod UID or verified management Task UID is no longer current. Legacy mirrors are adopted only when the UID-named mirror, live local Pod annotations, management namespace, Task UID, and available domain all agree; ambiguous legacy objects remain untouched and require manual cleanup. A delayed delete intentionally preserves a same-name replacement, so stale mirrors may remain until the next sweep interval.
| `--rlark-server-ssh-address` | string | `""` | RLark server SSH address (user@host:port) |
| `--rlark-server-ssh-host-key` | string | `""` | RLark server SSH host key |
+| `--ssh-max-connections-per-domain` | int | `4` | Maximum adaptive physical SSH connections per Domain |
| `--image` | string | `""` | RLark network sidecar image |
| `--enable-same-cluster-direct` | bool | `true` | Enable same-cluster direct Pod access |
| `--enable-cross-cluster-direct` | bool | `true` | Enable cross-cluster direct Pod access |
@@ -197,10 +219,13 @@ Network sidecar. Runs alongside each Task Pod to provide cross-cluster Pod-to-Po
| `--sidecar-tun-name` | string | `gnet0` | TUN device name |
| `--sidecar-tun-mtu` | int | `1500` | TUN device MTU |
| `--sidecar-proxy-listen` | string | `:5700` | Proxy TCP listen address |
-| `--sidecar-hosts-sync-enabled` | bool | `true` | Enable periodic hosts file sync |
-| `--sidecar-hosts-sync-interval` | duration | `30s` | Hosts sync interval |
+| `--sidecar-metrics-listen` | string | `:5790` | Metrics and pprof HTTP listen address; set to an empty value to disable |
+| `--sidecar-hosts-sync-enabled` | bool | `true` | Enable hosts file synchronization |
+| `--sidecar-hosts-sync-interval` | duration | `30s` | Fallback polling interval for NodeServers without the hosts watch API |
| `--sidecar-hosts-file` | string | `/etc/hosts` | Hosts file path |
+New sidecars use the NodeServer `/watch_hosts` long-poll endpoint to receive host changes within approximately one second. If the endpoint is unavailable, they automatically fall back to the configured polling interval, preserving compatibility with older NodeServers.
+
**Example:**
```bash
@@ -210,9 +235,9 @@ rlark-network-sidecar \
--sidecar-tun-mtu=1500
```
-## sshd
+## rlark-tools sshd
-SSH daemon. Provides SSH access to running Task Pods. Integrated into rlark-server via `--ssh-port`.
+The `sshd` subcommand provides SSH access to running Task Pods. The agent always injects the `rlark-tools` binary at `/rlark-tools/rlark-tools`; workloads that need SSH start it with `rlark-tools sshd`.
| Flag | Type | Default | Description |
| ------ | ------ | --------- | ------------- |
@@ -224,7 +249,8 @@ SSH daemon. Provides SSH access to running Task Pods. Integrated into rlark-serv
| Variable | Description |
| ---------- | ------------- |
| `RLARK_SSH_PUBLIC_KEY` | SSH public key for authorized_keys |
-| `RLARK_SSH_AUTHORIZED_KEYS_FILE` | Path to authorized_keys file |
+
+The Agent environment variable `RLARK_ENABLE_UNSAFE_TASK_PRIVILEGES=true` enables the legacy task mode that grants all task containers privileged access and enables host networking for tasks other than Ray heads. It is disabled by default and should only be used in trusted clusters.
## Storage Provider Configuration
@@ -264,8 +290,8 @@ These names are the exact YAML keys accepted by `rlarkadm`.
| `cert` | CertConfig | unset | Certificate configuration; required for the data plane |
| `insecure-skip-tls-verify` | bool | `false` | Skip Server TLS verification |
-!!! note "Environment selection"
- Choose exactly one of `kubernetes`, `docker`, or `raw`. The data plane also requires `control-plane-address` and `cert`.
+!!! warning "Runtime support"
+ The configuration schema retains `kubernetes`, `docker`, and `raw`, but the current supported workload path is Kubernetes only. Do not use Docker or Raw for current deployments; they are not recommended or supported workload paths. For a Kubernetes data plane, also provide `control-plane-address` and `cert`.
### DBConfig
@@ -281,6 +307,7 @@ These names are the exact YAML keys accepted by `rlarkadm`.
| Field | Type | Default | Description |
| ------- | ------ | --------- | ------------- |
+| `management-api` | string | `kcp` | Management API mode: `kcp` deploys kcp/optional etcd; `kubernetes` stores RLark resources in the target cluster and deploys neither kcp nor etcd |
| `kubeconfig` | string | `""` | kubeconfig file path; an empty value uses the normal client-go loading rules |
| `gateway-image` | string | `""` | Gateway image |
| `controller-manager-image` | string | `""` | Controller Manager image |
@@ -291,10 +318,11 @@ These names are the exact YAML keys accepted by `rlarkadm`.
| `etcd-image` | string | `""` | Built-in etcd image; built-in etcd is enabled only when set and no external address is configured |
| `postgresql-image` | string | `""` | PostgreSQL image; PostgreSQL is enabled only when the top-level `db` block is set |
| `ui-image` | string | `""` | UI image |
+| `image-pull-secrets` | string list | empty | Names of existing image pull Secrets in the `rlark-system` namespace, applied to all component Pods |
| `replicas` | int | `0` (resolved to `1`) | Default component replicas |
| `storage` | StorageConfig | unset | Default storage configuration |
-| `kcp` | ComponentConfig | unset | kcp component config |
-| `etcd` | EtcdConfig | unset | etcd component config |
+| `kcp` | ComponentConfig | unset | kcp component config. kcp is currently limited to one replica. Without `etcd`, it uses a StatefulSet and supports persistent storage; with deployed or external etcd, it uses a Deployment |
+| `etcd` | EtcdConfig | unset | etcd component config. An empty `address` deploys etcd; a non-empty value selects external etcd |
| `postgresql` | ComponentConfig | unset | PostgreSQL component config |
| `containerd-socket` | string | `/run/containerd/containerd.sock` | Node Agent containerd socket path |
@@ -303,6 +331,9 @@ These names are the exact YAML keys accepted by `rlarkadm`.
### DockerEnv
+!!! warning "Not currently supported"
+ These fields remain in the schema for compatibility, but Docker is not a supported workload path and is not recommended for deployment.
+
| Field | Type | Description |
| ------- | ------ | ------------- |
| `gateway-image` | string | Gateway image |
@@ -317,8 +348,8 @@ These names are the exact YAML keys accepted by `rlarkadm`.
### RawEnv
-!!! warning "Experimental"
- Raw deployment is experimental. Prefer Kubernetes or Docker.
+!!! warning "Not currently supported"
+ These fields remain in the schema for compatibility, but Raw is not a supported workload path and is not recommended for deployment. Use Kubernetes.
| Field | Type | Description |
| ------- | ------ | ------------- |
diff --git a/apps/rlark/docs/reference/crd.md b/apps/rlark/docs/reference/crd.md
index c6082b4..a9fecd4 100644
--- a/apps/rlark/docs/reference/crd.md
+++ b/apps/rlark/docs/reference/crd.md
@@ -1,6 +1,8 @@
# CRD Schema Reference
-Kubernetes resource operations and schemas generated from the current CRD manifests. This is not the RLark Gateway HTTP API reference.
+> **Generated file:** This page is generated from `api/config/crd/bases` by `apps/rlark/cmd/crd-api-docgen`. Do not edit it manually; run `make generate-crd-schema-docs` instead.
+
+Kubernetes resource operations and schemas generated from the current CRD manifests. This is not the RLark Gateway HTTP API reference. Descriptions are copied from source schemas, may retain their original language, and are shortened for readability.
## Addon
@@ -760,6 +762,10 @@ Responses:
- `domain`: `string`, optional
- `sshPublicKey`: `string`, optional
- `stopped`: `boolean`, optional
+ - `tags`: `array`, optional
+ - `items`: `object`, optional - JobTag 表示一个任务标签,由 key 和 value 组成。 key 和 value 长度均不超过 10 个字符;一个任务最多 10 个标签; 同一个 key 可添加多个不同 value(最多 10 个)。
+ - `key`: `string`, required
+ - `value`: `string`, required
- `tasks`: `array`, optional
- `items`: `object`, optional
- `agentType`: `string`, optional
@@ -1013,6 +1019,10 @@ Responses:
- `status`: `string`, required
- `total`: `integer`, required
- `reason`: `string`, optional
+ - `storage`: `object`, optional - NodeStorageStatus reports kubelet filesystem usage in bytes.
+ - `availableBytes`: `integer`, optional
+ - `capacityBytes`: `integer`, optional
+ - `usedBytes`: `integer`, optional
- `used`: `object`, optional - ResourceList is a set of (resource name, quantity) pairs.
## Pod
@@ -1208,7 +1218,7 @@ Responses:
- `ip`: `string`, optional
- `message`: `string`, optional
- `node`: `string`, optional
- - `phase`: `string`, optional
+ - `phase`: `string`, optional, enum=Pending,Running,Succeeded,Failed,Unknown
## Task
@@ -1403,8 +1413,8 @@ Responses:
- `kubernetes`: `object`, optional
- `workload`: `object`, optional
- `kind`: `string`, optional
- - `pvcSizeGbMap`: `object`, optional
- - `pvcStorageMap`: `object`, optional
+ - `pvcSizeGbMap`: `object`, optional - Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests instead.
+ - `pvcStorageMap`: `object`, optional - Deprecated: use Template.Spec.Volumes[].Ephemeral.VolumeClaimTemplate.Spec.StorageClassName instead.
- `replicas`: `integer`, optional
- `template`: `object`, optional - PodTemplateSpec describes the data a pod should have when created from a template
- `nodeSelector`: `object`, optional
@@ -1452,200 +1462,3 @@ Responses:
- `retryCount`: `integer`, optional
- `startTime`: `string`, optional
- `tensorBoardProxy`: `string`, optional
-
-## Workflow
-
-- Group: `rlinf.io`
-- Version: `v1alpha1`
-- Scope: `Cluster`
-- Resource: `workflows`
-
-### Operations
-
-#### `GET /api/v1/rlinf.io/v1alpha1/workflows`
-
-List workflows resources.
-
-Parameters:
-- `pretty` (query, optional)
-- `continue` (query, optional)
-- `limit` (query, optional)
-- `fieldSelector` (query, optional)
-- `labelSelector` (query, optional)
-
-Responses:
-- `200` OK → `WorkflowList`
-- `401` Unauthorized
-
-#### `POST /api/v1/rlinf.io/v1alpha1/workflows`
-
-Create a Workflow resource.
-
-Parameters:
-- `pretty` (query, optional)
-- `dryRun` (query, optional)
-- `fieldManager` (query, optional)
-- `fieldValidation` (query, optional)
-
-Request body: `Workflow`
-
-Responses:
-- `201` Created → `Workflow`
-- `202` Accepted → `Workflow`
-- `401` Unauthorized
-
-#### `DELETE /api/v1/rlinf.io/v1alpha1/workflows`
-
-Delete a collection of workflows resources.
-
-Parameters:
-- `pretty` (query, optional)
-- `continue` (query, optional)
-- `limit` (query, optional)
-- `fieldSelector` (query, optional)
-- `labelSelector` (query, optional)
-
-Responses:
-- `200` OK → `Status`
-- `401` Unauthorized
-
-#### `GET /api/v1/rlinf.io/v1alpha1/workflows/{name}`
-
-Get a Workflow resource.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-
-Responses:
-- `200` OK → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `PUT /api/v1/rlinf.io/v1alpha1/workflows/{name}`
-
-Replace a Workflow resource.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-- `fieldManager` (query, optional)
-- `fieldValidation` (query, optional)
-
-Request body: `Workflow`
-
-Responses:
-- `200` OK → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `PATCH /api/v1/rlinf.io/v1alpha1/workflows/{name}`
-
-Patch a Workflow resource.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-- `fieldManager` (query, optional)
-- `fieldValidation` (query, optional)
-- `force` (query, optional)
-
-Request body: `Workflow`
-
-Responses:
-- `200` OK → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `DELETE /api/v1/rlinf.io/v1alpha1/workflows/{name}`
-
-Delete a Workflow resource.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-
-Responses:
-- `200` OK → `Status`
-- `202` Accepted → `Status`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `GET /api/v1/rlinf.io/v1alpha1/workflows/{name}/status`
-
-Get the status subresource for Workflow.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-
-Responses:
-- `200` OK → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `PUT /api/v1/rlinf.io/v1alpha1/workflows/{name}/status`
-
-Replace the status subresource for Workflow.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-- `fieldManager` (query, optional)
-- `fieldValidation` (query, optional)
-
-Request body: `Workflow`
-
-Responses:
-- `200` OK → `Workflow`
-- `202` Accepted → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-#### `PATCH /api/v1/rlinf.io/v1alpha1/workflows/{name}/status`
-
-Patch the status subresource for Workflow.
-
-Parameters:
-- `name` (path)
-- `pretty` (query, optional)
-- `fieldManager` (query, optional)
-- `fieldValidation` (query, optional)
-- `force` (query, optional)
-
-Request body: `Workflow`
-
-Responses:
-- `200` OK → `Workflow`
-- `202` Accepted → `Workflow`
-- `401` Unauthorized
-- `404` Not Found
-
-### Request Schema
-
-- `apiVersion`: `string`, optional - APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schema...
-- `kind`: `string`, optional - Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoin...
-- `metadata`: `object`, optional
-- `spec`: `object`, optional
- - `jobTemplates`: `array`, optional
- - `items`: `object`, optional
- - `dependencies`: `array`, optional
- - `name`: `string`, optional
- - `spec`: `object`, optional
-- `status`: `object`, optional
- - `conditions`: `array`, optional
- - `items`: `object`, optional - Condition contains details for one aspect of the current state of this API Resource.
- - `lastTransitionTime`: `string`, required - lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the un...
- - `message`: `string`, required - message is a human readable message indicating details about the transition. This may be an empty string.
- - `observedGeneration`: `integer`, optional - observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metad...
- - `reason`: `string`, required - reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of spe...
- - `status`: `string`, required, enum=True,False,Unknown - status of the condition, one of True, False, Unknown.
- - `type`: `string`, required - type of condition in CamelCase or in foo.example.com/CamelCase.
- - `endTime`: `string`, optional
- - `jobs`: `array`, optional
- - `items`: `object`, optional
- - `message`: `string`, optional
- - `name`: `string`, optional
- - `phase`: `string`, optional
- - `phase`: `string`, optional
- - `startTime`: `string`, optional
diff --git a/apps/rlark/docs/rtd_patch.py b/apps/rlark/docs/rtd_patch.py
index c20e634..e7298d2 100644
--- a/apps/rlark/docs/rtd_patch.py
+++ b/apps/rlark/docs/rtd_patch.py
@@ -1,57 +1,64 @@
-"""Patch mkdocs.yml and zh markdown files for ReadTheDocs builds."""
-import re
+"""Patch mkdocs.yml for Read the Docs locale builds."""
+
import os
-import glob
+import re
+from urllib.parse import urlsplit, urlunsplit
+
lang = os.environ.get("READTHEDOCS_LANGUAGE", "en")
-# Normalize: RTD uses "zh-cn" but i18n plugin expects "zh"
if lang == "zh-cn":
lang = "zh"
config_path = "apps/rlark/mkdocs.yml"
-with open(config_path) as f:
- content = f.read()
+with open(config_path, encoding="utf-8") as config_file:
+ content = config_file.read()
-# Inject build_only_locale
-content = re.sub(
- r"(docs_structure: folder\n)",
+content, count = re.subn(
+ r"(docs_structure: folder\n)(?:\s+build_only_locale:.*\n)?",
rf"\1 build_only_locale: {lang}\n",
content,
+ count=1,
)
+if count != 1:
+ raise RuntimeError("could not locate the i18n docs_structure setting")
-# Inject language switcher (extra.alternate) for cross-project links on RTD
-# RTD uses "zh-cn" for Chinese translation subproject URL
-version = os.environ.get("READTHEDOCS_VERSION", "latest")
-alternate_block = f"""
+content = re.sub(
+ r"\n# BEGIN RTD ALTERNATES\n.*?\n# END RTD ALTERNATES\n?",
+ "\n",
+ content,
+ flags=re.DOTALL,
+)
+
+canonical_url = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
+parsed_url = urlsplit(canonical_url)
+path_parts = [part for part in parsed_url.path.split("/") if part]
+project_root = None
+if parsed_url.scheme in {"http", "https"} and parsed_url.netloc and len(path_parts) >= 2:
+ locale = path_parts[-2]
+ if locale in {"en", "zh", "zh-cn"}:
+ root_path = "/".join(path_parts[:-2])
+ project_root = urlunsplit(
+ (parsed_url.scheme, parsed_url.netloc, f"/{root_path}" if root_path else "", "", "")
+ ).rstrip("/")
+
+if project_root:
+ version = os.environ.get("READTHEDOCS_VERSION", "latest")
+ alternate_block = f"""
+# BEGIN RTD ALTERNATES
extra:
alternate:
- name: English
- link: https://rlark.readthedocs.io/en/{version}/
+ link: {project_root}/en/{version}/
lang: en
- name: 中文
- link: https://rlark.readthedocs.io/zh-cn/{version}/
+ link: {project_root}/zh-cn/{version}/
lang: zh-cn
+# END RTD ALTERNATES
"""
+ content = content.rstrip() + "\n" + alternate_block
+
+with open(config_path, "w", encoding="utf-8") as config_file:
+ config_file.write(content)
-content = re.sub(r"\n# .*navigation\.instant.*\n", "\n", content)
-content = content.rstrip() + "\n" + alternate_block
-
-with open(config_path, "w") as f:
- f.write(content)
-
-# Fix image paths in zh/ markdown files
-# When build_only_locale=zh, pages are at root, but markdown assumes zh/ subdir
-# ../../images/ -> ../images/ (for files in zh/subdir/)
-# ../images/ -> images/ (for files in zh/ root)
-for md_file in glob.glob("apps/rlark/docs/zh/**/*.md", recursive=True):
- with open(md_file) as f:
- md_content = f.read()
- # Remove one level of "../" from image paths
- md_content = md_content.replace("../../images/", "__TEMP_IMAGES__/")
- md_content = md_content.replace("../images/", "images/")
- md_content = md_content.replace("__TEMP_IMAGES__/", "../images/")
- with open(md_file, "w") as f:
- f.write(md_content)
-
-print(f"[i18n] build_only_locale={lang}")
\ No newline at end of file
+print(f"[i18n] build_only_locale={lang}")
diff --git a/apps/rlark/docs/storage-api.md b/apps/rlark/docs/storage-api.md
index b067d0b..fcb2d04 100644
--- a/apps/rlark/docs/storage-api.md
+++ b/apps/rlark/docs/storage-api.md
@@ -79,13 +79,13 @@ Updates the object storage configuration and associated clusters for the specifi
Deletes the specified StorageClass and its corresponding Secret from all associated clusters. Use the `clusters=agent-a,agent-b` query parameter to limit the deletion scope.
### 6. List Bucket Files
-**GET** `/api/v1/storage/storageclass/{cluster}/{name}/list`
+**GET** `/api/v1/storage/storageclass/{name}/{cluster}/list`
Lists files in the StorageClass bucket in the specified cluster.
#### Path Parameters
-- `cluster`: Cluster ID, such as `agent-beijing`
- `name`: StorageClass name
+- `cluster`: Cluster ID, such as `agent-beijing`
#### Example Response
```json
@@ -99,13 +99,13 @@ Lists files in the StorageClass bucket in the specified cluster.
```
### 7. Upload a File
-**POST** `/api/v1/storage/storageclass/{cluster}/{name}/upload`
+**POST** `/api/v1/storage/storageclass/{name}/{cluster}/upload`
Uploads a file to the specified StorageClass bucket using multipart/form-data.
#### Path Parameters
-- `cluster`: Cluster ID
- `name`: StorageClass name
+- `cluster`: Cluster ID
#### Request Body
Use multipart/form-data with the uploaded file in the `file` field.
@@ -119,13 +119,13 @@ Use multipart/form-data with the uploaded file in the `file` field.
```
### 8. Download a File
-**GET** `/api/v1/storage/storageclass/{cluster}/{name}/object/*key`
+**GET** `/api/v1/storage/storageclass/{name}/{cluster}/object/*key`
Downloads an object from the specified bucket and returns the raw file content.
#### Path Parameters
-- `cluster`: Cluster ID
- `name`: StorageClass name
+- `cluster`: Cluster ID
- `key`: Object path, such as `model-checkpoint.pt` or `logs/training.log`
#### Response
@@ -133,13 +133,13 @@ Downloads an object from the specified bucket and returns the raw file content.
- `404`: File not found
### 9. Delete a File
-**DELETE** `/api/v1/storage/storageclass/{cluster}/{name}/object/*key`
+**DELETE** `/api/v1/storage/storageclass/{name}/{cluster}/object/*key`
Deletes an object from the specified bucket.
#### Path Parameters
-- `cluster`: Cluster ID
- `name`: StorageClass name
+- `cluster`: Cluster ID
- `key`: Object path
#### Example Response
@@ -211,13 +211,23 @@ curl "http://localhost:8080/api/v1/storage/storageclass/provider"
## Integration with Task PVC Mounts
-A Task declares the PVCs it needs to mount through `pvcStorageMap`:
+A Task declares remote storage through a Kubernetes generic ephemeral volume:
```yaml
kubernetes:
workload:
- pvcStorageMap:
- my-data-pvc: "ceph-rbd"
+ template:
+ spec:
+ volumes:
+ - name: data
+ ephemeral:
+ volumeClaimTemplate:
+ spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: ceph-rbd
+ resources:
+ requests:
+ storage: 10Gi
```
-Before creating a workload, the Agent pull controller calls `ensurePVCs` to create the required PVCs from `pvcStorageMap`. The frontend uses the Storage API to retrieve available StorageClasses for users to select.
+Kubernetes creates a PVC for each Pod from `volumeClaimTemplate` and removes it with the Pod. The frontend uses the Storage API to retrieve available StorageClasses for users to select. `pvcStorageMap` and `pvcSizeGbMap` are deprecated and retained only for existing resources.
diff --git a/apps/rlark/docs/user-guide/best-practices.md b/apps/rlark/docs/user-guide/best-practices.md
index aa8b5ac..5caed17 100644
--- a/apps/rlark/docs/user-guide/best-practices.md
+++ b/apps/rlark/docs/user-guide/best-practices.md
@@ -22,6 +22,8 @@ Before creating a job, identify the cluster and nodes that will run your workloa
Navigate to the administrator console or the clusters page to see available clusters:
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

Note the cluster name and the labels applied to nodes in each cluster. You will need these when configuring node selectors in your job.
@@ -96,6 +98,7 @@ Network domains enable cross-cluster communication between Workers. Skip this st
4. Click **Create**
The domain will allocate virtual IPs to Workers that join it, establishing SSH tunnels for cross-cluster traffic.
+IPv4 addresses ending in `.0` or `.255` are reserved and are never allocated automatically, including within CIDR ranges larger than `/24`.
!!! warning "CIDR must not overlap"
The domain CIDR must not overlap with any cluster's pod or service CIDR, or with any other domain.
@@ -218,6 +221,5 @@ Check that checkpoints and training outputs are written to the configured storag
## Next Steps
- [Create a Training Job](jobs.md) — detailed field reference
-- [Plan Multi-Node Jobs](workflows.md) — multi-role and heterogeneous Workers
- [Use Storage in Jobs](storage.md) — hostPath and object storage configuration
- [Connect via SSH](ssh-keys.md) — SSH into running Workers
diff --git a/apps/rlark/docs/user-guide/clusters-nodes.md b/apps/rlark/docs/user-guide/clusters-nodes.md
index bf42a67..45b846f 100644
--- a/apps/rlark/docs/user-guide/clusters-nodes.md
+++ b/apps/rlark/docs/user-guide/clusters-nodes.md
@@ -9,6 +9,8 @@ Use this guide to find a data-plane cluster and confirm that it has suitable Wor
3. Check the online status, online rate, and Worker count.
4. Open the cluster that you plan to use.
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

## Task 2: Check Cluster Capacity
@@ -27,10 +29,12 @@ Use this guide to find a data-plane cluster and confirm that it has suitable Wor
4. Review CPU, memory, and GPU capacity and requested resources. The usage values are aggregated Kubernetes requests, not real-time hardware utilization.
5. Check the Jobs and Workers already placed on the node.
+Use **Refresh** on the Nodes page to update the full filtered node list in place. During the request, the current rows remain visible under a dimmed loading mask with a centered spinner, while the surrounding page keeps its position.
+

!!! note "Node categories"
- The platform groups Workers using RLark category labels for cloud, edge, and robot resources. Legacy category values and nodes that explicitly advertise supported resources remain visible. Kubernetes control-plane nodes are excluded from the platform Worker view.
+The platform groups Workers using RLark category labels for cloud, edge, and robot resources. Legacy category values and nodes that explicitly advertise supported resources remain visible. Kubernetes control-plane nodes are excluded from the platform Worker view.
## Result
diff --git a/apps/rlark/docs/user-guide/console.md b/apps/rlark/docs/user-guide/console.md
index 6df10eb..05f3ad6 100644
--- a/apps/rlark/docs/user-guide/console.md
+++ b/apps/rlark/docs/user-guide/console.md
@@ -4,44 +4,58 @@
RLark has two login entry points:
-| Entry | URL | Purpose |
-|-------|-----|---------|
-| Platform Console | `http://:5173` | Job management, Workers, Workflows, storage, SSH keys |
-| Admin Console | `http://:5173/admin` | Cluster onboarding, nodes, certificates, system configuration |
+| Entry | URL | Purpose |
+| ---------------- | -------------------------- | ------------------------------------------------------------- |
+| Platform Console | `http://:5173` | Job management, Workers, storage, SSH keys |
+| Admin Console | `http://:5173/admin` | Cluster onboarding, nodes, certificates, system configuration |
### Login Steps
+
1. Open the console URL in your browser
2. Enter your username and password
3. Click Login
-4. Browser session maintains login state; no Bearer token is issued
+4. The Gateway issues a JWT access token (8 hours by default), and the browser sends it as a Bearer token on subsequent API requests
+
+!!! warning "Role authorization is coarse-grained"
+ Gateway enforces `admin` access for control-plane configuration and credential management. The `user` role can manage platform workloads, but per-user resource ownership is not yet implemented, so authenticated users are not isolated from each other's Jobs or storage objects. Continue to use TLS and network controls.
## Console Navigation
### Platform Console Pages
-| Page | Purpose |
-|------|---------|
+| Page | Purpose |
+| -------- | -------------------------------------------------------------- |
| Overview | Dashboard summary of clusters, nodes, robots, and running jobs |
-| Clusters | Browse and inspect onboarded data-plane clusters |
-| Nodes | Filter and inspect node resources, scheduling, and health |
-| Jobs | Create, monitor, and manage training jobs |
-| Workflows | Create and monitor DAG-based job pipelines |
-| Storage | Browse storage classes and object storage |
-| SSH Keys | Manage public SSH keys for Worker access |
+| Clusters | Browse and inspect onboarded data-plane clusters |
+| Nodes | Filter and inspect node resources, scheduling, and health |
+| Jobs | Create, monitor, and manage training jobs |
+| Storage | Browse storage classes and object storage |
+| SSH Keys | Manage public SSH keys for Worker access |
+
+### Admin Console Pages
+
+The **Image Registries** page stores private-registry credentials and selects their distribution scope: store only, selected clusters, or all current and future clusters. Display names may be duplicated and edited; RLark uses an internal ID to identify each credential. Distribution and deletion are asynchronous. Delivered credentials are placed in `rlark-system` on each selected Kubernetes data plane.
+
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.

### Finding the Right Page
-| Question | Go to |
-|----------|-------|
-| Is a cluster or node available for scheduling? | Clusters or Nodes page |
-| How to create a training job? | Jobs → Create Job |
-| Which stage is a job stuck at? | Job Details → Workers tab |
-| How to find application errors? | Job Details → Logs tab |
-| How to check resource usage? | Nodes page → Node detail |
-| How to open a terminal in a container? | Job Details → Worker → WebTerminal |
+| Question | Go to |
+| ---------------------------------------------- | ---------------------------------- |
+| Is a cluster or node available for scheduling? | Clusters or Nodes page |
+| How to create a training job? | Jobs → Create Job |
+| Which stage is a job stuck at? | Job Details → Workers tab |
+| How to find application errors? | Job Details → Logs tab |
+| How to check resource usage? | Nodes page → Node detail |
+| How to open a terminal in a container? | Job Details → Worker → WebTerminal |
+| How to configure private image credentials? | Admin Console → Image Registries |
+
+### Refreshing Console Data
+
+Use **Refresh** to request the latest data without leaving the current page. While the request is in progress, the affected table or dashboard is dimmed, its controls are temporarily disabled, and a centered progress indicator is shown. Existing rows remain visible until the refreshed response arrives. This behavior is used consistently across overview dashboards, clusters and nodes, domains, jobs and Workers, storage and files, SSH keys, image registries, and system configuration.
## API Equivalent
-Supported resource operations are available through the [Gateway API](../api/reference.md). The standalone Gateway defaults to `http://:8080`; an `rlarkadm` deployment uses its configured Service and UI proxy. The API reference is authoritative because not every UI interaction has a one-to-one public endpoint.
\ No newline at end of file
+Supported resource operations are available through the [Gateway API](../api/reference.md). The standalone Gateway defaults to `http://:8080`; an `rlarkadm` deployment uses its configured Service and UI proxy. The API reference is authoritative because not every UI interaction has a one-to-one public endpoint.
diff --git a/apps/rlark/docs/user-guide/index.md b/apps/rlark/docs/user-guide/index.md
index fe99ac4..00e3f9d 100644
--- a/apps/rlark/docs/user-guide/index.md
+++ b/apps/rlark/docs/user-guide/index.md
@@ -1,12 +1,11 @@
# Platform User Guide
-This guide assumes an administrator has deployed the control plane and onboarded at least one data-plane cluster. Platform users can then discover available resources, submit jobs, inspect Workers, use WebTerminal, create workflows, and manage storage and SSH keys.
+This guide assumes an administrator has deployed the control plane and onboarded at least one data-plane cluster. Platform users can then discover available resources, submit jobs, inspect Workers, use WebTerminal, and manage storage and SSH keys.
Choose the task you want to complete:
- [Find available compute](clusters-nodes.md)
- [Submit and manage a Job](jobs.md)
-- [Build a multi-stage Workflow](workflows.md)
- [Attach and verify storage](storage.md)
- [Add an SSH key and connect to a Worker](ssh-keys.md)
diff --git a/apps/rlark/docs/user-guide/jobs.md b/apps/rlark/docs/user-guide/jobs.md
index 40dfe96..f82caf3 100644
--- a/apps/rlark/docs/user-guide/jobs.md
+++ b/apps/rlark/docs/user-guide/jobs.md
@@ -6,24 +6,26 @@ Before submitting, confirm that a compatible cluster and node resource are avail
## Using the UI
-Platform Console → Jobs → Create Job. Enter a name and define the Worker roles. For each role:
+Platform Console → Jobs → Create Job. Enter a display name and define the Worker roles. Job display names and role names are limited to 50 characters. Role names must be unique ignoring case; RLark normalizes them into Kubernetes-safe Task resource names. Job display names may be reused because RLark assigns each Job a separate generated resource ID.
+
+For each role:
1. Select the target cluster. Each option shows the cluster name, type label, and current status in one line.
2. Choose one GPU or embodied-device specification from the list, which shows available/total devices and nodes, then set the shared per-Worker resource request. Set the request to `0` for debugging workloads that should keep the placement constraint without requesting the selected device.
3. Choose one scheduling mode:
- **Automatic selection**: enter the desired Worker count. The console validates the total request against schedulable capacity and selects eligible nodes.
+
- **Select nodes**: click eligible nodes or drag across node cards. Each selected node creates one Worker; click or drag across selected nodes again to remove them.
When cloning a Job, the scheduling mode is preserved. A role that did not pin
specific nodes remains in automatic-selection mode instead of being converted
-to manual node selection.
-4. Review the shared placement summary, then configure the image, prepare script, environment variables, and storage mounts.
+to manual node selection. 4. Review the shared placement summary, then configure the image, prepare script, environment variables, and storage mounts.
Submit the Job, then open Job details to verify the running state and inspect Workers.
After selecting a role, its resource summary shows the GPU or embodied-device model configured on the assigned node and its requested quantity, such as `NVIDIA RTX 4090 · 1 GPU`. If the Worker has not reported its assigned node yet, the console resolves the model from the role's selected hostname candidates.
-When a Worker is Pending, hover or focus the information icon beside that Worker's status. RLark reads events for that exact data-plane Pod, so kubelet `Pulling`/`Pulled` events and image-pull failures appear without mixing in events from other Workers on the same node. Byte or percentage progress is shown only when the runtime reports it; the console never fabricates a progress percentage.
+When a Worker is Pending, hover or focus the information icon beside that Worker's status. RLark reads the related data-plane Pod, Task, or node events and converts recognized Kubernetes events into concise pending reasons in the current UI language. The mapping covers common scheduling, node pressure and availability, image-pull, volume, runtime environment, container-creation, and health-check issues. Unrecognized events use a generic pending label instead of exposing raw runtime messages. Image-pull progress remains separate and shows byte or percentage progress only when the runtime reports it.
## Job Types
@@ -45,7 +47,7 @@ For each worker role, configure the following:
- GPU model (e.g., A100, H100, RTX 4090)
- Physical location or zone
-- **Container Image** — Specify the container image for this role. You can use an image tag (e.g., `myimage:latest`) or a digest (e.g., `myimage@sha256:...`). Using a digest is recommended for reproducibility and auditability.
+- **Container Image** — Specify the container image for this role. You can select one of the 10 most recently used images, with its last-used time and Job usage count, or enter an image tag (for example, `myimage:latest`) or digest (`myimage@sha256:...`). Using a digest is recommended for reproducibility and auditability. For private images, configure credentials under **Admin Console → Image Registries** and distribute them to the target cluster. RLark matches both normal and init-container images by registry prefix and appends every matching delivered Secret to `imagePullSecrets`; duplicate credentials for the same registry are supported. Credentials currently target workloads in `rlark-system` only, and a Task does not wait for asynchronous delivery to finish.
- **Resource Requests** — Set the CPU, memory, and GPU resources required per worker:
- CPU: specified in cores (e.g., `4`)
@@ -58,13 +60,15 @@ For each worker role, configure the following:
- **hostPath**: Mount a directory from the host node's filesystem. Its data is not deleted by Job lifecycle actions.
- **PVC** (PersistentVolumeClaim): Mount a Kubernetes persistent volume using the selected storage class. Stopping, restarting, or deleting the Job deletes its task PVCs; starting or restarting creates empty PVCs.
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

## Shared Configuration
Configure settings that apply to all workers in the job:
-- **Header Role** — Select one role as the Header role. This role's first worker coordinates the distributed training, and its IP address is communicated to all other workers.
+- **Header Role** — Select one role as the Header role. For Ray workloads, configure this role with exactly one Worker; it coordinates distributed training and its IP address is communicated to all other Workers.
- **Cross-Cluster Network Domain** — If network domains are configured, the console automatically enables the first domain by name for every Job, regardless of Worker placement. No domain is added when none is configured.
@@ -82,7 +86,7 @@ Open the Job Details page from the Jobs list to see a high-level summary:
- **Name** — The user-facing display name. It may be reused; RLark assigns a separate `jo-<16 hexadecimal characters>` resource ID.
- **Type** — The job type (Reinforcement Learning, Data Collection, Evaluation, Custom)
-- **Status** — Current state: Pending, Running, Succeeded, Failed, Stopped
+- **Status** — Current state: Pending, Running, Stopping, Stopped, Succeeded, or Failed. `Stopping` is the transitional state while RLark waits for the Job and all child Tasks and Workers to stop.
- **Worker Count** — Total number of workers across all roles
- **Creation Time** — When the job was submitted
- **Header Role** — The role designated as the coordinator
@@ -101,6 +105,8 @@ Below the overview, the worker list shows every worker instance with:
Use **Refresh** in the list header to update Task, Pod, placement, IP, and status information without reloading the page.
+On both the Jobs page and the Worker list, **Refresh** updates only the corresponding data region. While the request is in progress, existing rows stay in place under a dimmed loading mask with a centered spinner, and actions in that region are temporarily disabled. The rest of the page remains available.
+
Click any worker to see its runtime details, including container status, resource usage, and events.
### Per-Role Configuration
@@ -115,12 +121,13 @@ The **Logs** tab aggregates the main container logs from all workers in the job.
### Log Features
-- **Aggregated View** — Logs from all workers are combined into a single stream, with each line tagged by worker and role.
-- **Line Limit** — Each Pod displays up to 1000 lines of log output.
-- **Filter by Role and Worker** — Narrow down the log view to specific roles or individual workers.
-- **Search** — Full-text search within the displayed logs.
-- **Auto-Refresh** — Logs automatically refresh every 5 seconds, so you can watch job progress in real time.
-- **Time Range** — Select a time window for log retrieval: 15 minutes, 1 hour, 6 hours, or 24 hours.
+- **Default selection and refresh** — The first role is selected by default. Logs refresh only when you click **Refresh**.
+- **Aggregated View** — Logs from the selected role or Worker are combined into a single stream, with each line tagged by Worker and role. Historical Workers remain selectable after their Pods have terminated.
+- **Time Range** — Select 15 minutes, 1 hour, 6 hours, 24 hours, 7 days, or a custom interval.
+- **Ordering** — Sort log entries in ascending or descending time order.
+- **Search** — Search for complete words or phrases; partial-word matching is not performed.
+- **Log-backend pagination** — When a log backend is configured, each page contains at most 99 entries and additional pages are retrieved with a cursor.
+- **Pod fallback** — Without a log backend, RLark falls back to Pod logs and retrieves up to 1000 lines from each Pod.
### Exporting Logs
@@ -138,7 +145,7 @@ You can open an interactive terminal directly into the main container of any run
### Opening a Terminal
-From the worker list, click the **Terminal** action on any worker. This opens a WebTerminal session that runs `/bin/sh` in the worker's main container. WebTerminal requires an authenticated user, a running Worker, and a reachable RLark SSH tunnel.
+From the Worker list, click **Terminal** on any running Worker. This opens `/bin/bash` by default in the Worker's main container. The browser establishes a WebSocket to Gateway, which proxies the session through Server to Agent and then execs into the container. The user does not need to log in with SSH.
### Diagnostic Commands
@@ -161,7 +168,7 @@ The key button in the Worker list copies the SSH connection command. It requires
The WebTerminal supports file upload and download:
- **Upload** — Upload files from your local machine to the container's default working directory. File names must match the pattern `[A-Za-z0-9._-]+`.
-- **Download** — Download files from the container to your local machine.
+- **Download** — Enter the path of the file inside the container, then download it to your local machine.
All file transfers are performed over the same WebSocket connection used by the terminal, ensuring security and simplicity.
@@ -171,9 +178,9 @@ All file transfers are performed over the same WebSocket connection used by the
The detail-page action bar supports the full Job lifecycle:
-- **Stop** pauses a running Job and preserves its configuration.
-- **Start** resumes a stopped Job.
-- **Restart** opens a choice: restart immediately with the current configuration, or edit the Job and restart after the updated configuration is saved. During edit-and-restart, available capacity includes resources that the current Job will release.
+- **Stop** sets a running Job to `Stopping`, deletes its child Task CRs, and waits for their Workers and PVCs to be cleaned up. It preserves the Job configuration.
+- **Start** starts a `Stopped` Job with the same configuration and newly created empty task PVCs, including Jobs stopped after reaching a terminal result.
+- **Restart** opens a choice: restart immediately with the current configuration, or edit the Job and restart after the updated configuration is saved. `Succeeded` and `Failed` Jobs restart as clean runs: RLark stops and cleans up the previous Tasks, Workers, and task PVCs before creating new Tasks, Workers, and empty PVCs. During edit-and-restart, available capacity includes resources that the current Job will release.
- **Delete** opens a danger confirmation that identifies the target Job and warns that the operation cannot be undone before permanently removing it.
Lifecycle actions require confirmation. While an action is in progress, the
@@ -190,13 +197,13 @@ Each Jobs table row directly exposes Clone, Restart, and Start/Stop text buttons
### Stop a Running Job
-Stopping a Job terminates all Worker Pods and deletes its task PVCs. Job configuration, logs, metadata, and hostPath data are preserved.
+Stopping a Job first changes its displayed state to `Stopping`. RLark deletes all child Task CRs and waits for their Worker Pods and task PVCs to be cleaned up. Job configuration, logs, metadata, and hostPath data are preserved.
The time at which a manually stopped Job enters `Stopped` is recorded and shown in the Jobs table.
### Resume a Stopped Job
-Starting a stopped Job recreates the Worker Pods and empty task PVCs from the same configuration. Previous PVC data is not restored; hostPath data remains available.
+Starting a stopped Job recreates the Tasks, Worker Pods, and empty task PVCs from the same configuration. Previous PVC data is not restored; hostPath data remains available. Restarting a succeeded or failed Job performs the same clean stop-and-recreate sequence rather than reusing terminal Tasks or PVC data.
### Delete a Job
@@ -217,15 +224,15 @@ Deleting a job performs a full cleanup:
Before submitting a job, verify the following:
-| Item | Description |
-|------|-------------|
-| Cluster Resources | The target cluster has sufficient CPU, memory, and GPU capacity for all workers |
-| Node Compatibility | Nodes matching your selector (type, GPU model, location) are available and schedulable |
-| GPU / Embodied Device | If requesting GPUs or embodied devices (robot, camera), confirm the required models are present on the selected nodes |
-| Container Image | The specified image is accessible from the target cluster. If using a private registry, ensure image pull secrets are configured |
-| Storage Paths | hostPath directories exist on the target nodes and have correct read/write permissions. PVC storage classes are available in the cluster |
-| Cross-Cluster Network | If the job spans multiple clusters, the network domain is configured and DomainPeer relationships are established |
-| SSH Keys | SSH public keys are valid and correctly formatted |
+| Item | Description |
+| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| Cluster Resources | The target cluster has sufficient CPU, memory, and GPU capacity for all workers |
+| Node Compatibility | Nodes matching your selector (type, GPU model, location) are available and schedulable |
+| GPU / Embodied Device | If requesting GPUs or embodied devices (robot, camera), confirm the required models are present on the selected nodes |
+| Container Image | The specified image is accessible from the target cluster. If using a private registry, ensure image pull secrets are configured |
+| Storage Paths | hostPath directories exist on the target nodes and have correct read/write permissions. PVC storage classes are available in the cluster |
+| Cross-Cluster Network | If the job spans multiple clusters, the network domain is configured and DomainPeer relationships are established |
+| SSH Keys | SSH public keys are valid and correctly formatted |
## API Equivalent
diff --git a/apps/rlark/docs/user-guide/ssh-keys.md b/apps/rlark/docs/user-guide/ssh-keys.md
index f6bacbd..63b54d3 100644
--- a/apps/rlark/docs/user-guide/ssh-keys.md
+++ b/apps/rlark/docs/user-guide/ssh-keys.md
@@ -2,6 +2,8 @@
Use this guide to register a public key for RLark SSH bastion authentication and, optionally, select it when creating a Job.
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+

## Task 1: Add a Public Key
@@ -12,7 +14,7 @@ Use this guide to register a public key for RLark SSH bastion authentication and
4. Paste one OpenSSH public key, such as `ssh-ed25519` or `ssh-rsa`.
5. Choose **Add** and confirm that the key appears in the list.
-RLark validates the public-key format and rejects duplicate keys. The page also shows the configured jump host command when the Server SSH address is available.
+RLark validates the public-key format and requires both the SSH username and public key content to be globally unique. The form shows an inline error before upload when either value already exists. The page also shows the configured jump host command when the Server SSH address is available.
## Task 2: Select a Key for a Job
@@ -21,7 +23,7 @@ RLark validates the public-key format and rejects duplicate keys. The page also
3. Review the YAML preview and confirm that `spec.sshPublicKey` contains the selected public key.
4. Submit the Job.
-This selection writes one public key into the Job configuration for workload injection. Registering a key on the SSH Keys page alone does not modify existing Jobs or Pods.
+Each selected public key is written once into the Job configuration for workload injection. Registering a key on the SSH Keys page alone does not modify existing Jobs or Pods. Job details display multiple injected keys in a scrollable list.
## Task 3: Connect Through the Bastion
@@ -57,4 +59,4 @@ Deleting a registered key prevents subsequent bastion authentication with that u
## API Equivalent
-Use `GET` and `POST /api/v1/ssh-user-keys` and `DELETE /api/v1/ssh-user-keys/{index}?user={user}`. See [API Reference](../api/reference.md).
+Use `GET` and `POST /api/v1/ssh-user-keys` and `DELETE /api/v1/ssh-user-keys/{index}?user={user}`. The index is zero-based within that user's registered keys. See [API Reference](../api/reference.md).
diff --git a/apps/rlark/docs/user-guide/storage.md b/apps/rlark/docs/user-guide/storage.md
index f3d5499..da0f526 100644
--- a/apps/rlark/docs/user-guide/storage.md
+++ b/apps/rlark/docs/user-guide/storage.md
@@ -7,7 +7,7 @@ RLark supports two storage types for training jobs:
| Type | Use Case | Lifecycle |
|------|----------|-----------|
| Host Directory | Data already on the node, high I/O | Job lifecycle actions do not delete data |
-| Object Storage (PVC) | Shared data within a Job run | Stop/restart/delete removes task PVCs; start/restart creates empty PVCs |
+| Object Storage (ephemeral PVC) | Remote storage within one Pod run | Kubernetes creates and removes the PVC with its Pod |
## Host Directory
@@ -17,10 +17,18 @@ RLark supports two storage types for training jobs:
## Object Storage
-- Uses Kubernetes StorageClass and PVC
-- PVC is auto-created with 10Gi request
+- Uses a Kubernetes generic ephemeral volume and StorageClass
+- Each PVC mount defaults to a 10Gi request; the configurable size range is 1–200Gi
- Select cluster first, then available StorageClasses appear in the dropdown
-- Multiple workers can share the same PVC (be aware of RWO access mode limitations)
+- Each worker Pod receives its own PVC
+
+## Managing Storage Classes
+
+Open **Storage** to review object-storage configurations, associated clusters, providers, and buckets. Create the required storage class before configuring a PVC mount for a Worker.
+
+> **Screenshot note:** Screenshots are from an example environment. Resource names and data are illustrative; your environment will differ.
+
+
## Using Storage in a Training Job
@@ -30,8 +38,6 @@ When creating a job, in the Worker configuration step:
3. Enter the container mount path
4. Your training code reads/writes to the mount path
-
-
## Checking Read/Write
Verify the storage chain:
@@ -43,10 +49,10 @@ Verify the storage chain:
## Lifecycle
-- Stop or restart: task PVCs are deleted; hostPath data is preserved
-- Start or restart: new empty task PVCs are created
-- Delete: task PVCs are deleted; hostPath data is preserved
+- Stop or restart: deleting worker Pods also deletes their ephemeral PVCs; hostPath data is preserved
+- Start or restart: Kubernetes creates new ephemeral PVCs for the new Pods
+- Delete: deleting worker Pods also deletes their ephemeral PVCs; hostPath data is preserved
## API Equivalent
-Use StorageClass, provider, and object-file endpoints described in the [Storage API](../storage-api.md).
\ No newline at end of file
+Use StorageClass, provider, and object-file endpoints described in the [Storage API](../storage-api.md).
diff --git a/apps/rlark/docs/user-guide/workflows.md b/apps/rlark/docs/user-guide/workflows.md
deleted file mode 100644
index d4b856a..0000000
--- a/apps/rlark/docs/user-guide/workflows.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# Workflows
-
-Use a Workflow to connect Job templates as a DAG. Each template becomes a child Job after its dependencies succeed.
-
-## Prerequisites
-
-Before creating a Workflow:
-
-- Confirm the control plane and Workflow controller are running and that you can create standalone Jobs.
-- Onboard every target cluster and verify that its nodes appear as available Workers.
-- Make each referenced image accessible from its target cluster, and create any required Domain, storage class, or PVC first.
-- Prepare each stage so that it exits with status 0 only after its work is complete; dependent stages are released from the child Job status, not from shell output.
-
-## Task 1: Create the DAG
-
-1. Open **Workflows** and choose **Create Workflow**.
-2. Enter the Workflow name.
-3. In **DAG Editor**, add a Job node for each stage.
-4. Double-click a node name to rename it.
-5. Drag from a node's right output port to the target node to create a dependency. Click an edge to remove it.
-6. Confirm that the graph has no self-loop or cycle; the editor rejects both.
-
-## Task 2: Configure Each Job
-
-1. Continue to **Job Details**.
-2. Select each Job tab in turn.
-3. Configure its type, roles, Header role, target cluster, Worker resources, node selector, image, environment, storage, Domain, and run script as required.
-4. Ensure every role has a target cluster and image and matches at least one available Worker.
-
-The Job options follow the standalone Job form, but SSH key and TensorBoard settings are not included in the current Workflow form.
-
-## Task 3: Review and Submit
-
-1. Continue to **YAML Preview**.
-2. Confirm that Workflow and template names are unique and valid Kubernetes resource names.
-3. Verify each template's `dependencies`, image, storage, Domain, and task configuration.
-4. Choose **Create Workflow**.
-
-The console submits a Workflow CR. It does not generate a shell installation command.
-
-## Task 4: Monitor the Run
-
-1. Open the Workflow from the list.
-2. Review the DAG execution view and the child Job table.
-3. Select a non-pending DAG node to open its generated child Job. Generated Job names use `-`.
-4. Inspect each child Job's Workers and logs when troubleshooting.
-
-A dependent stage starts only after its predecessors succeed. If a predecessor's script has ended but its Job remains Running, check whether background processes are still active.
-
-## Validate the Result
-
-- Confirm the Workflow phase becomes `Succeeded` and every DAG node is `Succeeded`.
-- Confirm the child Job table contains one Job for every template and that each generated name matches `-`.
-- Open each child Job and verify its Worker count, target cluster, logs, and expected output or artifacts. A green DAG alone does not validate application-level results.
-
-## Handle Failures
-
-If any child Job becomes `Failed`, the Workflow becomes terminal `Failed`, and dependent stages that have not started are not released. Existing child Jobs are not a rollback mechanism; inspect or stop them separately as needed.
-
-1. Open the failed DAG node and inspect its Workers, events, and container logs.
-2. Check image pull access, cluster and Worker availability, selectors and resource requests, storage mounts, Domain connectivity, and the script exit code.
-3. Correct the underlying configuration or workload. The current Workflow run cannot resume from the failed node; submit a new Workflow run with a unique name (or delete the old run before reusing its name).
-4. Verify the replacement run with the checks above.
-
-## API Equivalent
-
-Create a Workflow CR with `POST /api/v1/rlinf.io/v1alpha1/workflows`, then query the Workflow and generated Jobs. See [CRD Reference](../reference/crd.md).
diff --git a/apps/rlark/docs/zh/README.md b/apps/rlark/docs/zh/README.md
index e4bc4bc..1e583d0 100644
--- a/apps/rlark/docs/zh/README.md
+++ b/apps/rlark/docs/zh/README.md
@@ -4,7 +4,7 @@ RLark 中英文文档采用一致的、按受众划分的信息架构:
1. **概览**:产品能力、核心概念和系统架构。
2. **快速开始**:部署控制面、纳管 Kubernetes 数据面并运行第一个任务。
-3. **平台使用指南**:集群节点、任务与 Worker、工作流、存储和 SSH Key。
+3. **平台使用指南**:集群节点、任务与 Worker、存储和 SSH Key。
4. **管理员指南**:生产部署、数据面接入、网络安全、具身设备和运维。
5. **开发者指南**:源码开发、技术架构、调试、设备适配和贡献流程。
6. **参考手册**:CLI、API、CRD、配置项和发布说明。
diff --git a/apps/rlark/docs/zh/admin-guide/agent.md b/apps/rlark/docs/zh/admin-guide/agent.md
index 54061a1..f82d38d 100644
--- a/apps/rlark/docs/zh/admin-guide/agent.md
+++ b/apps/rlark/docs/zh/admin-guide/agent.md
@@ -43,6 +43,8 @@ rlarkadm install -f deploy-data-plane.yaml
多节点 Kubernetes 数据面不要在单个 Deployment 中混合 cluster 和 node 模式。`rlarkadm` 会创建正确的 Deployment、DaemonSet、证书 Secret、RBAC、Socket 挂载和容器运行时挂载。
+`rlarkadm` 为节点 Agent 分配独立的 `rlark-agent-node` ServiceAccount。本地集群 RBAC 仅允许读取当前 Node 以及列出和监听 Node Event,不会继承集群 Agent 的资源管理权限。
+
### 验证
```bash
diff --git a/apps/rlark/docs/zh/admin-guide/control-plane.md b/apps/rlark/docs/zh/admin-guide/control-plane.md
index 20136dd..befd0e8 100644
--- a/apps/rlark/docs/zh/admin-guide/control-plane.md
+++ b/apps/rlark/docs/zh/admin-guide/control-plane.md
@@ -10,7 +10,7 @@
| PostgreSQL | 配置顶层 `db` 块时使用的可选持久化存储 | 5432 |
| rlark-server | 证书管理、Agent 隧道、SSH、健康检查和指标 | 8443(HTTPS/WSS)、2222(SSH)、8888(内部 HTTP) |
| rlark-gateway | 控制台和 CLI 的 REST API 网关 | 8090 |
-| rlark-controller-manager | Job/Workflow/Domain 调和 | 8080(指标)、8081(健康检查) |
+| rlark-controller-manager | Job/Domain 调和 | 8080(指标)、8081(健康检查) |
| rlark-ui | Web 管理控制台和 `/api/` 反向代理 | 80 |
Gateway 独立二进制默认监听 `:8080`,`rlarkadm` 部署时会覆盖为 `:8090`。
diff --git a/apps/rlark/docs/zh/admin-guide/data-plane.md b/apps/rlark/docs/zh/admin-guide/data-plane.md
index bd36b1f..eb98645 100644
--- a/apps/rlark/docs/zh/admin-guide/data-plane.md
+++ b/apps/rlark/docs/zh/admin-guide/data-plane.md
@@ -13,10 +13,14 @@
3. 只需输入集群名称,例如 `my-cluster-01`。
4. 选择**签发证书**。
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

签发后,页面会显示集群名称、Server 地址和完整部署 YAML,并将名称加入**已签发集群**;之后仍可展开该记录并再次复制 YAML。
+管理员可在**系统配置 > 部署配置**中设置生成 YAML 的默认值,这些字段沿用 `rlarkadm` 的 `DeployConfig` 结构。已配置的控制面与 SSH 地址会覆盖生成值;TLS 验证、Kubeconfig、Agent 镜像、共享 RLark 镜像、镜像拉取策略、镜像拉取 Secret 和 containerd socket 也会在有值时写入 YAML。展开历史签发记录时会使用当前默认值重新生成 YAML。
+
!!! warning "保护 YAML"
页面显示的 `agent-key` 是私钥。请安全保存复制的 YAML,且不要在其他集群复用。
@@ -45,8 +49,8 @@ cert:
-----END PRIVATE KEY-----
kubernetes:
- kubeconfig: /path/to/kubeconfig.yaml
- agent-image: rlark-agent:latest
+ kubeconfig: ~/.kube/config
+ agent-image: rlark:latest
```
将 `kubernetes.kubeconfig` 替换为可向目标集群部署资源的 kubeconfig,并设置可用的 Agent 镜像。仅在启用需要共享 RLark 镜像的组件时添加可选的 `kubernetes.image`。所有可用字段参见[配置参考](../reference/configuration.md)。
diff --git a/apps/rlark/docs/zh/admin-guide/embodied-runtime.md b/apps/rlark/docs/zh/admin-guide/embodied-runtime.md
index 759d570..7dbe628 100644
--- a/apps/rlark/docs/zh/admin-guide/embodied-runtime.md
+++ b/apps/rlark/docs/zh/admin-guide/embodied-runtime.md
@@ -26,7 +26,7 @@ Embodied Runtime 有三层结构:
|------|------|------|
| Device Plugin | `device-plugin` | 向 Kubernetes 注册设备资源(`rlinf.io/device-*`) |
| 控制器 | `ros-controller`, `ros2-controller`, `camera-controller` | 管理设备生命周期的 gRPC 服务 |
-| Webhook | Mutating Webhook | 自动注入 `devinit` sidecar,用于 macvlan 网络 |
+| Webhook | Mutating Webhook | 可选地注入 `devinit` init container,用于 macvlan 网络 |
### 工作原理
@@ -46,9 +46,12 @@ Embodied Runtime 有三层结构:
### Helm(推荐)
+直接使用 Embodied Runtime Helm Chart 部署时,可通过 `config.ros2` 配置 ROS 2。RLark Addon 目录当前尚未暴露 ROS 2 配置,因此需要 ROS 2 时请直接使用 Helm Chart。
+
```bash
-helm install embodied-runtime ./charts/embodied-runtime \
+helm install embodied-runtime ./apps/embodied-runtime/charts/embodied-runtime \
--namespace rlark-system \
+ --create-namespace \
--set config.ros.enabled=true \
--set config.camera.enabled=true
```
@@ -59,18 +62,18 @@ helm install embodied-runtime ./charts/embodied-runtime \
```yaml
# device-plugin-config.yaml
+device_count: 1
+
host_devices:
- - name: rlinf.io/device-webcam
- count: 2
- devices:
- - /dev/video0
- - /dev/video1
+ - host_path: /dev/video0
+ - host_path: /dev/ttyUSB0
+ permissions: rw
host_macvlans:
- - name: rlinf.io/device-franka
- count: 1
- parent_interface: eth0
- robot_ip: 192.168.1.100
+ - host_nic: eno1
+ name: macvlan0
+ ip: 172.16.0.0/24
+ # gateway: 172.16.0.1
camera:
enabled: true
@@ -91,10 +94,9 @@ ros2:
```yaml
host_devices:
- - name: rlinf.io/device-webcam
- count: 1
- devices:
- - /dev/video0
+ - host_path: /dev/video0
+ # container_path: /dev/video0
+ # permissions: rwm
```
### Macvlan 网络机器人
@@ -103,13 +105,13 @@ host_devices:
```yaml
host_macvlans:
- - name: rlinf.io/device-franka
- count: 1
- parent_interface: eth0
- robot_ip: 192.168.1.100
+ - host_nic: eno1
+ name: macvlan0
+ ip: 172.16.0.0/24
+ # gateway: 172.16.0.1
```
-Mutating Webhook 会自动注入 `devinit` sidecar,在 Worker 容器中创建 macvlan 接口。
+Webhook 默认关闭。只有设置 `webhook.enabled=true` 且 `config.hostMacvlans` 列表非空时才会渲染;启用后,它会注入 `devinit` init container,在 Worker Pod 的网络命名空间中创建 macvlan 接口。
### 控制器 Pod
@@ -121,7 +123,7 @@ Mutating Webhook 会自动注入 `devinit` sidecar,在 Worker 容器中创建
```bash
# 1. 检查 Device Plugin 运行状态
-kubectl get pods -n rlark-system -l app=device-plugin
+kubectl get pods -n rlark-system -l app.kubernetes.io/name=embodied-runtime,app.kubernetes.io/component=device-plugin
# 2. 验证设备资源已注册
kubectl describe node | grep rlinf.io/device
diff --git a/apps/rlark/docs/zh/admin-guide/network-security.md b/apps/rlark/docs/zh/admin-guide/network-security.md
index a4caf0f..d3ad6d9 100644
--- a/apps/rlark/docs/zh/admin-guide/network-security.md
+++ b/apps/rlark/docs/zh/admin-guide/network-security.md
@@ -6,6 +6,8 @@ Domain 用于虚拟地址分组并限定跨集群转发范围。每个 Domain
### 创建 Domain
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

- 管理后台 → Domain 管理 → 创建 Domain
@@ -47,7 +49,7 @@ kubectl delete domain
## 安全最佳实践
-- 为不同安全区域使用独立的 Domain
+- 使用 Kubernetes NetworkPolicy 等基础设施控制实施安全区域隔离;不要仅依赖 Domain
- 通过 `rlarkadm` 定期轮换 TLS 证书
- 检查 DomainPeer 资源是否有意外的跨集群连接
- Worker 访问的 SSH 密钥应通过平台管理,不直接在节点上操作
diff --git a/apps/rlark/docs/zh/admin-guide/nodes.md b/apps/rlark/docs/zh/admin-guide/nodes.md
index 0f27436..b5d2c31 100644
--- a/apps/rlark/docs/zh/admin-guide/nodes.md
+++ b/apps/rlark/docs/zh/admin-guide/nodes.md
@@ -34,13 +34,16 @@
打开管理后台 → 节点,查看和管理节点:
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

节点详情包括:
- 调度状态(可调度 / 已停止调度)
- 节点类型、接入形态、操作系统、架构
- Agent 版本
-- 资源使用情况:CPU、内存、GPU
+- 资源使用情况:CPU、内存、磁盘、GPU 和具身设备资源
+- 磁盘使用率达到 90% 或 kubelet 上报 `DiskPressure=True` 时显示告警
- 节点上运行中的关联任务
## 通过 UI 操作
diff --git a/apps/rlark/docs/zh/api/examples.md b/apps/rlark/docs/zh/api/examples.md
index 2ab567c..dffdcc4 100644
--- a/apps/rlark/docs/zh/api/examples.md
+++ b/apps/rlark/docs/zh/api/examples.md
@@ -2,15 +2,14 @@
本页提供 RLark Gateway HTTP API 的端到端调用示例,重点围绕 **Kubernetes 运行时**(`agentType=Kubernetes`)展开。资源操作和字段定义请查看 [API 参考](reference.md),机器可读的接口契约请查看 [OpenAPI 规范](../../api/swagger.yaml)。
-!!! warning "认证限制"
- 登录接口只校验内置 Web UI 凭据,不会返回可用于后续请求的 Bearer token 或会话 cookie,其他 Gateway API 也不会根据该结果执行授权。以下命令只应在可信网络中运行,或通过强制实施认证和授权的入口访问。
+请先登录,并在后续 Gateway API 请求中以 `Authorization: Bearer ` 携带响应中的 `token`。token 默认 8 小时过期。
## 约定
- 独立运行的 Gateway 默认监听 `http://localhost:8080`。通过 `rlarkadm` 部署时,Gateway 在集群内部暴露于 `8090` 端口,浏览器流量经由 UI 服务路由。
- CRD API 根路径:`/api/v1/rlinf.io/v1alpha1`。
- `nodes`、`tasks` 等命名空间级资源必须在查询字符串中指定 `namespace=`。
-- `jobs`、`workflows` 等集群级资源不使用命名空间查询参数。
+- `jobs` 等集群级资源不使用命名空间查询参数。
- `spec.agentType` 可取 `Kubernetes`、`Docker` 或 `Raw`。目前仅实现 Kubernetes 运行时,Docker 和 Raw 尚在规划中。
- `spec.role` 为必填字段,可取 `Actor`、`Rollout` 或 `Env`。
- `kubernetes.workload.template` 是 Kubernetes `corev1.PodTemplateSpec`。
@@ -93,6 +92,41 @@ echo "$JOB_ID" # jo-<16 位十六进制字符>
镜像、命令、环境变量、资源和卷应放在 `kubernetes.workload.template.spec.containers` 下,而不是作为 Task 的顶层字段。
+### 通过 HostNetwork 访问未适配设备
+
+如果 embodied-runtime 尚未适配某个网络设备,但设备能从数据面节点直接访问,可以在 Task 的 PodTemplate 中显式启用宿主机网络:
+
+```json
+{
+ "nodeSelector": {"kubernetes.io/hostname": "worker-1"},
+ "kubernetes": {
+ "workload": {
+ "kind": "Deployment",
+ "replicas": 1,
+ "template": {
+ "metadata": {"labels": {"app": "vendor-device-client"}},
+ "spec": {
+ "hostNetwork": true,
+ "dnsPolicy": "ClusterFirstWithHostNet",
+ "containers": [
+ {
+ "name": "app",
+ "image": "registry.example.com/vendor/device-sdk:latest",
+ "command": ["sh", "-c"],
+ "args": ["./device-client --address 192.168.10.20"]
+ }
+ ]
+ }
+ }
+ }
+ }
+}
+```
+
+这种方式不会提供 embodied-runtime 的设备发现、资源隔离、controller、CLI 或 SDK 注入,设备驱动和生命周期管理由业务镜像负责。`hostNetwork` 会降低网络隔离并可能造成端口冲突,只应在可信数据面和专用设备节点使用。不要为此开启 `RLARK_ENABLE_UNSAFE_TASK_PRIVILEGES`;该变量会对多个任务全局启用旧版 privileged/hostNetwork 模式。
+
+完整的原生接入与兼容方案,请参阅 embodied-runtime 文档 `apps/embodied-runtime/docs/examples.zh-CN.md` 中的“未适配设备”章节。
+
```bash
# 按标签列出 Job。
curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/jobs?labelSelector=framework=ppo"
@@ -124,80 +158,7 @@ curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/tasks?namespace=default&labelSelec
curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/tasks/ppo-cartpole-actor-head?namespace=default"
```
-## 4. 创建 Workflow
-
-Workflow 包含通过依赖关系连接的 Job 模板。每个 `jobTemplates[].spec` 都是完整的 Job spec。
-
-```bash
-curl -X POST "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/workflows" \
- -H "Content-Type: application/json" \
- -d '{
- "apiVersion": "rlinf.io/v1alpha1",
- "kind": "Workflow",
- "metadata": {"name": "training-pipeline"},
- "spec": {
- "jobTemplates": [
- {
- "name": "prepare",
- "dependencies": [],
- "spec": {
- "tasks": [
- {
- "name": "prepare-data",
- "role": "Env",
- "agentType": "Kubernetes",
- "kubernetes": {
- "workload": {
- "kind": "Deployment",
- "replicas": 1,
- "template": {
- "spec": {
- "containers": [
- {"name": "prepare", "image": "registry.example.com/rl/prepare:v1"}
- ]
- }
- }
- }
- }
- }
- ]
- }
- },
- {
- "name": "train",
- "dependencies": ["prepare"],
- "spec": {
- "tasks": [
- {
- "name": "trainer",
- "head": true,
- "role": "Actor",
- "agentType": "Kubernetes",
- "kubernetes": {
- "workload": {
- "kind": "Deployment",
- "replicas": 1,
- "template": {
- "spec": {
- "containers": [
- {"name": "trainer", "image": "registry.example.com/rl/train:v1"}
- ]
- }
- }
- }
- }
- }
- ]
- }
- }
- ]
- }
- }'
-
-curl "$RLARK_GATEWAY/api/v1/rlinf.io/v1alpha1/workflows/training-pipeline"
-```
-
-## 5. UI 凭据校验
+## 4. UI 凭据校验
仅接受内置用户名 `admin` 和 `user`。成功响应为 `{"ok":true,"role":"admin"}` 或 `{"ok":true,"role":"user"}`。
diff --git a/apps/rlark/docs/zh/api/reference.md b/apps/rlark/docs/zh/api/reference.md
index 439563d..78e39cb 100644
--- a/apps/rlark/docs/zh/api/reference.md
+++ b/apps/rlark/docs/zh/api/reference.md
@@ -2,8 +2,9 @@
本页仅列出 Gateway 在 [`pkg/gateway/router.go`](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/gateway/router.go) 中实际注册的 HTTP 路由。可运行请求参见 [API 调用样例](examples.md),机器可读子集参见 [OpenAPI 规范](../../api/swagger.yaml)。
-!!! warning "认证限制"
- `POST /api/v1/auth/login` 只校验内置 Web UI 凭据并返回角色,不会建立服务端会话或签发 token。Gateway 当前不会在下述 API 路由上强制校验该登录结果。请勿将 Gateway 直接暴露到不受信任的网络;应在入口前配置带认证的反向代理或其他可信访问控制层。
+除 `POST /api/v1/auth/login` 和 `/metrics` 外,Gateway 路由均要求在 `Authorization: Bearer ` 请求头中携带登录返回的 JWT。token 默认 8 小时过期,可通过 `--jwt-token-ttl` 配置。缺失或无效 token 返回 `401`;已认证 `user` 调用仅管理员接口返回 `403`。
+
+仅管理员操作包括 Node 和 Domain 变更,以及全部证书、镜像仓库、Addon 接口、系统配置更新和 StorageClass provider/变更接口。系统配置读取及全部 SSH 密钥操作允许任一已认证角色访问。授权按角色执行,尚未按具体用户隔离资源,包括 SSH 密钥。
下表用 `{name}` 表示路径参数;Router 源码中的 Gin 等价写法为 `:name`。Namespaced CRD 路由需要 `namespace` query 参数。
@@ -12,8 +13,7 @@
| 资源 | 作用域 | 路由 |
|------|--------|------|
| `nodes` | Namespaced | `GET, POST /api/v1/rlinf.io/v1alpha1/nodes`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/nodes/{name}` |
-| `workflows` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/workflows`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/workflows/{name}` |
-| `jobs` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/jobs`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/jobs/{name}`;`GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs`;`GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/metrics` |
+| `jobs` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/jobs`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/jobs/{name}`;`GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs`;`GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/logs/label-values`;`GET /api/v1/rlinf.io/v1alpha1/jobs/{name}/metrics` |
| `tasks` | Namespaced | `GET, POST /api/v1/rlinf.io/v1alpha1/tasks`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/tasks/{name}`;`/api/v1/rlinf.io/v1alpha1/tasks/{name}/tensorboard/{path}` 接受所有方法 |
| `pods` | Namespaced | `GET /api/v1/rlinf.io/v1alpha1/pods`;`GET, PATCH /api/v1/rlinf.io/v1alpha1/pods/{name}`;`GET /api/v1/rlinf.io/v1alpha1/pods/{name}/events`;`GET /api/v1/rlinf.io/v1alpha1/pods/{name}/terminal` |
| `domains` | Cluster | `GET, POST /api/v1/rlinf.io/v1alpha1/domains`;`GET, PUT, PATCH, DELETE /api/v1/rlinf.io/v1alpha1/domains/{name}` |
@@ -22,6 +22,10 @@
Gateway Router 未暴露 CRD status 子资源路由;状态随普通资源响应返回。
+Job 日志接口已实现:`logs` 支持 `from`、`to`、`task`、`pod`、`query`、`cursor`、`order`。`from`、`to` 使用 RFC 3339 时间;`order=asc` 表示后端结果升序,其他值均为降序。提供 `from` 且已配置日志后端时,成功响应包含 `source: "backend"`、`entries`、`hasMore`、`nextCursor`;其他情况(包括后端查询失败)回退到 Pod 日志,响应包含 `source: "pod"` 和 `pods` 数组,数组项含 `taskName`、`podName`、`phase`、`node`、`logs`。
+
+`logs/label-values` 支持 `label`(默认 `pod`)、`from`、`to`、`task`、`pod`,返回 `{ "values": [...] }`;未配置日志后端时数组为空。Job `metrics` 已注册,但当前仅返回 HTTP `501 Not Implemented`。
+
## 集群与证书
| 方法 | 路径 |
@@ -41,16 +45,45 @@ Gateway 虽注册了 `POST /api/v1/certificates/revoke`,但该接口尚未实
| `POST` | `/api/v1/auth/login` |
| `GET` | `/api/v1/ssh-user-keys` |
| `POST` | `/api/v1/ssh-user-keys` |
-| `DELETE` | `/api/v1/ssh-user-keys/{id}` |
+| `DELETE` | `/api/v1/ssh-user-keys/{index}?user={user}` |
+
+## API 参考元数据
+
+| 方法 | 路径 |
+|------|------|
+| `GET` | `/api/v1/api-reference` |
+
+该接口要求认证,返回本地化分类名称、接口方法与路径、描述及响应示例。Web UI 的“接口参考”页面以此接口为数据源,不再单独维护接口列表。
+
+## Gateway 可观测性
-## 镜像仓库与系统配置
+| 方法 | 路径 |
+|------|------|
+| `GET` | `/metrics` |
+
+`GET /metrics` 以 Prometheus 文本格式暴露 Gateway 指标。
+
+## 镜像、镜像仓库与系统配置
| 方法 | 路径 |
|------|------|
+| `GET` | `/api/v1/images` |
| `GET, POST` | `/api/v1/image-registries` |
-| `GET, PUT, DELETE` | `/api/v1/image-registries/{name}` |
+| `GET, PUT, DELETE` | `/api/v1/image-registries/{id}` |
| `GET, PUT` | `/api/v1/system-config` |
+系统配置分为 `ssh` 和 `log` 两类。`ssh.jumpHost` 必须是不含协议、用户、路径和端口的主机名或 IP;设置 `ssh.jumpPort` 时,其值必须为 1 到 65535 之间的整数。将 `log.backend` 设置为 `none` 可关闭历史日志查询,设置为 `sls` 时必须提供完整的 SLS 连接参数。响应中的 SLS 敏感凭据显示为 `****`,将该占位符原样提交表示保留已存储值。`PUT` 成功后返回最终生效且已掩码的配置。
+
+可选的 `deployment` 分类沿用 `rlarkadm` `DeployConfig` 中与数据面 Agent 相关的子集,用于控制签发数据面 Agent 集群后展示的部署 YAML 默认值。仅接受 `controlPlaneAddress`、`sshAddress`、`insecureSkipTlsVerify`,以及 Kubernetes 下的 `kubeconfig`、`agentImage`、`image`、`imagePullPolicy`、`imagePullSecrets` 和 `containerdSocket`。控制面组件、数据库、证书、Docker 和 Raw 部署字段会被拒绝。`controlPlaneAddress` 为空时使用签发接口返回的 Server 地址;镜像拉取策略可为 `Always`、`IfNotPresent` 或 `Never`。
+
+镜像仓库凭证使用不可变的 `ir-<16 位小写十六进制字符>` ID;展示 `name` 可以重复或修改。创建请求必须包含 `name`、`registry`、`username`、`password` 和 `clusterSelection`:
+
+```json
+{"name":"生产 Harbor","registry":"harbor.example.com","username":"robot","password":"secret","clusterSelection":{"mode":"Selected","clusters":["cluster-a"]}}
+```
+
+`clusterSelection.mode` 可为 `None`(仅保存)、`Selected`(一个或多个逻辑集群名)或 `All`(当前及未来集群)。`None` 和 `All` 模式下 `clusters` 必须为空。响应不返回密码,但会返回 `id`。`POST` 返回 `201 Created`。执行 `PUT` 时,省略 `password` 表示保留原密码,显式空密码非法。`DELETE` 返回 `202 Accepted`,因为 Replication 和 Delivery 会异步删除已分发的 Secret。
+
## 存储
| 方法 | 路径 |
diff --git a/apps/rlark/docs/zh/architecture.md b/apps/rlark/docs/zh/architecture.md
index 4a23c09..fe93ff9 100644
--- a/apps/rlark/docs/zh/architecture.md
+++ b/apps/rlark/docs/zh/architecture.md
@@ -14,7 +14,7 @@ RLark 是一个面向跨集群具身智能场景的云原生纳管平台,核
RLark 采用**控制面—数据面**分离架构,控制面使用 kcp 作为兼容 Kubernetes 的 API Server,Agent 将云端 GPU 集群与端侧 Kubernetes 节点统一接入控制面。可选的 **embodied-runtime** Device Plugin 部署到配有机械臂(ROS 1/2)或摄像头硬件的 Kubernetes 节点,将这些设备作为 Kubernetes 资源暴露;后续将通过 Docker 和 Raw 数据面运行时适配更多轻量端侧环境。
-
+
## 3. 控制面组件
@@ -74,7 +74,6 @@ Controller-Manager 运行在控制面,负责协调高层资源的生命周期
| Domain Controller | 管理 Domain CRD,分配 IP 子网,签发 DomainPeer 证书 | [domain/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/domain/) |
| Task Controller | 监听 Task 状态,同步到对应的 Job | [task/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/task/) |
| Node Controller | 监听 Node 注册/离线事件 | [node/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/node/) |
-| Workflow Controller | DAG 编排,按依赖顺序调度 Job | [workflow/](https://github.com/RLinf/RLark/tree/main/apps/rlark/pkg/controllermanager/workflow/) |
**Job 状态机**:
@@ -196,11 +195,12 @@ func (a *containerNetworkAdapter) GetContainerNetworkDial(...) (utils.Dial, erro
按 Domain 维护的 SSH 连接池,设计要点:
-- 每个 Domain 至多一个 SSH 连接(ssh.Client 多路复用)
+- 每个 Domain 从一条 SSH 连接开始;现有连接均承载活跃 channel 时按需扩展,默认最多四条
+- 新 channel 选择负载最低的连接,空闲物理连接由后台 GC 回收
- 连接断开时自动重连,重连期间并发请求等待而非各自新建
- 重连失败指数退避(1s → 2s → 4s → ... → 30s)
-- 后台 GC 关闭空闲超时连接(默认 10 分钟)
-- 线程安全,正常路径读锁无阻塞
+- 后台 GC 关闭空闲超时连接(默认 24 小时)
+- 数据路径上的活跃时间使用原子、限频更新,避免每次读写获取互斥锁
### 4.6 Embodied Runtime
@@ -315,7 +315,6 @@ sequenceDiagram
```mermaid
flowchart LR
- wf["WorkflowCluster "]
job["JobCluster "]
task["TaskNamespaced "]
node["NodeNamespaced "]
@@ -351,4 +350,3 @@ iptables/CNI 方案需要修改节点网络配置,权限要求高。TUN + gVis
- 运行在用户态,但创建 TUN 设备仍需要 privileged 权限
- gVisor netstack 处理所需的网络协议
- 可以作为 Sidecar 容器注入,与业务容器解耦
-
diff --git a/apps/rlark/docs/zh/concepts.md b/apps/rlark/docs/zh/concepts.md
index 2cd3d69..df64c4f 100644
--- a/apps/rlark/docs/zh/concepts.md
+++ b/apps/rlark/docs/zh/concepts.md
@@ -5,7 +5,6 @@
RLark 采用多层资源抽象,从底层基础设施到上层具身智能工作负载逐层封装:
```
-Workflow ──── 工作流(DAG 编排多个 Job)
│
└── Job ──── 训练作业(一个完整的具身智能任务)
│
@@ -146,7 +145,7 @@ status:
业务平台的节点总数和集群详情节点列表包含带有 RLark 分类 Label 的可用 Worker;旧版 `rlark.io/node-category` 值及明确上报 GPU 或具身设备资源的未标注节点仍兼容展示。带有 Kubernetes `master` 或 `control-plane` 角色标签的节点仍只在管理平台中展示,不作为业务任务 Worker 统计和展示。
-节点详情中的 CPU、内存和 GPU 占用量按运行 Worker 的 Kubernetes `resources.requests` 汇总,反映调度器已预留资源,不代表 metrics-server 的实时硬件利用率。详情页同时列出该节点上的 Worker、所属 Job、角色、IP、资源申请和运行状态。
+节点详情中的 CPU、内存和 GPU 占用量按运行 Worker 的 Kubernetes `resources.requests` 汇总,反映调度器已预留资源,不代表 metrics-server 的实时硬件利用率。磁盘数据单独来自 kubelet Stats Summary:Agent 汇总 nodefs 与独立 imagefs,并在二者指向同一文件系统时避免重复计算。页面展示真实已使用、总量和剩余量,使用率达到 90% 或 kubelet 上报 `DiskPressure=True` 时显示告警。详情页同时列出该节点上的 Worker、所属 Job、角色、IP、资源申请和运行状态。
## 5. Node 调度控制(Cordon/Uncordon)
@@ -228,12 +227,17 @@ status:
```
(空) ──init──▶ Pending ──tasks-running──▶ Running
- │ │
- │ any-task-failed │ all-tasks-succeeded
- ▼ ▼
- Failed Succeeded
+ │ │ │
+ │ stop │ stop │ all-tasks-succeeded
+ ▼ ▼ ▼
+ Stopping ──cleanup-complete──▶ Stopped Succeeded
+ ▲
+ │ failed-run stop/restart cleanup
+ Failed
```
+`Stopping` 是过渡状态:RLark 会删除子 Task,并等待其 Worker 和任务 PVC 清理完成。停止后再启动终态 Job 时,会先删除原 Task、Worker 和任务 PVC,再执行全新运行。
+
### 与 Task 的关系
Job Controller 调谐完成后:
@@ -247,19 +251,23 @@ Job Controller 调谐完成后:
### 概念
-将 `spec.stopped: true` 设置为 Job 会通知 Job 控制器停止所有关联的工作负载(Pod、Deployment、StatefulSet),但不删除 Job 资源。将其设置回 `false`(或移除该字段)会重新启动工作负载。
+将 Job 的 `spec.stopped` 设为 `true` 会启动有序停止流程,但不删除 Job 资源。RLark 等待 Job 及其所有子 Task 和 Worker 停止期间显示 `Stopping`,完成后进入 `Stopped`;清除此字段可再次启动已停止的 Job。
### 工作原理
-1. **停止**:当 `spec.stopped` 设置为 `true` 时,Job 控制器检测到变化并删除底层 Kubernetes 工作负载(Deployment/StatefulSet),同时保留 Job CR
-2. **重启**:当 `spec.stopped` 被移除或设置为 `false` 时,Job 控制器根据 Task 模板重新创建工作负载
-3. **状态保留**:Job 的 phase 和 status 字段在停止/重启周期中保持不变
+1. **停止**:RLark 删除子 Task CR,等待其底层工作负载和 PVC 清理完成,同时保留 Job CR 和配置
+2. **启动**:清除 `Stopped` Job 的 `spec.stopped` 后,按模板重新创建 Task、工作负载和空的任务 PVC
+3. **重启**:先清理当前 Task、Worker 和任务 PVC,再重新创建;`Succeeded` 和 `Failed` Job 都会按模板开始一轮全新运行
+4. **终态结果**:停止终态 Job 时,会在新一轮运行开始前保留已完成 Task 的状态结果
+5. **删除**:先执行停止并等待完成,再删除 Job 和子 Task;任务 PVC 会删除,hostPath 数据不会删除
### 关键特性
-- **非破坏性**:停止 Job 不会删除 Job CR 或其 Task
-- **持久化状态**:PVC 和其他持久化资源不受停止影响
-- **Web UI 集成**:Web UI 在 Job 列表中提供一键停止/启动按钮
+- **保留配置**:停止保留 Job CR 和配置,但不保留任务 PVC 数据
+- **全新重建**:启动和重启会创建空的任务 PVC,应先将需要保留的输出复制到其他位置
+- **Web UI 集成**:Web UI 提供停止、启动、重启和删除操作,并根据生命周期状态控制可用性
+
+停止 Workflow 时,RLark 会删除当前运行的全部 Job,包括已经完成的 Job。恢复 Workflow 会创建一轮全新执行,从 DAG 起点重新运行,不复用上一轮的完成状态。
## 8. Task(任务单元)
@@ -339,60 +347,7 @@ graph LR
Env <-->|"观测"| Camera
```
-## 9. Workflow(工作流)
-
-Workflow 是**多 Job 的 DAG 编排**,支持有依赖关系的训练流水线。
-
-### 概念
-
-一个 Workflow 包含多个 Job 模板,每个模板可通过 `dependencies` 声明前置依赖。Workflow Controller 按拓扑顺序调度 Job:前置 Job 成功后,依赖它的 Job 才能启动。
-
-### 关键属性
-
-```yaml
-apiVersion: rlinf.io/v1alpha1
-kind: Workflow
-metadata:
- name: training-pipeline-v1
-spec:
- jobTemplates:
- - name: prepare-data
- dependencies: [] # 无依赖,立即启动
- spec:
- tasks:
- - name: prep
- role: Env
- agentType: Kubernetes
- kubernetes: ...
- - name: train
- dependencies: ["prepare-data"] # 等 prepare-data 成功后才启动
- spec:
- tasks:
- - name: actor-head
- head: true
- role: Actor
- agentType: Kubernetes
- kubernetes: ...
- - name: evaluate
- dependencies: ["train"]
- spec:
- tasks: ...
-```
-
-### 典型流水线
-
-```
-数据准备 ──▶ 模型训练 ──▶ 模型评估
- prepare train evaluate
-```
-
-### 状态机
-
-与 Job 类似,Workflow 的状态由各 Job 的状态汇总决定:
-- 所有 Job 成功 → Workflow Succeeded
-- 任一 Job 失败 → Workflow Failed
-
-## 10. Pod(容器实例)
+## 9. Pod(容器实例)
Pod CR 是数据面 Pod 的**控制面镜像**,由 Agent 的 Push 控制器上报。
@@ -406,11 +361,10 @@ Pod CR 是数据面 Pod 的**控制面镜像**,由 Agent 的 Push 控制器上
- **SSH 查找**:Server 的 PodCache 基于 Pod CR 快速定位 Pod 所在 Agent
- **日志查询**:Gateway 通过 Pod CR 找到 Pod 所在 Agent,转发日志请求
-## 11. 资源关系总结
+## 9. 资源关系总结
```mermaid
graph TD
- wf["Workflow (Cluster scoped) DAG 编排"] -->|"1:N"| job["Job (Cluster scoped) 训练任务定义"]
job -->|"1:N"| task["Task (Namespaced: agent-{id}) 任务执行单元"]
task -->|"1:1 (K8s workload)"| workload["Deployment / DaemonSet / StatefulSet (本地 k8s 集群)"]
workload -->|"1:N"| pod["Pod + Sidecar Agent Push 上报 → Pod CR"]
@@ -418,7 +372,7 @@ graph TD
node["Node (Namespaced) 计算节点信息"]
```
-## 12. 命名约定
+## 9. 命名约定
| 命名空间前缀 | 含义 | 示例 |
|-------------|------|------|
@@ -427,7 +381,7 @@ graph TD
| Label `rlinf.io/job` | Pod/Task 所属 Job | `rlinf.io/job=ppo-cartpole-v1` |
| Annotation `rlinf.io/ray-role` | Ray 集群角色 | `head` / `worker` |
-## 13. Ray 集群集成
+## 9. Ray 集群集成
RLark 支持通过 Task 注解声明式创建 Ray 集群:
@@ -449,38 +403,52 @@ annotations:
## 14. 对象存储与 PVC
-RLark 支持通过 Task 的 `pvcStorageMap` 为训练任务挂载持久化存储卷。
+RLark 通过 Kubernetes 通用临时卷为训练任务挂载远程存储。
### 概念
-当 Task 指定 `pvcStorageMap` 时,Agent 的 Pull 控制器在创建工作负载前自动创建指定 StorageClass 的 PVC,并在任务删除时自动清理。
+每个 Pod 根据卷中的 `ephemeral.volumeClaimTemplate` 获得一个 PVC。该 PVC 由 Kubernetes 管理并随 Pod 删除。
### 配置方式
```yaml
kubernetes:
workload:
- pvcStorageMap:
- my-data-pvc: "ceph-rbd" # PVC 名称 → StorageClass 名称
+ template:
+ spec:
+ volumes:
+ - name: data
+ ephemeral:
+ volumeClaimTemplate:
+ spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: ceph-rbd
+ resources:
+ requests:
+ storage: 10Gi
```
### 工作流程
-1. Agent 通过 `GET /api/v1/storage/storageclass?clusters=` 查询可用 StorageClass
-2. 创建工作负载时,Agent 调用 `ensurePVCs` 创建指定 StorageClass 的 PVC
-3. PVC 创建在目标命名空间中,作用域为当前 Task
-4. 任务删除时,PVC 自动清理
+1. 前端通过 `GET /api/v1/storage/storageclass?clusters=` 查询可用 StorageClass
+2. 所选存储类和申请容量写入 `volumeClaimTemplate`
+3. Kubernetes 为每个 Pod 创建一个 PVC,并通过所选 StorageClass 绑定存储
+4. Pod 删除时,Kubernetes 自动删除对应 PVC
+
+`pvcStorageMap` 和 `pvcSizeGbMap` 已废弃,仅为兼容已有 Task 保留。新 Task 应使用 `ephemeral.volumeClaimTemplate`。
## 15. 用户认证
-RLark 为 Web UI 提供登录和基于角色的导航。当前 `admin` 与 `user` 的区别**仅是前端门禁**:用于选择管理平台或业务平台,Gateway 不会把这些角色作为 API 授权策略执行。不要将 UI 角色视为安全边界,也不要据此向不受信任的客户端暴露 Gateway。
+RLark 使用短期 JWT access token 认证 Web UI 和 API 请求。经验证的 `admin` 与 `user` 角色用于执行粗粒度 API 授权:平台业务操作对两个角色开放,控制面配置和凭据管理仅允许 `admin`。
### 认证流程
-1. 部署时,`rlarkadm` 生成随机密码并存储在 KCP Secret(`rlark-ui-auth`)中
+1. 部署时,`rlarkadm` 生成随机密码和 JWT 签名密钥,并存储在 KCP Secret(`rlark-ui-auth`)中
2. Web UI 发送 `POST /api/v1/auth/login` 携带用户名和密码
-3. Gateway 对比 KCP Secret 中的凭据,返回角色
-4. 前端将登录结果存储在 `sessionStorage` 中,并以所选控制台路由作为角色门禁
+3. Gateway 对比 Secret 中的凭据,返回包含主体、角色、签发时间和过期时间的 HS256 JWT
+4. 前端将 token 存储在 `sessionStorage`,后续 API 请求携带 `Authorization: Bearer `;Gateway 在分发受保护路由前验证 token
+
+`user` 可管理 Job、Workflow、Task、Pod、终端会话、SSH 密钥和存储对象,并读取集群、节点、Domain、镜像、StorageClass 和系统配置。节点和 Domain 变更、证书、镜像仓库、系统配置更新、StorageClass 管理以及 Addon 管理要求 `admin`。这是角色级授权,尚未实施逐用户资源所有权隔离,包括 SSH 密钥所有权限制。
## 16. Addon(组件管理)
diff --git a/apps/rlark/docs/zh/deployment.md b/apps/rlark/docs/zh/deployment.md
index 6c345eb..b0b9bfe 100644
--- a/apps/rlark/docs/zh/deployment.md
+++ b/apps/rlark/docs/zh/deployment.md
@@ -71,6 +71,17 @@ kubernetes:
## 3. Kubernetes 部署
+默认情况下,`rlarkadm` 会部署 kcp 作为管理 API。如需直接使用目标 Kubernetes 集群,请配置:
+
+```yaml
+kubernetes:
+ management-api: kubernetes
+```
+
+该模式下,`rlarkadm` 会将 RLark CRD 直接安装到目标集群,不部署 kcp 和 etcd。Server、Gateway 和 Controller Manager 使用各自的 ServiceAccount、ClusterRole 和集群内凭据。普通卸载会保留集群级 CRD、RLark 自定义资源和管理 Secret;删除这些数据属于独立的破坏性操作。
+
+使用 kcp 时,`rlarkadm` 会将 Controller Manager 选举 Lease 存放在管理 API 的 `default` 命名空间中;直接使用目标 Kubernetes 集群作为管理 API 时,Lease 存放在控制面工作负载所在的 `rlark-system` 命名空间中。
+
### 3.1 控制面部署
```bash
@@ -170,7 +181,7 @@ volumes:
|------|--------|------|
| `--kubeconfig` | `$KUBECONFIG` | 控制面 kubeconfig |
| `--server-address` | `https://rlark-server.rlark-system.svc:8443` | Server 地址 |
-| `--leader-elect` | `true` | 是否启用 Leader 选举 |
+| `--leader-election` | `true` | 是否启用 Leader 选举 |
| `--metrics-bind-address` | `:8080` | 指标监听地址 |
| `--health-probe-bind-address` | `:8081` | `/healthz` 和 `/readyz` 监听地址 |
@@ -185,6 +196,8 @@ volumes:
### 5.4 Agent
+可直接使用的数据面清单:[agent-rbac.yaml](../examples/agent-rbac.yaml) 和 [agent-deploy.yaml](../examples/agent-deploy.yaml)。
+
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--kubeconfig` | `""` | 数据面 kubeconfig |
@@ -240,13 +253,20 @@ pkg/addons/catalog/
│ ├── configmap-template.yaml # ConfigMap 模板(camera/ROS 控制器配置)
│ ├── headless-services.yaml # Camera/ROS 控制器的 Headless Service
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding
-└── csi-driver-rclone/
- ├── addon.yaml # Addon 元数据(名称、版本、类别:storage)
+├── csi-driver-rclone/
+│ ├── addon.yaml # Addon 元数据(名称、版本、类别:storage)
+│ └── manifests/
+│ ├── controller.yaml # CSI Controller Deployment
+│ ├── node.yaml # CSI Node DaemonSet
+│ ├── configmap.yaml # RClone 配置
+│ ├── csidriver.yaml # CSIDriver 资源
+│ └── rbac.yaml # RBAC 权限
+└── fluent-bit/
+ ├── addon.yaml # Addon 元数据和日志后端配置
└── manifests/
- ├── controller.yaml # CSI Controller Deployment
- ├── node.yaml # CSI Node DaemonSet
- ├── configmap.yaml # RClone 配置
- ├── csidriver.yaml # CSIDriver 资源
+ ├── daemonset.yaml # Fluent Bit 日志采集 DaemonSet
+ ├── configmap.yaml # 输入、过滤和输出配置
+ ├── secret.yaml # 后端认证信息
└── rbac.yaml # RBAC 权限
```
@@ -254,12 +274,16 @@ pkg/addons/catalog/
| 参数 | 说明 | 默认值 |
|------|------|--------|
-| `image` | 设备插件容器镜像 | `rlark/embodied-device-plugin:0.1.0` |
+| `deviceCount` | 每个节点暴露的设备数量 | `"1"` |
+| `robots` | 所有节点共用的全局机器人 YAML 配置 | `""` |
+| `macvlans` | 所有节点共用的全局 MacVlan YAML 配置 | `""` |
+| `nodeOverrides` | 以节点名为键的节点级 YAML 覆盖配置 | `""` |
+| `image` | 设备插件容器镜像 | `rlinf/embodied-runtime:v0.1.0-b4b4d6f8` |
| `rendererImage` | 节点级配置渲染 initContainer 镜像(yq) | `yq:4.53.2` |
-| `cameraImage` | Camera 控制器容器镜像 | — |
-| `rosImage` | ROS 控制器容器镜像 | — |
-| `nodeSelector` | DaemonSet 调度的节点选择器 | `nvidia.com/gpu=true` |
-| `robotTolerationKey` | 机器人节点的容忍度键 | — |
+| `cameraImage` | Camera 控制器容器镜像 | `rlinf/camera-base:v0.1.0-946787a0` |
+| `rosImage` | ROS 控制器容器镜像 | `rlinf/serl_franka_controllers:v0.1.0-libfranka-0.19.0-frankaros-0.10.2` |
+| `nodeSelector` | DaemonSet 调度的节点选择器 | `""` |
+| `robotTolerationKey` | 机器人节点的容忍度键 | `rlinf.io/robot` |
该 Addon 还会部署两个 Headless Service(`camera-controller-headless` 和 `ros-controller-headless`),用于集群内基于 DNS 的稳定发现 camera 和 ROS 控制器。
@@ -267,17 +291,35 @@ pkg/addons/catalog/
| 参数 | 说明 | 默认值 |
|------|------|--------|
-| `rcloneImage` | RClone CSI 驱动容器镜像 | `csi-driver-rclone:v0.2.0` |
-| `csiProvisionerImage` | CSI Provisioner sidecar 镜像 | `csi-provisioner:v6.2.0` |
-| `livenessProbeImage` | Liveness Probe sidecar 镜像 | `livenessprobe:v2.18.0` |
-| `nodeDriverRegistrarImage` | Node Driver Registrar sidecar 镜像 | `csi-node-driver-registrar:v2.16.0` |
+| `rcloneImage` | RClone CSI 驱动容器镜像 | `rlinf/csi-rclone/csi-driver-rclone:v0.2.0` |
+| `csiProvisionerImage` | CSI Provisioner sidecar 镜像 | `rlinf/csi-rclone/csi-provisioner:v6.2.0` |
+| `livenessProbeImage` | Liveness Probe sidecar 镜像 | `rlinf/csi-rclone/livenessprobe:v2.18.0` |
+| `nodeDriverRegistrarImage` | Node Driver Registrar sidecar 镜像 | `rlinf/csi-rclone/csi-node-driver-registrar:v2.16.0` |
| `driverName` | CSI 驱动注册名称 | `rclone.csi.veloxpack.io` |
+| `nodeSelector` | Node DaemonSet 节点选择器,格式为 `label=value` | `""` |
| `controllerReplicas` | Controller Deployment 副本数 | `1` |
| `controllerLogLevel` | Controller 日志级别 (0-10) | `5` |
| `nodeLogLevel` | Node DaemonSet 日志级别 (0-10) | `5` |
RClone CSI 驱动支持通过 RClone 动态配置远程存储(S3、GCS、Azure Blob 等)支持的 PersistentVolume。
+`fluent-bit` 的关键可配置参数:
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `backend` | 日志后端;目前仅支持 `sls` | `sls` |
+| `endpoint` | 后端地址;SLS 使用 Kafka 接入地址 | `""` |
+| `project` | SLS Project 或后端租户/组织名 | `""` |
+| `logstore` | SLS Logstore 或后端索引名 | `""` |
+| `accessKeyId` | 后端认证 ID | `""` |
+| `accessKeySecret` | 后端认证 Secret | `""` |
+| `clusterId` | 写入日志的 `cluster_id` 标签值 | `""` |
+| `image` | Fluent Bit 容器镜像 | `fluent/fluent-bit:3.1.8` |
+| `cpuLimit` | Fluent Bit CPU 限额 | `200m` |
+| `memoryLimit` | Fluent Bit 内存限额 | `256Mi` |
+
+使用 SLS 时,`endpoint` 填写 `.:`(公网端口 `10012`,私网端口 `10011`),`project` 填写 SLS Project,`logstore` 作为 Kafka topic,并提供具有 SLS 写权限的 AccessKey。DaemonSet 采集 Pod stdout/stderr,并附加 `cluster_id`、`job`、`task`、`pod`、`namespace` 等标签。
+
### 6.2 安装 Addon
```bash
@@ -286,9 +328,9 @@ curl -X POST "http://localhost:8080/api/v1/clusters/agent-beijing/addons" \
-H "Content-Type: application/json" \
-d '{
"addonName": "embodied-runtime-device-plugin",
- "version": "0.1.0",
+ "version": "v0.1.0",
"values": {
- "image": "rlark/embodied-device-plugin:0.1.0"
+ "image": "rlinf/embodied-runtime:v0.1.0-b4b4d6f8"
}
}'
```
@@ -306,19 +348,19 @@ curl "http://localhost:8080/api/v1/installed-addons"
curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons"
# 获取 Addon 详情
-curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin"
+curl "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin"
# 更新 Addon 配置
-curl -X PUT "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin" \
+curl -X PUT "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin" \
-H "Content-Type: application/json" \
-d '{
"values": {
- "image": "rlark/embodied-device-plugin:0.2.0"
+ "image": "rlinf/embodied-runtime:v0.1.0-b4b4d6f8"
}
}'
# 卸载 Addon
-curl -X DELETE "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-device-plugin"
+curl -X DELETE "http://localhost:8080/api/v1/clusters/agent-beijing/addons/embodied-runtime-device-plugin"
```
## 7. 存储配置
@@ -393,20 +435,21 @@ curl -X POST "http://localhost:8080/api/v1/certificates/agent" \
### 9.3 UI 认证
-部署时,`rlarkadm` 会在 kcp 集群的 `default` 命名空间自动创建 `rlark-ui-auth` Secret,包含随机生成的 admin 和 user 角色密码:
+部署时,`rlarkadm` 会自动创建包含随机 admin/user 角色密码和 JWT 签名密钥的 `rlark-ui-auth` Secret。使用 kcp 时存放在 kcp 的 `default` 命名空间;直接使用目标 Kubernetes 集群作为管理 API 时存放在 `rlark-system`:
| 键 | 用途 |
|-----|------|
| `admin-password` | 管理员角色密码(16 位随机字符) |
| `user-password` | 用户角色密码(16 位随机字符) |
+| `jwt-signing-key` | 32 字节 HS256 签名密钥;升级已有 Secret 时补齐缺失字段,但不会轮换已有密钥 |
-密码会在安装摘要中显示。Web UI 通过 `POST /api/v1/auth/login` 进行认证。
+密码会在安装摘要中显示,签名密钥不会显示。Web UI 通过 `POST /api/v1/auth/login` 认证并获取有过期时间的 JWT。
## 10. 生产部署与高可用
### 10.1 当前 `rlarkadm` 能力范围
-仓库维护的 `rlarkadm` 示例为每个启用的控制面组件部署 1 个副本。虽然配置支持全局和组件级 `replicas`,RLark 目前没有为 Gateway、Server、kcp、etcd 或 PostgreSQL 提供经过验证的生产高可用拓扑;仅增加副本数不能视为实现了高可用。
+仓库维护的 `rlarkadm` 示例为每个启用的控制面组件部署 1 个副本。kcp 当前限制为单副本:显式设置大于 1 的 `kubernetes.kcp.replicas` 会被拒绝,全局 `replicas` 也不会扩展 kcp。RLark 目前没有为其他组件提供经过验证的生产高可用拓扑;仅增加副本数不能视为实现了高可用。
生产环境默认应沿用维护中的单副本拓扑,除非已独立设计并验证组件拓扑、共享状态、流量路由、故障恢复和存储行为。需要高可用数据服务时,应使用外部托管方案;`rlarkadm` 不会配置 PostgreSQL 主备复制。
@@ -490,13 +533,13 @@ kubectl get pods -n rlark-system
1. 检查 Agent 证书是否有效(未过期、由正确 CA 签发)
2. 检查网络连通性:`curl -k https://:8443`
-3. 检查 Server 日志:`kubectl logs -n rlark-system deployment/server`
+3. 检查 Server 日志:`kubectl logs -n rlark-system deployment/rlark-server`
### 训练任务无法启动
-1. 检查 Node 是否有足够资源:`kubectl get nodes -n rlark-system`
+1. 检查 Node 是否有足够资源:`kubectl describe node `
2. 检查 Task 状态:查询对应 Task CR
-3. 检查 Agent 日志:`kubectl logs -n rlark-system daemonset/agent`
+3. 检查集群 Agent 日志:`kubectl logs -n rlark-system deployment/rlark-agent`
### 跨集群网络不通
@@ -506,109 +549,4 @@ kubectl get pods -n rlark-system
## 14. 真机设备纳管
-rlark 支持纳管带有 GPU 或具身设备(摄像头、机械臂等)的真实物理节点。
-
-### 14.1 架构概览
-
-
-
-### 14.2 纳管流程
-
-**Step 1:在真机上加入集群**
-
-```bash
-# 在每台真机上安装 containerd/kubelet,加入集群
-# 给节点打上标签,标识设备类型
-kubectl label node robot-01 rlark.io/node-category=robot
-kubectl label node gpu-node-01 rlark.io/node-category=cloud rlark.io/model='NVIDIA H800'
-```
-
-**Step 2:安装 NVIDIA Device Plugin(GPU 节点)**
-
-```bash
-kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/main/deployments/static/nvidia-device-plugin.yml
-```
-
-**Step 3:安装 embodied-runtime Device Plugin(具身设备节点)**
-
-通过 rlark Addon 机制声明式安装:
-
-```bash
-curl -X POST "http://localhost:8080/api/v1/clusters/agent-beijing/addons" \
- -H "Content-Type: application/json" \
- -d '{
- "addonName": "embodied-runtime-device-plugin",
- "version": "0.1.0",
- "values": {
- "nodeSelector": "rlark.io/node-category=robot"
- }
- }'
-```
-
-**Step 4:验证设备注册**
-
-```bash
-# 查看节点设备信息
-kubectl describe node robot-01 | grep rlinf.io/device
-
-# 在 Web UI 的 Nodes 页面查看设备型号和空闲量
-```
-
-### 14.3 设备元数据
-
-管理员可以补充节点位置和设备型号元数据,这些信息会显示在 Web UI 中:
-
-```bash
-# 节点位置
-kubectl annotate node robot-01 \
- rlark.io/ip-location='{"province":"上海市","city":"上海市"}' --overwrite
-
-# 节点型号
-kubectl label node robot-01 rlark.io/model='NVIDIA H800' --overwrite
-```
-
-> 具身设备类型和数量由 Device Plugin 自动上报,无需手动标注。
-
-### 14.4 编写使用真机设备的 Job
-
-在 Job 的 Task 中通过 `nodeSelector` 和 `resources` 指定设备需求:
-
-```json
-{
- "tasks": [{
- "name": "robot-trainer",
- "nodeSelector": {
- "rlark.io/cluster-id": "rlark-agent-beijing",
- "rlark.io/node-category": "robot"
- },
- "kubernetes": {
- "workload": {
- "template": {
- "spec": {
- "containers": [{
- "name": "trainer",
- "image": "my-training-image:latest",
- "resources": {
- "limits": {
- "rlinf.io/device": "1",
- "rlinf.io/device-camera": "1"
- }
- }
- }]
- }
- }
- }
- }
- }]
-}
-```
-
-### 14.5 设备资源类型
-
-| 设备 | 资源名 | 上报方式 |
-|------|--------|---------|
-| NVIDIA GPU | `nvidia.com/gpu` | NVIDIA Device Plugin |
-| 摄像头 | `rlinf.io/device-camera` | embodied-runtime Device Plugin |
-| ROS 控制器 | `rlinf.io/device-ros` | embodied-runtime Device Plugin |
-| ROS2 控制器 | `rlinf.io/device-ros2` | embodied-runtime Device Plugin |
-| 通用具身设备 | `rlinf.io/device-` | embodied-runtime Device Plugin |
\ No newline at end of file
+有关 GPU 节点、机器人、摄像头及其他物理设备的完整纳管流程、Embodied Runtime 配置和设备任务提交方式,请参阅[具身设备纳管指南](admin-guide/embodied-runtime.md)。
diff --git a/apps/rlark/docs/zh/developer-guide/embodied-runtime-reference.md b/apps/rlark/docs/zh/developer-guide/embodied-runtime-reference.md
index 0319478..9141e91 100644
--- a/apps/rlark/docs/zh/developer-guide/embodied-runtime-reference.md
+++ b/apps/rlark/docs/zh/developer-guide/embodied-runtime-reference.md
@@ -2,6 +2,8 @@
一个面向边缘节点的 Kubernetes 原生运行时,用于管理机器人(ROS)与摄像头硬件。它通过 Device Plugin API 将机器人和摄像头暴露为**可调度的 Kubernetes 资源**。接入流程请参见 [具身设备集群接入](../admin-guide/embodied-runtime.md)。
+
+
## Manager 模式
每个控制器(`camera`、`ros`、`ros2`)可独立配置三种模式:
diff --git a/apps/rlark/docs/zh/developer-guide/local-development.md b/apps/rlark/docs/zh/developer-guide/local-development.md
index a2c61b2..8b9308a 100644
--- a/apps/rlark/docs/zh/developer-guide/local-development.md
+++ b/apps/rlark/docs/zh/developer-guide/local-development.md
@@ -66,7 +66,7 @@ rlark-gateway \
rlark-controller-manager \
--server-address=https://localhost:8443 \
--db-config=apps/rlark/docs/examples/db-config.yaml \
- --leader-elect=false \
+ --leader-election=false \
--metrics-bind-address=:0 \
--health-probe-bind-address=:0
```
@@ -212,4 +212,4 @@ make -C api generate-clients
- Go
- ESLint
- Prettier
-- YAML
\ No newline at end of file
+- YAML
diff --git a/apps/rlark/docs/zh/index.md b/apps/rlark/docs/zh/index.md
index 007ea55..5db1b67 100644
--- a/apps/rlark/docs/zh/index.md
+++ b/apps/rlark/docs/zh/index.md
@@ -13,10 +13,6 @@ hide:
-
-
-
-
RLark 具身智能云原生纳管平台
@@ -30,18 +26,20 @@ hide:
## 核心能力
-- **具身智能工作负载编排**:从云端 GPU 训练(RL/LLM)到端侧部署,统一的声明式 Job/Workflow/Task 抽象覆盖全链路
+- **具身智能工作负载编排**:从云端 GPU 训练(RL/LLM)到端侧部署,统一的声明式 Job/Task 抽象覆盖全链路
- **多运行时数据面**:基于 Kubernetes 统一纳管云端 GPU 集群与端侧设备,覆盖训练到具身设备部署的完整链路;面向不适合部署 Kubernetes 的轻量端侧场景,后续将扩展 Docker 和 Raw 运行时支持
- **跨集群资源抽象**:通过 Domain 和 Node CRD 统一管理多地 GPU 集群和端侧设备,控制面运行在 kcp 之上
-- **声明式训练任务**:多层抽象,支持 DAG 编排的训练流水线,声明式定义 Ray 集群
+- **声明式训练任务**:基于 Job/Task 的多角色编排,支持声明式定义 Ray 集群
- **跨集群 Pod 网络**:基于 TUN 设备 + gVisor 协议栈 + SSH 隧道的虚拟网络,Pod 跨集群通信无需 NAT 穿透
- **证书体系**:X.509 + SSH 双层证书,支持 Agent 接入、Domain 范围的跨集群转发鉴权、用户 SSH 登录鉴权
- **可观测性**:Prometheus 指标暴露、Pod 日志实时查询、Web 管理界面
## 架构概览
+下图是控制面和数据面主要组件的简化视图。
+
-
+
## 快速开始
diff --git a/apps/rlark/docs/zh/overview/capabilities.md b/apps/rlark/docs/zh/overview/capabilities.md
index 05413be..03b23f4 100644
--- a/apps/rlark/docs/zh/overview/capabilities.md
+++ b/apps/rlark/docs/zh/overview/capabilities.md
@@ -5,7 +5,7 @@ RLark 为异构具身智能基础设施提供统一控制面。
- **多集群资源纳管**:统一接入多种运行时,集中查看可用算力节点和具身设备。
- **Kubernetes Runtime — Preview**:已实现并可用于评估,但尚不承诺生产稳定性。
- **Docker / Raw Runtime — Planned**:仅有 API 与控制器框架,当前尚不能运行工作负载。
-- **任务与工作流编排**:用 Task 描述分布式任务角色,用 Workflow 组织可复用的任务流水线。
+- **任务编排**:用 Task 描述跨可用算力资源协同运行的分布式任务角色。
- **跨集群网络**:通过 TUN、gVisor netstack 与 SSH 隧道连接工作负载,不要求数据面具备公网入站能力。
- **交互式开发**:从控制台查看 Worker、复制 SSH 命令并打开 WebTerminal。
- **Embodied Runtime**:通过设备插件和运行时控制器将机器人、相机等设备转化为可调度资源。
diff --git a/apps/rlark/docs/zh/quickstart.md b/apps/rlark/docs/zh/quickstart.md
index b4ecbe0..7f24f52 100644
--- a/apps/rlark/docs/zh/quickstart.md
+++ b/apps/rlark/docs/zh/quickstart.md
@@ -76,6 +76,24 @@ bash apps/rlark/docs/examples/quickstart.sh
| 11 | 创建跨集群测试资源(Workspace、Domain、Job) |
| 12 | 验证跨集群网络连通性 |
+#### 高级选项
+
+一键脚本通过环境变量接受高级配置,而不是命令行参数:
+
+```bash
+# 创建 3 个数据面集群,而不是默认的 2 个
+CLUSTER_COUNT=3 bash apps/rlark/docs/examples/quickstart.sh
+
+# 使用其他 kind 节点镜像(默认:kindest/node:v1.31.0)
+KIND_IMAGE=kindest/node:v1.32.0 bash apps/rlark/docs/examples/quickstart.sh
+
+# 同时设置两项
+CLUSTER_COUNT=3 KIND_IMAGE=kindest/node:v1.32.0 \
+ bash apps/rlark/docs/examples/quickstart.sh
+```
+
+`CLUSTER_COUNT` 控制创建多少个 `rlark-data-N` 集群。`KIND_IMAGE` 必须是 kind 兼容的节点镜像;脚本会先检查本地 Docker 镜像缓存,再尝试从 Docker Hub 和配置的镜像源拉取。
+
### 2. 登录 UI
脚本完成后,启动本地 UI:
@@ -91,7 +109,7 @@ VITE_DATA_MODE=backend npm run dev
| 服务 | 地址 | 用途 |
|------|------|------|
| 管理平台 | `http://localhost:5173/admin` | 集群纳管、节点、证书 |
-| 业务平台 | `http://localhost:5173` | 任务、Worker、工作流、存储 |
+| 业务平台 | `http://localhost:5173` | 任务、Worker、存储 |
| Gateway API | `http://localhost:9000` | 自动化 |
### 3. 清理环境
@@ -126,6 +144,12 @@ bash apps/rlark/docs/examples/quickstart-cp.sh
!!! tip "保持终端打开"
UI 开发服务器运行在前台。请保持此终端打开以便使用 UI。完成后按 `Ctrl+C` 停止。
+如果只需启动控制面、不安装 Node.js 或运行 UI 开发服务器,请传入 `--no-ui`:
+
+```bash
+bash apps/rlark/docs/examples/quickstart-cp.sh --no-ui
+```
+
输出示例:
```
@@ -182,6 +206,22 @@ bash apps/rlark/docs/examples/quickstart-dp.sh \
--cluster-id my-cluster-2
```
+脚本根据重复传入的 `--cluster-id` 数量确定 kind 集群数量。可用 `--cluster-name` 修改 kind 集群名称前缀(默认 `rlark-data`);即使只有一个集群,也始终会追加序号:
+
+```bash
+# 为两个集群 ID 创建 edge-1 和 edge-2
+bash apps/rlark/docs/examples/quickstart-dp.sh \
+ --cluster-id my-cluster-1 \
+ --cluster-id my-cluster-2 \
+ --cluster-name edge
+
+# 覆盖 kind 节点镜像(默认:kindest/node:v1.31.0)
+KIND_IMAGE=kindest/node:v1.32.0 \
+ bash apps/rlark/docs/examples/quickstart-dp.sh --cluster-id my-cluster
+```
+
+`CLUSTER_COUNT` 不是 `quickstart-dp.sh` 的输入;该脚本会根据传入的集群 ID 在内部计算它。仅在运行一键脚本 `quickstart.sh` 时设置 `CLUSTER_COUNT`。
+
### 4. 验证集群和节点
**通过 UI:** 管理平台 → 集群与节点。确认两个集群均在线且节点已同步。
@@ -205,6 +245,8 @@ curl -s "http://localhost:9000/api/v1/rlinf.io/v1alpha1/nodes" | \
- **镜像**:`rayproject/ray:2.9.0-py310`
- **运行脚本**:`echo hello from RLark; sleep 3600`
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

6. 检查 YAML 预览,点击**提交**
@@ -256,7 +298,7 @@ kubectl --kubeconfig /tmp/kind-kubeconfig-2 exec -n rlark-system \
预期输出:`200`
-详见 [网络与安全](admin-guide/network-security.md)。
+详见 [网络与安全](admin-guide/network-security.md)。可复用清单:[domain.yaml](../examples/domain.yaml) 和 [cross-cluster-ping.yaml](../examples/cross-cluster-ping.yaml)。
### 7. 清理环境
diff --git a/apps/rlark/docs/zh/reference/configuration.md b/apps/rlark/docs/zh/reference/configuration.md
index 29cb18e..187d0d8 100644
--- a/apps/rlark/docs/zh/reference/configuration.md
+++ b/apps/rlark/docs/zh/reference/configuration.md
@@ -99,17 +99,26 @@ rlark-gateway \
## rlark-controller-manager
-控制器管理器。调和 Job、Workflow 和 Domain 资源。
+控制器管理器。调和 Job 和 Domain 资源。
| 参数 | 类型 | 默认值 | 说明 |
| ------ | ------ | -------- | ------ |
| `--server-address` | string | `https://rlark-server.rlark-system.svc:8443` | RLark Server 地址 |
| `--db-config` | string | `""` | 数据库配置文件路径 |
-| `--leader-elect` | bool | `true` | 启用 Leader Election(高可用) |
-| `--leader-election-id` | string | `rlark-controller-manager` | Leader Election 标识 |
+| `--leader-election` | bool | `true` | 启用 Leader Election(高可用) |
+| `--leader-election-key` | string | `rlark-controller-manager` | Leader Election 锁键(`name` 或 `namespace/name`) |
+| `--leader-election-id` | string | `""` | 预留的参与者标识;controller-runtime 当前自行生成标识 |
| `--metrics-bind-address` | string | `:8080` | Metrics 端点绑定地址 |
| `--health-probe-bind-address` | string | `:8081` | 健康检查端点绑定地址 |
-| `--sync-workers` | int | `5` | 并发同步 Worker 数 |
+| `--job-controller-workers` | int | `8` | Job 控制器最大并发调和数 |
+| `--task-controller-workers` | int | `8` | Task 控制器最大并发调和数 |
+| `--workflow-controller-workers` | int | `8` | Workflow 控制器最大并发调和数 |
+| `--node-controller-workers` | int | `8` | Node 控制器最大并发调和数 |
+| `--domain-controller-workers` | int | `8` | Domain 控制器最大并发调和数 |
+| `--job-sync-controller-workers` | int | `8` | Job 数据库同步控制器最大并发调和数 |
+| `--task-sync-controller-workers` | int | `8` | Task 数据库同步控制器最大并发调和数 |
+| `--workflow-sync-controller-workers` | int | `8` | Workflow 数据库同步控制器最大并发调和数 |
+| `--node-sync-controller-workers` | int | `8` | Node 数据库同步控制器最大并发调和数 |
| `--kubeconfig` | string | `$KUBECONFIG` | kubeconfig 文件路径 |
| `--master` | string | `""` | Kubernetes API Server 地址 |
| `--in-cluster` | bool | `false` | 使用 in-cluster 配置 |
@@ -119,7 +128,7 @@ rlark-gateway \
| `--kube-timeout` | duration | `0` | Kubernetes 客户端请求超时 |
!!! note "单实例部署"
- 单实例部署时建议设置 `--leader-elect=false` 以避免不必要的选举开销。
+ 单实例部署时建议设置 `--leader-election=false` 以避免不必要的选举开销。
**示例:**
@@ -127,7 +136,7 @@ rlark-gateway \
rlark-controller-manager \
--server-address=https://rlark-server:8443 \
--db-config=/etc/rlark/db-config.yaml \
- --leader-elect=false \
+ --leader-election=false \
--metrics-bind-address=:8080 \
--health-probe-bind-address=:8081
```
@@ -150,8 +159,21 @@ rlark-controller-manager \
| `--leader-election-key` | string | `default/rlark-agent` | Leader Election Key(namespace/name) |
| `--leader-election-id` | string | `hostname-pid` | Leader Election 标识 |
| `--metrics-bind-address` | string | `:8081` | Metrics 端点绑定地址 |
+| `--task-pull-controller-workers` | int | `8` | Task pull 控制器最大并发调和数 |
+| `--addon-pull-controller-workers` | int | `8` | Addon pull 控制器最大并发调和数 |
+| `--task-deployment-push-controller-workers` | int | `8` | Task Deployment push 控制器最大并发调和数 |
+| `--task-daemonset-push-controller-workers` | int | `8` | Task DaemonSet push 控制器最大并发调和数 |
+| `--task-statefulset-push-controller-workers` | int | `8` | Task StatefulSet push 控制器最大并发调和数 |
+| `--node-push-controller-workers` | int | `8` | Node push 控制器最大并发调和数 |
+| `--pod-push-controller-workers` | int | `8` | Pod push 控制器最大并发调和数 |
+| `--pod-orphan-sweep-interval` | duration | `5m` | Agent 范围内管理 Pod 孤儿扫描间隔 |
+| `--pod-orphan-sweep-page-size` | int | `200` | 每页扫描的管理 Pod 数量 |
+| `--pod-stale-ttl` | duration | `15m` | 本地 Pod 缺失后以 `Unknown`/陈旧状态保留、再删除管理 Pod 的时长 |
+
+Pod 孤儿扫描用于兜底处理遗漏的本地删除事件。它只删除 Agent 作用域内、本地 Pod UID 或已验证管理 Task UID 已失效的镜像。旧版镜像仅在以 UID 命名的镜像、存活本地 Pod 注解、管理命名空间、Task UID 以及可用的 Domain 全部一致时才会被接管;身份不明确的旧对象保持不变,需要手动清理。延迟删除会有意保留同名替代 Pod,因此旧镜像可能持续到下一次扫描。
| `--rlark-server-ssh-address` | string | `""` | RLark Server SSH 地址(user@host:port) |
| `--rlark-server-ssh-host-key` | string | `""` | RLark Server SSH Host Key |
+| `--ssh-max-connections-per-domain` | int | `4` | 每个 Domain 按负载自适应扩展的物理 SSH 连接上限 |
| `--image` | string | `""` | RLark 网络 Sidecar 镜像 |
| `--enable-same-cluster-direct` | bool | `true` | 启用同集群 Pod 直接访问 |
| `--enable-cross-cluster-direct` | bool | `true` | 启用跨集群 Pod 直接访问 |
@@ -197,10 +219,13 @@ rlark-agent \
| `--sidecar-tun-name` | string | `gnet0` | TUN 设备名称 |
| `--sidecar-tun-mtu` | int | `1500` | TUN 设备 MTU |
| `--sidecar-proxy-listen` | string | `:5700` | Proxy TCP 监听地址 |
-| `--sidecar-hosts-sync-enabled` | bool | `true` | 启用 hosts 文件定期同步 |
-| `--sidecar-hosts-sync-interval` | duration | `30s` | hosts 同步间隔 |
+| `--sidecar-metrics-listen` | string | `:5790` | Metrics 与 pprof HTTP 监听地址;设为空值可禁用 |
+| `--sidecar-hosts-sync-enabled` | bool | `true` | 启用 hosts 文件同步 |
+| `--sidecar-hosts-sync-interval` | duration | `30s` | NodeServer 不支持 hosts watch 接口时的兜底轮询间隔 |
| `--sidecar-hosts-file` | string | `/etc/hosts` | hosts 文件路径 |
+新版 sidecar 通过 NodeServer 的 `/watch_hosts` 长轮询接口接收 hosts 变化,通常可在约一秒内完成更新。如果接口不可用,会自动退回配置的轮询间隔,从而兼容旧版 NodeServer。
+
**示例:**
```bash
@@ -210,9 +235,9 @@ rlark-network-sidecar \
--sidecar-tun-mtu=1500
```
-## sshd
+## rlark-tools sshd
-SSH 守护进程。提供对运行中 Task Pod 的 SSH 访问。已集成到 rlark-server 中(通过 `--ssh-port` 参数)。
+`sshd` 子命令提供对运行中 Task Pod 的 SSH 访问。Agent 总是将 `rlark-tools` 注入到 `/rlark-tools/rlark-tools`,需要 SSH 的工作负载通过 `rlark-tools sshd` 启动服务。
| 参数 | 类型 | 默认值 | 说明 |
| ------ | ------ | -------- | ------ |
@@ -224,7 +249,8 @@ SSH 守护进程。提供对运行中 Task Pod 的 SSH 访问。已集成到 rla
| 变量 | 说明 |
| ------ | ------ |
| `RLARK_SSH_PUBLIC_KEY` | 用于 authorized_keys 的 SSH 公钥 |
-| `RLARK_SSH_AUTHORIZED_KEYS_FILE` | authorized_keys 文件路径 |
+
+Agent 环境变量 `RLARK_ENABLE_UNSAFE_TASK_PRIVILEGES=true` 用于开启旧版任务模式:授予所有 Task 容器 privileged 权限,并为 Ray Head 以外的任务启用宿主机网络。该功能默认关闭,仅应在可信集群中使用。
## 存储 Provider 配置
@@ -264,8 +290,8 @@ Gateway 使用的对象存储后端配置。
| `cert` | CertConfig | 未设置 | 证书配置;数据面部署时必填 |
| `insecure-skip-tls-verify` | bool | `false` | 跳过 Server TLS 验证 |
-!!! note "环境选择"
- `kubernetes`、`docker`、`raw` 三者选其一。数据面还必须提供 `control-plane-address` 和 `cert`。
+!!! warning "运行时支持范围"
+ 配置 schema 仍保留 `kubernetes`、`docker` 和 `raw`,但当前仅支持 Kubernetes 工作负载路径。现阶段部署不应使用 Docker 或 Raw;这两种路径不受支持,也不推荐使用。Kubernetes 数据面还必须提供 `control-plane-address` 和 `cert`。
### DBConfig
@@ -281,6 +307,7 @@ Gateway 使用的对象存储后端配置。
| 字段 | 类型 | 默认值 | 说明 |
| ------ | ------ | -------- | ------ |
+| `management-api` | string | `kcp` | 管理 API 模式:`kcp` 部署 kcp 和可选 etcd;`kubernetes` 将 RLark 资源存储在目标集群中,且不部署 kcp/etcd |
| `kubeconfig` | string | `""` | kubeconfig 文件路径;空值使用 client-go 常规加载规则 |
| `gateway-image` | string | `""` | Gateway 镜像 |
| `controller-manager-image` | string | `""` | Controller Manager 镜像 |
@@ -291,10 +318,11 @@ Gateway 使用的对象存储后端配置。
| `etcd-image` | string | `""` | 内置 etcd 镜像;仅设置该字段且未配置外部地址时启用内置 etcd |
| `postgresql-image` | string | `""` | PostgreSQL 镜像;仅设置顶层 `db` 块时启用 PostgreSQL |
| `ui-image` | string | `""` | UI 镜像 |
+| `image-pull-secrets` | 字符串列表 | 空 | `rlark-system` 命名空间中已有的镜像拉取 Secret 名称,应用到所有组件 Pod |
| `replicas` | int | `0`(解析为 `1`) | 组件默认副本数 |
| `storage` | StorageConfig | 未设置 | 默认存储配置 |
-| `kcp` | ComponentConfig | 未设置 | kcp 组件配置 |
-| `etcd` | EtcdConfig | 未设置 | etcd 组件配置 |
+| `kcp` | ComponentConfig | 未设置 | kcp 当前限制为单副本。未配置 `etcd` 时使用 StatefulSet 并支持持久化存储;部署或指定外部 etcd 时使用 Deployment |
+| `etcd` | EtcdConfig | 未设置 | etcd 组件配置。`address` 为空时部署 etcd,非空时使用外部 etcd |
| `postgresql` | ComponentConfig | 未设置 | PostgreSQL 组件配置 |
| `containerd-socket` | string | `/run/containerd/containerd.sock` | 节点 Agent 的 Containerd Socket 路径 |
@@ -303,6 +331,9 @@ Gateway 使用的对象存储后端配置。
### DockerEnv
+!!! warning "当前不支持"
+ 这些字段为兼容性保留在 schema 中,但 Docker 不是当前支持的工作负载路径,不建议用于部署。
+
| 字段 | 类型 | 说明 |
| ------ | ------ | ------ |
| `gateway-image` | string | Gateway 镜像 |
@@ -317,8 +348,8 @@ Gateway 使用的对象存储后端配置。
### RawEnv
-!!! warning "实验性功能"
- Raw 部署模式目前处于实验阶段,建议优先使用 Kubernetes 或 Docker 部署。
+!!! warning "当前不支持"
+ 这些字段为兼容性保留在 schema 中,但 Raw 不是当前支持的工作负载路径,不建议用于部署。请使用 Kubernetes。
| 字段 | 类型 | 说明 |
| ------ | ------ | ------ |
diff --git a/apps/rlark/docs/zh/reference/crd.md b/apps/rlark/docs/zh/reference/crd.md
index 4bc9705..f3bb4f1 100644
--- a/apps/rlark/docs/zh/reference/crd.md
+++ b/apps/rlark/docs/zh/reference/crd.md
@@ -1,3 +1,35 @@
# CRD 参考
-RLark API 类型定义在 `api/rlinf.io/v1alpha1`,主要资源包括 Domain、DomainPeer、Node、Job、Task 和 Workflow,生成的 Go Client 位于 `api/kubeclients`。面向用户的资源关系和状态模型参见[核心概念](../concepts.md),精确字段以源码类型定义为准。
+> **生成说明:** 完整英文参考由 `api/config/crd/bases` 中的 CRD manifests 自动生成,请勿手工修改;应运行 `make generate-crd-schema-docs` 重新生成。本中文页是人工维护的概要。
+
+本页说明由 kcp/Kubernetes API Server 提供的底层 Kubernetes CRD API,而不是 RLark Gateway HTTP API。Gateway 仅暴露其中一部分资源操作,并增加日志、镜像、存储、证书等路由;其准确边界参见 [Gateway API 参考](../api/reference.md)。
+
+## 资源与作用域
+
+当前 `rlinf.io/v1alpha1` CRD 包括:
+
+| 资源 | 作用域 |
+|------|--------|
+| `addons` | Namespaced |
+| `domains` | Cluster |
+| `domainpeers` | Namespaced |
+| `jobs` | Cluster |
+| `nodes` | Namespaced |
+| `pods` | Namespaced |
+| `tasks` | Namespaced |
+
+底层 CRD API 遵循 Kubernetes 资源语义,可包含列表、创建、读取、替换、Patch、删除、集合删除及 status 子资源等操作。是否支持某个字段或操作,以当前 CRD manifest 和生成结果为准。
+
+状态阶段枚举中,`Pod.status.phase` 支持 `Pending`、`Running`、`Succeeded`、`Failed`、`Unknown`。
+
+Gateway 不会原样透传这套完整 API:它实际只开放部分资源和方法,且没有 collection delete(集合删除)或 `/status` 路由;因此不能根据本页推断 Gateway 存在同名接口。Gateway 的准确路由边界请查阅 [Gateway API 参考](../api/reference.md),完整字段和底层操作请查阅 [英文生成参考](../../reference/crd.md)。
+
+## 生成来源与维护方式
+
+CRD 类型定义位于 `api/rlark.io/v1alpha1/`,CRD manifests 和生成脚本位于 `api/` 下,生成的 Go client 位于 `api/kubeclients/`。精确字段、枚举、必填项和完整操作清单以英文生成参考为准:
+
+- [完整英文 CRD Schema Reference](../../reference/crd.md)
+- [Gateway API 参考](../api/reference.md)
+- [核心概念](../concepts.md)
+
+中文页刻意保持为可维护摘要,不手工复制完整生成内容;CRD 变更后应重新生成英文参考,并同步更新本页的资源清单与边界说明。
diff --git a/apps/rlark/docs/zh/storage-api.md b/apps/rlark/docs/zh/storage-api.md
index 5ad9b21..ea8a51c 100644
--- a/apps/rlark/docs/zh/storage-api.md
+++ b/apps/rlark/docs/zh/storage-api.md
@@ -79,13 +79,13 @@ Storage API 提供多集群 StorageClass 管理能力。Gateway 通过 Server
从所有已关联集群删除指定 StorageClass 和配套 Secret。可通过查询参数 `clusters=agent-a,agent-b` 限制删除范围。
### 6. 列出存储桶文件
-**GET** `/api/v1/storage/storageclass/{cluster}/{name}/list`
+**GET** `/api/v1/storage/storageclass/{name}/{cluster}/list`
列出指定集群中 StorageClass 存储桶下的文件列表。
#### 路径参数
-- `cluster`:集群 ID(如 `agent-beijing`)
- `name`:StorageClass 名称
+- `cluster`:集群 ID(如 `agent-beijing`)
#### 响应示例
```json
@@ -99,13 +99,13 @@ Storage API 提供多集群 StorageClass 管理能力。Gateway 通过 Server
```
### 7. 上传文件
-**POST** `/api/v1/storage/storageclass/{cluster}/{name}/upload`
+**POST** `/api/v1/storage/storageclass/{name}/{cluster}/upload`
向指定 StorageClass 存储桶上传文件,使用 multipart/form-data 格式。
#### 路径参数
-- `cluster`:集群 ID
- `name`:StorageClass 名称
+- `cluster`:集群 ID
#### 请求体
multipart/form-data,字段 `file` 为上传的文件。
@@ -119,27 +119,27 @@ multipart/form-data,字段 `file` 为上传的文件。
```
### 8. 下载文件
-**GET** `/api/v1/storage/storageclass/{cluster}/{name}/object/*key`
+**GET** `/api/v1/storage/storageclass/{name}/{cluster}/object/*key`
下载指定存储桶中的对象,返回原始文件内容。
#### 路径参数
-- `cluster`:集群 ID
- `name`:StorageClass 名称
+- `cluster`:集群 ID
- `key`:对象路径(如 `model-checkpoint.pt` 或 `logs/training.log`)
#### 响应
- `200`:文件内容(二进制流)
- `404`:文件不存在
-### 7. 删除文件
-**DELETE** `/api/v1/storage/storageclass/{cluster}/{name}/object/*key`
+### 9. 删除文件
+**DELETE** `/api/v1/storage/storageclass/{name}/{cluster}/object/*key`
删除指定存储桶中的对象。
#### 路径参数
-- `cluster`:集群 ID
- `name`:StorageClass 名称
+- `cluster`:集群 ID
- `key`:对象路径
#### 响应示例
@@ -211,13 +211,23 @@ curl "http://localhost:8080/api/v1/storage/storageclass/provider"
## 与 Task PVC 挂载的集成
-Task 通过 `pvcStorageMap` 声明需要挂载的 PVC:
+Task 通过 Kubernetes 通用临时卷声明远程存储:
```yaml
kubernetes:
workload:
- pvcStorageMap:
- my-data-pvc: "ceph-rbd"
+ template:
+ spec:
+ volumes:
+ - name: data
+ ephemeral:
+ volumeClaimTemplate:
+ spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: ceph-rbd
+ resources:
+ requests:
+ storage: 10Gi
```
-Agent 的 Pull 控制器在创建 workload 前,调用 `ensurePVCs` 根据 `pvcStorageMap` 创建对应的 PVC。前端通过 Storage API 获取可用 StorageClass 列表供用户选择。
+Kubernetes 根据 `volumeClaimTemplate` 为每个 Pod 创建 PVC,并随 Pod 删除。前端通过 Storage API 获取可用 StorageClass 列表供用户选择。`pvcStorageMap` 和 `pvcSizeGbMap` 已废弃,仅为已有资源保留。
diff --git a/apps/rlark/docs/zh/user-guide/best-practices.md b/apps/rlark/docs/zh/user-guide/best-practices.md
index 207b711..1152c72 100644
--- a/apps/rlark/docs/zh/user-guide/best-practices.md
+++ b/apps/rlark/docs/zh/user-guide/best-practices.md
@@ -22,6 +22,8 @@
进入管理后台或集群页面查看可用集群:
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

记录集群名称和节点标签。这些信息将在配置任务节点选择器时使用。
@@ -96,6 +98,7 @@ ssh-keygen -t ed25519 -C "rlark-training"
4. 点击 **创建**
网络域会为加入的 Worker 分配虚拟 IP,并通过 SSH 隧道建立跨集群流量转发。
+末字节为 `.0` 或 `.255` 的 IPv4 地址始终保留,不会被自动分配;该规则同样适用于大于 `/24` 的 CIDR。
!!! warning "CIDR 不能重叠"
网络域的 CIDR 不能与任何集群的 Pod CIDR 或 Service CIDR 重叠,也不能与其他网络域重叠。
@@ -218,6 +221,5 @@ ssh-keygen -t ed25519 -C "rlark-training"
## 下一步
- [创建训练任务](jobs.md) — 详细字段参考
-- [工作流](workflows.md) — 多角色和异构 Worker
- [在任务中使用存储](storage.md) — hostPath 和对象存储配置
- [通过 SSH 连接 Worker](ssh-keys.md) — SSH 到运行中的 Worker
diff --git a/apps/rlark/docs/zh/user-guide/clusters-nodes.md b/apps/rlark/docs/zh/user-guide/clusters-nodes.md
index d24897b..84920eb 100644
--- a/apps/rlark/docs/zh/user-guide/clusters-nodes.md
+++ b/apps/rlark/docs/zh/user-guide/clusters-nodes.md
@@ -9,6 +9,8 @@
3. 检查在线状态、在线率和 Worker 数量。
4. 打开计划使用的集群。
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

## 任务二:检查集群容量
@@ -27,10 +29,12 @@
4. 查看 CPU、内存、GPU 容量及已申请资源。页面用量来自 Kubernetes requests 汇总,不是实时硬件利用率。
5. 检查已放置在该节点上的 Job 和 Worker。
+使用节点页面的**刷新**按钮可原位更新当前筛选条件下的全部节点。请求处理中会保留当前行并显示半透明局部遮罩和居中的旋转加载指示,页面其余部分的位置不会变化。
+

!!! note "节点分类"
- 业务平台使用 RLark 分类标签组织云算力、端算力和真机 Worker。旧版分类值及明确上报受支持资源的节点仍可见;Kubernetes 控制面节点不进入业务平台 Worker 视图。
+业务平台使用 RLark 分类标签组织云算力、端算力和真机 Worker。旧版分类值及明确上报受支持资源的节点仍可见;Kubernetes 控制面节点不进入业务平台 Worker 视图。
## 完成结果
diff --git a/apps/rlark/docs/zh/user-guide/console.md b/apps/rlark/docs/zh/user-guide/console.md
index 188fd69..e5b0844 100644
--- a/apps/rlark/docs/zh/user-guide/console.md
+++ b/apps/rlark/docs/zh/user-guide/console.md
@@ -4,44 +4,58 @@
RLark 有两个登录入口:
-| 入口 | 地址 | 用途 |
-|------|------|------|
-| 业务平台 | `http://:5173` | 任务管理、Worker、工作流、存储、SSH 密钥 |
-| 管理后台 | `http://:5173/admin` | 集群纳管、节点、证书、系统配置 |
+| 入口 | 地址 | 用途 |
+| -------- | -------------------------- | ---------------------------------------- |
+| 业务平台 | `http://:5173` | 任务管理、Worker、存储、SSH 密钥 |
+| 管理后台 | `http://:5173/admin` | 集群纳管、节点、证书、系统配置 |
### 登录步骤
+
1. 在浏览器中打开控制台地址
2. 输入用户名和密码
3. 点击登录
-4. 浏览器会话保存登录状态,不提供 Bearer token
+4. Gateway 签发 JWT access token(默认有效期 8 小时),浏览器在后续 API 请求中以 Bearer token 携带
+
+!!! warning "角色授权仍是粗粒度的"
+ Gateway 已对控制面配置和凭据管理强制要求 `admin`。`user` 可管理平台工作负载,但尚未实施逐用户资源所有权,因此已认证用户之间的 Job 或存储对象还没有相互隔离。请继续使用 TLS 和网络访问控制。
## 控制台导航
### 业务平台页面
-| 页面 | 用途 |
-|------|------|
-| 总览 | 集群、节点、机器人和运行中任务的仪表盘汇总 |
-| 集群 | 浏览和检查已纳管的数据面集群 |
-| 节点 | 筛选和检查节点资源、调度状态和健康状态 |
-| 任务 | 创建、监控和管理训练任务 |
-| 工作流 | 创建和监控基于 DAG 的任务流水线 |
-| 存储 | 浏览存储类和对象存储 |
-| SSH 密钥 | 管理 Worker 访问的 SSH 公钥 |
+| 页面 | 用途 |
+| -------- | ------------------------------------------ |
+| 总览 | 集群、节点、机器人和运行中任务的仪表盘汇总 |
+| 集群 | 浏览和检查已纳管的数据面集群 |
+| 节点 | 筛选和检查节点资源、调度状态和健康状态 |
+| 任务 | 创建、监控和管理训练任务 |
+| 存储 | 浏览存储类和对象存储 |
+| SSH 密钥 | 管理 Worker 访问的 SSH 公钥 |
+
+### 管理后台页面
+
+**镜像管理**页面用于保存私有镜像仓库凭证,并选择分发范围:仅保存、指定集群或当前及未来的所有集群。展示名称可以重复和修改,RLark 使用内部 ID 标识每条凭证。分发和删除均为异步操作;凭证最终投递到所选 Kubernetes 数据面集群的 `rlark-system` 命名空间。
+
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。

### 快速查找页面
-| 问题 | 前往 |
-|------|------|
-| 判断集群或节点是否可用于调度? | 集群或节点页面 |
-| 如何创建训练任务? | 任务 → 创建任务 |
-| 判断任务卡在哪个阶段? | 任务详情 → Worker 标签 |
-| 如何查找应用错误? | 任务详情 → 日志标签 |
-| 如何查看资源使用情况? | 节点页面 → 节点详情 |
-| 如何在容器中打开终端? | 任务详情 → Worker → WebTerminal |
+| 问题 | 前往 |
+| ------------------------------ | ------------------------------- |
+| 判断集群或节点是否可用于调度? | 集群或节点页面 |
+| 如何创建训练任务? | 任务 → 创建任务 |
+| 判断任务卡在哪个阶段? | 任务详情 → Worker 标签 |
+| 如何查找应用错误? | 任务详情 → 日志标签 |
+| 如何查看资源使用情况? | 节点页面 → 节点详情 |
+| 如何在容器中打开终端? | 任务详情 → Worker → WebTerminal |
+| 如何配置私有镜像凭证? | 管理后台 → 镜像管理 |
+
+### 刷新控制台数据
+
+点击**刷新**可在不离开当前页面的情况下获取最新数据。请求进行时,受影响的表格或仪表盘会变淡并暂时禁用交互,同时在区域中央显示进度指示;已有数据行会保留到新响应返回。总览、集群与节点、域、任务与 Worker、存储与文件、SSH 公钥、镜像仓库和系统配置均使用这一套一致的反馈方式。
## API 等效操作
-已支持的资源操作可通过 [Gateway API](../api/reference.md) 完成。独立 Gateway 默认使用 `http://:8080`;`rlarkadm` 部署使用其配置的 Service 与 UI 代理。并非每个 UI 交互都有一一对应的公开接口,请以 API 参考为准。
\ No newline at end of file
+已支持的资源操作可通过 [Gateway API](../api/reference.md) 完成。独立 Gateway 默认使用 `http://:8080`;`rlarkadm` 部署使用其配置的 Service 与 UI 代理。并非每个 UI 交互都有一一对应的公开接口,请以 API 参考为准。
diff --git a/apps/rlark/docs/zh/user-guide/index.md b/apps/rlark/docs/zh/user-guide/index.md
index 4bbd58b..c4e8460 100644
--- a/apps/rlark/docs/zh/user-guide/index.md
+++ b/apps/rlark/docs/zh/user-guide/index.md
@@ -1,12 +1,11 @@
# 平台使用指南
-本指南假设管理员已经部署控制面,并至少纳管了一个数据面集群。平台用户可以在此基础上查看资源、提交任务、检查 Worker、使用 WebTerminal、创建工作流,以及管理存储和 SSH Key。
+本指南假设管理员已经部署控制面,并至少纳管了一个数据面集群。平台用户可以在此基础上查看资源、提交任务、检查 Worker、使用 WebTerminal,以及管理存储和 SSH Key。
请选择要完成的任务:
- [查找可用算力](clusters-nodes.md)
- [提交和管理 Job](jobs.md)
-- [构建多阶段 Workflow](workflows.md)
- [挂载并验证存储](storage.md)
- [添加 SSH 密钥并连接 Worker](ssh-keys.md)
diff --git a/apps/rlark/docs/zh/user-guide/jobs.md b/apps/rlark/docs/zh/user-guide/jobs.md
index 3e71444..8b705f3 100644
--- a/apps/rlark/docs/zh/user-guide/jobs.md
+++ b/apps/rlark/docs/zh/user-guide/jobs.md
@@ -6,7 +6,9 @@ Job 是面向用户的工作负载。创建任务时选择模板或配置 Task
## 通过 UI
-业务平台 → 任务 → 创建任务。填写名称并定义 Worker 角色,然后逐个配置角色:
+业务平台 → 任务 → 创建任务。填写展示名称并定义 Worker 角色。任务展示名称和角色名称最长均为 50 个字符;角色名称忽略大小写后必须唯一,RLark 会将其规范化为 Kubernetes 可接受的 Task 资源名。任务展示名称可以重复,因为每个 Job 都有独立生成的资源 ID。
+
+然后逐个配置角色:
1. 选择目标集群;每个选项会在同一行展示集群名称、类型标签和当前状态。
2. 从规格列表中单选 GPU 或具身设备型号;列表会同时展示设备和节点的可用量/总量。选定后填写两种调度方式共用的单 Worker 资源数量。调试任务可以填写 `0`,此时保留所选型号的节点范围,但不申请对应设备。
@@ -14,14 +16,13 @@ Job 是面向用户的工作负载。创建任务时选择模板或配置 Task
- **自动选择**:填写需要创建的 Worker 数量,控制台会按可调度容量校验总申请量并自动选择符合条件的节点。
- **指定节点**:单击符合条件的节点或拖拽框选,每个选中节点创建一个 Worker;再次单击或框选已选节点可取消选择。
-复制任务时会保留原任务的调度方式。未指定具体节点的角色仍使用自动选择,不会被转换为指定节点。
-4. 检查统一的资源总结,然后配置镜像、准备脚本、环境变量和存储挂载。
+复制任务时会保留原任务的调度方式。未指定具体节点的角色仍使用自动选择,不会被转换为指定节点。4. 检查统一的资源总结,然后配置镜像、准备脚本、环境变量和存储挂载。
提交任务后,在任务详情页确认状态进入运行中并查看 Worker。
选择角色后,资源规格会直接展示实际节点上配置的 GPU 或具身设备型号与申请数量,例如 `NVIDIA RTX 4090 · 1 GPU`。Worker 尚未上报实际运行节点时,控制台会根据该角色选择的候选节点解析型号。
-Worker 处于等待中时,可悬停或聚焦其状态旁的信息图标。RLark 会查询该 Worker 对应的数据面 Pod 事件,因此 kubelet 的 `Pulling`、`Pulled` 和镜像拉取失败信息可以及时展示,也不会混入同一节点上其他 Worker 的事件。只有运行时实际上报字节进度时才显示百分比,控制台不会伪造下载进度。
+Worker 处于等待中时,可悬停或聚焦其状态旁的信息图标。RLark 会查询该 Worker 对应的数据面 Pod、Task 或节点事件,并按当前界面语言将已识别的 Kubernetes 事件归类为用户可读的等待原因。映射覆盖常见的调度、节点压力与可用性、镜像拉取、存储卷、运行环境、容器创建及健康检查问题;未识别事件显示通用等待提示,不直接展示底层原始消息。镜像拉取进度仍会单独展示,且只有运行时实际上报字节进度时才显示百分比。
## 任务类型
@@ -43,7 +44,7 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
- GPU 型号(如 A100、H100、RTX 4090)
- 物理位置或区域
-- **容器镜像** — 为该角色指定容器镜像。可使用镜像标签(如 `myimage:latest`)或 digest(如 `myimage@sha256:...`)。推荐使用 digest 以确保可复现性和可审计性。
+- **容器镜像** — 为该角色指定容器镜像。可从最近使用的 10 个镜像中选择,并查看最后使用时间和 Job 使用次数;也可输入镜像标签(如 `myimage:latest`)或 digest(如 `myimage@sha256:...`)。推荐使用 digest 以确保可复现性和可审计性。私有镜像应先在**管理后台 → 镜像管理**中配置凭证并分发到目标集群。RLark 会按仓库前缀匹配普通容器和 init container 镜像,并将所有匹配的已投递 Secret 追加到 `imagePullSecrets`;同一仓库允许配置多条凭证。当前凭证仅用于 `rlark-system` 中的工作负载,Task 也不会等待异步投递完成。
- **资源申请** — 设置每个 Worker 所需的 CPU、内存和 GPU 资源:
- CPU:以核心数指定(如 `4`)
@@ -56,13 +57,15 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
- **hostPath**:挂载宿主机节点上的目录,任务生命周期操作不会删除其中的数据。
- **PVC**(PersistentVolumeClaim):使用所选存储类挂载 Kubernetes 持久卷。停止、重启或删除任务会删除任务 PVC;启动或重启会新建空 PVC。
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

## 公共配置
配置适用于任务中所有 Worker 的设置:
-- **Header 角色** — 选择一个角色作为 Header。该角色的第一个 Worker 协调分布式训练,其 IP 地址会通知所有其他 Worker。
+- **Header 角色** — 选择一个角色作为 Header。Ray 任务中的 Header 角色必须恰好配置 1 个 Worker;它负责协调分布式训练,其 IP 地址会通知所有其他 Worker。
- **跨集群网络域** — 如果后台配置了网络域,控制台会按名称选择第一个网络域,并为所有任务自动启用,不受 Worker 是否跨集群影响;未配置时不会写入网络域。
@@ -80,7 +83,7 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
- **名称** — 面向用户的展示名,可以重复;RLark 会另外分配 `jo-<16 位十六进制字符>` 系统资源 ID。
- **类型** — 任务类型(强化学习、数据采集、评测、自定义)
-- **状态** — 当前状态:Pending、Running、Succeeded、Failed、Stopped
+- **状态** — 当前状态:Pending、Running、Stopping、Stopped、Succeeded 或 Failed。`Stopping` 是 RLark 等待 Job 及其所有子 Task 和 Worker 停止时的过渡状态。
- **Worker 数量** — 所有角色的 Worker 总数
- **创建时间** — 任务提交时间
- **Header 角色** — 被指定为协调者的角色
@@ -98,6 +101,8 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
使用列表表头的**刷新**按钮更新 Task、Pod、调度节点、IP 与状态信息,无需重载整个页面。
+任务列表和 Worker 列表的**刷新**都只更新对应的数据区域。请求处理中会保留当前行并显示半透明局部遮罩和居中的旋转加载指示,同时暂时禁用该区域内的操作;页面其余部分仍可使用。
+
点击任意 Worker 查看运行时详情,包括容器状态、资源使用情况和事件。
### 按角色查看配置
@@ -112,12 +117,13 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
### 日志功能
-- **聚合视图** — 所有 Worker 的日志合并到一个流中,每行标记 Worker 和角色。
-- **行数限制** — 每个 Pod 最多显示 1000 行日志。
-- **按角色和 Worker 筛选** — 缩小日志视图到特定角色或个别 Worker。
-- **搜索** — 在显示的日志内全文搜索。
-- **自动刷新** — 日志每 5 秒自动刷新,可实时查看任务进展。
-- **时间范围** — 选择日志检索的时间窗口:15 分钟、1 小时、6 小时或 24 小时。
+- **默认选择与刷新** — 默认选择第一个角色;日志仅在点击**刷新**后更新。
+- **聚合视图** — 所选角色或 Worker 的日志合并到一个流中,每行标记 Worker 和角色。Pod 终止后,历史 Worker 仍可选择。
+- **时间范围** — 可选择 15 分钟、1 小时、6 小时、24 小时、7 天或自定义时间段。
+- **排序** — 可按时间升序或降序排列日志。
+- **搜索** — 支持完整单词或词组搜索,不进行单词片段匹配。
+- **日志后端分页** — 配置日志后端时,每页最多返回 99 条,并通过游标继续加载。
+- **Pod 回退** — 未配置日志后端时,RLark 回退到 Pod 日志,每个 Pod 最多获取 1000 行。
### 导出日志
@@ -135,7 +141,7 @@ RLark 支持以下任务类型,每种类型预配置了适合该工作负载
### 打开终端
-从 Worker 列表中点击任意 Worker 的**终端**操作,将在 Worker 主容器中打开运行 `/bin/sh` 的 WebTerminal 会话。WebTerminal 需要用户已登录、Worker 正在运行且 RLark SSH 隧道可达。
+在 Worker 列表中点击任意运行中 Worker 的**终端**操作,默认会在其主容器中打开 `/bin/bash`。浏览器通过 WebSocket 连接 Gateway,再由 Gateway 经 Server 代理到 Agent,最终 exec 进入容器;用户无需进行 SSH 登录。
### 诊断命令
@@ -158,7 +164,7 @@ Worker 列表中的钥匙按钮用于复制 SSH 连接命令。该功能依赖
WebTerminal 支持文件上传和下载:
- **上传** — 从本地机器上传文件到容器默认工作目录。文件名需匹配 `[A-Za-z0-9._-]+` 模式。
-- **下载** — 从容器下载文件到本地机器。
+- **下载** — 输入文件在容器内的路径,再将其下载到本地机器。
所有文件传输通过终端使用的同一 WebSocket 连接进行,确保安全性和简洁性。
@@ -168,9 +174,9 @@ WebTerminal 支持文件上传和下载:
详情页右上角同时提供完整的任务生命周期操作:
-- **停止任务**:暂停运行中的任务并保留现有配置。
-- **启动任务**:恢复已停止的任务。
-- **重启任务**:选择使用当前配置一键重启,或先编辑任务配置并在保存后自动重启。编辑后重启时,资源可用量会包含当前任务即将释放的资源。
+- **停止任务**:运行中的 Job 先进入 `Stopping`,RLark 会删除其子 Task CR,并等待对应 Worker 和 PVC 清理完成。任务配置会保留。
+- **启动任务**:使用相同配置启动 `Stopped` Job,并新建空的任务 PVC;这也适用于到达终态后被停止的 Job。
+- **重启任务**:选择使用当前配置一键重启,或先编辑任务配置并在保存后自动重启。`Succeeded` 和 `Failed` Job 都会以全新运行方式重启:RLark 先停止并清理原 Task、Worker 和任务 PVC,再创建新的 Task、Worker 和空 PVC。编辑后重启时,资源可用量会包含当前任务即将释放的资源。
- **删除任务**:在危险操作弹窗中核对任务名称与不可恢复提示后,永久删除任务。
执行生命周期操作前需要确认。请求处理中会禁用其他操作按钮,失败信息会显示在操作区;操作成功后页面会返回任务列表并显示完成提示。
@@ -181,13 +187,13 @@ WebTerminal 支持文件上传和下载:
### 停止运行中的任务
-停止任务会终止所有 Worker Pod 并删除任务 PVC。任务配置、日志、元数据和 hostPath 数据会保留。
+停止任务后,页面会先显示 `Stopping`。RLark 删除全部子 Task CR,并等待对应 Worker Pod 和任务 PVC 清理完成;任务配置、日志、元数据和 hostPath 数据会保留。
手动停止的任务进入 `Stopped` 状态时会记录停止时间,并显示在任务列表中。
### 恢复已停止的任务
-启动已停止的任务会按相同配置重新创建 Worker Pod 和空的任务 PVC。原 PVC 数据不会恢复,hostPath 数据仍然可用。
+启动已停止的任务会按相同配置重新创建 Task、Worker Pod 和空的任务 PVC。原 PVC 数据不会恢复,hostPath 数据仍然可用。重启成功或失败的 Job 都会执行相同的停止、清理和重建流程,不复用终态 Task 或 PVC 数据。
### 删除任务
@@ -208,15 +214,15 @@ WebTerminal 支持文件上传和下载:
提交任务前请确认:
-| 检查项 | 说明 |
-|--------|------|
-| 集群资源 | 目标集群有足够的 CPU、内存和 GPU 容量满足所有 Worker 需求 |
-| 节点兼容性 | 匹配选择器的节点(类型、GPU 型号、位置)可用且可调度 |
-| GPU / 具身设备 | 如果申请 GPU 或具身设备(机器人、相机),确认所需型号在选定节点上存在 |
-| 容器镜像 | 指定镜像可从目标集群访问。如果使用私有 Registry,确保配置了镜像拉取凭据 |
-| 存储路径 | hostPath 目录在目标节点上存在且具有正确的读写权限。PVC 存储类在集群中可用 |
-| 跨集群网络 | 如果任务跨多个集群,网络域已配置且 DomainPeer 关系已建立 |
-| SSH 密钥 | SSH 公钥有效且格式正确 |
+| 检查项 | 说明 |
+| -------------- | ------------------------------------------------------------------------- |
+| 集群资源 | 目标集群有足够的 CPU、内存和 GPU 容量满足所有 Worker 需求 |
+| 节点兼容性 | 匹配选择器的节点(类型、GPU 型号、位置)可用且可调度 |
+| GPU / 具身设备 | 如果申请 GPU 或具身设备(机器人、相机),确认所需型号在选定节点上存在 |
+| 容器镜像 | 指定镜像可从目标集群访问。如果使用私有 Registry,确保配置了镜像拉取凭据 |
+| 存储路径 | hostPath 目录在目标节点上存在且具有正确的读写权限。PVC 存储类在集群中可用 |
+| 跨集群网络 | 如果任务跨多个集群,网络域已配置且 DomainPeer 关系已建立 |
+| SSH 密钥 | SSH 公钥有效且格式正确 |
## API 等效操作
diff --git a/apps/rlark/docs/zh/user-guide/ssh-keys.md b/apps/rlark/docs/zh/user-guide/ssh-keys.md
index 276a19e..cc4d05a 100644
--- a/apps/rlark/docs/zh/user-guide/ssh-keys.md
+++ b/apps/rlark/docs/zh/user-guide/ssh-keys.md
@@ -2,6 +2,8 @@
使用本指南登记用于 RLark SSH 堡垒机认证的公钥,并可选择在创建 Job 时将其写入任务配置。
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+

## 任务一:添加公钥
@@ -12,7 +14,7 @@
4. 粘贴一条 OpenSSH 公钥,例如 `ssh-ed25519` 或 `ssh-rsa`。
5. 选择**确认添加**,并确认密钥出现在列表中。
-RLark 会校验公钥格式并拒绝重复密钥。当 Server SSH 地址可用时,页面还会显示配置好的跳板连接命令。
+RLark 会校验公钥格式,并要求 SSH 用户名和公钥内容在全局范围内均不可重复。任一内容已存在时,表单会在上传前显示行内错误提示。当 Server SSH 地址可用时,页面还会显示配置好的跳板连接命令。
## 任务二:为 Job 选择密钥
@@ -21,7 +23,7 @@ RLark 会校验公钥格式并拒绝重复密钥。当 Server SSH 地址可用
3. 检查 YAML 预览,确认 `spec.sshPublicKey` 包含所选公钥。
4. 提交 Job。
-此操作会把一条公钥写入 Job 配置,供工作负载注入。只在 SSH 公钥页面登记密钥不会修改已有 Job 或 Pod。
+每条选中的公钥只会写入 Job 配置一次,供工作负载注入。只在 SSH 公钥页面登记密钥不会修改已有 Job 或 Pod。Job 详情中的多条已注入公钥会通过可滚动列表展示。
## 任务三:通过堡垒机连接
@@ -57,4 +59,4 @@ ssh -J : root@
## API 等效操作
-使用 `GET`、`POST /api/v1/ssh-user-keys` 和 `DELETE /api/v1/ssh-user-keys/{index}?user={user}`。详见 [API 参考](../api/reference.md)。
+使用 `GET`、`POST /api/v1/ssh-user-keys` 和 `DELETE /api/v1/ssh-user-keys/{index}?user={user}`。`index` 是该用户名下已登记密钥的零基索引。详见 [API 参考](../api/reference.md)。
diff --git a/apps/rlark/docs/zh/user-guide/storage.md b/apps/rlark/docs/zh/user-guide/storage.md
index 09bdfc9..c0d7734 100644
--- a/apps/rlark/docs/zh/user-guide/storage.md
+++ b/apps/rlark/docs/zh/user-guide/storage.md
@@ -7,7 +7,7 @@ RLark 支持两种存储类型:
| 类型 | 适用场景 | 生命周期 |
|------|----------|----------|
| 主机目录 | 数据已在节点上,高 I/O | 任务生命周期操作不删除数据 |
-| 对象存储(PVC) | 单次任务运行内共享数据 | 停止/重启/删除会删除任务 PVC;启动/重启会新建空 PVC |
+| 对象存储(临时 PVC) | 单个 Pod 运行期间的远程存储 | Kubernetes 随 Pod 创建和删除 PVC |
## 主机目录
@@ -17,10 +17,18 @@ RLark 支持两种存储类型:
## 对象存储
-- 使用 Kubernetes StorageClass 和 PVC
-- PVC 自动创建,请求 10Gi
+- 使用 Kubernetes 通用临时卷和 StorageClass
+- 每个 PVC 挂载默认申请 10Gi,可配置范围为 1–200Gi
- 先选择集群,可用 StorageClass 才会出现在下拉框中
-- 多个 Worker 可共享同一 PVC(注意 RWO 访问模式限制)
+- 每个 Worker Pod 使用独立的 PVC
+
+## 管理存储类
+
+进入 **存储** 页面可查看对象存储配置、关联集群、提供商和存储桶。为 Worker 配置 PVC 挂载前,需要先创建对应的存储类。
+
+> **截图说明:** 截图来自示例环境,资源名称和数据仅供说明,实际环境会有所不同。
+
+
## 在训练任务中使用存储
@@ -30,8 +38,6 @@ RLark 支持两种存储类型:
3. 输入容器挂载路径
4. 训练代码读写挂载路径
-
-
## 检查读写
验证存储链路:
@@ -43,10 +49,10 @@ RLark 支持两种存储类型:
## 生命周期
-- 停止或重启:删除任务 PVC,保留 hostPath 数据
-- 启动或重启:新建空的任务 PVC
-- 删除:删除任务 PVC,保留 hostPath 数据
+- 停止或重启:删除 Worker Pod 时同时删除其临时 PVC,保留 hostPath 数据
+- 启动或重启:Kubernetes 为新 Pod 创建新的临时 PVC
+- 删除:删除 Worker Pod 时同时删除其临时 PVC,保留 hostPath 数据
## API 等效操作
-使用 StorageClass、provider 和对象文件接口,详见 [Storage API](../storage-api.md)。
\ No newline at end of file
+使用 StorageClass、provider 和对象文件接口,详见 [Storage API](../storage-api.md)。
diff --git a/apps/rlark/docs/zh/user-guide/workflows.md b/apps/rlark/docs/zh/user-guide/workflows.md
deleted file mode 100644
index 178a7d8..0000000
--- a/apps/rlark/docs/zh/user-guide/workflows.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# 工作流
-
-使用 Workflow 将多个 Job 模板连接为 DAG。每个模板会在其依赖成功后生成一个子 Job。
-
-## 前置条件
-
-创建 Workflow 前:
-
-- 确认控制面和 Workflow 控制器正常运行,并且当前用户可以创建独立 Job。
-- 纳管所有目标集群,确认其节点已显示为可用 Worker。
-- 确保各目标集群可以拉取对应镜像,并提前创建所需的 Domain、StorageClass 或 PVC。
-- 确保每个阶段仅在工作真正完成后以状态码 0 退出;依赖阶段依据子 Job 状态放行,而不是依据 Shell 输出。
-
-## 任务一:创建 DAG
-
-1. 打开**工作流**并选择**创建工作流**。
-2. 输入 Workflow 名称。
-3. 在 **DAG 编排**中为每个阶段添加一个 Job 节点。
-4. 双击节点名称进行重命名。
-5. 从节点右侧输出端口拖到目标节点以创建依赖;点击连线可删除依赖。
-6. 确认图中没有自环或环路;编辑器会拒绝这两种关系。
-
-## 任务二:配置每个 Job
-
-1. 进入 **Job 详情**。
-2. 依次选择每个 Job 标签页。
-3. 按需配置类型、角色、Header 角色、目标集群、Worker 资源、节点选择器、镜像、环境变量、存储、Domain 和运行脚本。
-4. 确保每个角色都有目标集群和镜像,并至少匹配一个可用 Worker。
-
-Job 配置与独立 Job 表单基本一致,但当前 Workflow 表单不包含 SSH 密钥和 TensorBoard 设置。
-
-## 任务三:检查并提交
-
-1. 进入 **YAML 预览**。
-2. 确认 Workflow 和模板名称唯一,并符合 Kubernetes 资源命名规则。
-3. 检查各模板的 `dependencies`、镜像、存储、Domain 和 Task 配置。
-4. 选择**创建工作流**。
-
-控制台提交的是 Workflow CR,不会生成 Shell 安装命令。
-
-## 任务四:监控运行
-
-1. 从列表打开 Workflow。
-2. 查看 DAG 执行视图和子 Job 表格。
-3. 选择非 Pending 的 DAG 节点打开对应子 Job。生成的 Job 名称采用 `-`。
-4. 排障时分别检查子 Job 的 Worker 和日志。
-
-依赖阶段只会在所有前置阶段成功后启动。如果前置 Job 的脚本已结束但状态仍为 Running,请检查是否还有后台进程。
-
-## 验证结果
-
-- 确认 Workflow 状态变为 `Succeeded`,并且所有 DAG 节点均为 `Succeeded`。
-- 确认子 Job 表中每个模板都有一个 Job,且生成名称符合 `-`。
-- 打开各子 Job,核对 Worker 数量、目标集群、日志以及预期输出或产物。仅 DAG 显示绿色并不能验证应用层结果。
-
-## 失败处理
-
-任一子 Job 变为 `Failed` 后,Workflow 会进入终态 `Failed`,尚未启动的依赖阶段不会再被放行。已有子 Job 不提供回滚能力;如有需要,应分别检查或停止这些 Job。
-
-1. 打开失败的 DAG 节点,检查其 Worker、事件和容器日志。
-2. 检查镜像拉取权限、集群与 Worker 可用性、选择器和资源申请、存储挂载、Domain 连通性以及脚本退出码。
-3. 修正底层配置或工作负载。当前 Workflow 无法从失败节点续跑;请使用唯一名称提交新的 Workflow(或删除旧 Workflow 后复用其名称)。
-4. 按上述检查项验证替代运行。
-
-## API 等效操作
-
-通过 `POST /api/v1/rlinf.io/v1alpha1/workflows` 创建 Workflow CR,再查询 Workflow 和生成的 Job。详见 [CRD 参考](../reference/crd.md)。
diff --git a/apps/rlark/go.mod b/apps/rlark/go.mod
index a17dbad..f8812b7 100644
--- a/apps/rlark/go.mod
+++ b/apps/rlark/go.mod
@@ -17,13 +17,14 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/go-logr/logr v1.4.3
github.com/go-logr/zapr v1.3.0
+ github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/looplab/fsm v1.0.3
github.com/moby/sys/mountinfo v0.7.2
github.com/patrickmn/go-cache v2.1.0+incompatible
+ github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
- github.com/rancher/remotedialer v0.6.1
github.com/rlinf/rlark/api v0.0.0
github.com/sirupsen/logrus v1.9.4
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
@@ -36,6 +37,7 @@ require (
github.com/uptrace/bun/extra/bundebug v1.2.18
github.com/vishvananda/netlink v1.3.1
github.com/xjasonlyu/tun2socks/v2 v2.6.0
+ github.com/xtaci/smux v1.5.57
go.uber.org/zap v1.27.1
go.yaml.in/yaml/v2 v2.4.3
go.yaml.in/yaml/v3 v3.0.4
@@ -45,10 +47,12 @@ require (
golang.org/x/term v0.40.0
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20
k8s.io/api v0.36.3
+ k8s.io/apiextensions-apiserver v0.36.0
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
sigs.k8s.io/controller-runtime v0.24.1
+ sigs.k8s.io/yaml v1.6.0
)
require (
@@ -157,7 +161,6 @@ require (
github.com/opencontainers/selinux v1.11.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
- github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
@@ -198,7 +201,6 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/apiextensions-apiserver v0.36.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/streaming v0.36.3 // indirect
@@ -206,7 +208,6 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
- sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/rlinf/rlark/api => ../../api
diff --git a/apps/rlark/go.sum b/apps/rlark/go.sum
index cbcf357..b56b04b 100644
--- a/apps/rlark/go.sum
+++ b/apps/rlark/go.sum
@@ -272,6 +272,8 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -537,8 +539,6 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
-github.com/rancher/remotedialer v0.6.1 h1:smq2sHKJn+NxxIeQ8To9CGGkz8l6Ir7EPzfAdjUTt2s=
-github.com/rancher/remotedialer v0.6.1/go.mod h1:0+dmsw9TPjcqNUPrgAVFZpvbxy1r/fRaGPpVa84OMjU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -622,6 +622,8 @@ github.com/xjasonlyu/tun2socks/v2 v2.6.0 h1:gI9saJT3XgH4e6v9jBuHRLwK7l3aN9YFWec/
github.com/xjasonlyu/tun2socks/v2 v2.6.0/go.mod h1:35AwqxIxnMkfBfT0UJ1Lku7PZm2ZiZJ8sxHyp0gt1yw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/xtaci/smux v1.5.57 h1:N72VbGoSYxgcm6mPOYX0QzEZNVD3UI/JlVvAtXF+WrY=
+github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
diff --git a/apps/rlark/mkdocs.yml b/apps/rlark/mkdocs.yml
index 1122676..e75b0e1 100644
--- a/apps/rlark/mkdocs.yml
+++ b/apps/rlark/mkdocs.yml
@@ -58,7 +58,6 @@ plugins:
"Console Access and Navigation": 控制台访问与导航
"Find Compute Resources": 查找算力资源
"Submit and Manage Jobs": 提交和管理任务
- "Build Workflows": 构建工作流
"Attach Storage": 挂载存储
"Connect with SSH Keys": 使用 SSH 密钥连接
"RL Training Best Practices": "RL 训练最佳实践"
@@ -106,7 +105,6 @@ nav:
- Console Access and Navigation: user-guide/console.md
- Find Compute Resources: user-guide/clusters-nodes.md
- Submit and Manage Jobs: user-guide/jobs.md
- - Build Workflows: user-guide/workflows.md
- Attach Storage: user-guide/storage.md
- Connect with SSH Keys: user-guide/ssh-keys.md
- Administrator Guide:
@@ -149,4 +147,4 @@ markdown_extensions:
- pymdownx.details
- tables
- toc:
- permalink: true
\ No newline at end of file
+ permalink: true
diff --git a/apps/rlark/pkg/agent/agent.go b/apps/rlark/pkg/agent/agent.go
index 8505ad8..01f0dd8 100644
--- a/apps/rlark/pkg/agent/agent.go
+++ b/apps/rlark/pkg/agent/agent.go
@@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/http"
+ "sync"
+ "sync/atomic"
"golang.org/x/sync/errgroup"
"k8s.io/client-go/kubernetes"
@@ -32,6 +34,9 @@ type Agent struct {
localListener net.Listener
localDialer utils.Dial
+ ready atomic.Bool
+ drainMu sync.Mutex
+ drain context.CancelFunc
}
// NewAgent creates a new Agent.
@@ -41,9 +46,35 @@ func NewAgent(config Config) *Agent {
}
}
+func (a *Agent) SetReady(ready bool) {
+ a.ready.Store(ready)
+}
+
+func (a *Agent) startDrain() {
+ a.ready.Store(false)
+ a.drainMu.Lock()
+ drain := a.drain
+ a.drainMu.Unlock()
+ if drain != nil {
+ drain()
+ }
+}
+
func (a *Agent) init(ctx context.Context) error {
_ = ctx
var err error
+ if err := a.config.ControllerConcurrency.Validate(); err != nil {
+ return fmt.Errorf("validate controller concurrency: %w", err)
+ }
+ if a.config.PodOrphanSweepInterval <= 0 {
+ return fmt.Errorf("pod orphan sweep interval must be positive")
+ }
+ if a.config.PodOrphanSweepPageSize <= 0 {
+ return fmt.Errorf("pod orphan sweep page size must be positive")
+ }
+ if a.config.PodStaleTTL <= 0 {
+ return fmt.Errorf("pod stale TTL must be positive")
+ }
// Initialize server client
a.serverClient, err = server.NewClientFromConfig(a.config.ClientConfig)
@@ -101,6 +132,17 @@ func (a *Agent) Run(ctx context.Context) error {
if err := a.init(ctx); err != nil {
return err
}
+ ctx, cancel := context.WithCancel(ctx)
+ a.drainMu.Lock()
+ a.drain = cancel
+ a.drainMu.Unlock()
+ defer func() {
+ a.ready.Store(false)
+ a.drainMu.Lock()
+ a.drain = nil
+ a.drainMu.Unlock()
+ cancel()
+ }()
var eg errgroup.Group
diff --git a/apps/rlark/pkg/agent/cluster_agent.go b/apps/rlark/pkg/agent/cluster_agent.go
index e577b39..1366114 100644
--- a/apps/rlark/pkg/agent/cluster_agent.go
+++ b/apps/rlark/pkg/agent/cluster_agent.go
@@ -3,17 +3,22 @@ package agent
import (
"context"
"fmt"
+ "net/http"
+ "time"
"golang.org/x/sync/errgroup"
+ "k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/addon"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
+ "github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/delivery"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/node"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/pod"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/task"
@@ -63,7 +68,9 @@ func (c *clusterAgent) Run(ctx context.Context) error {
var lm interface {
Start(ctx context.Context) error
+ Add(manager.Runnable) error
}
+ var localManager ctrl.Manager
switch rlarkv1alpha1.AgentType(agentType) {
case rlarkv1alpha1.AgentTypeKubernetes:
@@ -81,8 +88,20 @@ func (c *clusterAgent) Run(ctx context.Context) error {
if err != nil {
return fmt.Errorf("create local direct client: %w", err)
}
+ transport, err := rest.TransportFor(c.a.localKubeConfig)
+ if err != nil {
+ return fmt.Errorf("create local Kubernetes HTTP transport: %w", err)
+ }
bc.LocalKubeClient = lclient
+ bc.LocalKubeCachedClient = m.GetClient()
+ bc.LocalKubeHTTP = &http.Client{
+ Transport: transport,
+ Timeout: 10 * time.Second,
+ }
+ bc.LocalKubeAPIHost = c.a.localKubeConfig.Host
+ bc.LocalKubeConfig = c.a.localKubeConfig
lm = m
+ localManager = m
case rlarkv1alpha1.AgentTypeDocker:
// TODO: initialize Docker controller manager
@@ -97,7 +116,14 @@ func (c *clusterAgent) Run(ctx context.Context) error {
}
// Setup Task controllers
- tc := task.NewTaskController(bc)
+ taskController := bc
+ taskController.PullMaxConcurrentReconciles = c.a.config.ControllerConcurrency.TaskPull
+ taskController.PushMaxConcurrentReconciles = map[string]int{
+ "task-deployment": c.a.config.ControllerConcurrency.TaskDeployment,
+ "task-daemonset": c.a.config.ControllerConcurrency.TaskDaemonSet,
+ "task-statefulset": c.a.config.ControllerConcurrency.TaskStatefulSet,
+ }
+ tc := task.NewTaskController(taskController)
if err := tc.SetupPullController(mm); err != nil {
return fmt.Errorf("setup task pull controller: %w", err)
}
@@ -106,7 +132,14 @@ func (c *clusterAgent) Run(ctx context.Context) error {
}
// Setup Node controllers
- nc := node.NewNodeController(bc)
+ nodeController := bc
+ nodeController.PushMaxConcurrentReconciles = map[string]int{
+ "node-k8snode": c.a.config.ControllerConcurrency.NodePush,
+ }
+ nc := node.NewNodeController(nodeController)
+ if err := node.IndexLocalFields(ctx, localManager); err != nil {
+ return fmt.Errorf("index local fields for node controller: %w", err)
+ }
if err := nc.SetupPullController(mm); err != nil {
return fmt.Errorf("setup node pull controller: %w", err)
}
@@ -115,7 +148,14 @@ func (c *clusterAgent) Run(ctx context.Context) error {
}
// Setup Pod controllers (push-only: reports local K8s Pods to management Pod CRs)
- pc := pod.NewPodController(bc)
+ podController := bc
+ podController.PushMaxConcurrentReconciles = map[string]int{
+ "pod-k8spod": c.a.config.ControllerConcurrency.PodPush,
+ }
+ pc := pod.NewPodController(podController)
+ if err := lm.Add(pod.NewOrphanSweeper(pc, c.a.config.PodOrphanSweepInterval, c.a.config.PodOrphanSweepPageSize, c.a.config.PodStaleTTL)); err != nil {
+ return fmt.Errorf("setup pod orphan sweeper: %w", err)
+ }
if err := pc.SetupPullController(mm); err != nil {
return fmt.Errorf("setup pod pull controller: %w", err)
}
@@ -124,7 +164,9 @@ func (c *clusterAgent) Run(ctx context.Context) error {
}
// Setup Addon controllers (pull-only: watches management Addon CRs and deploys to local cluster)
- ac := addon.NewAddonController(bc)
+ addonController := bc
+ addonController.PullMaxConcurrentReconciles = c.a.config.ControllerConcurrency.AddonPull
+ ac := addon.NewAddonController(addonController)
if err := ac.SetupPullController(mm); err != nil {
return fmt.Errorf("setup addon pull controller: %w", err)
}
@@ -132,6 +174,14 @@ func (c *clusterAgent) Run(ctx context.Context) error {
return fmt.Errorf("setup addon push controller: %w", err)
}
+ deliveryReconciler, err := delivery.New(c.a.localKubeConfig, mclient, bc.LocalKubeClient, clusterID)
+ if err != nil {
+ return fmt.Errorf("create delivery controller: %w", err)
+ }
+ if err := deliveryReconciler.Setup(mm, localManager); err != nil {
+ return fmt.Errorf("setup delivery controller: %w", err)
+ }
+
var eg errgroup.Group
eg.Go(func() error { return mm.Start(ctx) })
eg.Go(func() error { return lm.Start(ctx) })
diff --git a/apps/rlark/pkg/agent/config.go b/apps/rlark/pkg/agent/config.go
index 512e848..0031765 100644
--- a/apps/rlark/pkg/agent/config.go
+++ b/apps/rlark/pkg/agent/config.go
@@ -3,6 +3,7 @@ package agent
import (
"fmt"
"os"
+ "time"
"github.com/spf13/pflag"
@@ -12,6 +13,58 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/server"
)
+const defaultControllerMaxConcurrentReconciles = 8
+
+// ControllerConcurrencyConfig configures worker concurrency for Agent controllers.
+type ControllerConcurrencyConfig struct {
+ TaskPull int
+ AddonPull int
+ TaskDeployment int
+ TaskDaemonSet int
+ TaskStatefulSet int
+ NodePush int
+ PodPush int
+}
+
+func defaultControllerConcurrencyConfig() ControllerConcurrencyConfig {
+ return ControllerConcurrencyConfig{
+ TaskPull: defaultControllerMaxConcurrentReconciles,
+ AddonPull: defaultControllerMaxConcurrentReconciles,
+ TaskDeployment: defaultControllerMaxConcurrentReconciles,
+ TaskDaemonSet: defaultControllerMaxConcurrentReconciles,
+ TaskStatefulSet: defaultControllerMaxConcurrentReconciles,
+ NodePush: defaultControllerMaxConcurrentReconciles,
+ PodPush: defaultControllerMaxConcurrentReconciles,
+ }
+}
+
+func (c *ControllerConcurrencyConfig) SetupFlags(fs *pflag.FlagSet) {
+ fs.IntVar(&c.TaskPull, "task-pull-controller-workers", c.TaskPull, "Maximum concurrent Task pull reconciles")
+ fs.IntVar(&c.AddonPull, "addon-pull-controller-workers", c.AddonPull, "Maximum concurrent Addon pull reconciles")
+ fs.IntVar(&c.TaskDeployment, "task-deployment-push-controller-workers", c.TaskDeployment, "Maximum concurrent Task Deployment push reconciles")
+ fs.IntVar(&c.TaskDaemonSet, "task-daemonset-push-controller-workers", c.TaskDaemonSet, "Maximum concurrent Task DaemonSet push reconciles")
+ fs.IntVar(&c.TaskStatefulSet, "task-statefulset-push-controller-workers", c.TaskStatefulSet, "Maximum concurrent Task StatefulSet push reconciles")
+ fs.IntVar(&c.NodePush, "node-push-controller-workers", c.NodePush, "Maximum concurrent Node push reconciles")
+ fs.IntVar(&c.PodPush, "pod-push-controller-workers", c.PodPush, "Maximum concurrent Pod push reconciles")
+}
+
+func (c ControllerConcurrencyConfig) Validate() error {
+ for name, workers := range map[string]int{
+ "task-pull": c.TaskPull,
+ "addon-pull": c.AddonPull,
+ "task-deployment-push": c.TaskDeployment,
+ "task-daemonset-push": c.TaskDaemonSet,
+ "task-statefulset-push": c.TaskStatefulSet,
+ "node-push": c.NodePush,
+ "pod-push": c.PodPush,
+ } {
+ if workers <= 0 {
+ return fmt.Errorf("%s controller workers must be positive", name)
+ }
+ }
+ return nil
+}
+
// Config holds configuration options.
type Config struct {
ClientConfig server.ClientConfig
@@ -24,19 +77,22 @@ type Config struct {
// For single-node, use both mode to start both cluster and node functionality.
Mode string
- LeaderElection bool
- LeaderElectionKey string // namespace/name
- LeaderElectionID string // unique identifier for this agent instance, usually hostname
+ LeaderElection configs.LeaderElectionConfig
- MetricsBindAddress string
+ MetricsBindAddress string
+ ControllerConcurrency ControllerConcurrencyConfig
+ PodOrphanSweepInterval time.Duration
+ PodOrphanSweepPageSize int64
+ PodStaleTTL time.Duration
- NodeServerConfig nodeserver.Config
- Image string
- RLarkServerSSHAddress string
- RLarkServerSSHHostKey string
- EnableSameClusterDirect bool
- EnableCrossClusterDirect bool
- KubeletDir string
+ NodeServerConfig nodeserver.Config
+ Image string
+ RLarkServerSSHAddress string
+ RLarkServerSSHHostKey string
+ SSHMaxConnectionsPerDomain int
+ EnableSameClusterDirect bool
+ EnableCrossClusterDirect bool
+ KubeletDir string
// Image pre-pull (node-agent): pre-pull task images into the node's
// container runtime (containerd for Kubernetes, docker for Docker).
@@ -49,18 +105,27 @@ type Config struct {
// DefaultConfig returns the default config.
func DefaultConfig() Config {
return Config{
- ClientConfig: server.DefaultClientConfig(),
- KubeClientConfig: configs.DefaultKubernetesClientConfig(),
- AgentType: "Kubernetes",
- Mode: "cluster",
- LeaderElectionKey: "default/rlark-agent",
- LeaderElectionID: fmt.Sprintf("%s-%d", common.Hostname("node"), os.Getpid()),
- MetricsBindAddress: ":8081",
- NodeServerConfig: nodeserver.DefaultConfig(),
-
- EnableSameClusterDirect: true,
- EnableCrossClusterDirect: true,
- KubeletDir: "",
+ ClientConfig: server.DefaultClientConfig(),
+ KubeClientConfig: configs.DefaultKubernetesClientConfig(),
+ AgentType: "Kubernetes",
+ Mode: "cluster",
+ LeaderElection: func() configs.LeaderElectionConfig {
+ config := configs.DefaultLeaderElectionConfig()
+ config.Key = "default/rlark-agent"
+ config.Identity = common.Hostname("node")
+ return config
+ }(),
+ MetricsBindAddress: ":8081",
+ ControllerConcurrency: defaultControllerConcurrencyConfig(),
+ PodOrphanSweepInterval: 5 * time.Minute,
+ PodOrphanSweepPageSize: 200,
+ PodStaleTTL: 15 * time.Minute,
+ NodeServerConfig: nodeserver.DefaultConfig(),
+
+ EnableSameClusterDirect: true,
+ EnableCrossClusterDirect: true,
+ SSHMaxConnectionsPerDomain: 4,
+ KubeletDir: "",
ImagePullEnabled: true,
ContainerdSocket: "/run/containerd/containerd.sock",
@@ -77,13 +142,16 @@ func (c *Config) SetupFlags(fs *pflag.FlagSet) {
fs.StringVar(&c.AgentType, "agent-type", c.AgentType, "agent type: Kubernetes/Docker/Raw")
fs.StringVar(&c.Mode, "mode", c.Mode, "agent mode: cluster/node/both")
- fs.BoolVar(&c.LeaderElection, "leader-election", c.LeaderElection, "enable leader election for agent")
- fs.StringVar(&c.LeaderElectionKey, "leader-election-key", c.LeaderElectionKey, "leader election key (namespace/name)")
- fs.StringVar(&c.LeaderElectionID, "leader-election-id", c.LeaderElectionID, "leader election id (unique identifier for this agent instance)")
+ c.LeaderElection.SetupFlags(fs, "")
fs.StringVar(&c.MetricsBindAddress, "metrics-bind-address", c.MetricsBindAddress, "The address the metric endpoint binds to.")
+ c.ControllerConcurrency.SetupFlags(fs)
+ fs.DurationVar(&c.PodOrphanSweepInterval, "pod-orphan-sweep-interval", c.PodOrphanSweepInterval, "Interval between management Pod orphan sweeps")
+ fs.Int64Var(&c.PodOrphanSweepPageSize, "pod-orphan-sweep-page-size", c.PodOrphanSweepPageSize, "Management Pods processed per orphan sweep page")
+ fs.DurationVar(&c.PodStaleTTL, "pod-stale-ttl", c.PodStaleTTL, "Time a missing management Pod remains stale before deletion")
fs.StringVar(&c.RLarkServerSSHAddress, "rlark-server-ssh-address", c.RLarkServerSSHAddress, "RLark server SSH address (user@host:port)")
fs.StringVar(&c.RLarkServerSSHHostKey, "rlark-server-ssh-host-key", c.RLarkServerSSHHostKey, "RLark server SSH host key")
+ fs.IntVar(&c.SSHMaxConnectionsPerDomain, "ssh-max-connections-per-domain", c.SSHMaxConnectionsPerDomain, "Maximum adaptive SSH connections per domain")
fs.StringVar(&c.Image, "image", c.Image, "RLark container image (used for network sidecar, SSH server, etc.)")
fs.BoolVar(&c.EnableSameClusterDirect, "enable-same-cluster-direct", c.EnableSameClusterDirect, "Enable direct access to pods in the same cluster")
diff --git a/apps/rlark/pkg/agent/config_test.go b/apps/rlark/pkg/agent/config_test.go
new file mode 100644
index 0000000..84a80be
--- /dev/null
+++ b/apps/rlark/pkg/agent/config_test.go
@@ -0,0 +1,74 @@
+package agent
+
+import (
+ "testing"
+ "time"
+
+ "github.com/spf13/pflag"
+)
+
+func TestDefaultControllerConcurrency(t *testing.T) {
+ got := DefaultConfig().ControllerConcurrency
+ if got.TaskPull != 8 || got.AddonPull != 8 || got.TaskDeployment != 8 ||
+ got.TaskDaemonSet != 8 || got.TaskStatefulSet != 8 || got.NodePush != 8 || got.PodPush != 8 {
+ t.Fatalf("default controller concurrency = %+v, want all 8", got)
+ }
+ if err := got.Validate(); err != nil {
+ t.Fatalf("default controller concurrency is invalid: %v", err)
+ }
+}
+
+func TestDefaultPodOrphanSweep(t *testing.T) {
+ config := DefaultConfig()
+ if config.PodOrphanSweepInterval != 5*time.Minute || config.PodOrphanSweepPageSize != 200 || config.PodStaleTTL != 15*time.Minute {
+ t.Fatalf("default Pod orphan sweep = %s/%d/%s", config.PodOrphanSweepInterval, config.PodOrphanSweepPageSize, config.PodStaleTTL)
+ }
+}
+
+func TestControllerConcurrencyFlags(t *testing.T) {
+ config := DefaultConfig()
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ config.SetupFlags(fs)
+ if err := fs.Parse([]string{
+ "--task-pull-controller-workers=1",
+ "--addon-pull-controller-workers=2",
+ "--task-deployment-push-controller-workers=3",
+ "--task-daemonset-push-controller-workers=4",
+ "--task-statefulset-push-controller-workers=5",
+ "--node-push-controller-workers=6",
+ "--pod-push-controller-workers=7",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ want := ControllerConcurrencyConfig{
+ TaskPull: 1, AddonPull: 2, TaskDeployment: 3, TaskDaemonSet: 4,
+ TaskStatefulSet: 5, NodePush: 6, PodPush: 7,
+ }
+ if config.ControllerConcurrency != want {
+ t.Fatalf("controller concurrency = %+v, want %+v", config.ControllerConcurrency, want)
+ }
+}
+
+func TestControllerConcurrencyValidation(t *testing.T) {
+ config := defaultControllerConcurrencyConfig()
+ config.PodPush = 0
+ if err := config.Validate(); err == nil {
+ t.Fatal("expected validation error for zero Pod push workers")
+ }
+}
+
+func TestLeaderElectionFlags(t *testing.T) {
+ config := DefaultConfig()
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ config.SetupFlags(fs)
+ if err := fs.Parse([]string{
+ "--leader-election=true",
+ "--leader-election-key=rlark-system/agent",
+ "--leader-election-id=agent-1",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !config.LeaderElection.Enabled || config.LeaderElection.Key != "rlark-system/agent" || config.LeaderElection.Identity != "agent-1" {
+ t.Fatalf("leader election config = %+v", config.LeaderElection)
+ }
+}
diff --git a/apps/rlark/pkg/agent/container/network.go b/apps/rlark/pkg/agent/container/network.go
index e14b845..29d7a10 100644
--- a/apps/rlark/pkg/agent/container/network.go
+++ b/apps/rlark/pkg/agent/container/network.go
@@ -88,6 +88,7 @@ func NewContainerNetworkAdapter(
managementPodLister listerv1alpha1.PodLister,
sshAddr string,
sshHostKey string,
+ sshMaxConnectionsPerDomain int,
enableSameClusterDirect bool,
enableCrossClusterDirect bool,
kubeletDir string,
@@ -99,8 +100,9 @@ func NewContainerNetworkAdapter(
managementPodLister: managementPodLister,
sshAddr: sshAddr,
sshDialer: NewSSHDialer(SSHDialerConfig{
- HostKeyCallback: hostKeyCallback,
- OnReconnect: nodeservermetrics.OnReconnect(),
+ HostKeyCallback: hostKeyCallback,
+ OnReconnect: nodeservermetrics.OnReconnect(),
+ MaxConnectionsPerDomain: sshMaxConnectionsPerDomain,
}),
enableSameClusterDirect: enableSameClusterDirect,
enableCrossClusterDirect: enableCrossClusterDirect,
@@ -109,6 +111,10 @@ func NewContainerNetworkAdapter(
}
}
+func (a *containerNetworkAdapter) Close() error {
+ return a.sshDialer.Close()
+}
+
// makeHostKeyCallback 解析 SSH 主机公钥字符串,返回对应的 HostKeyCallback。
// 空字符串时使用 InsecureIgnoreHostKey(仅开发环境)。
func makeHostKeyCallback(sshHostKey string) ssh.HostKeyCallback {
diff --git a/apps/rlark/pkg/agent/container/ssh_dialer.go b/apps/rlark/pkg/agent/container/ssh_dialer.go
index 732c199..b32a3cd 100644
--- a/apps/rlark/pkg/agent/container/ssh_dialer.go
+++ b/apps/rlark/pkg/agent/container/ssh_dialer.go
@@ -35,8 +35,10 @@ const (
defaultSSHUser = "root"
defaultSSHTimeout = 10 * time.Second
defaultKeepaliveInterval = 30 * time.Second
+ defaultMaxConnections = 4
maxReconnectBackoff = 30 * time.Second
initialReconnectBackoff = 1 * time.Second
+ activityUpdateInterval = time.Second
)
const keepaliveRequest = "keepalive@openssh.com"
@@ -57,6 +59,8 @@ type SSHDialerConfig struct {
MaxReconnectBackoff time.Duration `json:"maxReconnectBackoff,omitempty" yaml:"maxReconnectBackoff,omitempty"`
// KeepaliveInterval 应用层 SSH 保活间隔。零值使用默认值(30 秒)。
KeepaliveInterval time.Duration `json:"keepaliveInterval,omitempty" yaml:"keepaliveInterval,omitempty"`
+ // MaxConnectionsPerDomain 是每个 Domain 按负载扩展的物理 SSH 连接上限。
+ MaxConnectionsPerDomain int `json:"maxConnectionsPerDomain,omitempty" yaml:"maxConnectionsPerDomain,omitempty"`
// OnReconnect 重连成功后的回调(用于 metrics 埋点)。可为 nil。
OnReconnect func(domainID string) `json:"-" yaml:"-"`
// HostKeyCallback SSH 主机密钥验证回调。nil 时使用 InsecureIgnoreHostKey(仅开发环境)。
@@ -85,31 +89,62 @@ func (c *SSHDialerConfig) setDefaults() {
if c.KeepaliveInterval <= 0 {
c.KeepaliveInterval = defaultKeepaliveInterval
}
+ if c.MaxConnectionsPerDomain <= 0 {
+ c.MaxConnectionsPerDomain = defaultMaxConnections
+ }
if c.HostKeyCallback == nil {
c.HostKeyCallback = ssh.InsecureIgnoreHostKey()
}
}
// domainEntry 管理一个 domain 的 SSH 连接和重连协调。
+type pooledSSHClient struct {
+ client *ssh.Client
+ active int
+ draining bool
+ lastUsedNanos atomic.Int64
+ keepaliveDone chan struct{}
+ closeOnce sync.Once
+}
+
+func newPooledSSHClient(client *ssh.Client) *pooledSSHClient {
+ p := &pooledSSHClient{client: client, keepaliveDone: make(chan struct{})}
+ p.touch()
+ return p
+}
+
+func (p *pooledSSHClient) touch() {
+ now := time.Now().UnixNano()
+ last := p.lastUsedNanos.Load()
+ if now-last >= int64(activityUpdateInterval) {
+ p.lastUsedNanos.CompareAndSwap(last, now)
+ }
+}
+
+func (p *pooledSSHClient) lastUsed() time.Time {
+ return time.Unix(0, p.lastUsedNanos.Load())
+}
+
+func (p *pooledSSHClient) close() {
+ p.closeOnce.Do(func() {
+ close(p.keepaliveDone)
+ _ = p.client.Close()
+ })
+}
+
type domainEntry struct {
domainID string
- mu sync.RWMutex
- client *ssh.Client
- lastUsed time.Time
- broken bool
+ mu sync.Mutex
+ clients []*pooledSSHClient
- // 重连协调
- reconMu sync.Mutex
- reconnecting bool
+ // 连接建立协调
+ reconnecting int
reconnectCh chan struct{}
+ lastReconnectErr error
lastReconnectAt time.Time
reconnectBackoff time.Duration
maxBackoff time.Duration
-
- // keepaliveDone 关闭时通知当前 keepalive goroutine 退出。
- // 每次 finishReconnect 成功时重建,markBroken/close 时关闭。
- keepaliveDone chan struct{}
}
// SSHDialer 提供按 domain 分组的全局 SSH 连接池。
@@ -140,17 +175,13 @@ func NewSSHDialer(cfg SSHDialerConfig) *SSHDialer {
return d
}
-// touch 刷新 lastUsed,表示该 domain 上有真实的业务数据流动。
-// 线程安全,用于 activityConn 在 Read/Write 成功时调用。
-func (entry *domainEntry) touch() {
- entry.mu.Lock()
- entry.lastUsed = time.Now()
- entry.mu.Unlock()
-}
-
type activityConn struct {
net.Conn
onActivity func()
+ onClose func()
+ onError func(error)
+ closeOnce sync.Once
+ errorOnce sync.Once
}
func (c *activityConn) Read(b []byte) (int, error) {
@@ -158,6 +189,9 @@ func (c *activityConn) Read(b []byte) (int, error) {
if n > 0 {
c.onActivity()
}
+ if err != nil && isSSHTransportError(err) {
+ c.errorOnce.Do(func() { c.onError(err) })
+ }
return n, err
}
@@ -166,9 +200,18 @@ func (c *activityConn) Write(b []byte) (int, error) {
if n > 0 {
c.onActivity()
}
+ if err != nil && isSSHTransportError(err) {
+ c.errorOnce.Do(func() { c.onError(err) })
+ }
return n, err
}
+func (c *activityConn) Close() error {
+ err := c.Conn.Close()
+ c.closeOnce.Do(c.onClose)
+ return err
+}
+
// DialContext 通过 SSH 隧道连接到目标 addr。
func (d *SSHDialer) DialContext(ctx context.Context, domainID, sshAddr, cert, key, addr string) (net.Conn, error) {
if d.closed.Load() {
@@ -176,13 +219,14 @@ func (d *SSHDialer) DialContext(ctx context.Context, domainID, sshAddr, cert, ke
}
entry := d.getOrCreate(domainID)
- client, err := entry.borrow(ctx, d, sshAddr, cert, key)
+ pooled, err := entry.borrow(ctx, d, sshAddr, cert, key)
if err != nil {
return nil, fmt.Errorf("ssh dialer: %w", err)
}
- conn, err := client.DialContext(ctx, "tcp", addr)
+ conn, err := pooled.client.DialContext(ctx, "tcp", addr)
if err != nil {
+ entry.release(pooled)
if isSSHTransportError(err) {
log.GetLogger().Info("SSH channel dial failed with transport error, marking broken",
"domain", domainID,
@@ -190,14 +234,24 @@ func (d *SSHDialer) DialContext(ctx context.Context, domainID, sshAddr, cert, ke
"err", err,
"errType", fmt.Sprintf("%T", err),
)
- entry.markBroken("channel-dial-error")
+ entry.markBroken(pooled, "channel-dial-error")
}
return nil, fmt.Errorf("ssh proxy to %s: %w", addr, err)
}
return &activityConn{
Conn: conn,
- onActivity: entry.touch,
+ onActivity: pooled.touch,
+ onClose: func() { entry.release(pooled) },
+ onError: func(err error) {
+ log.GetLogger().Info("SSH channel I/O failed with transport error, marking broken",
+ "domain", domainID,
+ "target", addr,
+ "err", err,
+ "errType", fmt.Sprintf("%T", err),
+ )
+ entry.markBroken(pooled, "channel-io-error")
+ },
}, nil
}
@@ -210,10 +264,7 @@ func (d *SSHDialer) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
for _, entry := range d.domains {
- // 加 reconMu 确保没有 in-flight 的 dialSSH 正在设置新连接
- entry.reconMu.Lock()
entry.close()
- entry.reconMu.Unlock()
}
return nil
}
@@ -223,11 +274,13 @@ func (d *SSHDialer) Stats() (open int) {
d.mu.RLock()
defer d.mu.RUnlock()
for _, entry := range d.domains {
- entry.mu.RLock()
- if !entry.broken && entry.client != nil {
- open++
+ entry.mu.Lock()
+ for _, client := range entry.clients {
+ if !client.draining {
+ open++
+ }
}
- entry.mu.RUnlock()
+ entry.mu.Unlock()
}
return
}
@@ -260,11 +313,12 @@ func (d *SSHDialer) getOrCreate(domainID string) *domainEntry {
// 连接借用
// ===========================================================================
-func (entry *domainEntry) borrow(ctx context.Context, d *SSHDialer, sshAddr, cert, key string) (*ssh.Client, error) {
+func (entry *domainEntry) borrow(ctx context.Context, d *SSHDialer, sshAddr, cert, key string) (*pooledSSHClient, error) {
entry.mu.Lock()
- if !entry.broken && entry.client != nil {
- entry.lastUsed = time.Now()
- client := entry.client
+ client := entry.leastLoadedLocked()
+ if client != nil && (client.active == 0 || entry.availableCountLocked()+entry.reconnecting >= d.cfg.MaxConnectionsPerDomain) {
+ client.active++
+ client.touch()
entry.mu.Unlock()
return client, nil
}
@@ -272,40 +326,89 @@ func (entry *domainEntry) borrow(ctx context.Context, d *SSHDialer, sshAddr, cer
return entry.reconnect(ctx, d, sshAddr, cert, key)
}
-// reconnect 协调重连,确保同一时刻只有一个 goroutine 执行 SSH 拨号。
-// 返回的 finish 函数必须在拨号完成后调用,以更新状态并通知等待者。
-func (entry *domainEntry) reconnect(ctx context.Context, d *SSHDialer, sshAddr, cert, key string) (*ssh.Client, error) {
- entry.reconMu.Lock()
+func (entry *domainEntry) leastLoadedLocked() *pooledSSHClient {
+ var selected *pooledSSHClient
+ for _, client := range entry.clients {
+ if client.draining {
+ continue
+ }
+ if selected == nil || client.active < selected.active {
+ selected = client
+ }
+ }
+ return selected
+}
+
+func (entry *domainEntry) availableCountLocked() int {
+ count := 0
+ for _, client := range entry.clients {
+ if !client.draining {
+ count++
+ }
+ }
+ return count
+}
+
+func (entry *domainEntry) release(client *pooledSSHClient) {
+ entry.mu.Lock()
+ if client.active > 0 {
+ client.active--
+ }
+ client.touch()
+ if client.draining && client.active == 0 {
+ entry.removeLocked(client)
+ client.close()
+ }
+ entry.mu.Unlock()
+}
+
+// reconnect 协调连接建立,允许同一 Domain 并行填充连接池。
+func (entry *domainEntry) reconnect(ctx context.Context, d *SSHDialer, sshAddr, cert, key string) (*pooledSSHClient, error) {
entry.mu.Lock()
- // 双检:可能有人在我们之前修好了
- if !entry.broken && entry.client != nil {
- entry.lastUsed = time.Now()
- client := entry.client
+ // 双检:已有空闲连接,或连接池(包括正在建立的连接)已满时直接复用。
+ if client := entry.leastLoadedLocked(); client != nil && (client.active == 0 || entry.availableCountLocked()+entry.reconnecting >= d.cfg.MaxConnectionsPerDomain) {
+ client.active++
+ client.touch()
entry.mu.Unlock()
- entry.reconMu.Unlock()
return client, nil
}
- if entry.reconnecting {
- // 有人已经在拨号,等待结果
+ if entry.reconnecting >= d.cfg.MaxConnectionsPerDomain {
+ // 所有建连槽都被占用,等待这一轮结束。失败时直接返回同一错误,
+ // 避免等待者依次进入下一轮指数退避。
ch := entry.reconnectCh
entry.mu.Unlock()
- entry.reconMu.Unlock()
select {
case <-ch:
- return entry.borrow(ctx, d, sshAddr, cert, key)
+ entry.mu.Lock()
+ client := entry.leastLoadedLocked()
+ err := entry.lastReconnectErr
+ if client != nil {
+ client.active++
+ client.touch()
+ }
+ entry.mu.Unlock()
+ if client != nil {
+ return client, nil
+ }
+ if err == nil {
+ err = fmt.Errorf("connection attempt failed")
+ }
+ return nil, fmt.Errorf("ssh reconnect: %w", err)
case <-ctx.Done():
return nil, ctx.Err()
}
}
- // 我就是拨号者
- entry.reconnecting = true
- entry.reconnectCh = make(chan struct{})
+ // 第一个拨号者创建本轮完成信号;后续拨号者并行填充剩余槽位。
+ if entry.reconnecting == 0 {
+ entry.reconnectCh = make(chan struct{})
+ entry.lastReconnectErr = nil
+ }
+ entry.reconnecting++
backoff := entry.reconnectBackoff
entry.mu.Unlock()
- entry.reconMu.Unlock()
// ---- 退避等待 ----
if backoff > 0 {
@@ -325,49 +428,61 @@ func (entry *domainEntry) reconnect(ctx context.Context, d *SSHDialer, sshAddr,
// ---- 执行拨号 ----
// 合并 caller ctx 和 dialer ctx:dialer 关闭时立即取消拨号
- client, err := d.dialSSHWithMergedCtx(ctx, sshAddr, cert, key)
- entry.finishReconnect(client, err, d.closed.Load(), d)
+ sshClient, err := d.dialSSHWithMergedCtx(ctx, sshAddr, cert, key)
+ client := entry.finishReconnect(sshClient, err, d.closed.Load(), d)
if err != nil {
+ entry.mu.Lock()
+ fallback := entry.leastLoadedLocked()
+ if fallback != nil {
+ fallback.active++
+ fallback.touch()
+ }
+ entry.mu.Unlock()
+ if fallback != nil {
+ return fallback, nil
+ }
return nil, fmt.Errorf("ssh reconnect: %w", err)
}
+ entry.mu.Lock()
+ client.active++
+ entry.mu.Unlock()
return client, nil
}
// finishReconnect 在拨号完成后更新状态并通知等待者。
// dialerClosed 为 true 时,即使拨号成功也丢弃新连接,防止泄漏。
-func (entry *domainEntry) finishReconnect(client *ssh.Client, err error, dialerClosed bool, d *SSHDialer) {
+func (entry *domainEntry) finishReconnect(client *ssh.Client, err error, dialerClosed bool, d *SSHDialer) *pooledSSHClient {
entry.mu.Lock()
defer entry.mu.Unlock()
- entry.reconnecting = false
+ entry.reconnecting--
entry.lastReconnectAt = time.Now()
if err == nil && !dialerClosed {
- // 成功且 dialer 未关闭 → 替换连接
- if entry.client != nil && !entry.broken {
- _ = entry.client.Close()
- }
- entry.client = client
- entry.broken = false
- entry.lastUsed = time.Now()
+ pooled := newPooledSSHClient(client)
+ entry.clients = append(entry.clients, pooled)
entry.reconnectBackoff = 0
- // 关闭上一个 keepalive goroutine(如果有),避免泄漏
- if entry.keepaliveDone != nil {
- close(entry.keepaliveDone)
- }
- entry.keepaliveDone = make(chan struct{})
- go entry.keepaliveLoop(client, d.cfg.KeepaliveInterval, entry.keepaliveDone)
+ entry.lastReconnectErr = nil
+ go entry.keepaliveLoop(pooled, d.cfg.KeepaliveInterval)
if d.cfg.OnReconnect != nil {
d.cfg.OnReconnect(entry.domainID)
}
+ if entry.reconnecting == 0 {
+ close(entry.reconnectCh)
+ }
+ return pooled
} else {
// 失败或 dialer 已关闭 → 丢弃新连接
if client != nil {
_ = client.Close()
}
entry.reconnectBackoff = nextBackoff(entry.reconnectBackoff, entry.maxBackoff)
+ entry.lastReconnectErr = err
}
- close(entry.reconnectCh)
+ if entry.reconnecting == 0 {
+ close(entry.reconnectCh)
+ }
+ return nil
}
// nextBackoff 指数退避,上限 maxReconnectBackoff。
@@ -385,56 +500,60 @@ func nextBackoff(current time.Duration, max time.Duration) time.Duration {
return next
}
-// markBroken 标记连接为损坏,下次 borrow 触发重连。
-func (entry *domainEntry) markBroken(reason string) {
+// markBroken 停止向可疑连接分配新 channel;已有 channel 释放后再安全关闭。
+func (entry *domainEntry) markBroken(client *pooledSSHClient, reason string) {
entry.mu.Lock()
defer entry.mu.Unlock()
- entry.markBrokenLocked(reason)
+ entry.markBrokenLocked(client, reason)
}
-// markBrokenLocked 在持有 entry.mu 的情况下标记连接损坏。
+// markBrokenLocked 在持有 entry.mu 的情况下将连接置为 draining。
// reason 记录触发关闭的路径(cleanup/keepalive/dial-error/close),便于定位断连根因。
-func (entry *domainEntry) markBrokenLocked(reason string) {
- if entry.client == nil {
+func (entry *domainEntry) markBrokenLocked(client *pooledSSHClient, reason string) {
+ found := false
+ for _, candidate := range entry.clients {
+ if candidate == client {
+ found = true
+ break
+ }
+ }
+ if !found || client.draining {
return
}
- log.GetLogger().Info("SSH connection marked broken",
+ client.draining = true
+ log.GetLogger().Info("SSH connection draining",
"domain", entry.domainID,
"reason", reason,
- "lastUsed", entry.lastUsed,
- "idleFor", time.Since(entry.lastUsed).Round(time.Second),
+ "activeChannels", client.active,
+ "lastUsed", client.lastUsed(),
+ "idleFor", time.Since(client.lastUsed()).Round(time.Second),
)
- if entry.keepaliveDone != nil {
- close(entry.keepaliveDone)
- entry.keepaliveDone = nil
+ if client.active == 0 {
+ entry.removeLocked(client)
+ client.close()
}
- entry.broken = true
- _ = entry.client.Close()
- entry.client = nil
}
-// markBrokenIfCurrent 仅当 client 仍是当前连接时才标记损坏。
-// keepalive goroutine 检测失败时,entry 可能已被重连成新 client,
-// 防止误标新连接。
-func (entry *domainEntry) markBrokenIfCurrent(client *ssh.Client, reason string) {
- entry.mu.Lock()
- defer entry.mu.Unlock()
- if entry.client == client && client != nil {
- entry.markBrokenLocked(reason)
+func (entry *domainEntry) removeLocked(client *pooledSSHClient) {
+ for i, candidate := range entry.clients {
+ if candidate == client {
+ entry.clients = append(entry.clients[:i], entry.clients[i+1:]...)
+ return
+ }
}
}
// keepaliveLoop 按 KeepaliveInterval 发送 SSH 应用层保活请求。
// 任一失败(SendRequest 报错或底层连接断开)即标记 broken 并退出。
// 通过 done channel 在连接被替换/关闭时退出,避免 goroutine 泄漏。
-func (entry *domainEntry) keepaliveLoop(client *ssh.Client, interval time.Duration, done chan struct{}) {
+func (entry *domainEntry) keepaliveLoop(client *pooledSSHClient, interval time.Duration) {
logger := log.GetLogger()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
- if _, _, err := client.SendRequest(keepaliveRequest, true, nil); err != nil {
+ if _, _, err := client.client.SendRequest(keepaliveRequest, true, nil); err != nil {
// 记录具体错误类型,便于定位断连根因:
// - i/o timeout: 对端无响应,像会话被中间设备静默丢
// - connection reset by peer: 被主动 RST,像有设备踢连接
@@ -444,10 +563,10 @@ func (entry *domainEntry) keepaliveLoop(client *ssh.Client, interval time.Durati
"err", err,
"errType", fmt.Sprintf("%T", err),
)
- entry.markBrokenIfCurrent(client, "keepalive-failed")
+ entry.markBroken(client, "keepalive-failed")
return
}
- case <-done:
+ case <-client.keepaliveDone:
return
}
}
@@ -457,7 +576,11 @@ func (entry *domainEntry) keepaliveLoop(client *ssh.Client, interval time.Durati
func (entry *domainEntry) close() {
entry.mu.Lock()
defer entry.mu.Unlock()
- entry.markBrokenLocked("dialer-close")
+ clients := entry.clients
+ entry.clients = nil
+ for _, client := range clients {
+ client.close()
+ }
}
// ===========================================================================
@@ -489,10 +612,15 @@ func (d *SSHDialer) cleanup() {
for _, entry := range entries {
entry.mu.Lock()
- if entry.client != nil && !entry.broken && entry.lastUsed.Before(cutoff) {
- // 走统一关闭逻辑,确保 keepalive goroutine 被通知退出
- entry.markBrokenLocked("idle-cleanup")
+ kept := entry.clients[:0]
+ for _, client := range entry.clients {
+ if client.active == 0 && client.lastUsed().Before(cutoff) {
+ client.close()
+ continue
+ }
+ kept = append(kept, client)
}
+ entry.clients = kept
entry.mu.Unlock()
}
}
@@ -563,16 +691,20 @@ func parseSSHAddr(addr string, defaultUser string) (string, string) {
// isSSHTransportError 返回 true 当错误指示 SSH 传输层连接本身已断开,
// 而非远端目标连接失败(如 target unreachable)。
-// 调用方主动取消(context.Canceled/DeadlineExceeded)不算传输错误,
-// 避免误标健康连接。
+// 调用方主动取消(context.Canceled)不算传输错误,避免误标健康连接。
+// DeadlineExceeded 需要淘汰连接:SSH channel 建立超时无法区分目标不可达和
+// transport 静默失效,继续复用会让后续请求持续超时。
func isSSHTransportError(err error) bool {
if err == nil {
return false
}
- // 调用方主动取消不应标 broken
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ // 调用方主动取消不应标 broken;超时则保守淘汰当前 transport。
+ if errors.Is(err, context.Canceled) {
return false
}
+ if errors.Is(err, context.DeadlineExceeded) {
+ return true
+ }
// SSH 底层 TCP 断开会返回 net.OpError
var opErr *net.OpError
if errors.As(err, &opErr) {
diff --git a/apps/rlark/pkg/agent/container/ssh_dialer_test.go b/apps/rlark/pkg/agent/container/ssh_dialer_test.go
index 1e84641..531e892 100644
--- a/apps/rlark/pkg/agent/container/ssh_dialer_test.go
+++ b/apps/rlark/pkg/agent/container/ssh_dialer_test.go
@@ -100,22 +100,97 @@ func testDialer(t *testing.T) *SSHDialer {
func TestDomainEntry_Borrow(t *testing.T) {
entry := &domainEntry{domainID: "test"}
client := newSSHClient(t)
- entry.client = client
- entry.lastUsed = time.Now()
+ pooled := newPooledSSHClient(client)
+ entry.clients = []*pooledSSHClient{pooled}
// 健康的连接走 fast path,d 不会被使用
got, err := entry.borrow(context.Background(), nil, "", "", "")
if err != nil {
t.Fatalf("borrow: %v", err)
}
- if got != client {
+ if got != pooled {
t.Fatal("borrow returned wrong client")
}
- if entry.lastUsed.Equal(time.Time{}) {
+ if pooled.lastUsed().Equal(time.Time{}) {
t.Fatal("expected lastUsed to be updated")
}
+ entry.release(got)
}
+func TestDomainEntry_AdaptivePoolSelection(t *testing.T) {
+ d := NewSSHDialer(SSHDialerConfig{MaxConnectionsPerDomain: 2})
+ t.Cleanup(func() { _ = d.Close() })
+ entry := &domainEntry{domainID: "test"}
+ first := newPooledSSHClient(newSSHClient(t))
+ second := newPooledSSHClient(newSSHClient(t))
+ first.active = 2
+ second.active = 1
+ entry.clients = []*pooledSSHClient{first, second}
+
+ got, err := entry.borrow(context.Background(), d, "", "", "")
+ if err != nil {
+ t.Fatalf("borrow: %v", err)
+ }
+ if got != second {
+ t.Fatal("expected least-loaded SSH connection")
+ }
+ if second.active != 2 {
+ t.Fatalf("expected active=2, got %d", second.active)
+ }
+ entry.release(got)
+ if second.active != 1 {
+ t.Fatalf("expected active=1 after release, got %d", second.active)
+ }
+}
+
+func TestActivityConn_CloseReleasesOnce(t *testing.T) {
+ left, right := net.Pipe()
+ t.Cleanup(func() { _ = right.Close() })
+ releases := 0
+ conn := &activityConn{
+ Conn: left,
+ onActivity: func() {},
+ onClose: func() { releases++ },
+ onError: func(error) {},
+ }
+
+ _ = conn.Close()
+ _ = conn.Close()
+ if releases != 1 {
+ t.Fatalf("expected one release, got %d", releases)
+ }
+}
+
+func TestActivityConn_TransportErrorReportedOnce(t *testing.T) {
+ transportErr := &net.OpError{Op: "write", Err: syscall.ETIMEDOUT}
+ errorsReported := 0
+ conn := &activityConn{
+ Conn: &errorConn{err: transportErr},
+ onActivity: func() {},
+ onClose: func() {},
+ onError: func(err error) {
+ if !errors.Is(err, transportErr) {
+ t.Errorf("unexpected transport error: %v", err)
+ }
+ errorsReported++
+ },
+ }
+
+ _, _ = conn.Write(nil)
+ _, _ = conn.Read(nil)
+ if errorsReported != 1 {
+ t.Fatalf("expected one transport error report, got %d", errorsReported)
+ }
+}
+
+type errorConn struct {
+ net.Conn
+ err error
+}
+
+func (c *errorConn) Read([]byte) (int, error) { return 0, c.err }
+func (c *errorConn) Write([]byte) (int, error) { return 0, c.err }
+
// TestDomainEntry_BorrowBroken 测试连接损坏后触发重连。
func TestDomainEntry_BorrowBroken(t *testing.T) {
d := testDialer(t)
@@ -128,28 +203,50 @@ func TestDomainEntry_BorrowBroken(t *testing.T) {
}
// 重连失败后不应有 client
- entry.mu.RLock()
- if entry.client != nil {
+ entry.mu.Lock()
+ if len(entry.clients) != 0 {
t.Fatal("expected nil client after failed reconnect")
}
- entry.mu.RUnlock()
+ entry.mu.Unlock()
}
// TestDomainEntry_MarkBroken 测试标记为损坏并关闭连接。
func TestDomainEntry_MarkBroken(t *testing.T) {
entry := &domainEntry{domainID: "test"}
client := newSSHClient(t)
- entry.client = client
+ pooled := newPooledSSHClient(client)
+ entry.clients = []*pooledSSHClient{pooled}
- entry.markBroken("test")
- if !entry.broken {
- t.Fatal("expected broken=true")
- }
- if entry.client != nil {
+ entry.markBroken(pooled, "test")
+ if len(entry.clients) != 0 {
t.Fatal("expected client to be nil after markBroken")
}
}
+func TestDomainEntry_MarkBrokenDrainsActiveChannels(t *testing.T) {
+ entry := &domainEntry{domainID: "test"}
+ pooled := newPooledSSHClient(newSSHClient(t))
+ pooled.active = 2
+ entry.clients = []*pooledSSHClient{pooled}
+
+ entry.markBroken(pooled, "test")
+ if len(entry.clients) != 1 || !pooled.draining {
+ t.Fatal("active client should remain tracked while draining")
+ }
+ if got := entry.leastLoadedLocked(); got != nil {
+ t.Fatal("draining client must not accept new channels")
+ }
+
+ entry.release(pooled)
+ if len(entry.clients) != 1 {
+ t.Fatal("client should remain until all channels are released")
+ }
+ entry.release(pooled)
+ if len(entry.clients) != 0 {
+ t.Fatal("client should close after its last channel is released")
+ }
+}
+
// TestSSHDialer_ConcurrentSafety 高并发下不 panic 不死锁。
func TestSSHDialer_ConcurrentSafety(t *testing.T) {
d := NewSSHDialer(SSHDialerConfig{
@@ -174,7 +271,7 @@ func TestSSHDialer_ConcurrentSafety(t *testing.T) {
t.Log("50 concurrent dials completed without panic")
}
-// TestSSHDialer_ConcurrentReconnect 50 个并发请求,连接断开后全部应等待重连而非直接失败。
+// TestSSHDialer_ConcurrentReconnect 50 个并发请求失败后都应及时返回。
func TestSSHDialer_ConcurrentReconnect(t *testing.T) {
d := NewSSHDialer(SSHDialerConfig{
InitialReconnectBackoff: 1 * time.Millisecond,
@@ -184,9 +281,9 @@ func TestSSHDialer_ConcurrentReconnect(t *testing.T) {
entry := d.getOrCreate("test-domain")
// 模拟连接断开
- entry.markBroken("test")
+ entry.close()
- // 50 个并发请求,全部尝试重连(预期失败,但无惊群)
+ // 50 个并发请求,最多并行建立连接池容量个连接。
var wg sync.WaitGroup
errCh := make(chan error, 50)
for i := 0; i < 50; i++ {
@@ -213,8 +310,7 @@ func TestSSHDialer_ConcurrentReconnect(t *testing.T) {
t.Logf("50 concurrent reconnects: all completed, none deadlocked")
}
-// TestSSHDialer_ReconnectCoordination 重连协调测试:
-// 连接断开后,多个 goroutine 同时 borrow,只有第一个执行 dialSSH,其余等待。
+// TestSSHDialer_ReconnectCoordination 验证并发请求可并行填充连接池。
func TestSSHDialer_ReconnectCoordination(t *testing.T) {
d := NewSSHDialer(SSHDialerConfig{
InitialReconnectBackoff: 1 * time.Millisecond,
@@ -246,8 +342,8 @@ func TestSSHDialer_ReconnectCoordination(t *testing.T) {
if err != nil {
return
}
- time.Sleep(200 * time.Millisecond)
go func() {
+ time.Sleep(200 * time.Millisecond)
_, _, _, err := ssh.NewServerConn(tcpConn, serverConfig)
if err != nil {
_ = tcpConn.Close()
@@ -256,9 +352,10 @@ func TestSSHDialer_ReconnectCoordination(t *testing.T) {
}
}()
+ const requestCount = defaultMaxConnections * 10
var wg sync.WaitGroup
start := time.Now()
- for i := 0; i < 10; i++ {
+ for i := 0; i < requestCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
@@ -271,9 +368,12 @@ func TestSSHDialer_ReconnectCoordination(t *testing.T) {
wg.Wait()
elapsed := time.Since(start)
- t.Logf("10 concurrent borrows with 200ms handshake: took %v", elapsed)
- if elapsed > 2*time.Second {
- t.Fatalf("expected reconnect coordination, but took %v (should be ~200-400ms)", elapsed)
+ t.Logf("%d concurrent borrows with 200ms handshake: took %v", requestCount, elapsed)
+ if elapsed > time.Second {
+ t.Fatalf("expected parallel connection setup, but took %v", elapsed)
+ }
+ if got := d.Stats(); got != defaultMaxConnections {
+ t.Fatalf("expected pool capped at %d connections, got %d", defaultMaxConnections, got)
}
}
@@ -281,7 +381,7 @@ func TestSSHDialer_ReconnectCoordination(t *testing.T) {
func TestSSHDialer_Close(t *testing.T) {
d := NewSSHDialer(SSHDialerConfig{})
entry := d.getOrCreate("test-domain")
- entry.client = newSSHClient(t)
+ entry.clients = []*pooledSSHClient{newPooledSSHClient(newSSHClient(t))}
_ = d.Close()
}
@@ -306,9 +406,9 @@ func TestSSHDialer_CloseRacesReconnect(t *testing.T) {
<-done
// Close 后不应有 client
- entry.mu.RLock()
- hasClient := entry.client != nil
- entry.mu.RUnlock()
+ entry.mu.Lock()
+ hasClient := len(entry.clients) != 0
+ entry.mu.Unlock()
if hasClient {
t.Fatal("expected no client after Close, got leaked connection")
}
@@ -323,15 +423,15 @@ func TestSSHDialer_Stats(t *testing.T) {
defer func() { _ = d.Close() }()
entry1 := d.getOrCreate("a")
- entry1.client = newSSHClient(t)
+ entry1.clients = []*pooledSSHClient{newPooledSSHClient(newSSHClient(t))}
entry2 := d.getOrCreate("b")
- entry2.client = newSSHClient(t)
+ entry2.clients = []*pooledSSHClient{newPooledSSHClient(newSSHClient(t))}
if stats := d.Stats(); stats != 2 {
t.Fatalf("expected 2 open, got %d", stats)
}
- entry1.markBroken("test")
+ entry1.markBroken(entry1.clients[0], "test")
if stats := d.Stats(); stats != 1 {
t.Fatalf("expected 1 open after broken, got %d", stats)
}
@@ -348,18 +448,18 @@ func TestSSHDialer_GC(t *testing.T) {
entry := d.getOrCreate("test-domain")
client := newSSHClient(t)
entry.mu.Lock()
- entry.client = client
- entry.lastUsed = time.Now().Add(-1 * time.Hour)
+ pooled := newPooledSSHClient(client)
+ pooled.lastUsedNanos.Store(time.Now().Add(-1 * time.Hour).UnixNano())
+ entry.clients = []*pooledSSHClient{pooled}
entry.mu.Unlock()
time.Sleep(100 * time.Millisecond)
- entry.mu.RLock()
- broken := entry.broken
- hasClient := entry.client != nil
- entry.mu.RUnlock()
+ entry.mu.Lock()
+ hasClient := len(entry.clients) != 0
+ entry.mu.Unlock()
- if !broken || hasClient {
+ if hasClient {
t.Fatal("expected idle connection to be GC'd")
}
}
@@ -400,7 +500,7 @@ func TestDialSSH_ParsePubkey(t *testing.T) {
}
}
-// TestReconnectCoord_Parallelism 验证单次重连期间其余请求等待而非并行新建。
+// TestReconnectCoord_Parallelism 验证池满时请求等待当前一轮建连。
func TestReconnectCoord_Parallelism(t *testing.T) {
d := NewSSHDialer(SSHDialerConfig{
InitialReconnectBackoff: 1 * time.Millisecond,
@@ -412,23 +512,19 @@ func TestReconnectCoord_Parallelism(t *testing.T) {
blockCh := make(chan struct{})
startedCh := make(chan struct{})
- // 模拟一个正在进行的重连
+ // 模拟所有建连槽都正在使用。
go func() {
- entry.reconMu.Lock()
entry.mu.Lock()
- entry.reconnecting = true
+ entry.reconnecting = d.cfg.MaxConnectionsPerDomain
entry.reconnectCh = make(chan struct{})
entry.mu.Unlock()
- entry.reconMu.Unlock()
close(startedCh)
<-blockCh
entry.mu.Lock()
- entry.reconnecting = false
- entry.client = newSSHClient(t)
- entry.broken = false
- entry.lastUsed = time.Now()
+ entry.reconnecting = 0
+ entry.clients = []*pooledSSHClient{newPooledSSHClient(newSSHClient(t))}
close(entry.reconnectCh)
entry.mu.Unlock()
}()
@@ -446,6 +542,9 @@ func TestReconnectCoord_Parallelism(t *testing.T) {
if err != nil || client == nil {
t.Error("expected successful borrow after reconnect")
}
+ if client != nil {
+ entry.release(client)
+ }
}()
}
@@ -459,7 +558,7 @@ func TestReconnectCoord_Parallelism(t *testing.T) {
close(blockCh)
wg.Wait()
- t.Log("all 5 borrowers correctly waited for single reconnect")
+ t.Log("all 5 borrowers correctly waited for the active connection attempts")
}
// waitCh returns a channel that is never closed (for timeout selects).
@@ -472,6 +571,7 @@ func TestSSHDialer_BackoffReset(t *testing.T) {
d := testDialer(t)
entry := &domainEntry{domainID: "test", maxBackoff: maxReconnectBackoff}
entry.reconnectCh = make(chan struct{})
+ entry.reconnecting = 1
entry.reconnectBackoff = 10 * time.Second
// 模拟成功
@@ -481,6 +581,7 @@ func TestSSHDialer_BackoffReset(t *testing.T) {
}
entry.reconnectCh = make(chan struct{})
+ entry.reconnecting = 1
// 模拟失败
entry.finishReconnect(nil, assertAnError("fail"), false, d)
@@ -489,6 +590,7 @@ func TestSSHDialer_BackoffReset(t *testing.T) {
}
entry.reconnectCh = make(chan struct{})
+ entry.reconnecting = 1
// 模拟再次失败
entry.finishReconnect(nil, assertAnError("fail again"), false, d)
@@ -546,8 +648,9 @@ func TestIsSSHTransportError(t *testing.T) {
{"wrapped net.OpError", fmt.Errorf("proxy: %w", &net.OpError{Op: "read", Err: syscall.ECONNRESET}), true},
{"ssh transport closed", errors.New("ssh: tcp transport closed"), true},
{"context.Canceled", context.Canceled, false},
- {"context.DeadlineExceeded", context.DeadlineExceeded, false},
+ {"context.DeadlineExceeded", context.DeadlineExceeded, true},
{"wrapped context.Canceled", fmt.Errorf("dial: %w", context.Canceled), false},
+ {"wrapped context.DeadlineExceeded", fmt.Errorf("dial: %w", context.DeadlineExceeded), true},
{"generic error", errors.New("connection refused"), false},
}
for _, tt := range tests {
@@ -563,29 +666,22 @@ func TestIsSSHTransportError(t *testing.T) {
func TestDomainEntry_MarkBrokenIfCurrent(t *testing.T) {
entry := &domainEntry{domainID: "test"}
- old := newSSHClient(t)
- entry.client = old
- entry.broken = false
+ old := newPooledSSHClient(newSSHClient(t))
+ entry.clients = []*pooledSSHClient{old}
// 换一个"新"client 进来(模拟重连成功)
- newClient := newSSHClient(t)
- entry.client = newClient
+ newClient := newPooledSSHClient(newSSHClient(t))
+ entry.clients = []*pooledSSHClient{newClient}
// keepalive 拿旧 client 来标 broken,不应影响新连接
- entry.markBrokenIfCurrent(old, "test")
- if entry.broken {
- t.Fatal("should not mark broken when client has been replaced")
- }
- if entry.client != newClient {
+ entry.markBroken(old, "test")
+ if len(entry.clients) != 1 || entry.clients[0] != newClient {
t.Fatal("current client should remain untouched")
}
// 用当前 client 标 broken,应生效
- entry.markBrokenIfCurrent(newClient, "test")
- if !entry.broken {
- t.Fatal("expected broken=true when marking current client")
- }
- if entry.client != nil {
+ entry.markBroken(newClient, "test")
+ if len(entry.clients) != 0 {
t.Fatal("expected client to be nil after markBrokenIfCurrent")
}
}
diff --git a/apps/rlark/pkg/agent/controllers/base/controller.go b/apps/rlark/pkg/agent/controllers/base/controller.go
index 97388a7..411ec3b 100644
--- a/apps/rlark/pkg/agent/controllers/base/controller.go
+++ b/apps/rlark/pkg/agent/controllers/base/controller.go
@@ -3,14 +3,17 @@ package base
import (
"context"
"fmt"
+ "net/http"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
@@ -69,22 +72,46 @@ func podOwnerRequests(ctx context.Context, localClient client.Client, pod *corev
return nil
}
// Direct ownership (StatefulSet/DaemonSet pods).
- if owner.Kind == wantKind {
+ if owner.APIVersion == appsv1.SchemeGroupVersion.String() && owner.Kind == wantKind {
+ workload := workloadForKind(wantKind)
+ if workload == nil || localClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}, workload) != nil || workload.GetUID() != owner.UID {
+ return nil
+ }
return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}}}
}
// Indirect ownership: Pod -> ReplicaSet -> Deployment.
- if owner.Kind == "ReplicaSet" && wantKind == "Deployment" {
+ if owner.APIVersion == appsv1.SchemeGroupVersion.String() && owner.Kind == "ReplicaSet" && wantKind == "Deployment" {
var rs appsv1.ReplicaSet
if err := localClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}, &rs); err != nil {
return nil
}
- if depOwner := metav1.GetControllerOf(&rs); depOwner != nil && depOwner.Kind == wantKind {
+ if rs.UID != owner.UID {
+ return nil
+ }
+ if depOwner := metav1.GetControllerOf(&rs); depOwner != nil && depOwner.APIVersion == appsv1.SchemeGroupVersion.String() && depOwner.Kind == wantKind {
+ var deploy appsv1.Deployment
+ if err := localClient.Get(ctx, types.NamespacedName{Name: depOwner.Name, Namespace: rs.Namespace}, &deploy); err != nil || deploy.UID != depOwner.UID {
+ return nil
+ }
return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: depOwner.Name, Namespace: rs.Namespace}}}
}
}
return nil
}
+func workloadForKind(kind string) client.Object {
+ switch kind {
+ case "Deployment":
+ return &appsv1.Deployment{}
+ case "StatefulSet":
+ return &appsv1.StatefulSet{}
+ case "DaemonSet":
+ return &appsv1.DaemonSet{}
+ default:
+ return nil
+ }
+}
+
// hasManagementTaskAnnotation is a predicate that only lets through K8s objects
// carrying the rlark management-task annotation. It restricts the Pod watch to
// rlark task pods so non-rlark pods in the cluster don't trigger (potentially
@@ -104,17 +131,27 @@ type Reconciler interface {
AsRawPushReconcilers() map[RawResource]RawReconciler
}
+type pushWatchProvider interface {
+ PushWatch(client.Object) handler.EventHandler
+}
+
// Controller manages resources.
type Controller struct {
ManagementClient client.Client
ManagementNamespace string
AgentType string // Kubernetes/Docker/Raw
- LocalKubeClient client.Client
- LocalDockerClient any // TODO
- LocalRawClient any // TODO
+ LocalKubeClient client.Client
+ LocalKubeCachedClient client.Client
+ LocalKubeHTTP *http.Client
+ LocalKubeAPIHost string
+ LocalKubeConfig *rest.Config
+ LocalDockerClient any // TODO
+ LocalRawClient any // TODO
- Image string
+ Image string
+ PullMaxConcurrentReconciles int
+ PushMaxConcurrentReconciles map[string]int
C Reconciler
}
@@ -127,10 +164,13 @@ func (c *Controller) SetupPullController(mgr ctrl.Manager) error {
return nil
}
kubeResource := c.C.KubernetesResource()
- return ctrl.NewControllerManagedBy(mgr).
+ blder := ctrl.NewControllerManagedBy(mgr).
For(kubeResource.Type).
- Named(kubeResource.Name + "-pull").
- Complete(pullReconciler)
+ Named(kubeResource.Name + "-pull")
+ if c.PullMaxConcurrentReconciles > 0 {
+ blder = blder.WithOptions(controller.Options{MaxConcurrentReconciles: c.PullMaxConcurrentReconciles})
+ }
+ return blder.Complete(pullReconciler)
}
// SetupPushController sets the upPushController.
@@ -147,6 +187,14 @@ func (c *Controller) SetupPushController(mgr any) error {
blder := ctrl.NewControllerManagedBy(kubeMgr).
For(kubeResource.Type).
Named(kubeResource.Name + "-push")
+ if workers := c.PushMaxConcurrentReconciles[kubeResource.Name]; workers > 0 {
+ blder = blder.WithOptions(controller.Options{MaxConcurrentReconciles: workers})
+ }
+ if provider, ok := c.C.(pushWatchProvider); ok {
+ if eventHandler := provider.PushWatch(kubeResource.Type); eventHandler != nil {
+ blder = blder.Watches(kubeResource.Type, eventHandler)
+ }
+ }
// For pod-owning workloads (Deployment/StatefulSet/DaemonSet),
// also watch Pods so that container status changes — most
@@ -156,9 +204,13 @@ func (c *Controller) SetupPushController(mgr any) error {
// crashes after the workload status stabilized is never detected
// and the Task keeps reporting a stale phase.
if kind := podOwningKind(kubeResource.Type); kind != "" {
+ localClient := c.LocalKubeCachedClient
+ if localClient == nil {
+ localClient = c.LocalKubeClient
+ }
blder = blder.Watches(
&corev1.Pod{},
- enqueueOwningWorkload(c.LocalKubeClient, kind),
+ enqueueOwningWorkload(localClient, kind),
builder.WithPredicates(hasManagementTaskAnnotation()),
)
}
diff --git a/apps/rlark/pkg/agent/controllers/base/controller_test.go b/apps/rlark/pkg/agent/controllers/base/controller_test.go
index b9fe92f..1d8d03c 100644
--- a/apps/rlark/pkg/agent/controllers/base/controller_test.go
+++ b/apps/rlark/pkg/agent/controllers/base/controller_test.go
@@ -9,13 +9,19 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/ptr"
+ "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
-func controllerRef(kind, name string) metav1.OwnerReference {
+func controllerRef(kind, name string, uid ...types.UID) metav1.OwnerReference {
t := true
- return metav1.OwnerReference{Kind: kind, Name: name, Controller: &t, APIVersion: "apps/v1"}
+ ref := metav1.OwnerReference{Kind: kind, Name: name, Controller: &t, APIVersion: "apps/v1"}
+ if len(uid) > 0 {
+ ref.UID = uid[0]
+ }
+ return ref
}
func makePod(name, ns string, owners ...metav1.OwnerReference) *corev1.Pod {
@@ -33,15 +39,17 @@ func TestPodOwnerRequests(t *testing.T) {
ctx := context.Background()
// StatefulSet pod: owned directly by the StatefulSet.
- stsPod := makePod("actor-0", "rlark-system", controllerRef("StatefulSet", "robot-policy-training-actor"))
- got := podOwnerRequests(ctx, fake.NewClientBuilder().Build(), stsPod, "StatefulSet")
+ sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "robot-policy-training-actor", Namespace: "rlark-system", UID: "sts-uid"}}
+ stsPod := makePod("actor-0", "rlark-system", controllerRef("StatefulSet", "robot-policy-training-actor", sts.UID))
+ got := podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(sts).Build(), stsPod, "StatefulSet")
assert.Equal(t, []reconcile.Request{
{NamespacedName: types.NamespacedName{Name: "robot-policy-training-actor", Namespace: "rlark-system"}},
}, got)
// DaemonSet pod: owned directly by the DaemonSet.
- dsPod := makePod("ds-pod", "rlark-system", controllerRef("DaemonSet", "my-daemonset"))
- got = podOwnerRequests(ctx, fake.NewClientBuilder().Build(), dsPod, "DaemonSet")
+ ds := &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "my-daemonset", Namespace: "rlark-system", UID: "ds-uid"}}
+ dsPod := makePod("ds-pod", "rlark-system", controllerRef("DaemonSet", "my-daemonset", ds.UID))
+ got = podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(ds).Build(), dsPod, "DaemonSet")
assert.Equal(t, []reconcile.Request{
{NamespacedName: types.NamespacedName{Name: "my-daemonset", Namespace: "rlark-system"}},
}, got)
@@ -49,19 +57,42 @@ func TestPodOwnerRequests(t *testing.T) {
// Deployment pod: owned by a ReplicaSet that is owned by the Deployment.
rs := &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
- Name: "app-abc",
+ Name: "app-abc", UID: "rs-uid",
Namespace: "rlark-system",
- OwnerReferences: []metav1.OwnerReference{controllerRef("Deployment", "my-app")},
+ OwnerReferences: []metav1.OwnerReference{controllerRef("Deployment", "my-app", "dep-uid")},
},
}
- depPod := makePod("app-xyz", "rlark-system", controllerRef("ReplicaSet", "app-abc"))
- got = podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(rs).Build(), depPod, "Deployment")
+ dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "rlark-system", UID: "dep-uid"}}
+ depPod := makePod("app-xyz", "rlark-system", controllerRef("ReplicaSet", "app-abc", rs.UID))
+ got = podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(rs, dep).Build(), depPod, "Deployment")
assert.Equal(t, []reconcile.Request{
{NamespacedName: types.NamespacedName{Name: "my-app", Namespace: "rlark-system"}},
}, got)
+ for _, tc := range []struct {
+ name string
+ pod *corev1.Pod
+ kind string
+ objs []client.Object
+ }{
+ {"direct API version", makePod("actor-0", "rlark-system", metav1.OwnerReference{APIVersion: "extensions/v1beta1", Kind: "StatefulSet", Name: sts.Name, UID: sts.UID, Controller: ptr.To(true)}), "StatefulSet", []client.Object{sts}},
+ {"direct UID", makePod("actor-0", "rlark-system", controllerRef("StatefulSet", sts.Name, "wrong")), "StatefulSet", []client.Object{sts}},
+ {"ReplicaSet API version", makePod("app-xyz", "rlark-system", metav1.OwnerReference{APIVersion: "extensions/v1beta1", Kind: "ReplicaSet", Name: rs.Name, UID: rs.UID, Controller: ptr.To(true)}), "Deployment", []client.Object{rs, dep}},
+ {"ReplicaSet UID", makePod("app-xyz", "rlark-system", controllerRef("ReplicaSet", rs.Name, "wrong")), "Deployment", []client.Object{rs, dep}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Empty(t, podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(tc.objs...).Build(), tc.pod, tc.kind))
+ })
+ }
+ badRS := rs.DeepCopy()
+ badRS.OwnerReferences[0].APIVersion = "extensions/v1beta1"
+ assert.Empty(t, podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(badRS, dep).Build(), depPod, "Deployment"))
+ badRS = rs.DeepCopy()
+ badRS.OwnerReferences[0].UID = "wrong"
+ assert.Empty(t, podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(badRS, dep).Build(), depPod, "Deployment"))
+
// A StatefulSet push controller must not enqueue for a Deployment-owned pod.
- got = podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(rs).Build(), depPod, "StatefulSet")
+ got = podOwnerRequests(ctx, fake.NewClientBuilder().WithObjects(rs, dep).Build(), depPod, "StatefulSet")
assert.Empty(t, got)
// Pod without a controller owner yields nothing.
diff --git a/apps/rlark/pkg/agent/controllers/delivery/controller.go b/apps/rlark/pkg/agent/controllers/delivery/controller.go
new file mode 100644
index 0000000..9c15cac
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/delivery/controller.go
@@ -0,0 +1,139 @@
+package delivery
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/client-go/discovery"
+ "k8s.io/client-go/discovery/cached/memory"
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/restmapper"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+)
+
+const resyncInterval = 5 * time.Minute
+
+type Reconciler struct {
+ managementClient client.Client
+ managementNamespace string
+ engine *distribution.Engine
+ reconcileDelivery func(context.Context, ctrl.Request) (ctrl.Result, error)
+}
+
+func New(config *rest.Config, managementClient, localClient client.Client, managementNamespace string) (*Reconciler, error) {
+ targetClient, err := dynamic.NewForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("create dynamic client: %w", err)
+ }
+ discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("create discovery client: %w", err)
+ }
+ mode := distribution.DeliveryMode
+ return &Reconciler{
+ managementClient: managementClient,
+ managementNamespace: managementNamespace,
+ engine: &distribution.Engine{
+ DeclarationClient: managementClient,
+ TargetClient: targetClient,
+ TargetMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
+ Namespaces: namespaceResolver{client: localClient},
+ InventoryStore: distribution.ConfigMapInventoryStore{Client: managementClient, Mode: mode},
+ Policy: restrictedPolicy{},
+ Mode: mode,
+ MaxTargets: 1000,
+ },
+ }, nil
+}
+
+func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
+ if r.reconcileDelivery != nil {
+ return r.reconcileDelivery(ctx, request)
+ }
+ if request.Namespace != r.managementNamespace {
+ return ctrl.Result{}, nil
+ }
+ declaration := &corev1.Secret{}
+ if err := r.managementClient.Get(ctx, request.NamespacedName, declaration); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if declaration.Type != distribution.DeliverySecretType {
+ return ctrl.Result{}, nil
+ }
+ result, err := r.engine.Reconcile(ctx, declaration)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if result.Requeue {
+ return ctrl.Result{RequeueAfter: time.Nanosecond}, nil
+ }
+ return ctrl.Result{RequeueAfter: resyncInterval}, nil
+}
+
+func (r *Reconciler) Setup(managementManager, localManager ctrl.Manager) error {
+ if err := ctrl.NewControllerManagedBy(managementManager).
+ Named("resource-delivery").
+ For(&corev1.Secret{}, builder.WithPredicates(secretPredicate())).
+ Complete(r); err != nil {
+ return err
+ }
+ return ctrl.NewControllerManagedBy(localManager).
+ Named("resource-delivery-namespace-watch").
+ For(&corev1.Namespace{}, builder.WithPredicates(predicate.Funcs{})).
+ Complete(reconcile.Func(r.reconcileNamespace))
+}
+
+func (r *Reconciler) reconcileNamespace(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) {
+ var secrets corev1.SecretList
+ if err := r.managementClient.List(ctx, &secrets, client.InNamespace(r.managementNamespace)); err != nil {
+ return reconcile.Result{}, err
+ }
+ var reconcileErrors []error
+ for i := range secrets.Items {
+ if secrets.Items[i].Type != distribution.DeliverySecretType {
+ continue
+ }
+ if _, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKey{Namespace: r.managementNamespace, Name: secrets.Items[i].Name}}); err != nil {
+ reconcileErrors = append(reconcileErrors, fmt.Errorf("reconcile delivery %s: %w", secrets.Items[i].Name, err))
+ }
+ }
+ return reconcile.Result{}, errors.Join(reconcileErrors...)
+}
+
+type namespaceResolver struct{ client client.Client }
+
+func (r namespaceResolver) List(ctx context.Context) ([]distribution.NamespaceIdentity, error) {
+ var namespaces corev1.NamespaceList
+ if err := r.client.List(ctx, &namespaces); err != nil {
+ return nil, err
+ }
+ result := make([]distribution.NamespaceIdentity, 0, len(namespaces.Items))
+ for _, namespace := range namespaces.Items {
+ result = append(result, distribution.NamespaceIdentity{Name: namespace.Name, UID: namespace.UID, Labels: namespace.Labels, Phase: namespace.Status.Phase})
+ }
+ return result, nil
+}
+
+func secretPredicate() predicate.Predicate {
+ matches := func(object client.Object) bool {
+ secret, ok := object.(*corev1.Secret)
+ return ok && secret.Type == distribution.DeliverySecretType
+ }
+ return predicate.Funcs{
+ CreateFunc: func(e event.CreateEvent) bool { return matches(e.Object) },
+ UpdateFunc: func(e event.UpdateEvent) bool { return matches(e.ObjectOld) || matches(e.ObjectNew) },
+ DeleteFunc: func(e event.DeleteEvent) bool { return matches(e.Object) },
+ GenericFunc: func(e event.GenericEvent) bool { return matches(e.Object) },
+ }
+}
diff --git a/apps/rlark/pkg/agent/controllers/delivery/controller_test.go b/apps/rlark/pkg/agent/controllers/delivery/controller_test.go
new file mode 100644
index 0000000..71d39cf
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/delivery/controller_test.go
@@ -0,0 +1,83 @@
+package delivery
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeliveryPredicateEvents(t *testing.T) {
+ p := secretPredicate()
+ delivery := &corev1.Secret{Type: distribution.DeliverySecretType}
+ replication := &corev1.Secret{Type: distribution.ReplicationSecretType}
+
+ assert.True(t, p.Create(event.CreateEvent{Object: delivery}))
+ assert.False(t, p.Create(event.CreateEvent{Object: replication}))
+ assert.True(t, p.Update(event.UpdateEvent{ObjectOld: delivery, ObjectNew: replication}))
+ assert.True(t, p.Delete(event.DeleteEvent{Object: delivery}))
+ assert.True(t, p.Generic(event.GenericEvent{Object: delivery}))
+}
+
+func TestDeliveryReconcileIgnoresOtherManagementNamespace(t *testing.T) {
+ r := &Reconciler{managementNamespace: "rlark-cluster-a"}
+ result, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: clientKey("other", "delivery")})
+ require.NoError(t, err)
+ assert.Equal(t, ctrl.Result{}, result)
+}
+
+func TestDeliveryNamespaceResolver(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: "namespace-uid", Labels: map[string]string{"managed": "true"}},
+ Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive},
+ }).Build()
+
+ items, err := (namespaceResolver{client: c}).List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+ assert.Equal(t, "tenant-a", items[0].Name)
+ assert.Equal(t, "namespace-uid", string(items[0].UID))
+}
+
+func TestNamespaceReconcileContinuesAfterDeliveryFailure(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "broken", Namespace: "rlark-cluster-a"}, Type: distribution.DeliverySecretType},
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "healthy", Namespace: "rlark-cluster-a"}, Type: distribution.DeliverySecretType},
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ignored", Namespace: "rlark-cluster-a"}, Type: corev1.SecretTypeOpaque},
+ ).Build()
+ var reconciled []string
+ r := &Reconciler{
+ managementClient: c,
+ managementNamespace: "rlark-cluster-a",
+ reconcileDelivery: func(_ context.Context, request ctrl.Request) (ctrl.Result, error) {
+ reconciled = append(reconciled, request.Name)
+ if request.Name == "broken" {
+ return ctrl.Result{}, errors.New("broken delivery")
+ }
+ return ctrl.Result{}, nil
+ },
+ }
+
+ _, err := r.reconcileNamespace(context.Background(), ctrl.Request{})
+ require.ErrorContains(t, err, "broken delivery")
+ assert.ElementsMatch(t, []string{"broken", "healthy"}, reconciled)
+}
+
+func clientKey(namespace, name string) types.NamespacedName {
+ return types.NamespacedName{Namespace: namespace, Name: name}
+}
diff --git a/apps/rlark/pkg/agent/controllers/delivery/policy.go b/apps/rlark/pkg/agent/controllers/delivery/policy.go
new file mode 100644
index 0000000..4cd6dcd
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/delivery/policy.go
@@ -0,0 +1,34 @@
+package delivery
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+)
+
+type restrictedPolicy struct{}
+
+func (restrictedPolicy) Validate(_ context.Context, _ *corev1.Secret, mapping *meta.RESTMapping, _ distribution.NamespaceIdentity, object *unstructured.Unstructured, apply distribution.ApplyPolicy) error {
+ if apply.AdoptExisting || apply.ConflictPolicy == "Force" {
+ return fmt.Errorf("adoption and force apply are disabled")
+ }
+ groupResource := mapping.Resource.GroupResource().String()
+ switch groupResource {
+ case "nodes", "serviceaccounts/token", "certificatesigningrequests.certificates.k8s.io", "apiservices.apiregistration.k8s.io",
+ "mutatingwebhookconfigurations.admissionregistration.k8s.io", "validatingwebhookconfigurations.admissionregistration.k8s.io",
+ "clusterroles.rbac.authorization.k8s.io", "clusterrolebindings.rbac.authorization.k8s.io", "customresourcedefinitions.apiextensions.k8s.io":
+ return fmt.Errorf("resource %s is denied", groupResource)
+ }
+ if object.GetKind() == "Secret" {
+ secretType, _, _ := unstructured.NestedString(object.Object, "type")
+ if secretType == string(corev1.SecretTypeServiceAccountToken) {
+ return fmt.Errorf("service account token secrets are denied")
+ }
+ }
+ return nil
+}
diff --git a/apps/rlark/pkg/agent/controllers/delivery/policy_test.go b/apps/rlark/pkg/agent/controllers/delivery/policy_test.go
new file mode 100644
index 0000000..9df44e2
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/delivery/policy_test.go
@@ -0,0 +1,43 @@
+package delivery
+
+import (
+ "context"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRestrictedPolicy(t *testing.T) {
+ policy := restrictedPolicy{}
+ allowed := &meta.RESTMapping{Resource: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}}
+ denied := &meta.RESTMapping{Resource: schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}}
+ object := &unstructured.Unstructured{}
+
+ require.NoError(t, policy.Validate(context.Background(), &corev1.Secret{}, allowed, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{}))
+ assert.Error(t, policy.Validate(context.Background(), &corev1.Secret{}, denied, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{}))
+ assert.Error(t, policy.Validate(context.Background(), &corev1.Secret{}, allowed, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{ConflictPolicy: "Force"}))
+}
+
+func TestRestrictedPolicyRejectsSensitiveSecretAndAdoption(t *testing.T) {
+ policy := restrictedPolicy{}
+ mapping := &meta.RESTMapping{Resource: schema.GroupVersionResource{Version: "v1", Resource: "secrets"}}
+ object := &unstructured.Unstructured{Object: map[string]any{"kind": "Secret", "type": string(corev1.SecretTypeServiceAccountToken)}}
+
+ assert.ErrorContains(t, policy.Validate(context.Background(), &corev1.Secret{}, mapping, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{}), "service account token")
+ assert.ErrorContains(t, policy.Validate(context.Background(), &corev1.Secret{}, mapping, distribution.NamespaceIdentity{}, &unstructured.Unstructured{}, distribution.ApplyPolicy{AdoptExisting: true}), "disabled")
+}
+
+func TestDeliveryPredicateOnlyMatchesDeliverySecrets(t *testing.T) {
+ p := secretPredicate()
+ assert.True(t, p.Create(event.CreateEvent{Object: &corev1.Secret{Type: distribution.DeliverySecretType}}))
+ assert.False(t, p.Create(event.CreateEvent{Object: &corev1.Secret{Type: distribution.ReplicationSecretType}}))
+ assert.False(t, p.Create(event.CreateEvent{Object: &corev1.Secret{}}))
+}
diff --git a/apps/rlark/pkg/agent/controllers/node/controller.go b/apps/rlark/pkg/agent/controllers/node/controller.go
index 5bf969f..ff722a4 100644
--- a/apps/rlark/pkg/agent/controllers/node/controller.go
+++ b/apps/rlark/pkg/agent/controllers/node/controller.go
@@ -1,12 +1,18 @@
package node
import (
+ "context"
+
corev1 "k8s.io/api/core/v1"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
)
+const podNodeNameField = "spec.nodeName"
+
// Controller manages node reporting from data-plane to management cluster.
type Controller struct {
base.Controller
@@ -23,6 +29,17 @@ func NewNodeController(bc base.Controller) *Controller {
return nc
}
+// IndexLocalFields registers cache indexes used by node reconcilers.
+func IndexLocalFields(ctx context.Context, mgr ctrl.Manager) error {
+ return mgr.GetFieldIndexer().IndexField(ctx, &corev1.Pod{}, podNodeNameField, func(obj client.Object) []string {
+ pod, ok := obj.(*corev1.Pod)
+ if !ok || pod.Spec.NodeName == "" {
+ return nil
+ }
+ return []string{pod.Spec.NodeName}
+ })
+}
+
// KubernetesResource is an exported method.
func (c *Controller) KubernetesResource() base.KubernetesResource {
return base.KubernetesResource{
@@ -39,7 +56,7 @@ func (c *Controller) AsPullReconciler() base.KubernetesReconciler {
// AsKubePushReconcilers is an exported method.
func (c *Controller) AsKubePushReconcilers() map[base.KubernetesResource]base.KubernetesReconciler {
return map[base.KubernetesResource]base.KubernetesReconciler{
- base.KubernetesResource{
+ {
Name: "node-k8snode",
Type: &corev1.Node{},
}: &pushNodeReconciler{c: c},
diff --git a/apps/rlark/pkg/agent/controllers/node/push.go b/apps/rlark/pkg/agent/controllers/node/push.go
index 68ac03a..06d378a 100644
--- a/apps/rlark/pkg/agent/controllers/node/push.go
+++ b/apps/rlark/pkg/agent/controllers/node/push.go
@@ -2,6 +2,10 @@ package node
import (
"context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
"strings"
"time"
@@ -95,13 +99,24 @@ func (r *pushNodeReconciler) Reconcile(ctx context.Context, req reconcile.Reques
}
var podList corev1.PodList
- if err := r.c.LocalKubeClient.List(ctx, &podList); err != nil {
+ localCachedClient := r.c.LocalKubeCachedClient
+ if localCachedClient == nil {
+ localCachedClient = r.c.LocalKubeClient
+ }
+ if err := localCachedClient.List(ctx, &podList, client.MatchingFields{podNodeNameField: k8sNode.Name}); err != nil {
logger.Error(err, "failed to list local Pods for node resource usage")
return reconcile.Result{RequeueAfter: HeartbeatInterval}, err
}
desiredNode := r.buildRLarkNodeFromK8sNode(&k8sNode, podList.Items)
- return r.updateManagementNode(ctx, logger, desiredNode)
+ storage, err := r.nodeStorageStatus(ctx, k8sNode.Name)
+ storageCollected := err == nil && storage != nil
+ if err != nil {
+ logger.Error(err, "failed to get kubelet storage stats")
+ } else {
+ desiredNode.Status.Storage = storage
+ }
+ return r.updateManagementNode(ctx, logger, desiredNode, storageCollected)
}
func (r *pushNodeReconciler) buildRLarkNodeFromK8sNode(k8sNode *corev1.Node, pods []corev1.Pod) *rlarkv1alpha1.Node {
@@ -147,6 +162,86 @@ func (r *pushNodeReconciler) buildRLarkNodeFromK8sNode(k8sNode *corev1.Node, pod
}
}
+type nodeFSStats struct {
+ CapacityBytes *uint64 `json:"capacityBytes"`
+ UsedBytes *uint64 `json:"usedBytes"`
+ AvailableBytes *uint64 `json:"availableBytes"`
+}
+
+type nodeStatsSummary struct {
+ Node struct {
+ FS *nodeFSStats `json:"fs"`
+ Runtime *struct {
+ ImageFS *nodeFSStats `json:"imageFs"`
+ } `json:"runtime"`
+ } `json:"node"`
+}
+
+func (r *pushNodeReconciler) nodeStorageStatus(ctx context.Context, nodeName string) (*rlarkv1alpha1.NodeStorageStatus, error) {
+ if r.c.LocalKubeHTTP == nil || r.c.LocalKubeAPIHost == "" {
+ return nil, nil
+ }
+ endpoint, err := url.JoinPath(r.c.LocalKubeAPIHost, "api", "v1", "nodes", nodeName, "proxy", "stats", "summary")
+ if err != nil {
+ return nil, fmt.Errorf("build stats summary URL: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create stats summary request: %w", err)
+ }
+ resp, err := r.c.LocalKubeHTTP.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request stats summary: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("request stats summary: %s", resp.Status)
+ }
+ var summary nodeStatsSummary
+ if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
+ return nil, fmt.Errorf("decode stats summary: %w", err)
+ }
+
+ filesystems := []*nodeFSStats{summary.Node.FS}
+ if summary.Node.Runtime != nil {
+ filesystems = append(filesystems, summary.Node.Runtime.ImageFS)
+ }
+
+ storage := &rlarkv1alpha1.NodeStorageStatus{}
+ seen := make(map[[2]uint64]struct{})
+ collected := false
+ for _, fs := range filesystems {
+ if fs == nil || fs.CapacityBytes == nil || fs.AvailableBytes == nil {
+ continue
+ }
+
+ // nodefs and imagefs can refer to the same underlying filesystem. In
+ // that case their capacity and available bytes are identical, so only
+ // count the filesystem once. A dedicated containerd image filesystem
+ // is aggregated with nodefs instead.
+ identity := [2]uint64{*fs.CapacityBytes, *fs.AvailableBytes}
+ if _, exists := seen[identity]; exists {
+ continue
+ }
+ seen[identity] = struct{}{}
+
+ capacity := int64(*fs.CapacityBytes)
+ available := int64(*fs.AvailableBytes)
+ used := capacity - available
+ if fs.UsedBytes != nil {
+ used = int64(*fs.UsedBytes)
+ }
+ storage.CapacityBytes += capacity
+ storage.UsedBytes += used
+ storage.AvailableBytes += available
+ collected = true
+ }
+ if !collected {
+ return nil, nil
+ }
+ return storage, nil
+}
+
func diskPressure(node *corev1.Node) *bool {
for _, condition := range node.Status.Conditions {
if condition.Type != corev1.NodeDiskPressure {
@@ -234,7 +329,7 @@ func (r *pushNodeReconciler) getPhase(k8sNode *corev1.Node) rlarkv1alpha1.NodePh
return rlarkv1alpha1.NodeOffline
}
-func (r *pushNodeReconciler) updateManagementNode(ctx context.Context, logger logr.Logger, desiredNode *rlarkv1alpha1.Node) (reconcile.Result, error) {
+func (r *pushNodeReconciler) updateManagementNode(ctx context.Context, logger logr.Logger, desiredNode *rlarkv1alpha1.Node, storageCollected bool) (reconcile.Result, error) {
var mgmtNode rlarkv1alpha1.Node
err := r.c.ManagementClient.Get(ctx, types.NamespacedName{Name: desiredNode.Name, Namespace: desiredNode.Namespace}, &mgmtNode)
if err != nil && client.IgnoreNotFound(err) != nil {
@@ -271,6 +366,11 @@ func (r *pushNodeReconciler) updateManagementNode(ctx context.Context, logger lo
// - events: owned by the node events watcher (nodeevents.Watcher)
desiredNode.Status.PullProgress = mgmtNode.Status.PullProgress
desiredNode.Status.Events = mgmtNode.Status.Events
+ if !storageCollected {
+ // Keep the last successful nodefs/imagefs result when kubelet stats are
+ // unavailable or incomplete during this reconciliation.
+ desiredNode.Status.Storage = mgmtNode.Status.Storage
+ }
mgmtNode.Status = desiredNode.Status
if err := r.c.ManagementClient.Status().Update(ctx, &mgmtNode); err != nil {
logger.Error(err, "failed to update management Node status")
diff --git a/apps/rlark/pkg/agent/controllers/node/push_test.go b/apps/rlark/pkg/agent/controllers/node/push_test.go
index 8073e27..12f675e 100644
--- a/apps/rlark/pkg/agent/controllers/node/push_test.go
+++ b/apps/rlark/pkg/agent/controllers/node/push_test.go
@@ -1,13 +1,66 @@
package node
import (
+ "context"
+ "net/http"
+ "net/http/httptest"
"reflect"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
)
+func TestReconcileListsOnlyPodsOnCurrentNode(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ local := fake.NewClientBuilder().WithScheme(scheme).
+ WithIndex(&corev1.Pod{}, podNodeNameField, func(obj client.Object) []string {
+ pod := obj.(*corev1.Pod)
+ if pod.Spec.NodeName == "" {
+ return nil
+ }
+ return []string{pod.Spec.NodeName}
+ }).
+ WithObjects(
+ &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-a"}},
+ &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-a", Namespace: "default"}, Spec: corev1.PodSpec{NodeName: "node-a", Containers: []corev1.Container{{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}}}}}},
+ &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-b", Namespace: "default"}, Spec: corev1.PodSpec{NodeName: "node-b", Containers: []corev1.Container{{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("8")}}}}}},
+ ).Build()
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Node{}).Build()
+ r := &pushNodeReconciler{c: &Controller{Controller: base.Controller{
+ LocalKubeClient: local,
+ ManagementClient: management,
+ ManagementNamespace: "cluster-a",
+ AgentType: string(rlarkv1alpha1.AgentTypeKubernetes),
+ }}}
+
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "node-a"}}); err != nil {
+ t.Fatal(err)
+ }
+ var node rlarkv1alpha1.Node
+ if err := management.Get(context.Background(), types.NamespacedName{Name: "node-a", Namespace: "cluster-a"}, &node); err != nil {
+ t.Fatal(err)
+ }
+ if cpu := node.Status.Used.Cpu().String(); cpu != "1" {
+ t.Fatalf("reported CPU requests = %s, want 1", cpu)
+ }
+}
+
func TestMergeManagementNodeMetadata(t *testing.T) {
managementLabels := map[string]string{
"rlark.io/node-category-cloud": "true",
@@ -89,6 +142,79 @@ func TestRequestedResourcesForNode(t *testing.T) {
}
}
+func TestNodeStorageStatus(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ statusCode int
+ want [3]int64
+ wantNil bool
+ wantErr bool
+ }{
+ {
+ name: "node filesystem",
+ statusCode: http.StatusOK,
+ response: `{"node":{"fs":{"capacityBytes":1000,"usedBytes":900,"availableBytes":100}}}`,
+ want: [3]int64{1000, 900, 100},
+ },
+ {
+ name: "derives used bytes",
+ statusCode: http.StatusOK,
+ response: `{"node":{"fs":{"capacityBytes":1000,"availableBytes":250}}}`,
+ want: [3]int64{1000, 750, 250},
+ },
+ {
+ name: "deduplicates shared image filesystem",
+ statusCode: http.StatusOK,
+ response: `{"node":{"fs":{"capacityBytes":1000,"usedBytes":600,"availableBytes":400},"runtime":{"imageFs":{"capacityBytes":1000,"usedBytes":300,"availableBytes":400}}}}`,
+ want: [3]int64{1000, 600, 400},
+ },
+ {
+ name: "aggregates dedicated image filesystem",
+ statusCode: http.StatusOK,
+ response: `{"node":{"fs":{"capacityBytes":1000,"usedBytes":600,"availableBytes":400},"runtime":{"imageFs":{"capacityBytes":500,"usedBytes":450,"availableBytes":50}}}}`,
+ want: [3]int64{1500, 1050, 450},
+ },
+ {name: "missing filesystem", statusCode: http.StatusOK, response: `{"node":{}}`, wantNil: true},
+ {name: "invalid response", statusCode: http.StatusOK, response: `{`, wantErr: true},
+ {name: "http error", statusCode: http.StatusServiceUnavailable, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path != "/api/v1/nodes/gpu20/proxy/stats/summary" {
+ t.Fatalf("request path = %s", req.URL.Path)
+ }
+ w.WriteHeader(tt.statusCode)
+ _, _ = w.Write([]byte(tt.response))
+ }))
+ defer server.Close()
+
+ reconciler := &pushNodeReconciler{c: &Controller{Controller: base.Controller{
+ LocalKubeHTTP: server.Client(),
+ LocalKubeAPIHost: server.URL,
+ }}}
+ got, err := reconciler.nodeStorageStatus(context.Background(), "gpu20")
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("nodeStorageStatus() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if tt.wantErr {
+ return
+ }
+ if tt.wantNil {
+ if got != nil {
+ t.Fatalf("nodeStorageStatus() = %#v, want nil", got)
+ }
+ return
+ }
+ if got == nil || [3]int64{got.CapacityBytes, got.UsedBytes, got.AvailableBytes} != tt.want {
+ t.Fatalf("nodeStorageStatus() = %#v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestDiskPressure(t *testing.T) {
tests := []struct {
name string
diff --git a/apps/rlark/pkg/agent/controllers/pod/controller.go b/apps/rlark/pkg/agent/controllers/pod/controller.go
index d4d6aee..c896d99 100644
--- a/apps/rlark/pkg/agent/controllers/pod/controller.go
+++ b/apps/rlark/pkg/agent/controllers/pod/controller.go
@@ -1,7 +1,16 @@
package pod
import (
+ "context"
+ "sync"
+
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/util/workqueue"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
@@ -10,6 +19,8 @@ import (
// Controller manages pod reporting from data-plane to management cluster.
type Controller struct {
base.Controller
+ deleteMu sync.Mutex
+ deleteUIDs map[types.NamespacedName][]types.UID
}
var _ base.Reconciler = (*Controller)(nil)
@@ -18,11 +29,55 @@ var _ base.Reconciler = (*Controller)(nil)
func NewPodController(bc base.Controller) *Controller {
pc := &Controller{
Controller: bc,
+ deleteUIDs: map[types.NamespacedName][]types.UID{},
}
pc.C = pc
return pc
}
+func (c *Controller) deleteEventHandler() handler.EventHandler {
+ return handler.Funcs{DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+ if e.Object == nil || e.Object.GetUID() == "" {
+ return
+ }
+ key := types.NamespacedName{Name: e.Object.GetName(), Namespace: e.Object.GetNamespace()}
+ c.deleteMu.Lock()
+ c.deleteUIDs[key] = append(c.deleteUIDs[key], e.Object.GetUID())
+ c.deleteMu.Unlock()
+ q.Add(reconcile.Request{NamespacedName: key})
+ }}
+}
+
+func (c *Controller) pendingDeleteUIDs(key types.NamespacedName) []types.UID {
+ c.deleteMu.Lock()
+ defer c.deleteMu.Unlock()
+ return append([]types.UID(nil), c.deleteUIDs[key]...)
+}
+
+func (c *Controller) acknowledgeDeleteUID(key types.NamespacedName, uid types.UID) {
+ c.deleteMu.Lock()
+ defer c.deleteMu.Unlock()
+ uids := c.deleteUIDs[key]
+ for i, queuedUID := range uids {
+ if queuedUID != uid {
+ continue
+ }
+ c.deleteUIDs[key] = append(uids[:i], uids[i+1:]...)
+ if len(c.deleteUIDs[key]) == 0 {
+ delete(c.deleteUIDs, key)
+ }
+ return
+ }
+}
+
+// PushWatch captures the UID carried by delete events before the cache loses it.
+func (c *Controller) PushWatch(obj client.Object) handler.EventHandler {
+ if _, ok := obj.(*corev1.Pod); !ok {
+ return nil
+ }
+ return c.deleteEventHandler()
+}
+
// KubernetesResource is an exported method.
func (c *Controller) KubernetesResource() base.KubernetesResource {
return base.KubernetesResource{
diff --git a/apps/rlark/pkg/agent/controllers/pod/push.go b/apps/rlark/pkg/agent/controllers/pod/push.go
index d00dd41..5b0bad6 100644
--- a/apps/rlark/pkg/agent/controllers/pod/push.go
+++ b/apps/rlark/pkg/agent/controllers/pod/push.go
@@ -2,11 +2,16 @@ package pod
import (
"context"
+ "fmt"
+ "reflect"
"github.com/go-logr/logr"
+ appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
@@ -22,6 +27,12 @@ type pushPodReconciler struct {
// Reconcile reconciles the resource.
func (r *pushPodReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
logger := log.FromContext(ctx).WithValues("pod", req.NamespacedName)
+ for _, uid := range r.c.pendingDeleteUIDs(req.NamespacedName) {
+ if _, err := r.deleteManagementPod(ctx, logger, uid); err != nil {
+ return reconcile.Result{}, err
+ }
+ r.c.acknowledgeDeleteUID(req.NamespacedName, uid)
+ }
var k8sPod corev1.Pod
if err := r.c.LocalKubeClient.Get(ctx, req.NamespacedName, &k8sPod); err != nil {
@@ -29,8 +40,7 @@ func (r *pushPodReconciler) Reconcile(ctx context.Context, req reconcile.Request
logger.Error(err, "failed to get local K8s Pod")
return reconcile.Result{}, err
}
- // Pod deleted — clean up management Pod CR
- return r.deleteManagementPod(ctx, logger, req.Name, req.Namespace)
+ return reconcile.Result{}, nil
}
// Only reconcile pods managed by rlark (have management-task annotation)
@@ -40,15 +50,109 @@ func (r *pushPodReconciler) Reconcile(ctx context.Context, req reconcile.Request
}
taskName := annotations["rlark.io/management-task-name"]
taskNamespace := annotations["rlark.io/management-task-namespace"]
+ taskUID := annotations["rlark.io/management-task-uid"]
if taskName == "" {
return reconcile.Result{}, nil
}
+ if taskNamespace == "" {
+ logger.Info("ignoring Pod with incomplete management Task identity")
+ return reconcile.Result{}, nil
+ }
+ if taskUID == "" {
+ resolvedUID, err := r.c.resolveLegacyTaskUID(ctx, &k8sPod, taskName, taskNamespace)
+ if err != nil {
+ return reconcile.Result{}, err
+ }
+ if resolvedUID == "" {
+ return reconcile.Result{}, nil
+ }
+ taskUID = resolvedUID
+ }
- desiredPod := r.buildRLarkPodFromK8sPod(&k8sPod, taskName, taskNamespace)
+ desiredPod, err := r.buildRLarkPodFromK8sPod(ctx, &k8sPod, taskName, taskNamespace, taskUID)
+ if err != nil {
+ return reconcile.Result{}, err
+ }
return r.updateManagementPod(ctx, logger, desiredPod)
}
-func (r *pushPodReconciler) buildRLarkPodFromK8sPod(k8sPod *corev1.Pod, taskName, taskNamespace string) *rlarkv1alpha1.Pod {
+func (c *Controller) resolveLegacyTaskUID(ctx context.Context, pod *corev1.Pod, taskName, taskNamespace string) (string, error) {
+ var task rlarkv1alpha1.Task
+ if err := c.ManagementClient.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task); err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return "", nil
+ }
+ return "", err
+ }
+ owner := metav1.GetControllerOf(pod)
+ if owner == nil || owner.APIVersion != appsv1.SchemeGroupVersion.String() {
+ return "", nil
+ }
+ var workload client.Object
+ switch owner.Kind {
+ case "StatefulSet":
+ workload = &appsv1.StatefulSet{}
+ case "DaemonSet":
+ workload = &appsv1.DaemonSet{}
+ case "ReplicaSet":
+ var rs appsv1.ReplicaSet
+ if err := c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}, &rs); err != nil || rs.UID != owner.UID {
+ return "", nil
+ }
+ owner = metav1.GetControllerOf(&rs)
+ if owner == nil || owner.APIVersion != appsv1.SchemeGroupVersion.String() || owner.Kind != "Deployment" {
+ return "", nil
+ }
+ workload = &appsv1.Deployment{}
+ default:
+ return "", nil
+ }
+ if err := c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}, workload); err != nil || workload.GetUID() != owner.UID {
+ return "", nil
+ }
+ a := workload.GetAnnotations()
+ if a["rlark.io/management-task-name"] != taskName || a["rlark.io/management-task-namespace"] != taskNamespace {
+ return "", nil
+ }
+ if uid := a["rlark.io/management-task-uid"]; uid != "" && uid != string(task.UID) {
+ return "", nil
+ }
+ return string(task.UID), nil
+}
+
+func validateManagementPodIdentity(existing, desired *rlarkv1alpha1.Pod) error {
+ // A same-name object with no RLark identity cannot be proven to be ours.
+ if existing.Labels[rlarkv1alpha1.PodLabelLocalPodUID] == "" &&
+ existing.Labels[rlarkv1alpha1.PodLabelTaskUID] == "" && metav1.GetControllerOf(existing) == nil &&
+ existing.Spec.PodName == "" && existing.Spec.TaskName == "" && len(existing.Annotations) == 0 {
+ return apierrors.NewConflict(rlarkv1alpha1.Resource("pods"), existing.Name, fmt.Errorf("existing Pod has no authoritative RLark identity"))
+ }
+ checks := []struct{ name, got, want string }{
+ {"object name", existing.Name, desired.Name},
+ {"local Pod UID", existing.Labels[rlarkv1alpha1.PodLabelLocalPodUID], desired.Labels[rlarkv1alpha1.PodLabelLocalPodUID]},
+ {"local Pod name", existing.Labels[rlarkv1alpha1.PodLabelLocalPodName], desired.Labels[rlarkv1alpha1.PodLabelLocalPodName]},
+ {"local Pod namespace", existing.Labels[rlarkv1alpha1.PodLabelLocalPodNamespace], desired.Labels[rlarkv1alpha1.PodLabelLocalPodNamespace]},
+ {"Task name", existing.Labels[rlarkv1alpha1.PodLabelTaskName], desired.Labels[rlarkv1alpha1.PodLabelTaskName]},
+ {"Task UID", existing.Labels[rlarkv1alpha1.PodLabelTaskUID], desired.Labels[rlarkv1alpha1.PodLabelTaskUID]},
+ {"agent scope", existing.Labels[rlarkv1alpha1.PodLabelAgentScope], desired.Labels[rlarkv1alpha1.PodLabelAgentScope]},
+ {"spec local Pod name", existing.Spec.PodName, desired.Spec.PodName},
+ {"spec local Pod namespace", existing.Spec.PodNamespace, desired.Spec.PodNamespace},
+ {"spec Task name", existing.Spec.TaskName, desired.Spec.TaskName},
+ {"spec Task namespace", existing.Spec.TaskNamespace, desired.Spec.TaskNamespace},
+ }
+ for _, check := range checks {
+ if check.got != "" && check.want != "" && check.got != check.want {
+ return apierrors.NewConflict(rlarkv1alpha1.Resource("pods"), existing.Name, fmt.Errorf("%s is %q, not %q", check.name, check.got, check.want))
+ }
+ }
+ existingOwner, desiredOwner := metav1.GetControllerOf(existing), metav1.GetControllerOf(desired)
+ if existingOwner != nil && (desiredOwner == nil || existingOwner.APIVersion != desiredOwner.APIVersion || existingOwner.UID != desiredOwner.UID || existingOwner.Kind != desiredOwner.Kind || existingOwner.Name != desiredOwner.Name) {
+ return apierrors.NewConflict(rlarkv1alpha1.Resource("pods"), existing.Name, fmt.Errorf("controller owner does not match reporting Task"))
+ }
+ return nil
+}
+
+func (r *pushPodReconciler) buildRLarkPodFromK8sPod(ctx context.Context, k8sPod *corev1.Pod, taskName, taskNamespace, taskUID string) (*rlarkv1alpha1.Pod, error) {
phase := convertK8sPodPhase(k8sPod.Status.Phase)
message := k8sPod.Status.Message
// A K8s Pod can be phase=Running while its main container (the container
@@ -81,19 +185,50 @@ func (r *pushPodReconciler) buildRLarkPodFromK8sPod(k8sPod *corev1.Pod, taskName
// Labels enable lookup by k8s pod name/namespace (e.g. for deletion when only the
// pod name is available, not the UID).
- return &rlarkv1alpha1.Pod{
+ labels := map[string]string{
+ rlarkv1alpha1.PodLabelLocalPodName: k8sPod.Name,
+ rlarkv1alpha1.PodLabelLocalPodNamespace: k8sPod.Namespace,
+ rlarkv1alpha1.PodLabelLocalPodUID: string(k8sPod.UID),
+ rlarkv1alpha1.PodLabelTaskName: taskName,
+ rlarkv1alpha1.PodLabelTaskUID: taskUID,
+ rlarkv1alpha1.PodLabelAgentScope: r.c.ManagementNamespace,
+ }
+ mgmtPod := &rlarkv1alpha1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: string(k8sPod.UID),
Namespace: r.c.ManagementNamespace,
- Labels: map[string]string{
- rlarkv1alpha1.PodLabelLocalPodName: k8sPod.Name,
- rlarkv1alpha1.PodLabelLocalPodNamespace: k8sPod.Namespace,
- rlarkv1alpha1.PodLabelTaskName: taskName,
- },
+ Labels: labels,
},
Spec: podSpec,
Status: podStatus,
}
+
+ var task rlarkv1alpha1.Task
+ if err := r.c.ManagementClient.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task); err != nil {
+ if client.IgnoreNotFound(err) != nil {
+ return nil, fmt.Errorf("verify management Task identity: %w", err)
+ }
+ return nil, fmt.Errorf("verify management Task identity: Task %s/%s not found", taskNamespace, taskName)
+ }
+ if string(task.UID) != taskUID {
+ return nil, fmt.Errorf("verify management Task identity: Task %s/%s UID is %q, not %q", taskNamespace, taskName, task.UID, taskUID)
+ }
+ if podSpec.Domain != "" && task.Spec.Domain != podSpec.Domain {
+ return nil, fmt.Errorf("verify management Task identity: Task %s/%s domain is %q, not %q", taskNamespace, taskName, task.Spec.Domain, podSpec.Domain)
+ }
+
+ // Cross-namespace owner references are illegal. Identity is still verified
+ // above for cross-namespace Tasks, but ownership is only set when legal.
+ if taskNamespace == r.c.ManagementNamespace {
+ mgmtPod.OwnerReferences = []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(),
+ Kind: "Task",
+ Name: task.Name,
+ UID: task.UID,
+ Controller: ptr.To(true),
+ }}
+ }
+ return mgmtPod, nil
}
func (r *pushPodReconciler) updateManagementPod(ctx context.Context, logger logr.Logger, desiredPod *rlarkv1alpha1.Pod) (reconcile.Result, error) {
@@ -120,47 +255,52 @@ func (r *pushPodReconciler) updateManagementPod(ctx context.Context, logger logr
}
return reconcile.Result{}, nil
}
-
- // Update labels + spec in one call
- mgmtPod.Labels = desiredPod.Labels
- mgmtPod.Spec = desiredPod.Spec
- if err := r.c.ManagementClient.Update(ctx, &mgmtPod); err != nil {
- logger.Error(err, "failed to update management Pod")
+ if err := validateManagementPodIdentity(&mgmtPod, desiredPod); err != nil {
return reconcile.Result{}, err
}
- // Update status
- mgmtPod.Status = desiredPod.Status
- if err := r.c.ManagementClient.Status().Update(ctx, &mgmtPod); err != nil {
- logger.Error(err, "failed to update management Pod status")
- return reconcile.Result{}, err
+ labels := convergeMetadata(mgmtPod.Labels, desiredPod.Labels, podManagedLabelKeys)
+ annotations := convergeMetadata(mgmtPod.Annotations, desiredPod.Annotations, nil)
+ ownerReferences := convergeControllerOwner(mgmtPod.OwnerReferences, desiredPod.OwnerReferences)
+ if !reflect.DeepEqual(mgmtPod.Labels, labels) || !reflect.DeepEqual(mgmtPod.Annotations, annotations) ||
+ !reflect.DeepEqual(mgmtPod.OwnerReferences, ownerReferences) || !reflect.DeepEqual(mgmtPod.Spec, desiredPod.Spec) {
+ original := mgmtPod.DeepCopy()
+ mgmtPod.Labels = labels
+ mgmtPod.Annotations = annotations
+ mgmtPod.OwnerReferences = ownerReferences
+ mgmtPod.Spec = desiredPod.Spec
+ if err := r.c.ManagementClient.Patch(ctx, &mgmtPod, client.MergeFrom(original)); err != nil {
+ logger.Error(err, "failed to update management Pod")
+ return reconcile.Result{}, err
+ }
+ }
+
+ if !reflect.DeepEqual(mgmtPod.Status, desiredPod.Status) {
+ original := mgmtPod.DeepCopy()
+ mgmtPod.Status = desiredPod.Status
+ if err := r.c.ManagementClient.Status().Patch(ctx, &mgmtPod, client.MergeFrom(original)); err != nil {
+ logger.Error(err, "failed to update management Pod status")
+ return reconcile.Result{}, err
+ }
}
logger.V(1).Info("management Pod reported successfully")
return reconcile.Result{}, nil
}
-func (r *pushPodReconciler) deleteManagementPod(ctx context.Context, logger logr.Logger, name, namespace string) (reconcile.Result, error) {
- // Find management Pod(s) by the k8s pod name/namespace labels (name alone isn't
- // enough since management Pods are named by k8s UID, not pod name).
- var mgmtPodList rlarkv1alpha1.PodList
- if err := r.c.ManagementClient.List(ctx, &mgmtPodList,
- client.InNamespace(r.c.ManagementNamespace),
- client.MatchingLabels{
- rlarkv1alpha1.PodLabelLocalPodName: name,
- rlarkv1alpha1.PodLabelLocalPodNamespace: namespace,
- }); err != nil {
- logger.Error(err, "failed to list management Pods for deletion")
- return reconcile.Result{}, err
+func (r *pushPodReconciler) deleteManagementPod(ctx context.Context, logger logr.Logger, uid types.UID) (reconcile.Result, error) {
+ var candidate rlarkv1alpha1.Pod
+ key := types.NamespacedName{Name: string(uid), Namespace: r.c.ManagementNamespace}
+ if err := r.c.ManagementClient.Get(ctx, key, &candidate); err != nil {
+ return reconcile.Result{}, client.IgnoreNotFound(err)
}
- for i := range mgmtPodList.Items {
- if err := r.c.ManagementClient.Delete(ctx, &mgmtPodList.Items[i]); err != nil && client.IgnoreNotFound(err) != nil {
- logger.Error(err, "failed to delete management Pod", "managementPod", mgmtPodList.Items[i].Name)
- return reconcile.Result{}, err
- }
+ if candidate.Labels[rlarkv1alpha1.PodLabelLocalPodUID] != string(uid) ||
+ candidate.Labels[rlarkv1alpha1.PodLabelAgentScope] != r.c.ManagementNamespace {
+ return reconcile.Result{}, nil
}
- if len(mgmtPodList.Items) > 0 {
- logger.V(1).Info("management Pod(s) deleted", "count", len(mgmtPodList.Items))
+ if err := r.c.ManagementClient.Delete(ctx, &candidate); err != nil && client.IgnoreNotFound(err) != nil {
+ logger.Error(err, "failed to delete management Pod", "managementPod", candidate.Name)
+ return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}
@@ -176,8 +316,52 @@ func convertK8sPodPhase(phase corev1.PodPhase) rlarkv1alpha1.PodPhase {
case corev1.PodFailed:
return rlarkv1alpha1.PodPhaseFailed
default:
- return rlarkv1alpha1.PodPhasePending
+ return rlarkv1alpha1.PodPhaseUnknown
+ }
+}
+
+var podManagedLabelKeys = []string{
+ rlarkv1alpha1.PodLabelTaskName,
+ rlarkv1alpha1.PodLabelTaskUID,
+ rlarkv1alpha1.PodLabelLocalPodName,
+ rlarkv1alpha1.PodLabelLocalPodNamespace,
+ rlarkv1alpha1.PodLabelLocalPodUID,
+ rlarkv1alpha1.PodLabelAgentScope,
+}
+
+func convergeMetadata(existing, managed map[string]string, managedKeys []string) map[string]string {
+ if existing == nil && managed == nil {
+ return nil
+ }
+ merged := make(map[string]string, len(existing)+len(managed))
+ for key, value := range existing {
+ merged[key] = value
+ }
+ for _, key := range managedKeys {
+ delete(merged, key)
+ }
+ for key, value := range managed {
+ if value == "" {
+ delete(merged, key)
+ } else {
+ merged[key] = value
+ }
+ }
+ return merged
+}
+
+func convergeControllerOwner(existing, desired []metav1.OwnerReference) []metav1.OwnerReference {
+ owners := make([]metav1.OwnerReference, 0, len(existing)+len(desired))
+ for _, owner := range existing {
+ if owner.Controller == nil || !*owner.Controller {
+ owners = append(owners, owner)
+ }
+ }
+ owners = append(owners, desired...)
+ if len(owners) == 0 {
+ return nil
}
+ return owners
}
// mainContainerCrashLoopMessage inspects the workload's main container (the
diff --git a/apps/rlark/pkg/agent/controllers/pod/push_test.go b/apps/rlark/pkg/agent/controllers/pod/push_test.go
new file mode 100644
index 0000000..abbf902
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/pod/push_test.go
@@ -0,0 +1,576 @@
+package pod
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/go-logr/logr"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
+)
+
+type countingClient struct {
+ client.Client
+ patches int
+ statusPatches int
+}
+
+type failingGetClient struct {
+ client.Client
+ err error
+}
+
+type failingNamedGetClient struct {
+ client.Client
+ name string
+ err error
+}
+
+type failingDeleteClient struct {
+ client.Client
+ failName string
+}
+
+func (c *failingDeleteClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
+ if obj.GetName() == c.failName {
+ return errors.New("management delete failed")
+ }
+ return c.Client.Delete(ctx, obj, opts...)
+}
+
+func (c *failingGetClient) Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error {
+ return c.err
+}
+
+func (c *failingNamedGetClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
+ if key.Name == c.name {
+ return c.err
+ }
+ return c.Client.Get(ctx, key, obj, opts...)
+}
+
+func testScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ return scheme
+}
+
+func (c *countingClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
+ c.patches++
+ return c.Client.Patch(ctx, obj, patch, opts...)
+}
+
+func TestBuildManagementPodIdentityOwnerAndUnknown(t *testing.T) {
+ scheme := testScheme(t)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "task-uid"}, Spec: rlarkv1alpha1.TaskSpec{Domain: "domain-a"}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management, ManagementNamespace: "agent-a"})}
+ local := &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid", Annotations: map[string]string{"rlark.io/management-task-domain": "domain-a"}},
+ Status: corev1.PodStatus{Phase: corev1.PodUnknown},
+ }
+
+ got, err := r.buildRLarkPodFromK8sPod(context.Background(), local, "task", "agent-a", "task-uid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for key, want := range map[string]string{
+ rlarkv1alpha1.PodLabelTaskName: "task",
+ rlarkv1alpha1.PodLabelTaskUID: "task-uid",
+ rlarkv1alpha1.PodLabelLocalPodName: "pod",
+ rlarkv1alpha1.PodLabelLocalPodNamespace: "work",
+ rlarkv1alpha1.PodLabelLocalPodUID: "pod-uid",
+ rlarkv1alpha1.PodLabelAgentScope: "agent-a",
+ } {
+ if got.Labels[key] != want {
+ t.Errorf("label %s = %q, want %q", key, got.Labels[key], want)
+ }
+ }
+ if _, ok := got.Labels[rlarkv1alpha1.PodLabelDomain]; ok {
+ t.Errorf("domain must not be copied into a label: %v", got.Labels)
+ }
+ if got.Spec.Domain != "domain-a" {
+ t.Errorf("spec domain = %q, want domain-a", got.Spec.Domain)
+ }
+ if got.Status.Phase != rlarkv1alpha1.PodPhaseUnknown {
+ t.Errorf("phase = %q, want Unknown", got.Status.Phase)
+ }
+ if len(got.OwnerReferences) != 1 || got.OwnerReferences[0].UID != task.UID {
+ t.Fatalf("owner references = %+v, want verified Task", got.OwnerReferences)
+ }
+
+ if _, err = r.buildRLarkPodFromK8sPod(context.Background(), local, "task", "agent-a", "stale-uid"); err == nil {
+ t.Fatal("stale Task UID was accepted")
+ }
+}
+
+func TestLegacyManagementPodBackfillPreservesMetadata(t *testing.T) {
+ scheme := testScheme(t)
+ legacy := &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{
+ Name: "pod-uid", Namespace: "agent-a",
+ Labels: map[string]string{"other.io/label": "keep"}, Annotations: map[string]string{"other.io/annotation": "keep"},
+ }}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(legacy).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management, ManagementNamespace: "agent-a"})}
+ desired := legacy.DeepCopy()
+ desired.Labels = map[string]string{rlarkv1alpha1.PodLabelLocalPodUID: "pod-uid", rlarkv1alpha1.PodLabelAgentScope: "agent-a"}
+ desired.Annotations = nil
+
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desired); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(legacy), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Labels["other.io/label"] != "keep" || got.Annotations["other.io/annotation"] != "keep" ||
+ got.Labels[rlarkv1alpha1.PodLabelLocalPodUID] != "pod-uid" {
+ t.Fatalf("metadata was not preserved/backfilled: labels=%v annotations=%v", got.Labels, got.Annotations)
+ }
+}
+
+func TestUpdateManagementPodRejectsContradictoryPartialIdentity(t *testing.T) {
+ scheme := testScheme(t)
+ for _, tc := range []struct {
+ name string
+ mutate func(*rlarkv1alpha1.Pod)
+ }{
+ {"local UID", func(p *rlarkv1alpha1.Pod) { p.Labels[rlarkv1alpha1.PodLabelLocalPodUID] = "other" }},
+ {"local name", func(p *rlarkv1alpha1.Pod) { p.Spec.PodName = "other" }},
+ {"Task name", func(p *rlarkv1alpha1.Pod) { p.Labels[rlarkv1alpha1.PodLabelTaskName] = "other" }},
+ {"Task namespace", func(p *rlarkv1alpha1.Pod) { p.Spec.TaskNamespace = "other" }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ existing := &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-uid", Namespace: "agent-a", Labels: map[string]string{}}}
+ tc.mutate(existing)
+ desired := &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-uid", Namespace: "agent-a", Labels: map[string]string{
+ rlarkv1alpha1.PodLabelLocalPodUID: "pod-uid", rlarkv1alpha1.PodLabelLocalPodName: "pod", rlarkv1alpha1.PodLabelTaskName: "task",
+ }}, Spec: rlarkv1alpha1.PodSpec{PodName: "pod", TaskNamespace: "agent-a"}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(existing).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management})}
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desired); !apierrors.IsConflict(err) {
+ t.Fatalf("error = %v, want conflict", err)
+ }
+ })
+ }
+}
+
+func TestUpdateManagementPodRejectsCompletelyUnannotatedSameName(t *testing.T) {
+ scheme := testScheme(t)
+ existing := &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-uid", Namespace: "agent-a"}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(existing).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management})}
+ desired := managementPod("pod-uid", "agent-a", "work", "pod", "pod-uid")
+ desired.Labels[rlarkv1alpha1.PodLabelTaskName] = "task"
+ desired.Labels[rlarkv1alpha1.PodLabelTaskUID] = "task-uid"
+ desired.Spec.TaskName, desired.Spec.TaskNamespace = "task", "agent-a"
+
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desired); !apierrors.IsConflict(err) {
+ t.Fatalf("error = %v, want conflict", err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(existing), &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Labels) != 0 || got.Spec != (rlarkv1alpha1.PodSpec{}) || got.Status != (rlarkv1alpha1.PodStatus{}) {
+ t.Fatalf("unannotated same-name Pod was modified: labels=%v spec=%+v status=%+v", got.Labels, got.Spec, got.Status)
+ }
+}
+
+func TestUpdateManagementPodRejectsControllerOwnerMismatch(t *testing.T) {
+ scheme := testScheme(t)
+ controller := true
+ existing := &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{
+ Name: "pod-uid", Namespace: "agent-a",
+ Labels: map[string]string{rlarkv1alpha1.PodLabelDomain: "old", "other.io/label": "keep"},
+ OwnerReferences: []metav1.OwnerReference{{Kind: "Task", Name: "old", UID: "old", Controller: &controller}},
+ }}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(existing).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management})}
+ desired := existing.DeepCopy()
+ desired.Labels = map[string]string{rlarkv1alpha1.PodLabelAgentScope: "agent-a"}
+ desired.OwnerReferences = nil
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desired); !apierrors.IsConflict(err) {
+ t.Fatalf("error = %v, want conflict", err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(existing), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Labels[rlarkv1alpha1.PodLabelDomain] != "old" || got.Labels["other.io/label"] != "keep" || len(got.OwnerReferences) != 1 {
+ t.Fatalf("conflicting Pod was modified: labels=%v owners=%v", got.Labels, got.OwnerReferences)
+ }
+}
+
+func TestDelayedDeleteDoesNotDeleteReplacementUID(t *testing.T) {
+ scheme := testScheme(t)
+ current := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "uid-b"}}
+ old := managementPod("uid-a", "agent-a", "work", "pod", "uid-a")
+ newPod := managementPod("uid-b", "agent-a", "work", "pod", "uid-b")
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old, newPod).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(current).Build()
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: local, ManagementNamespace: "agent-a"})}
+
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "pod", Namespace: "work"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(newPod), &rlarkv1alpha1.Pod{}); err != nil {
+ t.Fatalf("replacement management Pod deleted: %v", err)
+ }
+}
+
+func TestUIDDeleteEventDeletesOnlyMatchingScopedPod(t *testing.T) {
+ scheme := testScheme(t)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "task-uid"}}
+ old := managementPod("uid-a", "agent-a", "work", "pod", "uid-a")
+ replacement := managementPod("uid-b", "agent-a", "work", "pod", "uid-b")
+ unrelated := managementPod("uid-c", "agent-a", "work", "other", "uid-c")
+ localReplacement := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "uid-b", Annotations: map[string]string{
+ "rlark.io/management-task-name": "task", "rlark.io/management-task-namespace": "agent-a", "rlark.io/management-task-uid": "task-uid",
+ }}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(old, replacement, unrelated, task).Build()
+ c := NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).WithObjects(localReplacement).Build(), ManagementNamespace: "agent-a"})
+ c.deleteUIDs[types.NamespacedName{Name: "pod", Namespace: "work"}] = []types.UID{"uid-a"}
+ r := &pushPodReconciler{c: c}
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "pod", Namespace: "work"}}); err != nil {
+ t.Fatal(err)
+ }
+ assertNotFound(t, management, old)
+ assertExists(t, management, replacement)
+ assertExists(t, management, unrelated)
+}
+
+func TestDeleteQueueAcknowledgesOnlySuccessfulItems(t *testing.T) {
+ scheme := testScheme(t)
+ first := managementPod("uid-a", "agent-a", "work", "pod", "uid-a")
+ failed := managementPod("uid-b", "agent-a", "work", "pod", "uid-b")
+ rest := managementPod("uid-c", "agent-a", "work", "pod", "uid-c")
+ baseClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(first, failed, rest).Build()
+ management := &failingDeleteClient{Client: baseClient, failName: "uid-b"}
+ c := NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).Build(), ManagementNamespace: "agent-a"})
+ key := types.NamespacedName{Name: "pod", Namespace: "work"}
+ c.deleteUIDs[key] = []types.UID{"uid-a", "uid-b", "uid-c"}
+ if _, err := (&pushPodReconciler{c: c}).Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err == nil {
+ t.Fatal("expected delete failure")
+ }
+ assertNotFound(t, baseClient, first)
+ assertExists(t, baseClient, failed)
+ assertExists(t, baseClient, rest)
+ got := c.pendingDeleteUIDs(key)
+ if len(got) != 2 || got[0] != "uid-b" || got[1] != "uid-c" {
+ t.Fatalf("pending UIDs = %v, want [uid-b uid-c]", got)
+ }
+}
+
+func TestOrphanSweepRecoveryScopeLegacyAndUIDRace(t *testing.T) {
+ scheme := testScheme(t)
+ localCurrent := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "reused", Namespace: "work", UID: "uid-b"}}
+ orphan := managementPod("orphan", "agent-a", "work", "gone", "uid-orphan")
+ stale := managementPod("uid-a", "agent-a", "work", "reused", "uid-a")
+ current := managementPod("uid-b", "agent-a", "work", "reused", "uid-b")
+ otherScope := managementPod("other", "agent-b", "work", "gone", "uid-other")
+ legacy := managementPod("legacy", "", "work", "gone", "")
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(orphan, stale, current, otherScope, legacy).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(localCurrent).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: local, ManagementNamespace: "agent-a"}), time.Minute, 1)
+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ sweeper.now = func() time.Time { return now }
+
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertExists(t, management, orphan)
+ assertExists(t, management, stale)
+ now = now.Add(16 * time.Minute)
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertNotFound(t, management, orphan)
+ assertNotFound(t, management, stale)
+ assertExists(t, management, current)
+ assertExists(t, management, otherScope)
+ assertExists(t, management, legacy)
+}
+
+func TestOrphanSweepAbortsOnLocalAPIError(t *testing.T) {
+ scheme := testScheme(t)
+ first := managementPod("first", "agent-a", "work", "first", "uid-first")
+ second := managementPod("second", "agent-a", "work", "second", "uid-second")
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(first, second).Build()
+ localErr := errors.New("local API unavailable")
+ local := &failingGetClient{Client: fake.NewClientBuilder().WithScheme(scheme).Build(), err: localErr}
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: local, ManagementNamespace: "agent-a"}), time.Minute, 1)
+
+ if err := sweeper.Sweep(context.Background()); !errors.Is(err, localErr) {
+ t.Fatalf("Sweep error = %v, want %v", err, localErr)
+ }
+ assertExists(t, management, first)
+ assertExists(t, management, second)
+}
+
+func TestOrphanSweepContinuesAfterObjectError(t *testing.T) {
+ scheme := testScheme(t)
+ first := managementPod("first", "agent-a", "work", "first", "uid-first")
+ second := managementPod("second", "agent-a", "work", "second", "uid-second")
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(first, second).Build()
+ localErr := errors.New("later local API unavailable")
+ localBase := fake.NewClientBuilder().WithScheme(scheme).Build()
+ local := &failingNamedGetClient{Client: localBase, name: "second", err: localErr}
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: local, ManagementNamespace: "agent-a"}), time.Minute, 10)
+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ sweeper.now = func() time.Time { return now }
+
+ if err := sweeper.Sweep(context.Background()); !errors.Is(err, localErr) {
+ t.Fatalf("Sweep error = %v, want %v", err, localErr)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(first), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[staleSinceAnnotation] != now.Format(time.RFC3339Nano) || got.Status.Phase != rlarkv1alpha1.PodPhaseUnknown {
+ t.Fatalf("failure for another Pod blocked cleanup: annotations=%v status=%+v", got.Annotations, got.Status)
+ }
+}
+
+func TestOrphanSweepRechecksBeforeDeleting(t *testing.T) {
+ scheme := testScheme(t)
+ pod := managementPod("pod-uid", "agent-a", "work", "pod", "pod-uid")
+ pod.Annotations = map[string]string{staleSinceAnnotation: time.Now().Add(-time.Hour).Format(time.RFC3339Nano)}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pod).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid"}}).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: local, ManagementNamespace: "agent-a"}), time.Minute, 10)
+ sweeper.now = func() time.Time { return time.Now() }
+
+ if err := sweeper.markOrDeleteStale(context.Background(), pod); err != nil {
+ t.Fatal(err)
+ }
+ assertExists(t, management, pod)
+}
+
+func TestOrphanSweepDeletesMissingOrReplacedTask(t *testing.T) {
+ scheme := testScheme(t)
+ local := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid"}}
+ missing := managementPod("missing", "agent-a", "work", "pod", "pod-uid")
+ missing.Spec.TaskName, missing.Spec.TaskNamespace = "gone", "agent-a"
+ missing.Labels[rlarkv1alpha1.PodLabelTaskName], missing.Labels[rlarkv1alpha1.PodLabelTaskUID] = "gone", "old-task"
+ replaced := managementPod("replaced", "agent-a", "work", "pod", "pod-uid")
+ replaced.Spec.TaskName, replaced.Spec.TaskNamespace = "task", "agent-a"
+ replaced.Labels[rlarkv1alpha1.PodLabelTaskName], replaced.Labels[rlarkv1alpha1.PodLabelTaskUID] = "task", "old-task"
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "new-task"}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(missing, replaced, task).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).WithObjects(local).Build(), ManagementNamespace: "agent-a"}), time.Minute, 10)
+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ sweeper.now = func() time.Time { return now }
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertExists(t, management, missing)
+ assertExists(t, management, replaced)
+ now = now.Add(16 * time.Minute)
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertNotFound(t, management, missing)
+ assertNotFound(t, management, replaced)
+}
+
+func TestOrphanSweepResetsMalformedStaleSince(t *testing.T) {
+ scheme := testScheme(t)
+ pod := managementPod("uid", "agent-a", "work", "gone", "uid")
+ pod.Annotations = map[string]string{staleSinceAnnotation: "invalid"}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(pod).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).Build(), ManagementNamespace: "agent-a"}), time.Minute, 10)
+ now := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)
+ sweeper.now = func() time.Time { return now }
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(pod), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[staleSinceAnnotation] != now.Format(time.RFC3339Nano) {
+ t.Fatalf("stale-since = %q, want %q", got.Annotations[staleSinceAnnotation], now.Format(time.RFC3339Nano))
+ }
+}
+
+func TestOrphanSweepRecoveryUpdatesStatusAndClearsStale(t *testing.T) {
+ scheme := testScheme(t)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "task-uid"}}
+ local := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid", Annotations: map[string]string{
+ "rlark.io/management-task-name": "task", "rlark.io/management-task-namespace": "agent-a", "rlark.io/management-task-uid": "task-uid",
+ }}, Spec: corev1.PodSpec{NodeName: "node-a"}, Status: corev1.PodStatus{Phase: corev1.PodRunning, PodIP: "10.0.0.2"}}
+ pod := managementPod("pod-uid", "agent-a", "work", "pod", "pod-uid")
+ pod.Labels[rlarkv1alpha1.PodLabelTaskName] = "task"
+ pod.Labels[rlarkv1alpha1.PodLabelTaskUID] = "task-uid"
+ pod.Spec.TaskName, pod.Spec.TaskNamespace = "task", "agent-a"
+ pod.Annotations = map[string]string{staleSinceAnnotation: time.Now().Add(-time.Minute).Format(time.RFC3339Nano)}
+ pod.Status = rlarkv1alpha1.PodStatus{Phase: rlarkv1alpha1.PodPhaseUnknown, Message: "Stale: local Pod is missing or its identity changed"}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(pod, task).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).WithObjects(local).Build(), ManagementNamespace: "agent-a"}), time.Minute, 10)
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(pod), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[staleSinceAnnotation] != "" || got.Status.Phase != rlarkv1alpha1.PodPhaseRunning || got.Status.Node != "node-a" || got.Status.IP != "10.0.0.2" || got.Status.Message != "" {
+ t.Fatalf("recovered Pod = annotations=%v status=%+v", got.Annotations, got.Status)
+ }
+}
+
+func TestOrphanSweepRecoversLegacyStatefulSetPodWithoutTaskUID(t *testing.T) {
+ scheme := testScheme(t)
+ if err := appsv1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "task-uid"}}
+ controller := true
+ statefulSet := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "workload", Namespace: "work", UID: "sts-uid", Annotations: map[string]string{
+ "rlark.io/management-task-name": "task", "rlark.io/management-task-namespace": "agent-a", "rlark.io/management-task-uid": "task-uid",
+ }}}
+ local := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid", Annotations: map[string]string{
+ "rlark.io/management-task-name": "task", "rlark.io/management-task-namespace": "agent-a",
+ }, OwnerReferences: []metav1.OwnerReference{{APIVersion: appsv1.SchemeGroupVersion.String(), Kind: "StatefulSet", Name: statefulSet.Name, UID: statefulSet.UID, Controller: &controller}}},
+ Spec: corev1.PodSpec{NodeName: "node-a"}, Status: corev1.PodStatus{Phase: corev1.PodRunning, PodIP: "10.0.0.2"}}
+ pod := managementPod("pod-uid", "agent-a", "work", "pod", "pod-uid")
+ pod.Labels[rlarkv1alpha1.PodLabelTaskName] = "task"
+ pod.Labels[rlarkv1alpha1.PodLabelTaskUID] = "task-uid"
+ pod.Spec.TaskName, pod.Spec.TaskNamespace = "task", "agent-a"
+ pod.Annotations = map[string]string{staleSinceAnnotation: time.Now().Add(-time.Minute).Format(time.RFC3339Nano)}
+ pod.Status = rlarkv1alpha1.PodStatus{Phase: rlarkv1alpha1.PodPhaseUnknown, Message: "stale"}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(pod, task).Build()
+ localClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(local, statefulSet).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: localClient, ManagementNamespace: "agent-a"}), time.Minute, 10)
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(pod), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[staleSinceAnnotation] != "" || got.Status.Phase != rlarkv1alpha1.PodPhaseRunning || got.Status.Node != "node-a" || got.Status.IP != "10.0.0.2" || got.Labels[rlarkv1alpha1.PodLabelTaskUID] != "task-uid" {
+ t.Fatalf("legacy Pod was not recovered: labels=%v annotations=%v status=%+v", got.Labels, got.Annotations, got.Status)
+ }
+}
+
+func TestOrphanSweepConservativelyBackfillsLegacyIdentity(t *testing.T) {
+ scheme := testScheme(t)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "agent-a", UID: "task-uid"}, Spec: rlarkv1alpha1.TaskSpec{Domain: "domain-a"}}
+ local := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "work", UID: "pod-uid", Annotations: map[string]string{
+ "rlark.io/management-task-name": "task", "rlark.io/management-task-namespace": "agent-a", "rlark.io/management-task-uid": "task-uid",
+ }}}
+ legacy := managementPod("pod-uid", "", "work", "pod", "")
+ legacy.Spec.TaskName, legacy.Spec.TaskNamespace, legacy.Spec.Domain = "task", "agent-a", "domain-a"
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Pod{}).WithObjects(legacy, task).Build()
+ sweeper := NewOrphanSweeper(NewPodController(base.Controller{ManagementClient: management, LocalKubeClient: fake.NewClientBuilder().WithScheme(scheme).WithObjects(local).Build(), ManagementNamespace: "agent-a"}), time.Minute, 10)
+ sweeper.now = func() time.Time { return time.Now() }
+ if err := sweeper.Sweep(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Pod
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(legacy), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Labels[rlarkv1alpha1.PodLabelAgentScope] != "agent-a" || got.Labels[rlarkv1alpha1.PodLabelTaskUID] != "task-uid" || got.Labels[rlarkv1alpha1.PodLabelLocalPodUID] != "pod-uid" {
+ t.Fatalf("legacy identity not backfilled: %v", got.Labels)
+ }
+}
+
+func managementPod(name, scope, namespace, localName, uid string) *rlarkv1alpha1.Pod {
+ labels := map[string]string{
+ rlarkv1alpha1.PodLabelLocalPodName: localName, rlarkv1alpha1.PodLabelLocalPodNamespace: namespace,
+ }
+ if scope != "" {
+ labels[rlarkv1alpha1.PodLabelAgentScope] = scope
+ }
+ if uid != "" {
+ labels[rlarkv1alpha1.PodLabelLocalPodUID] = uid
+ }
+ return &rlarkv1alpha1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "agent-a", Labels: labels}, Spec: rlarkv1alpha1.PodSpec{PodName: localName, PodNamespace: namespace}}
+}
+
+func assertExists(t *testing.T, c client.Client, pod *rlarkv1alpha1.Pod) {
+ t.Helper()
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(pod), &rlarkv1alpha1.Pod{}); err != nil {
+ t.Fatalf("expected %s to exist: %v", pod.Name, err)
+ }
+}
+
+func assertNotFound(t *testing.T, c client.Client, pod *rlarkv1alpha1.Pod) {
+ t.Helper()
+ err := c.Get(context.Background(), client.ObjectKeyFromObject(pod), &rlarkv1alpha1.Pod{})
+ if !apierrors.IsNotFound(err) {
+ t.Fatalf("expected %s to be deleted, got %v", pod.Name, err)
+ }
+}
+
+func (c *countingClient) Status() client.StatusWriter {
+ return &countingStatusWriter{SubResourceWriter: c.Client.Status(), parent: c}
+}
+
+type countingStatusWriter struct {
+ client.SubResourceWriter
+ parent *countingClient
+}
+
+func (w *countingStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
+ w.parent.statusPatches++
+ return w.SubResourceWriter.Patch(ctx, obj, patch, opts...)
+}
+
+func TestUpdateManagementPodSkipsUnchangedWrites(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ pod := &rlarkv1alpha1.Pod{}
+ pod.Name = "uid"
+ pod.Namespace = "cluster"
+ pod.Labels = map[string]string{"task": "task", rlarkv1alpha1.PodLabelAgentScope: "cluster"}
+ pod.Spec.TaskName = "task"
+ pod.Status.Phase = rlarkv1alpha1.PodPhaseRunning
+
+ wrapped := &countingClient{Client: fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithStatusSubresource(&rlarkv1alpha1.Pod{}).
+ WithObjects(pod).
+ Build()}
+ r := &pushPodReconciler{c: NewPodController(base.Controller{ManagementClient: wrapped})}
+ desiredUnchanged := pod.DeepCopy()
+
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desiredUnchanged); err != nil {
+ t.Fatal(err)
+ }
+ if wrapped.patches != 0 || wrapped.statusPatches != 0 {
+ t.Fatalf("unchanged Pod caused patches: spec=%d status=%d", wrapped.patches, wrapped.statusPatches)
+ }
+
+ desired := pod.DeepCopy()
+ desired.Status.IP = "10.0.0.1"
+ if _, err := r.updateManagementPod(context.Background(), logr.Discard(), desired); err != nil {
+ t.Fatal(err)
+ }
+ if wrapped.patches != 0 || wrapped.statusPatches != 1 {
+ t.Fatalf("status-only change caused patches: spec=%d status=%d, want 0/1", wrapped.patches, wrapped.statusPatches)
+ }
+}
diff --git a/apps/rlark/pkg/agent/controllers/pod/sweep.go b/apps/rlark/pkg/agent/controllers/pod/sweep.go
new file mode 100644
index 0000000..8c6a9ec
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/pod/sweep.go
@@ -0,0 +1,324 @@
+package pod
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+const staleSinceAnnotation = "rlark.io/stale-since"
+
+// OrphanSweeper repairs missed local Pod delete events. It only considers
+// management Pods explicitly owned by this agent and carrying a local UID.
+type OrphanSweeper struct {
+ c *Controller
+ interval time.Duration
+ pageSize int64
+ staleTTL time.Duration
+ now func() time.Time
+}
+
+func NewOrphanSweeper(c *Controller, interval time.Duration, pageSize int64, staleTTL ...time.Duration) *OrphanSweeper {
+ ttl := 15 * time.Minute
+ if len(staleTTL) > 0 {
+ ttl = staleTTL[0]
+ }
+ return &OrphanSweeper{c: c, interval: interval, pageSize: pageSize, staleTTL: ttl, now: time.Now}
+}
+
+func (s *OrphanSweeper) Start(ctx context.Context) error {
+ if err := s.Sweep(ctx); err != nil {
+ log.FromContext(ctx).Error(err, "initial management Pod orphan sweep failed")
+ }
+ ticker := time.NewTicker(s.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-ticker.C:
+ if err := s.Sweep(ctx); err != nil {
+ log.FromContext(ctx).Error(err, "management Pod orphan sweep failed")
+ }
+ }
+ }
+}
+
+func (s *OrphanSweeper) NeedLeaderElection() bool { return true }
+
+func (s *OrphanSweeper) Sweep(ctx context.Context) error {
+ var errs []error
+ if err := s.observeLegacyPods(ctx); err != nil {
+ errs = append(errs, err)
+ }
+ pods, err := s.listManagedPods(ctx)
+ if err != nil {
+ errs = append(errs, err)
+ return errors.Join(errs...)
+ }
+ for i := range pods {
+ pod := &pods[i]
+ uid := pod.Labels[rlarkv1alpha1.PodLabelLocalPodUID]
+ if uid == "" || pod.Spec.PodName == "" || pod.Spec.PodNamespace == "" {
+ continue
+ }
+ if orphan, err := s.taskIdentityGone(ctx, pod); err != nil {
+ errs = append(errs, fmt.Errorf("inspect management Pod %s: %w", pod.Name, err))
+ continue
+ } else if orphan {
+ if err := s.markOrDeleteStale(ctx, pod); err != nil {
+ errs = append(errs, fmt.Errorf("mark or delete management Pod %s: %w", pod.Name, err))
+ }
+ continue
+ }
+ var local corev1.Pod
+ err := s.c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: pod.Spec.PodName, Namespace: pod.Spec.PodNamespace}, &local)
+ if err == nil && string(local.UID) == uid {
+ action, err := s.observeLocalPod(ctx, &local)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("observe local Pod %s/%s: %w", local.Namespace, local.Name, err))
+ continue
+ }
+ if action != nil {
+ if err := action(ctx); err != nil {
+ errs = append(errs, fmt.Errorf("refresh management Pod %s: %w", pod.Name, err))
+ }
+ }
+ continue
+ }
+ if err != nil && client.IgnoreNotFound(err) != nil {
+ errs = append(errs, fmt.Errorf("get local Pod %s/%s: %w", pod.Spec.PodNamespace, pod.Spec.PodName, err))
+ continue
+ }
+ if err := s.markOrDeleteStale(ctx, pod); err != nil {
+ errs = append(errs, fmt.Errorf("mark or delete management Pod %s: %w", pod.Name, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func (s *OrphanSweeper) listManagedPods(ctx context.Context) ([]rlarkv1alpha1.Pod, error) {
+ var result []rlarkv1alpha1.Pod
+ continueToken := ""
+ for {
+ var pods rlarkv1alpha1.PodList
+ opts := &client.ListOptions{Namespace: s.c.ManagementNamespace, Limit: s.pageSize, Continue: continueToken}
+ client.MatchingLabels{rlarkv1alpha1.PodLabelAgentScope: s.c.ManagementNamespace}.ApplyToList(opts)
+ if err := s.c.ManagementClient.List(ctx, &pods, opts); err != nil {
+ return nil, err
+ }
+ result = append(result, pods.Items...)
+ continueToken = pods.Continue
+ if continueToken == "" {
+ return result, nil
+ }
+ }
+}
+
+func (s *OrphanSweeper) markOrDeleteStale(ctx context.Context, pod *rlarkv1alpha1.Pod) error {
+ if value := pod.Annotations[staleSinceAnnotation]; value != "" {
+ staleSince, err := time.Parse(time.RFC3339Nano, value)
+ if err == nil && s.now().Sub(staleSince) >= s.staleTTL {
+ current, stale, err := s.currentlyStale(ctx, pod)
+ if err != nil || !stale {
+ return err
+ }
+ if err := s.c.ManagementClient.Delete(ctx, current); err != nil && client.IgnoreNotFound(err) != nil {
+ return err
+ }
+ return nil
+ }
+ if err == nil {
+ return nil
+ }
+ }
+ original := pod.DeepCopy()
+ if pod.Annotations == nil {
+ pod.Annotations = map[string]string{}
+ }
+ pod.Annotations[staleSinceAnnotation] = s.now().UTC().Format(time.RFC3339Nano)
+ if err := s.c.ManagementClient.Patch(ctx, pod, client.MergeFrom(original)); err != nil {
+ return err
+ }
+ var current rlarkv1alpha1.Pod
+ if err := s.c.ManagementClient.Get(ctx, client.ObjectKeyFromObject(pod), ¤t); err != nil {
+ return err
+ }
+ statusOriginal := current.DeepCopy()
+ current.Status.Phase = rlarkv1alpha1.PodPhaseUnknown
+ current.Status.Message = "Stale: local Pod is missing or its identity changed"
+ if err := s.c.ManagementClient.Status().Patch(ctx, ¤t, client.MergeFrom(statusOriginal)); err != nil &&
+ client.IgnoreNotFound(err) != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *OrphanSweeper) currentlyStale(ctx context.Context, observed *rlarkv1alpha1.Pod) (*rlarkv1alpha1.Pod, bool, error) {
+ var current rlarkv1alpha1.Pod
+ if err := s.c.ManagementClient.Get(ctx, client.ObjectKeyFromObject(observed), ¤t); err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ if current.UID != observed.UID || current.Annotations[staleSinceAnnotation] != observed.Annotations[staleSinceAnnotation] {
+ return ¤t, false, nil
+ }
+ uid := current.Labels[rlarkv1alpha1.PodLabelLocalPodUID]
+ if uid == "" || current.Spec.PodName == "" || current.Spec.PodNamespace == "" {
+ return ¤t, false, nil
+ }
+ orphan, err := s.taskIdentityGone(ctx, ¤t)
+ if err != nil || orphan {
+ return ¤t, orphan, err
+ }
+ var local corev1.Pod
+ err = s.c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: current.Spec.PodName, Namespace: current.Spec.PodNamespace}, &local)
+ if err == nil {
+ return ¤t, string(local.UID) != uid, nil
+ }
+ if client.IgnoreNotFound(err) == nil {
+ return ¤t, true, nil
+ }
+ return ¤t, false, err
+}
+
+func (s *OrphanSweeper) observeLocalPod(ctx context.Context, local *corev1.Pod) (func(context.Context) error, error) {
+ annotations := local.Annotations
+ taskName := annotations["rlark.io/management-task-name"]
+ taskNamespace := annotations["rlark.io/management-task-namespace"]
+ taskUID := annotations["rlark.io/management-task-uid"]
+ if taskName == "" || taskNamespace == "" {
+ return nil, nil
+ }
+ if taskUID == "" {
+ resolvedUID, err := s.c.resolveLegacyTaskUID(ctx, local, taskName, taskNamespace)
+ if err != nil {
+ return nil, err
+ }
+ if resolvedUID == "" {
+ return nil, nil
+ }
+ taskUID = resolvedUID
+ }
+ reconciler := &pushPodReconciler{c: s.c}
+ desired, err := reconciler.buildRLarkPodFromK8sPod(ctx, local, taskName, taskNamespace, taskUID)
+ if err != nil {
+ return nil, err
+ }
+ desired.Annotations = map[string]string{staleSinceAnnotation: ""}
+ return func(ctx context.Context) error {
+ _, err := reconciler.updateManagementPod(ctx, log.FromContext(ctx), desired)
+ return err
+ }, nil
+}
+
+func (s *OrphanSweeper) observeLegacyPods(ctx context.Context) error {
+ var errs []error
+ continueToken := ""
+ for {
+ var pods rlarkv1alpha1.PodList
+ if err := s.c.ManagementClient.List(ctx, &pods, &client.ListOptions{Namespace: s.c.ManagementNamespace, Limit: s.pageSize, Continue: continueToken}); err != nil {
+ errs = append(errs, err)
+ return errors.Join(errs...)
+ }
+ for i := range pods.Items {
+ pod := &pods.Items[i]
+ backfill, err := s.observeLegacyIdentity(ctx, pod)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("inspect legacy management Pod %s: %w", pod.Name, err))
+ continue
+ }
+ if backfill != nil {
+ if err := backfill(ctx); err != nil {
+ errs = append(errs, fmt.Errorf("backfill legacy management Pod %s: %w", pod.Name, err))
+ }
+ }
+ }
+ continueToken = pods.Continue
+ if continueToken == "" {
+ return errors.Join(errs...)
+ }
+ }
+}
+
+func (s *OrphanSweeper) taskIdentityGone(ctx context.Context, pod *rlarkv1alpha1.Pod) (bool, error) {
+ taskName := pod.Labels[rlarkv1alpha1.PodLabelTaskName]
+ taskUID := pod.Labels[rlarkv1alpha1.PodLabelTaskUID]
+ if taskName == "" || taskUID == "" || pod.Spec.TaskNamespace == "" {
+ return false, nil
+ }
+ var task rlarkv1alpha1.Task
+ err := s.c.ManagementClient.Get(ctx, types.NamespacedName{Name: taskName, Namespace: pod.Spec.TaskNamespace}, &task)
+ if err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return true, nil
+ }
+ return false, fmt.Errorf("verify Task for management Pod %s: %w", pod.Name, err)
+ }
+ if string(task.UID) != taskUID {
+ return true, nil
+ }
+ domain := pod.Labels[rlarkv1alpha1.PodLabelDomain]
+ return domain != "" && task.Spec.Domain != domain, nil
+}
+
+// backfillLegacyIdentity adopts only the old UID-named form when the local Pod
+// and current management Task prove a unique identity. Ambiguous legacy
+// objects are deliberately left untouched for operator review.
+func (s *OrphanSweeper) observeLegacyIdentity(ctx context.Context, pod *rlarkv1alpha1.Pod) (func(context.Context) error, error) {
+ if pod.Labels[rlarkv1alpha1.PodLabelAgentScope] != "" || pod.Labels[rlarkv1alpha1.PodLabelLocalPodUID] != "" ||
+ pod.Spec.PodName == "" || pod.Spec.PodNamespace == "" || pod.Spec.TaskName == "" || pod.Spec.TaskNamespace != s.c.ManagementNamespace {
+ return nil, nil
+ }
+ var local corev1.Pod
+ if err := s.c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: pod.Spec.PodName, Namespace: pod.Spec.PodNamespace}, &local); err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if pod.Name != string(local.UID) {
+ return nil, nil
+ }
+ annotations := local.Annotations
+ if annotations["rlark.io/management-task-name"] != pod.Spec.TaskName || annotations["rlark.io/management-task-namespace"] != pod.Spec.TaskNamespace || annotations["rlark.io/management-task-uid"] == "" {
+ return nil, nil
+ }
+ var task rlarkv1alpha1.Task
+ if err := s.c.ManagementClient.Get(ctx, types.NamespacedName{Name: pod.Spec.TaskName, Namespace: pod.Spec.TaskNamespace}, &task); err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if string(task.UID) != annotations["rlark.io/management-task-uid"] || (pod.Spec.Domain != "" && task.Spec.Domain != pod.Spec.Domain) {
+ return nil, nil
+ }
+ pod = pod.DeepCopy()
+ return func(ctx context.Context) error {
+ original := pod.DeepCopy()
+ if pod.Labels == nil {
+ pod.Labels = map[string]string{}
+ }
+ pod.Labels[rlarkv1alpha1.PodLabelAgentScope] = s.c.ManagementNamespace
+ pod.Labels[rlarkv1alpha1.PodLabelLocalPodUID] = string(local.UID)
+ pod.Labels[rlarkv1alpha1.PodLabelLocalPodName] = local.Name
+ pod.Labels[rlarkv1alpha1.PodLabelLocalPodNamespace] = local.Namespace
+ pod.Labels[rlarkv1alpha1.PodLabelTaskName] = task.Name
+ pod.Labels[rlarkv1alpha1.PodLabelTaskUID] = string(task.UID)
+ if pod.Spec.Domain != "" {
+ pod.Labels[rlarkv1alpha1.PodLabelDomain] = pod.Spec.Domain
+ }
+ return s.c.ManagementClient.Patch(ctx, pod, client.MergeFrom(original))
+ }, nil
+}
diff --git a/apps/rlark/pkg/agent/controllers/task/controller.go b/apps/rlark/pkg/agent/controllers/task/controller.go
index 758b605..0d23d9b 100644
--- a/apps/rlark/pkg/agent/controllers/task/controller.go
+++ b/apps/rlark/pkg/agent/controllers/task/controller.go
@@ -39,15 +39,15 @@ func (c *Controller) AsPullReconciler() base.KubernetesReconciler {
// AsKubePushReconcilers is an exported method.
func (c *Controller) AsKubePushReconcilers() map[base.KubernetesResource]base.KubernetesReconciler {
return map[base.KubernetesResource]base.KubernetesReconciler{
- base.KubernetesResource{
+ {
Name: "task-deployment",
Type: &appsv1.Deployment{},
}: &pushDeploymentReconciler{c: c},
- base.KubernetesResource{
+ {
Name: "task-daemonset",
Type: &appsv1.DaemonSet{},
}: &pushDaemonSetReconciler{c: c},
- base.KubernetesResource{
+ {
Name: "task-statefulset",
Type: &appsv1.StatefulSet{},
}: &pushStatefulSetReconciler{c: c},
diff --git a/apps/rlark/pkg/agent/controllers/task/files/ray_head.sh b/apps/rlark/pkg/agent/controllers/task/files/ray_head.sh
index 955f043..f325e25 100644
--- a/apps/rlark/pkg/agent/controllers/task/files/ray_head.sh
+++ b/apps/rlark/pkg/agent/controllers/task/files/ray_head.sh
@@ -8,15 +8,15 @@ if [ -n "$WAIT_NETWORK_SCRIPT" ]; then
bash "$WAIT_NETWORK_SCRIPT" "network"
fi
-# Phase 0: Start rlark-sshd if the binary is available
-if [ -n "$RLARK_SSH_PUBLIC_KEY" ] && [ -x /sshd/rlark-sshd ]; then
- nohup /sshd/rlark-sshd -port 22 > /tmp/rlark-sshd.log 2>&1 &
- echo "rlark-sshd started on port 22"
+# Phase 0: Start rlark-tools sshd if the binary is available
+if [ -n "$RLARK_SSH_PUBLIC_KEY" ] && [ -x /rlark-tools/rlark-tools ]; then
+ nohup /rlark-tools/rlark-tools sshd -port 22 > /tmp/rlark-sshd.log 2>&1 &
+ echo "rlark-tools sshd started on port 22"
elif [ -n "$RLARK_SSH_PUBLIC_KEY" ]; then
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$RLARK_SSH_PUBLIC_KEY" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
- echo "SSH public key injected into authorized_keys (fallback: no rlark-sshd binary)"
+ echo "SSH public key injected into authorized_keys (fallback: no rlark-tools binary)"
fi
# Phase 1: Prepare script (executed before Ray starts)
@@ -52,6 +52,14 @@ if [ -n "$RLARK_DOMAIN" ]; then
done
fi
+# Extend health-check / heartbeat timeouts so that the cluster survives
+# transient network interruptions (e.g. node-agent restarts) without Ray
+# marking nodes dead prematurely.
+export RAY_health_check_period_ms="${RAY_health_check_period_ms:-5000}"
+export RAY_health_check_timeout_ms="${RAY_health_check_timeout_ms:-15000}"
+export RAY_health_check_failure_threshold="${RAY_health_check_failure_threshold:-30}"
+export RAY_num_heartbeats_timeout="${RAY_num_heartbeats_timeout:-60}"
+
ray start --head --dashboard-host=0.0.0.0 --disable-usage-stats \
--node-ip-address="$NODE_IP" --port=${RLARK_RAY_PORT} --temp-dir $TEMP_DIR &
RAY_HEAD_PID=$!
diff --git a/apps/rlark/pkg/agent/controllers/task/files/ray_worker.sh b/apps/rlark/pkg/agent/controllers/task/files/ray_worker.sh
index ba84db9..314ae64 100644
--- a/apps/rlark/pkg/agent/controllers/task/files/ray_worker.sh
+++ b/apps/rlark/pkg/agent/controllers/task/files/ray_worker.sh
@@ -9,9 +9,9 @@ if [ -n "$WAIT_NETWORK_SCRIPT" ]; then
fi
# Phase 0: Inject SSH public key into authorized_keys
-if [ -n "$RLARK_SSH_PUBLIC_KEY" ] && [ -x /sshd/rlark-sshd ]; then
- nohup /sshd/rlark-sshd -port 2222 > /tmp/rlark-sshd.log 2>&1 &
- echo "rlark-sshd started on port 2222"
+if [ -n "$RLARK_SSH_PUBLIC_KEY" ] && [ -x /rlark-tools/rlark-tools ]; then
+ nohup /rlark-tools/rlark-tools sshd -port 2222 > /tmp/rlark-sshd.log 2>&1 &
+ echo "rlark-tools sshd started on port 2222"
elif [ -n "$RLARK_SSH_PUBLIC_KEY" ]; then
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$RLARK_SSH_PUBLIC_KEY" >> ~/.ssh/authorized_keys
@@ -58,6 +58,14 @@ if command -v getent >/dev/null 2>&1; then
done
fi
+# Extend health-check / heartbeat timeouts so that the cluster survives
+# transient network interruptions (e.g. node-agent restarts) without Ray
+# marking nodes dead prematurely.
+export RAY_health_check_period_ms="${RAY_health_check_period_ms:-5000}"
+export RAY_health_check_timeout_ms="${RAY_health_check_timeout_ms:-15000}"
+export RAY_health_check_failure_threshold="${RAY_health_check_failure_threshold:-30}"
+export RAY_num_heartbeats_timeout="${RAY_num_heartbeats_timeout:-60}"
+
ray start --address="${HEAD_IP:-$RLARK_HEAD_ADDRESS}:${RLARK_RAY_PORT}" --temp-dir $TEMP_DIR --block &
RAY_WORKER_PID=$!
diff --git a/apps/rlark/pkg/agent/controllers/task/lifecycle_test.go b/apps/rlark/pkg/agent/controllers/task/lifecycle_test.go
new file mode 100644
index 0000000..0e98435
--- /dev/null
+++ b/apps/rlark/pkg/agent/controllers/task/lifecycle_test.go
@@ -0,0 +1,667 @@
+package task
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/go-logr/logr"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
+)
+
+type failingListClient struct {
+ client.Client
+ failEvents bool
+}
+
+func (c *failingListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
+ if _, ok := list.(*corev1.PodList); ok && !c.failEvents {
+ return fmt.Errorf("pod list failed")
+ }
+ if _, ok := list.(*corev1.EventList); ok && c.failEvents {
+ return fmt.Errorf("event list failed")
+ }
+ return c.Client.List(ctx, list, opts...)
+}
+
+func TestWorkloadIdentityPreservesUserLabels(t *testing.T) {
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "task-uid"}}
+ template := corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"user": "label"}}}
+ applyWorkloadIdentity(&template, task)
+ spec := &rlarkv1alpha1.KubernetesWorkloadSpec{Template: template}
+ deploy := buildDeployment(task, spec)
+
+ if deploy.Spec.Template.Labels["user"] != "label" {
+ t.Fatal("user label was not preserved")
+ }
+ if got := deploy.Spec.Selector.MatchLabels; !reflect.DeepEqual(got, map[string]string{ManagementTaskUIDLabel: "task-uid"}) {
+ t.Fatalf("selector = %v", got)
+ }
+ if deploy.Spec.Template.Labels[ManagementTaskUIDLabel] != "task-uid" || deploy.Spec.Template.Annotations[ManagementTaskUIDAnnotation] != "task-uid" {
+ t.Fatal("template is missing Task UID identity")
+ }
+}
+
+func TestCreateOrUpdateWorkloadWaitsForOldKind(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ old := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Finalizers: []string{"hold"}, Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid",
+ }}}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}
+ desired := buildStatefulSet(task, &rlarkv1alpha1.KubernetesWorkloadSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ManagementTaskUIDLabel: "uid"}}}})
+
+ result, err := r.createOrUpdateWorkload(context.Background(), task, "StatefulSet", &appsv1.StatefulSet{}, desired, func(client.Object, client.Object) {})
+ if err != nil || result.RequeueAfter == 0 {
+ t.Fatalf("result=%v err=%v", result, err)
+ }
+ var created appsv1.StatefulSet
+ err = local.Get(context.Background(), client.ObjectKeyFromObject(desired), &created)
+ if err == nil {
+ t.Fatal("new kind was created before old kind disappeared")
+ }
+ if client.IgnoreNotFound(err) != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCreateOrUpdateWorkloadAdoptsLegacyKindsWithoutChangingSpec(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 4}, Spec: rlarkv1alpha1.TaskSpec{Domain: "domain"}}
+ one := int32(1)
+ for _, tt := range []struct {
+ name string
+ existing client.Object
+ desired client.Object
+ }{
+ {"Deployment", &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: &one, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task", "legacy": "true"}}}}}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{})},
+ {"DaemonSet", &appsv1.DaemonSet{Spec: appsv1.DaemonSetSpec{Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task", "legacy": "true"}}}}}, buildDaemonSet(task, &rlarkv1alpha1.KubernetesWorkloadSpec{})},
+ {"StatefulSet", &appsv1.StatefulSet{Spec: appsv1.StatefulSetSpec{Replicas: &one, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task", "legacy": "true"}}}}}, buildStatefulSet(task, &rlarkv1alpha1.KubernetesWorkloadSpec{})},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.existing.SetName("task")
+ tt.existing.SetNamespace("rlark-system")
+ tt.existing.SetAnnotations(map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default"})
+ before := tt.existing.DeepCopyObject().(client.Object)
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ applied := false
+ result, err := r.createOrUpdateWorkload(context.Background(), task, tt.name, tt.existing.DeepCopyObject().(client.Object), tt.desired, func(client.Object, client.Object) { applied = true })
+ if err != nil || result.RequeueAfter != 0 || applied {
+ t.Fatalf("result=%v err=%v", result, err)
+ }
+ got := tt.existing.DeepCopyObject().(client.Object)
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(tt.existing), got); err != nil || !got.GetDeletionTimestamp().IsZero() {
+ t.Fatalf("legacy workload was deleted: %v", err)
+ }
+ if !reflect.DeepEqual(workloadLabelSelector(got), workloadLabelSelector(before)) || !reflect.DeepEqual(workloadTemplate(got), workloadTemplate(before)) || !reflect.DeepEqual(workloadReplicas(got), workloadReplicas(before)) {
+ t.Fatal("legacy workload selector, template, or replicas changed")
+ }
+ if got.GetAnnotations()[ManagementTaskUIDAnnotation] != "uid" || got.GetAnnotations()[ManagementTaskAdoptedGenerationAnnotation] != "4" || got.GetAnnotations()[ManagementTaskGenerationAnnotation] != "" || got.GetAnnotations()[ManagementTaskDomainAnnotation] != "domain" {
+ t.Fatalf("annotations = %v", got.GetAnnotations())
+ }
+ })
+ }
+}
+
+func TestCreateOrUpdateWorkloadAdoptsUnmarkedLegacyWorkload(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ existing := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system"}, Spec: appsv1.DeploymentSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}},
+ Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task"}}},
+ }}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}
+ result, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) {})
+ if err != nil || result.RequeueAfter != 0 {
+ t.Fatalf("result=%v err=%v", result, err)
+ }
+ var got appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(existing), &got); err != nil || got.Annotations[ManagementTaskUIDAnnotation] != "uid" {
+ t.Fatalf("unmarked legacy workload was not adopted: %v annotations=%v", err, got.Annotations)
+ }
+}
+
+func TestCreateOrUpdateWorkloadKeepsMatchingUIDWithLegacySelector(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ one := int32(1)
+ existing := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid",
+ }}, Spec: appsv1.DeploymentSpec{
+ Replicas: &one,
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}},
+ Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task"}}},
+ }}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 2}}
+ applied := false
+ result, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) { applied = true })
+ if err != nil || result.RequeueAfter != 0 || applied {
+ t.Fatalf("result=%v applied=%v err=%v", result, applied, err)
+ }
+ var got appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(existing), &got); err != nil || !reflect.DeepEqual(got.Spec, existing.Spec) {
+ t.Fatalf("partially migrated workload changed: err=%v spec=%v", err, got.Spec)
+ }
+ if got.Annotations[ManagementTaskAdoptedGenerationAnnotation] != "2" {
+ t.Fatalf("adopted generation annotation = %q", got.Annotations[ManagementTaskAdoptedGenerationAnnotation])
+ }
+}
+
+func TestLegacyWorkloadAppliesNextGenerationWithoutChangingSelector(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ for _, kind := range []string{"Deployment", "DaemonSet", "StatefulSet"} {
+ t.Run(kind, func(t *testing.T) {
+ one, three := int32(1), int32(3)
+ selector := &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: metav1.LabelSelectorOpIn, Values: []string{"worker"}}}}
+ template := corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "task", "tier": "worker"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main", Image: "old", Command: []string{"old"}}}}}
+ meta := metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default"}}
+ var existing client.Object
+ switch kind {
+ case "Deployment":
+ existing = &appsv1.Deployment{ObjectMeta: meta, Spec: appsv1.DeploymentSpec{Replicas: &one, Selector: selector, Template: template}}
+ case "DaemonSet":
+ existing = &appsv1.DaemonSet{ObjectMeta: meta, Spec: appsv1.DaemonSetSpec{Selector: selector, Template: template}}
+ case "StatefulSet":
+ existing = &appsv1.StatefulSet{ObjectMeta: meta, Spec: appsv1.StatefulSetSpec{Replicas: &one, Selector: selector, Template: template}}
+ }
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 4}}
+ desiredSpec := &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: &three, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"tier": "worker"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main", Image: "new", Command: []string{"new"}}}}}}
+ reconcileKind := func() error {
+ var err error
+ switch kind {
+ case "Deployment":
+ _, err = r.createOrUpdateDeployment(context.Background(), task, desiredSpec)
+ case "DaemonSet":
+ _, err = r.createOrUpdateDaemonSet(context.Background(), task, desiredSpec)
+ case "StatefulSet":
+ _, err = r.createOrUpdateStatefulSet(context.Background(), task, desiredSpec)
+ }
+ return err
+ }
+ if err := reconcileKind(); err != nil {
+ t.Fatal(err)
+ }
+ task.Generation++
+ if err := reconcileKind(); err != nil {
+ t.Fatal(err)
+ }
+ got := existing.DeepCopyObject().(client.Object)
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(existing), got); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(workloadLabelSelector(got), selector) || workloadTemplate(got).Labels["app"] != "task" || workloadTemplate(got).Spec.Containers[0].Image != "new" || workloadTemplate(got).Spec.Containers[0].Command[0] != "new" {
+ t.Fatalf("workload update was incompatible: selector=%v template=%v", workloadLabelSelector(got), workloadTemplate(got))
+ }
+ if replicas := workloadReplicas(got); kind != "DaemonSet" && (replicas == nil || *replicas != 3) {
+ t.Fatalf("replicas = %v", replicas)
+ }
+ })
+ }
+}
+
+func TestLegacySelectorIsPreservedWithoutDeletion(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ existing := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default"}}, Spec: appsv1.DeploymentSpec{Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"other": "value"}}}}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}
+ desired := buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{})
+ result, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, desired, func(client.Object, client.Object) {})
+ if err != nil || result.RequeueAfter != 0 {
+ t.Fatalf("result=%v err=%v", result, err)
+ }
+ var got appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(existing), &got); err != nil || !got.DeletionTimestamp.IsZero() {
+ t.Fatalf("mismatched workload was deleted: %v", err)
+ }
+}
+
+func workloadReplicas(obj client.Object) *int32 {
+ switch workload := obj.(type) {
+ case *appsv1.Deployment:
+ return workload.Spec.Replicas
+ case *appsv1.StatefulSet:
+ return workload.Spec.Replicas
+ default:
+ return nil
+ }
+}
+
+func TestKindMigrationNeverDeletesConflict(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ conflict := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "other",
+ }}}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(conflict).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "StatefulSet", &appsv1.StatefulSet{}, buildStatefulSet(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) {}); err == nil {
+ t.Fatal("expected conflicting workload error")
+ }
+ var got appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(conflict), &got); err != nil || !got.DeletionTimestamp.IsZero() {
+ t.Fatalf("conflicting workload was deleted: %v", err)
+ }
+}
+
+func TestPushOwnsTaskFencesOldKind(t *testing.T) {
+ task := &rlarkv1alpha1.Task{
+ ObjectMeta: metav1.ObjectMeta{UID: "uid"},
+ Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeKubernetes, Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{
+ Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: rlarkv1alpha1.KubernetesWorkloadStatefulSet},
+ }},
+ }
+ workload := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ManagementTaskUIDAnnotation: "uid"}}}
+ if pushOwnsTask(task, string(rlarkv1alpha1.AgentTypeKubernetes), workload, rlarkv1alpha1.KubernetesWorkloadDeployment) {
+ t.Fatal("old Deployment push controller retained status ownership")
+ }
+}
+
+func TestPushOwnsTaskAcceptsMissingUIDAndRejectsConflict(t *testing.T) {
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}, Spec: rlarkv1alpha1.TaskSpec{
+ AgentType: rlarkv1alpha1.AgentTypeKubernetes,
+ Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: rlarkv1alpha1.KubernetesWorkloadDeployment}},
+ }}
+ legacy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default"}}}
+ if !pushOwnsTask(task, string(rlarkv1alpha1.AgentTypeKubernetes), legacy, rlarkv1alpha1.KubernetesWorkloadDeployment) {
+ t.Fatal("legacy workload without UID did not retain status ownership")
+ }
+ legacy.Annotations[ManagementTaskUIDAnnotation] = "other"
+ if pushOwnsTask(task, string(rlarkv1alpha1.AgentTypeKubernetes), legacy, rlarkv1alpha1.KubernetesWorkloadDeployment) {
+ t.Fatal("conflicting UID retained status ownership")
+ }
+}
+
+func TestPushReconcilersObserveLegacyWorkloadsWithoutUID(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ kind rlarkv1alpha1.KubernetesWorkloadKind
+ workload func(metav1.ObjectMeta, *metav1.LabelSelector) client.Object
+ reconcile func(*Controller) base.KubernetesReconciler
+ ownerKind string
+ }{
+ {"Deployment", rlarkv1alpha1.KubernetesWorkloadDeployment, func(meta metav1.ObjectMeta, selector *metav1.LabelSelector) client.Object {
+ return &appsv1.Deployment{ObjectMeta: meta, Spec: appsv1.DeploymentSpec{Selector: selector}}
+ }, func(c *Controller) base.KubernetesReconciler { return &pushDeploymentReconciler{c: c} }, "Deployment"},
+ {"DaemonSet", rlarkv1alpha1.KubernetesWorkloadDaemonSet, func(meta metav1.ObjectMeta, selector *metav1.LabelSelector) client.Object {
+ return &appsv1.DaemonSet{ObjectMeta: meta, Spec: appsv1.DaemonSetSpec{Selector: selector}}
+ }, func(c *Controller) base.KubernetesReconciler { return &pushDaemonSetReconciler{c: c} }, "DaemonSet"},
+ {"StatefulSet", rlarkv1alpha1.KubernetesWorkloadStatefulSet, func(meta metav1.ObjectMeta, selector *metav1.LabelSelector) client.Object {
+ return &appsv1.StatefulSet{ObjectMeta: meta, Spec: appsv1.StatefulSetSpec{Selector: selector}}
+ }, func(c *Controller) base.KubernetesReconciler { return &pushStatefulSetReconciler{c: c} }, "StatefulSet"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "task-uid"}, Spec: rlarkv1alpha1.TaskSpec{
+ AgentType: rlarkv1alpha1.AgentTypeKubernetes,
+ Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: tt.kind}},
+ }}
+ selector := &metav1.LabelSelector{MatchLabels: map[string]string{"app": "task"}}
+ workload := tt.workload(metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", UID: "workload-uid", Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default",
+ }}, selector)
+ controller := true
+ pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "rlark-system", Labels: selector.MatchLabels, OwnerReferences: []metav1.OwnerReference{{
+ Kind: tt.ownerKind, Name: "task", UID: "workload-uid", Controller: &controller,
+ }}}, Spec: corev1.PodSpec{NodeName: "node-a"}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(task).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, pod).Build()
+ controllerConfig := NewTaskController(base.Controller{AgentType: string(rlarkv1alpha1.AgentTypeKubernetes), ManagementClient: management, LocalKubeClient: local})
+ if _, err := tt.reconcile(controllerConfig).Reconcile(context.Background(), reconcile.Request{NamespacedName: client.ObjectKeyFromObject(workload)}); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil || !reflect.DeepEqual(got.Status.ObservedNodes, []string{"node-a"}) {
+ t.Fatalf("observedNodes=%v err=%v", got.Status.ObservedNodes, err)
+ }
+ })
+ }
+}
+
+func TestListTaskPodsValidatesOwnerUID(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ controller := true
+ sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "ns", UID: "current"}}
+ pods := []client.Object{
+ &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "current", Namespace: "ns", Labels: map[string]string{"x": "y"}, OwnerReferences: []metav1.OwnerReference{{Kind: "StatefulSet", Name: "task", UID: "current", Controller: &controller}}}},
+ &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "stale", Namespace: "ns", Labels: map[string]string{"x": "y"}, OwnerReferences: []metav1.OwnerReference{{Kind: "StatefulSet", Name: "task", UID: "old", Controller: &controller}}}},
+ }
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pods...).Build()
+ got, err := listTaskPods(context.Background(), local, sts, map[string]string{"x": "y"})
+ if err != nil || len(got) != 1 || got[0].Name != "current" {
+ t.Fatalf("pods=%v err=%v", got, err)
+ }
+}
+
+func TestRolloutReducersAndObservedNodes(t *testing.T) {
+ one := int32(1)
+ deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Generation: 2}, Spec: appsv1.DeploymentSpec{Replicas: &one}, Status: appsv1.DeploymentStatus{ObservedGeneration: 1, ReadyReplicas: 1, Replicas: 1, UnavailableReplicas: 1}}
+ if phase, _ := deploymentStatusPhase(deploy); phase != rlarkv1alpha1.TaskPhasePending {
+ t.Fatalf("stale deployment phase = %s", phase)
+ }
+ deploy.Status = appsv1.DeploymentStatus{ObservedGeneration: 2, Replicas: 1, UpdatedReplicas: 1, ReadyReplicas: 1, AvailableReplicas: 1}
+ if phase, _ := deploymentStatusPhase(deploy); phase != rlarkv1alpha1.TaskPhaseRunning {
+ t.Fatalf("ready deployment phase = %s", phase)
+ }
+ if got := podNodeNames([]corev1.Pod{{Spec: corev1.PodSpec{NodeName: "b"}}, {Spec: corev1.PodSpec{NodeName: "a"}}, {Spec: corev1.PodSpec{NodeName: "b"}}}); !reflect.DeepEqual(got, []string{"a", "b"}) {
+ t.Fatalf("nodes = %v", got)
+ }
+}
+
+func TestExactRolloutPredicates(t *testing.T) {
+ one := int32(1)
+ deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Generation: 1}, Spec: appsv1.DeploymentSpec{Replicas: &one}, Status: appsv1.DeploymentStatus{
+ ObservedGeneration: 1, Replicas: 1, UpdatedReplicas: 1, ReadyReplicas: 1,
+ }}
+ if phase, _ := deploymentStatusPhase(deploy); phase != rlarkv1alpha1.TaskPhasePending {
+ t.Fatalf("unavailable Deployment phase = %s", phase)
+ }
+ deploy.Status.Conditions = []appsv1.DeploymentCondition{{Type: appsv1.DeploymentProgressing, Status: corev1.ConditionFalse, Reason: "ProgressDeadlineExceeded", Message: "deadline"}}
+ if phase, message := deploymentStatusPhase(deploy); phase != rlarkv1alpha1.TaskPhaseFailed || message != "deadline" {
+ t.Fatalf("deadline Deployment phase=%s message=%q", phase, message)
+ }
+
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ local := fake.NewClientBuilder().WithScheme(scheme).Build()
+ sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Generation: 1}, Spec: appsv1.StatefulSetSpec{Replicas: &one, Selector: &metav1.LabelSelector{}}, Status: appsv1.StatefulSetStatus{
+ ObservedGeneration: 1, CurrentReplicas: 1, UpdatedReplicas: 1, ReadyReplicas: 1, CurrentRevision: "old", UpdateRevision: "new",
+ }}
+ if phase, _, _, err := statefulSetPhase(context.Background(), local, sts); err != nil || phase != rlarkv1alpha1.TaskPhasePending {
+ t.Fatalf("revision-mismatched StatefulSet phase=%s err=%v", phase, err)
+ }
+ ds := &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Generation: 1}, Spec: appsv1.DaemonSetSpec{Selector: &metav1.LabelSelector{}}, Status: appsv1.DaemonSetStatus{
+ ObservedGeneration: 1, DesiredNumberScheduled: 1, UpdatedNumberScheduled: 1, NumberReady: 1,
+ }}
+ if phase, _, _, err := daemonSetPhase(context.Background(), local, ds); err != nil || phase != rlarkv1alpha1.TaskPhasePending {
+ t.Fatalf("unavailable DaemonSet phase=%s err=%v", phase, err)
+ }
+}
+
+func TestMergeWorkloadAnnotationsPreservesUnrelated(t *testing.T) {
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 2}}
+ deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"example.com/user": "keep"}}}
+ mergeWorkloadAnnotations(deploy, task)
+ if deploy.Annotations["example.com/user"] != "keep" || deploy.Annotations[ManagementTaskUIDAnnotation] != "uid" {
+ t.Fatalf("annotations = %v", deploy.Annotations)
+ }
+}
+
+func TestPhasePropagatesPodAndEventListErrors(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ controller := true
+ deploy := &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "ns", UID: "uid", Generation: 1},
+ Spec: appsv1.DeploymentSpec{Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"x": "y"}}},
+ Status: appsv1.DeploymentStatus{ObservedGeneration: 1},
+ }
+ baseClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+ Name: "pod", Namespace: "ns", UID: "pod-uid", Labels: map[string]string{"x": "y"},
+ OwnerReferences: []metav1.OwnerReference{{Kind: "Deployment", Name: "task", UID: "uid", Controller: &controller}},
+ }}).Build()
+ if _, _, _, err := deploymentPhase(context.Background(), &failingListClient{Client: baseClient}, deploy); err == nil {
+ t.Fatal("pod list error was swallowed")
+ }
+ if _, _, _, err := deploymentPhase(context.Background(), &failingListClient{Client: baseClient, failEvents: true}, deploy); err == nil {
+ t.Fatal("event list error was swallowed")
+ }
+}
+
+func TestFinalizerAddedOnlyAfterResponsibilityValidation(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default"}, Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeDocker}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: string(rlarkv1alpha1.AgentTypeKubernetes), ManagementClient: management})}
+ _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "task", Namespace: "default"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Task
+ _ = management.Get(context.Background(), client.ObjectKeyFromObject(task), &got)
+ if len(got.Finalizers) != 0 {
+ t.Fatalf("unexpected finalizers: %v", got.Finalizers)
+ }
+}
+
+func TestWrongAgentDoesNotCleanOrRemoveClaimedFinalizer(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ now := metav1.Now()
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "task", Namespace: "default", UID: "uid", DeletionTimestamp: &now, Finalizers: []string{ManagementTaskFinalizer},
+ Annotations: map[string]string{ManagementTaskClaimantAnnotation: "other/Kubernetes", ManagementTaskClaimedDomainAnnotation: "domain"},
+ }, Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "domain"}}
+ workload := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Finalizers: []string{"hold"}, Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid",
+ }}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{
+ AgentType: string(rlarkv1alpha1.AgentTypeKubernetes), ManagementNamespace: "shared", ManagementClient: management, LocalKubeClient: local,
+ })}
+
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: client.ObjectKeyFromObject(task)}); err != nil {
+ t.Fatal(err)
+ }
+ var gotTask rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &gotTask); err != nil || !containsString(gotTask.Finalizers, ManagementTaskFinalizer) {
+ t.Fatalf("wrong agent removed finalizer: %v", err)
+ }
+ var gotWorkload appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(workload), &gotWorkload); err != nil || !gotWorkload.DeletionTimestamp.IsZero() {
+ t.Fatalf("wrong agent deleted workload: %v", err)
+ }
+}
+
+func TestFinalizerWithoutClaimantIsClaimedBeforeNormalReconcile(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Finalizers: []string{ManagementTaskFinalizer}}, Spec: rlarkv1alpha1.TaskSpec{
+ AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "domain", Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: rlarkv1alpha1.KubernetesWorkloadDeployment}},
+ }}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "cluster", ManagementClient: management, LocalKubeClient: local})}
+
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: client.ObjectKeyFromObject(task)}); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[ManagementTaskClaimantAnnotation] != "cluster/Kubernetes" || got.Annotations[ManagementTaskClaimedDomainAnnotation] != "domain" {
+ t.Fatalf("claim annotations = %#v", got.Annotations)
+ }
+ var workload appsv1.Deployment
+ if err := local.Get(context.Background(), types.NamespacedName{Name: "task", Namespace: "rlark-system"}, &workload); err != nil {
+ t.Fatalf("legacy Task was not reconciled: %v", err)
+ }
+}
+
+func TestDeletingFinalizerWithoutClaimantIsClaimedAndCleaned(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ now := metav1.Now()
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", DeletionTimestamp: &now, Finalizers: []string{ManagementTaskFinalizer}}, Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "domain"}}
+ workload := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskUIDAnnotation: "uid"}}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "cluster", ManagementClient: management, LocalKubeClient: local})}
+ request := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(task)}
+
+ for range 2 {
+ if _, err := r.Reconcile(context.Background(), request); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); client.IgnoreNotFound(err) != nil {
+ t.Fatal(err)
+ }
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(workload), &appsv1.Deployment{}); !errors.IsNotFound(err) {
+ t.Fatalf("legacy workload was not cleaned: %v", err)
+ }
+}
+
+func TestClaimantIdentityDoesNotDependOnLeaderIdentity(t *testing.T) {
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "cluster"})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ManagementTaskClaimantAnnotation: "cluster/Kubernetes"}}}
+ if r.claimantIdentity() != "cluster/Kubernetes" || !r.isTaskClaimant(task) {
+ t.Fatal("stable namespace/type scope did not retain ownership")
+ }
+ for _, claimant := range []string{"other/Kubernetes", "cluster/Docker"} {
+ task.Annotations[ManagementTaskClaimantAnnotation] = claimant
+ if r.isTaskClaimant(task) {
+ t.Fatalf("wrong scope %q was accepted", claimant)
+ }
+ }
+}
+
+func TestClaimantHandoffAfterDesiredSpecChanges(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ mutate func(*rlarkv1alpha1.Task)
+ }{
+ {"AgentType", func(task *rlarkv1alpha1.Task) { task.Spec.AgentType = rlarkv1alpha1.AgentTypeDocker }},
+ {"domain", func(task *rlarkv1alpha1.Task) { task.Spec.Domain = "new-domain" }},
+ {"unsupported workload", func(task *rlarkv1alpha1.Task) { task.Spec.Kubernetes.Workload.Kind = "Unsupported" }},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Finalizers: []string{ManagementTaskFinalizer}, Annotations: map[string]string{ManagementTaskClaimantAnnotation: "namespace/Kubernetes", ManagementTaskClaimedDomainAnnotation: "old-domain"}}, Spec: rlarkv1alpha1.TaskSpec{
+ AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "old-domain", Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: rlarkv1alpha1.KubernetesWorkloadDeployment}},
+ }}
+ tt.mutate(task)
+ workload := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskUIDAnnotation: "uid"}}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "namespace", ManagementClient: management, LocalKubeClient: local})}
+ request := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(task)}
+ for range 2 {
+ if _, err := r.Reconcile(context.Background(), request); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if containsString(got.Finalizers, ManagementTaskFinalizer) || got.Annotations[ManagementTaskClaimantAnnotation] != "" {
+ t.Fatalf("claim was not released: finalizers=%v annotations=%v", got.Finalizers, got.Annotations)
+ }
+ var deleted appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(workload), &deleted); client.IgnoreNotFound(err) != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestLegacyClaimDomainHandoffOnlyByExactScope(t *testing.T) {
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ManagementTaskClaimantAnnotation: "shared/Kubernetes/old-domain"}}, Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "new-domain", Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Kind: rlarkv1alpha1.KubernetesWorkloadDeployment}}}}
+ owner := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "shared"})}
+ other := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "other"})}
+ if !owner.isTaskClaimant(task) || owner.claimedDomain(task) != "old-domain" || owner.canRealize(task) {
+ t.Fatal("legacy claimant did not retain ownership for domain handoff")
+ }
+ if other.isTaskClaimant(task) {
+ t.Fatal("wrong namespace matched legacy claim")
+ }
+}
+
+func TestUnownedConflictDoesNotRetainFinalizer(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ now := metav1.Now()
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", DeletionTimestamp: &now, Finalizers: []string{ManagementTaskFinalizer}, Annotations: map[string]string{ManagementTaskClaimantAnnotation: "namespace/Kubernetes", ManagementTaskClaimedDomainAnnotation: "domain"}}, Spec: rlarkv1alpha1.TaskSpec{AgentType: rlarkv1alpha1.AgentTypeKubernetes, Domain: "domain"}}
+ conflict := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskUIDAnnotation: "other"}}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(conflict).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{AgentType: "Kubernetes", ManagementNamespace: "namespace", ManagementClient: management, LocalKubeClient: local})}
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: client.ObjectKeyFromObject(task)}); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); client.IgnoreNotFound(err) != nil {
+ t.Fatal(err)
+ }
+ var remaining appsv1.Deployment
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(conflict), &remaining); err != nil || !remaining.DeletionTimestamp.IsZero() {
+ t.Fatalf("unowned conflict was modified: %v", err)
+ }
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
+func TestUpdateStatusClearsObservedNodesSnapshot(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default"}, Status: rlarkv1alpha1.TaskStatus{
+ Phase: rlarkv1alpha1.TaskPhaseRunning, ObservedNodes: []string{"old"},
+ }}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(task).WithObjects(task).Build()
+ var current rlarkv1alpha1.Task
+ _ = management.Get(context.Background(), client.ObjectKeyFromObject(task), ¤t)
+ if _, err := updateMgmtTaskStatus(context.Background(), logr.Discard(), management, ¤t, rlarkv1alpha1.TaskPhaseRunning, "", []string{}); err != nil {
+ t.Fatal(err)
+ }
+ _ = management.Get(context.Background(), client.ObjectKeyFromObject(task), ¤t)
+ if len(current.Status.ObservedNodes) != 0 {
+ t.Fatalf("observedNodes was not cleared: %v", current.Status.ObservedNodes)
+ }
+}
diff --git a/apps/rlark/pkg/agent/controllers/task/pull.go b/apps/rlark/pkg/agent/controllers/task/pull.go
index ffad445..c0c2825 100644
--- a/apps/rlark/pkg/agent/controllers/task/pull.go
+++ b/apps/rlark/pkg/agent/controllers/task/pull.go
@@ -1,19 +1,29 @@
package task
import (
+ "bytes"
"context"
"fmt"
+ "io"
+ "os"
+ "reflect"
"slices"
+ "sort"
+ "strconv"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/remotecommand"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
@@ -27,18 +37,25 @@ import (
// Constants used by the package.
const (
- ManagementTaskNameAnnotation = "rlark.io/management-task-name"
- ManagementTaskNamespaceAnnotation = "rlark.io/management-task-namespace"
- ManagementTaskResourceVersionAnnotation = "rlark.io/management-task-resource-version"
- ManagementTaskUIDAnnotation = "rlark.io/management-task-uid"
- ManagementTaskDomainAnnotation = "rlark.io/management-task-domain"
- ManagementTaskFinalizer = "rlark.io/agent-cleanup"
- PVCTaskLabel = "rlark.io/task"
- PVCOwnerAnnotation = "rlark.io/pvc-owner"
- PVCOwnerTaskAnnotation = "rlark.io/pvc-owner-task"
- RestartedAtAnnotation = "rlark.io/restarted-at"
- StoppedAnnotation = "rlark.io/stopped"
- CleanupRequeueInterval = 2 * time.Second
+ ManagementTaskNameAnnotation = "rlark.io/management-task-name"
+ ManagementTaskNamespaceAnnotation = "rlark.io/management-task-namespace"
+ ManagementTaskGenerationAnnotation = "rlark.io/management-task-generation"
+ ManagementTaskAdoptedGenerationAnnotation = "rlark.io/management-task-adopted-generation"
+ ManagementTaskUIDAnnotation = "rlark.io/management-task-uid"
+ ManagementTaskDomainAnnotation = "rlark.io/management-task-domain"
+ ManagementTaskClaimantAnnotation = "rlark.io/management-task-claimant"
+ ManagementTaskClaimedDomainAnnotation = "rlark.io/management-task-claimed-domain"
+ ManagementTaskUIDLabel = "rlark.io/management-task-uid"
+ ManagementTaskFinalizer = "rlark.io/agent-cleanup"
+ PVCTaskLabel = "rlark.io/task"
+ PVCOwnerAnnotation = "rlark.io/pvc-owner"
+ PVCOwnerTaskAnnotation = "rlark.io/pvc-owner-task"
+ RestartedAtAnnotation = "rlark.io/restarted-at"
+ StoppedAnnotation = "rlark.io/stopped"
+ CleanupRequeueInterval = 2 * time.Second
+ UnsafeTaskPrivilegesEnv = "RLARK_ENABLE_UNSAFE_TASK_PRIVILEGES"
+ WorkloadProgressingCondition = "Progressing"
+ WorkloadKindMigrationReason = "WorkloadKindMigration"
)
// pullReconciler watches management Tasks and creates workloads on local cluster.
@@ -46,6 +63,14 @@ type pullReconciler struct {
c *Controller
}
+type workloadOwnership int
+
+const (
+ workloadConflict workloadOwnership = iota
+ workloadLegacyOwned
+ workloadOwned
+)
+
// Reconcile reconciles the resource.
func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
logger := log.FromContext(ctx).WithValues("task", req.NamespacedName)
@@ -53,25 +78,36 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
var mgmtTask rlarkv1alpha1.Task
if err := r.c.ManagementClient.Get(ctx, req.NamespacedName, &mgmtTask); err != nil {
if client.IgnoreNotFound(err) == nil {
- logger.Info("management Task deleted, cleaning up local workload")
- pending, cleanupErr := r.cleanupWorkload(ctx, req.Name, "rlark-system")
- if cleanupErr != nil {
- logger.Error(cleanupErr, "failed to clean up workload")
- return reconcile.Result{}, cleanupErr
- }
- if pending {
- return reconcile.Result{RequeueAfter: CleanupRequeueInterval}, nil
- }
+ // A recreated Task may have the same name. Without the deleted Task UID,
+ // name-only cleanup cannot prove ownership and is therefore unsafe.
+ logger.Info("management Task deleted; skipping unverified name-only cleanup")
return reconcile.Result{}, nil
}
return reconcile.Result{}, err
}
- // Handle deletion: clean up local workload and remove finalizer
- if mgmtTask.DeletionTimestamp != nil {
+ claimed := slices.Contains(mgmtTask.Finalizers, ManagementTaskFinalizer)
+ claimantMissing := claimed && mgmtTask.Annotations[ManagementTaskClaimantAnnotation] == ""
+ isClaimant := claimed && (claimantMissing || r.isTaskClaimant(&mgmtTask))
+ if claimantMissing && (mgmtTask.DeletionTimestamp != nil || r.canRealize(&mgmtTask)) {
+ if mgmtTask.Annotations == nil {
+ mgmtTask.Annotations = map[string]string{}
+ }
+ mgmtTask.Annotations[ManagementTaskClaimantAnnotation] = r.claimantIdentity()
+ mgmtTask.Annotations[ManagementTaskClaimedDomainAnnotation] = mgmtTask.Spec.Domain
+ if err := r.c.ManagementClient.Update(ctx, &mgmtTask); err != nil {
+ logger.Error(err, "failed to persist Task claim")
+ return reconcile.Result{}, err
+ }
+ }
+ if mgmtTask.DeletionTimestamp != nil || (isClaimant && !r.canRealize(&mgmtTask)) {
+ if !isClaimant {
+ logger.Info("Task cleanup is claimed by another agent, skipping")
+ return reconcile.Result{}, nil
+ }
logger.Info("management Task being deleted, cleaning up local workload")
workloadNs := getWorkloadNamespace(&mgmtTask)
- pending, err := r.cleanupWorkload(ctx, mgmtTask.Name, workloadNs)
+ pending, err := r.cleanupWorkload(ctx, &mgmtTask, workloadNs)
if err != nil {
logger.Error(err, "failed to clean up workload")
return reconcile.Result{}, err
@@ -82,6 +118,8 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
mgmtTask.Finalizers = slices.DeleteFunc(mgmtTask.Finalizers, func(s string) bool {
return s == ManagementTaskFinalizer
})
+ delete(mgmtTask.Annotations, ManagementTaskClaimantAnnotation)
+ delete(mgmtTask.Annotations, ManagementTaskClaimedDomainAnnotation)
if err := r.c.ManagementClient.Update(ctx, &mgmtTask); err != nil {
logger.Error(err, "failed to remove finalizer")
return reconcile.Result{}, err
@@ -89,16 +127,6 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
return reconcile.Result{}, nil
}
- // Add finalizer if not present
- if !slices.Contains(mgmtTask.Finalizers, ManagementTaskFinalizer) {
- mgmtTask.Finalizers = append(mgmtTask.Finalizers, ManagementTaskFinalizer)
- if err := r.c.ManagementClient.Update(ctx, &mgmtTask); err != nil {
- logger.Error(err, "failed to add finalizer")
- return reconcile.Result{}, err
- }
- return reconcile.Result{Requeue: true}, nil
- }
-
// Compare mgmtTask.Spec.AgentType with controller AgentType — skip if mismatch
if mgmtTask.Spec.AgentType != rlarkv1alpha1.AgentType(r.c.AgentType) {
logger.Info(fmt.Sprintf("Task AgentType %s does not match controller AgentType %s, skipping", mgmtTask.Spec.AgentType, r.c.AgentType))
@@ -109,6 +137,31 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
logger.Info("Task has no Kubernetes workload spec, skipping")
return reconcile.Result{}, nil
}
+ if !validWorkloadKind(mgmtTask.Spec.Kubernetes.Workload.Kind) {
+ logger.Info("Task has unsupported Kubernetes workload kind, skipping")
+ return reconcile.Result{}, nil
+ }
+
+ // Only claim Tasks this controller can actually realize. Existing finalizers
+ // without ownership metadata predate claimant annotations and are adopted by
+ // the responsible agent before any normal work or cleanup.
+ if !claimed {
+ if mgmtTask.Annotations == nil {
+ mgmtTask.Annotations = map[string]string{}
+ }
+ mgmtTask.Annotations[ManagementTaskClaimantAnnotation] = r.claimantIdentity()
+ mgmtTask.Annotations[ManagementTaskClaimedDomainAnnotation] = mgmtTask.Spec.Domain
+ if !claimed {
+ mgmtTask.Finalizers = append(mgmtTask.Finalizers, ManagementTaskFinalizer)
+ }
+ if err := r.c.ManagementClient.Update(ctx, &mgmtTask); err != nil {
+ logger.Error(err, "failed to persist Task claim")
+ return reconcile.Result{}, err
+ }
+ } else if !r.isTaskClaimant(&mgmtTask) {
+ logger.Info("Task is claimed by another agent, skipping")
+ return reconcile.Result{}, nil
+ }
workloadSpec := mgmtTask.Spec.Kubernetes.Workload
workloadNamespace := getWorkloadNamespace(&mgmtTask)
@@ -122,7 +175,7 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
return updateMgmtTaskStatus(ctx, logger, r.c.ManagementClient, &mgmtTask, rlarkv1alpha1.TaskPhasePending, "", nil)
}
if mgmtTask.Annotations[StoppedAnnotation] == "true" || restarting {
- pending, err := r.cleanupWorkload(ctx, mgmtTask.Name, workloadNamespace)
+ pending, err := r.cleanupWorkload(ctx, &mgmtTask, workloadNamespace)
if err != nil {
return reconcile.Result{}, err
}
@@ -135,6 +188,16 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
}
applyTemplateMutations(&workloadSpec.Template, &mgmtTask, r.c.Image)
+ applyWorkloadIdentity(&workloadSpec.Template, &mgmtTask)
+
+ if mgmtTask.Status.Phase == rlarkv1alpha1.TaskPhaseRunning {
+ if mgmtTask.Spec.SSHPublicKey != "" {
+ if err := r.appendSSHPublicKeyToRunningPods(ctx, &mgmtTask, workloadNamespace, workloadSpec.Template.Labels); err != nil {
+ return reconcile.Result{}, err
+ }
+ }
+ return reconcile.Result{}, nil
+ }
if err := r.ensureImagePullSecrets(ctx, &workloadSpec.Template, workloadNamespace); err != nil {
return reconcile.Result{}, fmt.Errorf("ensure image pull secrets: %w", err)
@@ -163,22 +226,35 @@ func (r *pullReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
}
}
+func (r *pullReconciler) canRealize(task *rlarkv1alpha1.Task) bool {
+ return task.Spec.AgentType == rlarkv1alpha1.AgentType(r.c.AgentType) && task.Spec.Domain == r.claimedDomain(task) && task.Spec.Kubernetes != nil &&
+ task.Spec.Kubernetes.Workload != nil && validWorkloadKind(task.Spec.Kubernetes.Workload.Kind)
+}
+
// createOrUpdateWorkload is a generic helper that handles the create-or-update pattern for all workload types.
// existingObj is a pre-allocated empty object for Get, newObj is the fully-built object for Create,
// applyUpdate is a callback that applies spec changes to the existing object when the management Task's
-// ResourceVersion has changed (indicating the Task spec was updated).
+// generation has changed (indicating the Task spec was updated).
func (r *pullReconciler) createOrUpdateWorkload(
ctx context.Context,
mgmtTask *rlarkv1alpha1.Task,
workloadKind string,
existingObj client.Object,
newObj client.Object,
- applyUpdate func(existingObj client.Object),
+ applyUpdate func(existingObj, desiredObj client.Object),
) (reconcile.Result, error) {
logger := log.FromContext(ctx)
key := types.NamespacedName{Name: newObj.GetName(), Namespace: newObj.GetNamespace()}
- err := r.c.LocalKubeClient.Get(ctx, key, existingObj)
+ pending, err := r.deleteOtherWorkloadKinds(ctx, key, workloadKind, mgmtTask)
+ if err != nil {
+ return reconcile.Result{}, err
+ }
+ if pending {
+ return reconcile.Result{RequeueAfter: CleanupRequeueInterval}, nil
+ }
+
+ err = r.c.LocalKubeClient.Get(ctx, key, existingObj)
if err != nil && client.IgnoreNotFound(err) != nil {
return reconcile.Result{}, err
}
@@ -189,19 +265,331 @@ func (r *pullReconciler) createOrUpdateWorkload(
logger.Error(err, fmt.Sprintf("failed to create %s", workloadKind))
return reconcile.Result{}, err
}
- return reconcile.Result{}, nil
+ return reconcile.Result{}, r.clearWorkloadKindMigration(ctx, mgmtTask)
}
+ ownership := workloadOwnershipForTask(existingObj, mgmtTask)
+ if ownership == workloadConflict {
+ return reconcile.Result{}, fmt.Errorf("%s %s is not owned by Task UID %q", workloadKind, key, mgmtTask.UID)
+ }
+ selectorChanged := workloadSelectorChanged(existingObj, newObj)
+ if selectorChanged && !workloadSelectorIdentityOnlyMigration(existingObj, newObj) {
+ return reconcile.Result{}, fmt.Errorf("%s %s has an immutable selector that differs from the desired selector", workloadKind, key)
+ }
annotations := existingObj.GetAnnotations()
- if annotations == nil || annotations[ManagementTaskResourceVersionAnnotation] != mgmtTask.ResourceVersion {
- logger.Info(fmt.Sprintf("%s spec changed (Task ResourceVersion mismatch), updating", workloadKind))
- applyUpdate(existingObj)
+ missingGenerationBaseline := annotations[ManagementTaskGenerationAnnotation] == "" && annotations[ManagementTaskAdoptedGenerationAnnotation] == ""
+ if ownership == workloadLegacyOwned || (selectorChanged && missingGenerationBaseline) {
+ adoptWorkloadAnnotations(existingObj, mgmtTask)
+ if err := r.c.LocalKubeClient.Update(ctx, existingObj); err != nil {
+ return reconcile.Result{}, fmt.Errorf("backfill %s identity metadata: %w", workloadKind, err)
+ }
+ return reconcile.Result{}, r.clearWorkloadKindMigration(ctx, mgmtTask)
+ }
+
+ annotations = existingObj.GetAnnotations()
+ if annotations == nil || (annotations[ManagementTaskGenerationAnnotation] != taskGeneration(mgmtTask) && annotations[ManagementTaskAdoptedGenerationAnnotation] != taskGeneration(mgmtTask)) {
+ logger.Info(fmt.Sprintf("%s spec changed (Task generation mismatch), updating", workloadKind))
+ if err := prepareWorkloadTemplateUpdate(existingObj, newObj); err != nil {
+ return reconcile.Result{}, fmt.Errorf("update %s %s: %w", workloadKind, key, err)
+ }
+ applyUpdate(existingObj, newObj)
+ annotations := existingObj.GetAnnotations()
+ delete(annotations, ManagementTaskAdoptedGenerationAnnotation)
+ existingObj.SetAnnotations(annotations)
if err := r.c.LocalKubeClient.Update(ctx, existingObj); err != nil {
logger.Error(err, fmt.Sprintf("failed to update %s", workloadKind))
return reconcile.Result{}, err
}
}
- return reconcile.Result{}, nil
+ return reconcile.Result{}, r.clearWorkloadKindMigration(ctx, mgmtTask)
+}
+
+func validWorkloadKind(kind rlarkv1alpha1.KubernetesWorkloadKind) bool {
+ switch kind {
+ case rlarkv1alpha1.KubernetesWorkloadDeployment, rlarkv1alpha1.KubernetesWorkloadDaemonSet, rlarkv1alpha1.KubernetesWorkloadStatefulSet:
+ return true
+ default:
+ return false
+ }
+}
+
+func (r *pullReconciler) deleteOtherWorkloadKinds(ctx context.Context, key types.NamespacedName, wantKind string, task *rlarkv1alpha1.Task) (bool, error) {
+ for kind, obj := range map[string]client.Object{
+ "Deployment": &appsv1.Deployment{}, "DaemonSet": &appsv1.DaemonSet{}, "StatefulSet": &appsv1.StatefulSet{},
+ } {
+ if kind == wantKind {
+ continue
+ }
+ err := r.c.LocalKubeClient.Get(ctx, key, obj)
+ if errors.IsNotFound(err) {
+ continue
+ }
+ if err != nil {
+ return false, err
+ }
+ if workloadOwnershipForTask(obj, task) == workloadConflict {
+ return false, fmt.Errorf("cannot migrate Task %s: conflicting %s %s is not owned by Task UID %q", client.ObjectKeyFromObject(task), kind, key, task.UID)
+ }
+ if err := r.markWorkloadKindMigration(ctx, task, kind, wantKind); err != nil {
+ return false, err
+ }
+ if obj.GetDeletionTimestamp().IsZero() {
+ if err := r.c.LocalKubeClient.Delete(ctx, obj, client.PropagationPolicy(metav1.DeletePropagationForeground)); err != nil && !errors.IsNotFound(err) {
+ return false, err
+ }
+ }
+ return true, nil
+ }
+ return false, nil
+}
+
+func (r *pullReconciler) markWorkloadKindMigration(ctx context.Context, task *rlarkv1alpha1.Task, fromKind, toKind string) error {
+ desired := task.DeepCopy()
+ desired.Status.Phase = rlarkv1alpha1.TaskPhasePending
+ desired.Status.ObservedNodes = nil
+ apimeta.SetStatusCondition(&desired.Status.Conditions, metav1.Condition{
+ Type: WorkloadProgressingCondition,
+ Status: metav1.ConditionTrue,
+ Reason: WorkloadKindMigrationReason,
+ Message: fmt.Sprintf("Migrating workload from %s to %s", fromKind, toKind),
+ ObservedGeneration: task.Generation,
+ })
+ if reflect.DeepEqual(task.Status, desired.Status) {
+ return nil
+ }
+ if r.c.ManagementClient == nil {
+ task.Status = desired.Status
+ return nil
+ }
+ if err := r.c.ManagementClient.Status().Patch(ctx, desired, client.MergeFrom(task)); err != nil {
+ return fmt.Errorf("mark Task workload kind migration: %w", err)
+ }
+ task.Status = desired.Status
+ return nil
+}
+
+func (r *pullReconciler) clearWorkloadKindMigration(ctx context.Context, task *rlarkv1alpha1.Task) error {
+ condition := apimeta.FindStatusCondition(task.Status.Conditions, WorkloadProgressingCondition)
+ if condition == nil || condition.Reason != WorkloadKindMigrationReason {
+ return nil
+ }
+ desired := task.DeepCopy()
+ apimeta.RemoveStatusCondition(&desired.Status.Conditions, WorkloadProgressingCondition)
+ if r.c.ManagementClient == nil {
+ task.Status = desired.Status
+ return nil
+ }
+ if err := r.c.ManagementClient.Status().Patch(ctx, desired, client.MergeFrom(task)); err != nil {
+ return fmt.Errorf("clear Task workload kind migration: %w", err)
+ }
+ task.Status = desired.Status
+ return nil
+}
+
+func workloadOwnershipForTask(obj client.Object, task *rlarkv1alpha1.Task) workloadOwnership {
+ a := obj.GetAnnotations()
+ if uid := a[ManagementTaskUIDAnnotation]; uid != "" {
+ if task.UID == "" || uid != string(task.UID) {
+ return workloadConflict
+ }
+ } else {
+ if name := a[ManagementTaskNameAnnotation]; name != "" && name != task.Name {
+ return workloadConflict
+ }
+ if namespace := a[ManagementTaskNamespaceAnnotation]; namespace != "" && namespace != task.Namespace {
+ return workloadConflict
+ }
+ // Pre-identity workloads may have no management annotations. Their
+ // deterministic name and namespace are the only available identity, so
+ // accept them unless stronger metadata contradicts the current Task.
+ return workloadLegacyOwned
+ }
+ if name := a[ManagementTaskNameAnnotation]; name != "" && name != task.Name {
+ return workloadConflict
+ }
+ if namespace := a[ManagementTaskNamespaceAnnotation]; namespace != "" && namespace != task.Namespace {
+ return workloadConflict
+ }
+ return workloadOwned
+}
+
+func (r *pullReconciler) claimantIdentity() string {
+ return r.c.ManagementNamespace + "/" + r.c.AgentType
+}
+
+func (r *pullReconciler) isTaskClaimant(task *rlarkv1alpha1.Task) bool {
+ claimant := task.Annotations[ManagementTaskClaimantAnnotation]
+ if claimant == r.claimantIdentity() {
+ return true
+ }
+ // The previous implementation appended the claimed domain to the stable
+ // namespace/runtime scope. Accept it without consulting leader identity.
+ parts := strings.Split(claimant, "/")
+ return len(parts) == 3 && parts[0] == r.c.ManagementNamespace && parts[1] == r.c.AgentType
+}
+
+func (r *pullReconciler) claimedDomain(task *rlarkv1alpha1.Task) string {
+ if domain, ok := task.Annotations[ManagementTaskClaimedDomainAnnotation]; ok {
+ return domain
+ }
+ parts := strings.Split(task.Annotations[ManagementTaskClaimantAnnotation], "/")
+ if len(parts) == 3 {
+ return parts[2]
+ }
+ return task.Spec.Domain
+}
+
+func workloadSelectorChanged(existing, desired client.Object) bool {
+ return !reflect.DeepEqual(workloadLabelSelector(existing), workloadLabelSelector(desired))
+}
+
+func workloadLabelSelector(obj client.Object) *metav1.LabelSelector {
+ switch o := obj.(type) {
+ case *appsv1.Deployment:
+ return o.Spec.Selector
+ case *appsv1.StatefulSet:
+ return o.Spec.Selector
+ case *appsv1.DaemonSet:
+ return o.Spec.Selector
+ default:
+ return nil
+ }
+}
+
+func workloadTemplate(obj client.Object) corev1.PodTemplateSpec {
+ switch workload := obj.(type) {
+ case *appsv1.Deployment:
+ return workload.Spec.Template
+ case *appsv1.DaemonSet:
+ return workload.Spec.Template
+ case *appsv1.StatefulSet:
+ return workload.Spec.Template
+ default:
+ return corev1.PodTemplateSpec{}
+ }
+}
+
+// workloadSelectorIdentityOnlyMigration identifies selectors that differ only
+// because the desired workload adds the Task UID identity selector.
+func workloadSelectorIdentityOnlyMigration(existing, desired client.Object) bool {
+ existingSelector := workloadLabelSelector(existing)
+ desiredSelector := workloadLabelSelector(desired)
+ if existingSelector == nil || desiredSelector == nil {
+ return false
+ }
+ uid := desiredSelector.MatchLabels[ManagementTaskUIDLabel]
+ if uid == "" || len(desiredSelector.MatchLabels) != 1 || len(desiredSelector.MatchExpressions) != 0 ||
+ existingSelector.MatchLabels[ManagementTaskUIDLabel] != "" {
+ return false
+ }
+ for _, expression := range existingSelector.MatchExpressions {
+ if expression.Key == ManagementTaskUIDLabel {
+ return false
+ }
+ }
+ _, err := metav1.LabelSelectorAsSelector(existingSelector)
+ return err == nil
+}
+
+func prepareWorkloadTemplateUpdate(existing, desired client.Object) error {
+ selector := workloadLabelSelector(existing)
+ if selector == nil {
+ return nil
+ }
+ template := workloadTemplate(desired)
+ if template.Labels == nil {
+ template.Labels = map[string]string{}
+ }
+ for key, value := range selector.MatchLabels {
+ template.Labels[key] = value
+ }
+ parsed, err := metav1.LabelSelectorAsSelector(selector)
+ if err != nil {
+ return fmt.Errorf("invalid existing selector: %w", err)
+ }
+ if !parsed.Matches(labels.Set(template.Labels)) {
+ return fmt.Errorf("desired pod template labels do not satisfy existing selector")
+ }
+ setWorkloadTemplate(desired, template)
+ return nil
+}
+
+func setWorkloadTemplate(obj client.Object, template corev1.PodTemplateSpec) {
+ switch workload := obj.(type) {
+ case *appsv1.Deployment:
+ workload.Spec.Template = template
+ case *appsv1.DaemonSet:
+ workload.Spec.Template = template
+ case *appsv1.StatefulSet:
+ workload.Spec.Template = template
+ }
+}
+
+func (r *pullReconciler) appendSSHPublicKeyToRunningPods(ctx context.Context, mgmtTask *rlarkv1alpha1.Task, namespace string, podLabels map[string]string) error {
+ if r.c.LocalKubeConfig == nil {
+ return fmt.Errorf("local Kubernetes config is required to update SSH authorized keys")
+ }
+
+ var pods corev1.PodList
+ if err := r.c.LocalKubeClient.List(ctx, &pods, client.InNamespace(namespace), client.MatchingLabels(podLabels)); err != nil {
+ return fmt.Errorf("list running task Pods: %w", err)
+ }
+
+ for _, pod := range pods.Items {
+ if pod.Status.Phase != corev1.PodRunning || !containerRunning(&pod, "main") {
+ continue
+ }
+ if err := appendSSHPublicKeyToPod(ctx, r.c.LocalKubeConfig, pod.Namespace, pod.Name, mgmtTask.Spec.SSHPublicKey); err != nil {
+ return fmt.Errorf("append SSH public key to Pod %s: %w", pod.Name, err)
+ }
+ }
+ return nil
+}
+
+func containerRunning(pod *corev1.Pod, container string) bool {
+ for _, status := range pod.Status.ContainerStatuses {
+ if status.Name == container {
+ return status.State.Running != nil
+ }
+ }
+ return false
+}
+
+func appendSSHPublicKeyToPod(ctx context.Context, kubeConfig *rest.Config, namespace, podName, publicKey string) error {
+ kubeClient, err := kubernetes.NewForConfig(kubeConfig)
+ if err != nil {
+ return fmt.Errorf("create Kubernetes client: %w", err)
+ }
+
+ execReq := kubeClient.CoreV1().RESTClient().Post().
+ Resource("pods").
+ Namespace(namespace).
+ Name(podName).
+ SubResource("exec").
+ Param("container", "main").
+ Param("command", "sh").
+ Param("command", "-c").
+ Param("command", "mkdir -p /root/.ssh && cat >> /root/.ssh/authorized_keys").
+ Param("stdin", "true").
+ Param("stdout", "true").
+ Param("stderr", "true").
+ Param("tty", "false")
+
+ executor, err := remotecommand.NewSPDYExecutor(kubeConfig, "POST", execReq.URL())
+ if err != nil {
+ return fmt.Errorf("create executor: %w", err)
+ }
+
+ var stderr bytes.Buffer
+ if err := executor.StreamWithContext(ctx, remotecommand.StreamOptions{
+ Stdin: strings.NewReader(publicKey + "\n"),
+ Stdout: io.Discard,
+ Stderr: &stderr,
+ Tty: false,
+ }); err != nil {
+ return fmt.Errorf("exec stream: %w (stderr: %s)", err, stderr.String())
+ }
+ return nil
}
func (r *pullReconciler) restartCleanupRequired(ctx context.Context, mgmtTask *rlarkv1alpha1.Task, namespace string) (bool, error) {
@@ -219,7 +607,6 @@ func (r *pullReconciler) restartCleanupRequired(ctx context.Context, mgmtTask *r
return false, err
}
}
-
var pvcs corev1.PersistentVolumeClaimList
if err := r.c.LocalKubeClient.List(ctx, &pvcs, client.InNamespace(namespace), client.MatchingLabels{PVCTaskLabel: mgmtTask.Name}); err != nil {
return false, err
@@ -231,12 +618,11 @@ func (r *pullReconciler) createOrUpdateDeployment(ctx context.Context, mgmtTask
return r.createOrUpdateWorkload(ctx, mgmtTask, "Deployment",
&appsv1.Deployment{},
buildDeployment(mgmtTask, spec),
- func(obj client.Object) {
+ func(obj, desired client.Object) {
deploy := obj.(*appsv1.Deployment)
- deploy.Annotations = workloadAnnotations(mgmtTask)
- deploy.Annotations[ManagementTaskResourceVersionAnnotation] = mgmtTask.ResourceVersion
+ mergeWorkloadAnnotations(deploy, mgmtTask)
deploy.Spec.Replicas = spec.Replicas
- deploy.Spec.Template = spec.Template
+ deploy.Spec.Template = desired.(*appsv1.Deployment).Spec.Template
})
}
@@ -244,11 +630,10 @@ func (r *pullReconciler) createOrUpdateDaemonSet(ctx context.Context, mgmtTask *
return r.createOrUpdateWorkload(ctx, mgmtTask, "DaemonSet",
&appsv1.DaemonSet{},
buildDaemonSet(mgmtTask, spec),
- func(obj client.Object) {
+ func(obj, desired client.Object) {
ds := obj.(*appsv1.DaemonSet)
- ds.Annotations = workloadAnnotations(mgmtTask)
- ds.Annotations[ManagementTaskResourceVersionAnnotation] = mgmtTask.ResourceVersion
- ds.Spec.Template = spec.Template
+ mergeWorkloadAnnotations(ds, mgmtTask)
+ ds.Spec.Template = desired.(*appsv1.DaemonSet).Spec.Template
})
}
@@ -256,21 +641,20 @@ func (r *pullReconciler) createOrUpdateStatefulSet(ctx context.Context, mgmtTask
return r.createOrUpdateWorkload(ctx, mgmtTask, "StatefulSet",
&appsv1.StatefulSet{},
buildStatefulSet(mgmtTask, spec),
- func(obj client.Object) {
+ func(obj, desired client.Object) {
sts := obj.(*appsv1.StatefulSet)
- sts.Annotations = workloadAnnotations(mgmtTask)
- sts.Annotations[ManagementTaskResourceVersionAnnotation] = mgmtTask.ResourceVersion
+ mergeWorkloadAnnotations(sts, mgmtTask)
sts.Spec.Replicas = spec.Replicas
- sts.Spec.Template = spec.Template
+ sts.Spec.Template = desired.(*appsv1.StatefulSet).Spec.Template
})
}
-func (r *pullReconciler) cleanupWorkload(ctx context.Context, name string, namespace string) (bool, error) {
+func (r *pullReconciler) cleanupWorkload(ctx context.Context, task *rlarkv1alpha1.Task, namespace string) (bool, error) {
if r.c.LocalKubeClient == nil {
return false, nil
}
pending := false
- workloadKey := types.NamespacedName{Name: name, Namespace: namespace}
+ workloadKey := types.NamespacedName{Name: task.Name, Namespace: namespace}
for _, obj := range []client.Object{&appsv1.Deployment{}, &appsv1.DaemonSet{}, &appsv1.StatefulSet{}} {
err := r.c.LocalKubeClient.Get(ctx, workloadKey, obj)
@@ -278,16 +662,19 @@ func (r *pullReconciler) cleanupWorkload(ctx context.Context, name string, names
return false, err
}
if err == nil {
+ if workloadOwnershipForTask(obj, task) == workloadConflict {
+ continue
+ }
pending = true
if obj.GetDeletionTimestamp().IsZero() {
- if err := r.c.LocalKubeClient.Delete(ctx, obj); err != nil && !errors.IsNotFound(err) {
+ if err := r.c.LocalKubeClient.Delete(ctx, obj, client.PropagationPolicy(metav1.DeletePropagationForeground)); err != nil && !errors.IsNotFound(err) {
return false, err
}
}
}
}
- svcKey := types.NamespacedName{Name: rayHeadServiceName(name), Namespace: namespace}
+ svcKey := types.NamespacedName{Name: rayHeadServiceName(task.Name), Namespace: namespace}
var svc corev1.Service
if err := r.c.LocalKubeClient.Get(ctx, svcKey, &svc); err == nil && svc.DeletionTimestamp.IsZero() {
if err := r.c.LocalKubeClient.Delete(ctx, &svc); err != nil && !errors.IsNotFound(err) {
@@ -297,11 +684,15 @@ func (r *pullReconciler) cleanupWorkload(ctx context.Context, name string, names
return false, err
}
- pvcsPending, err := r.cleanupPVCs(ctx, name, namespace)
+ if pending {
+ return true, nil
+ }
+
+ pvcsPending, err := r.cleanupPVCs(ctx, task, namespace)
if err != nil {
return false, err
}
- return pending || pvcsPending, nil
+ return pvcsPending, nil
}
func (r *pullReconciler) ensureRayResources(ctx context.Context, mgmtTask *rlarkv1alpha1.Task, owner client.Object) error {
@@ -328,12 +719,15 @@ func (r *pullReconciler) ensureRayResources(ctx context.Context, mgmtTask *rlark
return fmt.Errorf("create ray ConfigMap %s: %w", cm.Name, err)
}
} else {
- existingCM.Data = cm.Data
- if owner != nil {
- existingCM.OwnerReferences = cm.OwnerReferences
- }
- if err := r.c.LocalKubeClient.Update(ctx, &existingCM); err != nil {
- return fmt.Errorf("update ray ConfigMap %s: %w", cm.Name, err)
+ ownerReferencesChanged := owner != nil && !reflect.DeepEqual(existingCM.OwnerReferences, cm.OwnerReferences)
+ if !reflect.DeepEqual(existingCM.Data, cm.Data) || ownerReferencesChanged {
+ existingCM.Data = cm.Data
+ if owner != nil {
+ existingCM.OwnerReferences = cm.OwnerReferences
+ }
+ if err := r.c.LocalKubeClient.Update(ctx, &existingCM); err != nil {
+ return fmt.Errorf("update ray ConfigMap %s: %w", cm.Name, err)
+ }
}
}
@@ -377,10 +771,12 @@ func (r *pullReconciler) ensurePVCs(ctx context.Context, mgmtTask *rlarkv1alpha1
}
storageClassName := ""
+ //nolint:staticcheck // Support for deprecated PVC storage class mapping
if workloadSpec.PvcStorageMap != nil {
storageClassName = workloadSpec.PvcStorageMap[claimName]
}
pvcSizeGb := int32(10)
+ //nolint:staticcheck // Support for deprecated PVC storage class mapping
if workloadSpec.PvcSizeGbMap != nil && workloadSpec.PvcSizeGbMap[claimName] > 0 {
pvcSizeGb = workloadSpec.PvcSizeGbMap[claimName]
}
@@ -429,11 +825,11 @@ func (r *pullReconciler) ensurePVCs(ctx context.Context, mgmtTask *rlarkv1alpha1
}
} else {
if storageClassName != "" && (existing.Spec.StorageClassName == nil || *existing.Spec.StorageClassName != storageClassName) {
- logger.Info("Updating PVC storage class", "pvc", claimName, "storageClass", storageClassName)
- existing.Spec.StorageClassName = &storageClassName
- if err := r.c.LocalKubeClient.Update(ctx, existing); err != nil {
- return fmt.Errorf("update PVC %s: %w", claimName, err)
+ existingClass := ""
+ if existing.Spec.StorageClassName != nil {
+ existingClass = *existing.Spec.StorageClassName
}
+ return fmt.Errorf("PVC %s storageClassName is immutable: existing %q, requested %q", claimName, existingClass, storageClassName)
}
}
}
@@ -441,19 +837,17 @@ func (r *pullReconciler) ensurePVCs(ctx context.Context, mgmtTask *rlarkv1alpha1
return nil
}
-func (r *pullReconciler) cleanupPVCs(ctx context.Context, taskName string, namespace string) (bool, error) {
+func (r *pullReconciler) cleanupPVCs(ctx context.Context, task *rlarkv1alpha1.Task, namespace string) (bool, error) {
if r.c.LocalKubeClient == nil {
return false, nil
}
pvcList := &corev1.PersistentVolumeClaimList{}
- if err := r.c.LocalKubeClient.List(ctx, pvcList, client.InNamespace(namespace), client.MatchingLabels{
- PVCTaskLabel: taskName,
- }); err != nil {
+ if err := r.c.LocalKubeClient.List(ctx, pvcList, client.InNamespace(namespace), client.MatchingLabels{PVCTaskLabel: task.Name}); err != nil {
if errors.IsNotFound(err) {
return false, nil
}
- return false, fmt.Errorf("list PVCs for task %s: %w", taskName, err)
+ return false, fmt.Errorf("list PVCs for task %s: %w", task.Name, err)
}
if len(pvcList.Items) == 0 {
@@ -465,18 +859,19 @@ func (r *pullReconciler) cleanupPVCs(ctx context.Context, taskName string, names
return false, fmt.Errorf("list all PVCs in namespace %s: %w", namespace, err)
}
for _, pvc := range allPVCs.Items {
- if pvc.Annotations != nil && pvc.Annotations[PVCOwnerTaskAnnotation] == taskName {
+ if pvc.Annotations != nil && pvc.Annotations[PVCOwnerTaskAnnotation] == task.Name {
pvcList.Items = append(pvcList.Items, pvc)
}
}
}
for i := range pvcList.Items {
- logger := log.FromContext(ctx).WithValues("pvc", pvcList.Items[i].Name)
- logger.Info("Deleting PVC owned by task", "pvc", pvcList.Items[i].Name, "task", taskName)
- if pvcList.Items[i].DeletionTimestamp.IsZero() {
- if err := r.c.LocalKubeClient.Delete(ctx, &pvcList.Items[i]); err != nil && !errors.IsNotFound(err) {
- return false, fmt.Errorf("delete PVC %s: %w", pvcList.Items[i].Name, err)
+ pvc := &pvcList.Items[i]
+ logger := log.FromContext(ctx).WithValues("pvc", pvc.Name)
+ logger.Info("Deleting PVC owned by task", "pvc", pvc.Name, "task", task.Name)
+ if pvc.DeletionTimestamp.IsZero() {
+ if err := r.c.LocalKubeClient.Delete(ctx, pvc); err != nil && !errors.IsNotFound(err) {
+ return false, fmt.Errorf("delete PVC %s: %w", pvc.Name, err)
}
}
}
@@ -497,10 +892,13 @@ func getWorkloadNamespace(mgmtTask *rlarkv1alpha1.Task) string {
func workloadAnnotations(mgmtTask *rlarkv1alpha1.Task) map[string]string {
annotations := map[string]string{
- ManagementTaskNameAnnotation: mgmtTask.Name,
- ManagementTaskNamespaceAnnotation: mgmtTask.Namespace,
- ManagementTaskUIDAnnotation: string(mgmtTask.UID),
- ManagementTaskResourceVersionAnnotation: mgmtTask.ResourceVersion,
+ ManagementTaskNameAnnotation: mgmtTask.Name,
+ ManagementTaskNamespaceAnnotation: mgmtTask.Namespace,
+ ManagementTaskUIDAnnotation: string(mgmtTask.UID),
+ ManagementTaskGenerationAnnotation: taskGeneration(mgmtTask),
+ }
+ if mgmtTask.Spec.Domain != "" {
+ annotations[ManagementTaskDomainAnnotation] = mgmtTask.Spec.Domain
}
if restartedAt := mgmtTask.Annotations[RestartedAtAnnotation]; restartedAt != "" {
annotations[RestartedAtAnnotation] = restartedAt
@@ -508,6 +906,32 @@ func workloadAnnotations(mgmtTask *rlarkv1alpha1.Task) map[string]string {
return annotations
}
+func mergeWorkloadAnnotations(obj client.Object, mgmtTask *rlarkv1alpha1.Task) {
+ annotations := obj.GetAnnotations()
+ if annotations == nil {
+ annotations = map[string]string{}
+ }
+ for key, value := range workloadAnnotations(mgmtTask) {
+ annotations[key] = value
+ }
+ if mgmtTask.Annotations[RestartedAtAnnotation] == "" {
+ delete(annotations, RestartedAtAnnotation)
+ }
+ obj.SetAnnotations(annotations)
+}
+
+func adoptWorkloadAnnotations(obj client.Object, mgmtTask *rlarkv1alpha1.Task) {
+ mergeWorkloadAnnotations(obj, mgmtTask)
+ annotations := obj.GetAnnotations()
+ delete(annotations, ManagementTaskGenerationAnnotation)
+ annotations[ManagementTaskAdoptedGenerationAnnotation] = taskGeneration(mgmtTask)
+ obj.SetAnnotations(annotations)
+}
+
+func taskGeneration(mgmtTask *rlarkv1alpha1.Task) string {
+ return strconv.FormatInt(mgmtTask.Generation, 10)
+}
+
// ensureLabels ensures the pod template has labels, adding a default if none are set.
func ensureLabels(template *corev1.PodTemplateSpec, name string) {
if template == nil || len(template.Labels) == 0 {
@@ -517,6 +941,21 @@ func ensureLabels(template *corev1.PodTemplateSpec, name string) {
}
}
+func applyWorkloadIdentity(template *corev1.PodTemplateSpec, mgmtTask *rlarkv1alpha1.Task) {
+ if template.Labels == nil {
+ template.Labels = map[string]string{}
+ }
+ template.Labels[ManagementTaskUIDLabel] = string(mgmtTask.UID)
+ if template.Annotations == nil {
+ template.Annotations = map[string]string{}
+ }
+ template.Annotations[ManagementTaskUIDAnnotation] = string(mgmtTask.UID)
+}
+
+func workloadSelector(mgmtTask *rlarkv1alpha1.Task) *metav1.LabelSelector {
+ return &metav1.LabelSelector{MatchLabels: map[string]string{ManagementTaskUIDLabel: string(mgmtTask.UID)}}
+}
+
// applyAntiAffinity injects a pod anti-affinity rule so that pods from the same
// workload are scheduled on different nodes when possible.
func applyAntiAffinity(template *corev1.PodTemplateSpec) {
@@ -548,12 +987,15 @@ func applyTemplateMutations(template *corev1.PodTemplateSpec, mgmtTask *rlarkv1a
applyDomainAnnotation(template, mgmtTask)
applyRayInit(template, mgmtTask)
applyNetworkSidecar(template, mgmtTask, image)
- applySSHServer(template, mgmtTask, image)
+ applyRLarkTools(template, image)
ensureLabels(template, mgmtTask.Name)
applyNodeSelector(&template.Spec, mgmtTask.Spec.NodeSelector)
applyAntiAffinity(template)
// todo 后续 rlinf 使用新方式访问真机设备后去掉
+ if os.Getenv(UnsafeTaskPrivilegesEnv) != "true" {
+ return
+ }
role := ""
if mgmtTask.Annotations != nil {
role = mgmtTask.Annotations[rlarkv1alpha1.RayRoleAnnotation]
@@ -576,16 +1018,11 @@ func applyTemplateMutations(template *corev1.PodTemplateSpec, mgmtTask *rlarkv1a
}
}
-// ensureImagePullSecrets syncs image registry secrets from the management cluster
-// to the local workload namespace and injects matching ImagePullSecrets into the template.
+// ensureImagePullSecrets injects locally delivered credentials matching template images.
func (r *pullReconciler) ensureImagePullSecrets(ctx context.Context, template *corev1.PodTemplateSpec, workloadNamespace string) error {
- logger := log.FromContext(ctx)
-
- // List all image registry secrets from the management cluster
secretList := &corev1.SecretList{}
- if err := r.c.ManagementClient.List(ctx, secretList, &client.ListOptions{
- LabelSelector: labels.Set{common.ImageRegistrySecretLabel: "true"}.AsSelector(),
- Namespace: common.SecretNamespace,
+ if err := r.c.LocalKubeClient.List(ctx, secretList, client.InNamespace(workloadNamespace), client.MatchingLabels{
+ common.ImageRegistryCredentialLabel: "true",
}); err != nil {
return fmt.Errorf("list image registry secrets: %w", err)
}
@@ -611,24 +1048,28 @@ func (r *pullReconciler) ensureImagePullSecrets(ctx context.Context, template *c
return nil
}
- // Build a map of registry prefix -> secret name
- registryToSecret := make(map[string]string, len(secretList.Items))
+ type registryCredential struct {
+ registry string
+ name string
+ }
+ credentials := make([]registryCredential, 0, len(secretList.Items))
for _, secret := range secretList.Items {
+ if secret.Type != corev1.SecretTypeDockerConfigJson || len(secret.Data[corev1.DockerConfigJsonKey]) == 0 {
+ continue
+ }
registry := common.NormalizeRegistry(secret.Annotations[common.ImageRegistryAnnotationRegistry])
if registry == "" {
continue
}
- registryToSecret[registry] = secret.Name
+ credentials = append(credentials, registryCredential{registry: registry, name: secret.Name})
}
- // Find matching registries for our images
- matchedSecrets := make(map[string]bool)
+ matchedSecrets := make(map[string]struct{})
for _, image := range imageRefs {
image = common.NormalizeRegistry(image)
- for registry, secretName := range registryToSecret {
- if strings.HasPrefix(image, registry+"/") || image == registry {
- matchedSecrets[secretName] = true
- break
+ for _, credential := range credentials {
+ if strings.HasPrefix(image, credential.registry+"/") || image == credential.registry {
+ matchedSecrets[credential.name] = struct{}{}
}
}
}
@@ -637,63 +1078,21 @@ func (r *pullReconciler) ensureImagePullSecrets(ctx context.Context, template *c
return nil
}
- // Sync each matched secret to the local workload namespace
- for secretName := range matchedSecrets {
- if err := r.syncImagePullSecret(ctx, secretName, workloadNamespace); err != nil {
- logger.Error(err, "failed to sync image pull secret", "secret", secretName, "namespace", workloadNamespace)
- return fmt.Errorf("sync image pull secret %q: %w", secretName, err)
- }
- }
-
- // Add matched secrets to template.Spec.ImagePullSecrets (avoid duplicates)
- existing := make(map[string]bool, len(template.Spec.ImagePullSecrets))
+ existing := make(map[string]struct{}, len(template.Spec.ImagePullSecrets))
for _, ips := range template.Spec.ImagePullSecrets {
- existing[ips.Name] = true
+ existing[ips.Name] = struct{}{}
}
+ names := make([]string, 0, len(matchedSecrets))
for secretName := range matchedSecrets {
- if !existing[secretName] {
- template.Spec.ImagePullSecrets = append(template.Spec.ImagePullSecrets, corev1.LocalObjectReference{Name: secretName})
+ if _, found := existing[secretName]; !found {
+ names = append(names, secretName)
}
}
-
- return nil
-}
-
-// syncImagePullSecret copies a dockerconfigjson secret from the management cluster
-// to the local workload namespace.
-func (r *pullReconciler) syncImagePullSecret(ctx context.Context, secretName, destNamespace string) error {
- logger := log.FromContext(ctx)
-
- var srcSecret corev1.Secret
- if err := r.c.ManagementClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: common.SecretNamespace}, &srcSecret); err != nil {
- return fmt.Errorf("get source secret: %w", err)
- }
-
- var destSecret corev1.Secret
- err := r.c.LocalKubeClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: destNamespace}, &destSecret)
- if err == nil {
- destSecret.Data = srcSecret.Data
- destSecret.Type = srcSecret.Type
- destSecret.Labels = srcSecret.Labels
- destSecret.Annotations = srcSecret.Annotations
- return r.c.LocalKubeClient.Update(ctx, &destSecret)
- }
- if !errors.IsNotFound(err) {
- return fmt.Errorf("get local secret: %w", err)
- }
-
- newSecret := corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{
- Name: secretName,
- Namespace: destNamespace,
- Labels: srcSecret.Labels,
- Annotations: srcSecret.Annotations,
- },
- Type: srcSecret.Type,
- Data: srcSecret.Data,
+ sort.Strings(names)
+ for _, name := range names {
+ template.Spec.ImagePullSecrets = append(template.Spec.ImagePullSecrets, corev1.LocalObjectReference{Name: name})
}
- logger.Info("syncing image pull secret to local namespace", "secret", secretName, "namespace", destNamespace)
- return r.c.LocalKubeClient.Create(ctx, &newSecret)
+ return nil
}
func buildDeployment(mgmtTask *rlarkv1alpha1.Task, spec *rlarkv1alpha1.KubernetesWorkloadSpec) *appsv1.Deployment {
@@ -705,9 +1104,7 @@ func buildDeployment(mgmtTask *rlarkv1alpha1.Task, spec *rlarkv1alpha1.Kubernete
},
Spec: appsv1.DeploymentSpec{
Replicas: spec.Replicas,
- Selector: &metav1.LabelSelector{
- MatchLabels: spec.Template.Labels,
- },
+ Selector: workloadSelector(mgmtTask),
Template: spec.Template,
},
}
@@ -721,9 +1118,7 @@ func buildDaemonSet(mgmtTask *rlarkv1alpha1.Task, spec *rlarkv1alpha1.Kubernetes
Annotations: workloadAnnotations(mgmtTask),
},
Spec: appsv1.DaemonSetSpec{
- Selector: &metav1.LabelSelector{
- MatchLabels: spec.Template.Labels,
- },
+ Selector: workloadSelector(mgmtTask),
Template: spec.Template,
},
}
@@ -737,11 +1132,10 @@ func buildStatefulSet(mgmtTask *rlarkv1alpha1.Task, spec *rlarkv1alpha1.Kubernet
Annotations: workloadAnnotations(mgmtTask),
},
Spec: appsv1.StatefulSetSpec{
- Replicas: spec.Replicas,
- Selector: &metav1.LabelSelector{
- MatchLabels: spec.Template.Labels,
- },
- Template: spec.Template,
+ Replicas: spec.Replicas,
+ Selector: workloadSelector(mgmtTask),
+ Template: spec.Template,
+ PodManagementPolicy: appsv1.ParallelPodManagement,
},
}
}
diff --git a/apps/rlark/pkg/agent/controllers/task/pull_test.go b/apps/rlark/pkg/agent/controllers/task/pull_test.go
index f081bd7..85b1c70 100644
--- a/apps/rlark/pkg/agent/controllers/task/pull_test.go
+++ b/apps/rlark/pkg/agent/controllers/task/pull_test.go
@@ -2,6 +2,8 @@ package task
import (
"context"
+ "fmt"
+ "reflect"
"testing"
appsv1 "k8s.io/api/apps/v1"
@@ -9,13 +11,433 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/rest"
+ "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/agent/controllers/base"
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
)
-func TestCleanupWorkloadWaitsForStatefulSetAndPVC(t *testing.T) {
+type countingClient struct {
+ client.Client
+ updates int
+ statusPatches int
+}
+
+type failingWriteClient struct {
+ client.Client
+ createErr error
+ updateErr error
+}
+
+func (c *failingWriteClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ if c.createErr != nil {
+ return c.createErr
+ }
+ return c.Client.Create(ctx, obj, opts...)
+}
+
+func (c *failingWriteClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
+ if c.updateErr != nil {
+ return c.updateErr
+ }
+ return c.Client.Update(ctx, obj, opts...)
+}
+
+func (c *countingClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
+ c.updates++
+ return c.Client.Update(ctx, obj, opts...)
+}
+
+func (c *countingClient) Status() client.StatusWriter {
+ return &countingTaskStatusWriter{SubResourceWriter: c.Client.Status(), parent: c}
+}
+
+type countingTaskStatusWriter struct {
+ client.SubResourceWriter
+ parent *countingClient
+}
+
+func (w *countingTaskStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
+ w.parent.statusPatches++
+ return w.SubResourceWriter.Patch(ctx, obj, patch, opts...)
+}
+
+func TestAppendSSHPublicKeyToRunningPodsRequiresKubeConfig(t *testing.T) {
+ r := &pullReconciler{c: NewTaskController(base.Controller{
+ LocalKubeClient: fake.NewClientBuilder().Build(),
+ })}
+ err := r.appendSSHPublicKeyToRunningPods(context.Background(), &rlarkv1alpha1.Task{}, "default", nil)
+ if err == nil {
+ t.Fatal("appendSSHPublicKeyToRunningPods() error = nil, want local Kubernetes config error")
+ }
+}
+
+func TestAppendSSHPublicKeyToPodRequiresValidConfig(t *testing.T) {
+ err := appendSSHPublicKeyToPod(context.Background(), &rest.Config{Host: "://invalid"}, "default", "pod", "ssh-ed25519 AAAA")
+ if err == nil {
+ t.Fatal("appendSSHPublicKeyToPod() error = nil, want Kubernetes client error")
+ }
+}
+
+func TestTaskPullConcurrency(t *testing.T) {
+ if got := NewTaskController(base.Controller{PullMaxConcurrentReconciles: 4}).PullMaxConcurrentReconciles; got != 4 {
+ t.Fatalf("configured Task pull concurrency = %d, want 4", got)
+ }
+}
+
+func TestEnsureRayResourcesSkipsUnchangedConfigMapUpdate(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ mgmtTask := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "worker", Annotations: map[string]string{rlarkv1alpha1.RayRoleAnnotation: "worker"},
+ }}
+ cm := buildRayConfigMap("rlark-system", "worker")
+ wrapped := &countingClient{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build()}
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: wrapped})}
+
+ if err := r.ensureRayResources(context.Background(), mgmtTask, nil); err != nil {
+ t.Fatal(err)
+ }
+ if wrapped.updates != 0 {
+ t.Fatalf("unchanged Ray ConfigMap caused %d updates, want 0", wrapped.updates)
+ }
+
+ cm.Data = map[string]string{"changed": "true"}
+ if err := wrapped.Client.Update(context.Background(), cm); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.ensureRayResources(context.Background(), mgmtTask, nil); err != nil {
+ t.Fatal(err)
+ }
+ if wrapped.updates != 1 {
+ t.Fatalf("changed Ray ConfigMap caused %d updates, want 1", wrapped.updates)
+ }
+}
+
+func TestEnsureRayResourcesReusesServiceFromPreviousTask(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "head", Namespace: "default", UID: "new-uid",
+ Annotations: map[string]string{rlarkv1alpha1.RayRoleAnnotation: rlarkv1alpha1.RayRoleHead},
+ }}
+ service := buildRayHeadService("rlark-system", task.Name)
+ service.Annotations = map[string]string{ManagementTaskUIDAnnotation: "old-uid"}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(service).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+
+ if err := r.ensureRayResources(context.Background(), task, nil); err != nil {
+ t.Fatalf("reuse Ray Service: %v", err)
+ }
+}
+
+func TestEnsureImagePullSecretsUsesAllMatchingLocalCredentials(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ credential := func(name, registry string, labeled bool) *corev1.Secret {
+ labels := map[string]string{}
+ if labeled {
+ labels[common.ImageRegistryCredentialLabel] = "true"
+ }
+ return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
+ Name: name, Namespace: "rlark-system", Labels: labels,
+ Annotations: map[string]string{common.ImageRegistryAnnotationRegistry: registry},
+ }, Type: corev1.SecretTypeDockerConfigJson, Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte("config")}}
+ }
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+ credential("ir-z", "registry.example.com", true),
+ credential("ir-a", "registry.example.com", true),
+ credential("ir-team", "registry.example.com/team", true),
+ credential("unrelated", "registry.example.com", false),
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "malformed", Namespace: "rlark-system", Labels: map[string]string{common.ImageRegistryCredentialLabel: "true"}, Annotations: map[string]string{common.ImageRegistryAnnotationRegistry: "registry.example.com"}}},
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrong-namespace", Namespace: "other", Labels: map[string]string{common.ImageRegistryCredentialLabel: "true"}, Annotations: map[string]string{common.ImageRegistryAnnotationRegistry: "registry.example.com"}}},
+ ).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{
+ LocalKubeClient: local,
+ })}
+ template := corev1.PodTemplateSpec{Spec: corev1.PodSpec{
+ Containers: []corev1.Container{{Image: "registry.example.com/team/app:v1"}},
+ InitContainers: []corev1.Container{{Image: "registry.example.com/init:v1"}},
+ ImagePullSecrets: []corev1.LocalObjectReference{{Name: "user-secret"}, {Name: "ir-z"}},
+ }}
+ if err := r.ensureImagePullSecrets(context.Background(), &template, "rlark-system"); err != nil {
+ t.Fatal(err)
+ }
+ want := []corev1.LocalObjectReference{{Name: "user-secret"}, {Name: "ir-z"}, {Name: "ir-a"}, {Name: "ir-team"}}
+ if !reflect.DeepEqual(template.Spec.ImagePullSecrets, want) {
+ t.Fatalf("imagePullSecrets = %#v, want %#v", template.Spec.ImagePullSecrets, want)
+ }
+
+ var secrets corev1.SecretList
+ if err := local.List(context.Background(), &secrets); err != nil {
+ t.Fatal(err)
+ }
+ if len(secrets.Items) != 6 {
+ t.Fatalf("local secret count = %d, want 6", len(secrets.Items))
+ }
+}
+
+func TestReconcileAddsFinalizerAndCreatesStatefulSet(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := appsv1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+
+ task := &rlarkv1alpha1.Task{
+ ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 3},
+ Spec: rlarkv1alpha1.TaskSpec{
+ AgentType: rlarkv1alpha1.AgentTypeKubernetes,
+ Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{
+ Kind: rlarkv1alpha1.KubernetesWorkloadStatefulSet,
+ Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{
+ Containers: []corev1.Container{{Name: "main", Image: "example/image:latest"}},
+ }},
+ }},
+ },
+ }
+ managementClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ localClient := fake.NewClientBuilder().WithScheme(scheme).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{
+ ManagementNamespace: "cluster-1",
+ AgentType: string(rlarkv1alpha1.AgentTypeKubernetes),
+ ManagementClient: managementClient,
+ LocalKubeClient: localClient,
+ })}
+
+ if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Name: "task", Namespace: "default"}}); err != nil {
+ t.Fatal(err)
+ }
+
+ var updatedTask rlarkv1alpha1.Task
+ if err := managementClient.Get(context.Background(), client.ObjectKeyFromObject(task), &updatedTask); err != nil {
+ t.Fatal(err)
+ }
+ if len(updatedTask.Finalizers) != 1 || updatedTask.Finalizers[0] != ManagementTaskFinalizer {
+ t.Fatalf("Task finalizers = %v, want %q", updatedTask.Finalizers, ManagementTaskFinalizer)
+ }
+ if got := updatedTask.Annotations[ManagementTaskClaimantAnnotation]; got != "cluster-1/Kubernetes" {
+ t.Fatalf("Task claimant = %q", got)
+ }
+
+ var sts appsv1.StatefulSet
+ if err := localClient.Get(context.Background(), types.NamespacedName{Name: "task", Namespace: "rlark-system"}, &sts); err != nil {
+ t.Fatalf("StatefulSet was not created in the first reconcile: %v", err)
+ }
+ if got := sts.Annotations[ManagementTaskGenerationAnnotation]; got != "3" {
+ t.Fatalf("StatefulSet Task generation annotation = %q, want 3", got)
+ }
+}
+
+func TestCreateOrUpdateWorkloadUsesTaskGeneration(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := appsv1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+
+ existing := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{
+ Name: "task", Namespace: "rlark-system",
+ Annotations: map[string]string{
+ ManagementTaskGenerationAnnotation: "2",
+ ManagementTaskNameAnnotation: "task",
+ ManagementTaskNamespaceAnnotation: "default",
+ ManagementTaskUIDAnnotation: "uid",
+ },
+ }}
+ localClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: localClient})}
+ mgmtTask := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "task", Namespace: "default", UID: "uid", Generation: 2, ResourceVersion: "different",
+ }}
+ updates := 0
+ applyUpdate := func(client.Object, client.Object) { updates++ }
+ newWorkload := func() *appsv1.StatefulSet {
+ return &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system"}}
+ }
+
+ if _, err := r.createOrUpdateWorkload(context.Background(), mgmtTask, "StatefulSet", &appsv1.StatefulSet{}, newWorkload(), applyUpdate); err != nil {
+ t.Fatal(err)
+ }
+ if updates != 0 {
+ t.Fatalf("same Task generation caused %d workload updates, want 0", updates)
+ }
+
+ mgmtTask.Generation = 3
+ if _, err := r.createOrUpdateWorkload(context.Background(), mgmtTask, "StatefulSet", &appsv1.StatefulSet{}, newWorkload(), applyUpdate); err != nil {
+ t.Fatal(err)
+ }
+ if updates != 1 {
+ t.Fatalf("changed Task generation caused %d workload updates, want 1", updates)
+ }
+}
+
+func TestWorkloadKindMigrationResetsStatusAndPreservesOtherFields(t *testing.T) {
+ for _, phase := range []rlarkv1alpha1.TaskPhase{rlarkv1alpha1.TaskPhaseRunning, rlarkv1alpha1.TaskPhaseFailed, rlarkv1alpha1.TaskPhaseStopped} {
+ t.Run(string(phase), func(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 4}, Status: rlarkv1alpha1.TaskStatus{
+ Phase: phase, ObservedNodes: []string{"old-node"}, Message: "keep", RetryCount: 3,
+ Conditions: []metav1.Condition{{Type: "Other", Status: metav1.ConditionTrue, Reason: "Keep"}},
+ }}
+ old := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid",
+ }}}
+ management := &countingClient{Client: fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Task{}).WithObjects(task).Build()}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(old).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{ManagementClient: management, LocalKubeClient: local})}
+
+ pending, err := r.deleteOtherWorkloadKinds(context.Background(), types.NamespacedName{Name: "task", Namespace: "rlark-system"}, "Deployment", task)
+ if err != nil || !pending {
+ t.Fatalf("deleteOtherWorkloadKinds() = %v, %v", pending, err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status.Phase != rlarkv1alpha1.TaskPhasePending || len(got.Status.ObservedNodes) != 0 || got.Status.Message != "keep" || got.Status.RetryCount != 3 || len(got.Status.Conditions) != 2 {
+ t.Fatalf("migration status = %#v", got.Status)
+ }
+ condition := got.Status.Conditions[1]
+ if condition.Type != WorkloadProgressingCondition || condition.Status != metav1.ConditionTrue || condition.Reason != WorkloadKindMigrationReason {
+ t.Fatalf("migration condition = %#v", condition)
+ }
+ if management.statusPatches != 1 {
+ t.Fatalf("status patches = %d, want 1", management.statusPatches)
+ }
+ if _, err := r.deleteOtherWorkloadKinds(context.Background(), types.NamespacedName{Name: "task", Namespace: "rlark-system"}, "Deployment", &got); err != nil {
+ t.Fatal(err)
+ }
+ if management.statusPatches != 1 {
+ t.Fatalf("unchanged migration status patched again: %d", management.statusPatches)
+ }
+ })
+ }
+}
+
+func TestTargetWorkloadClearsMigrationCondition(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 2}, Status: rlarkv1alpha1.TaskStatus{
+ Phase: rlarkv1alpha1.TaskPhasePending, Message: "keep", Conditions: []metav1.Condition{{Type: WorkloadProgressingCondition, Status: metav1.ConditionTrue, Reason: WorkloadKindMigrationReason}},
+ }}
+ target := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{
+ ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid", ManagementTaskGenerationAnnotation: "2",
+ }}}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Task{}).WithObjects(task).Build()
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(target).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{ManagementClient: management, LocalKubeClient: local})}
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, target.DeepCopy(), func(client.Object, client.Object) {}); err != nil {
+ t.Fatal(err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status.Message != "keep" || !reflect.DeepEqual(got.Status.Conditions, []metav1.Condition(nil)) {
+ t.Fatalf("target status = %#v", got.Status)
+ }
+ if got.Status.Phase != rlarkv1alpha1.TaskPhasePending || len(got.Status.ObservedNodes) != 0 {
+ t.Fatalf("phase and observed nodes should remain reset until push observes the target: %#v", got.Status)
+ }
+}
+
+func TestTargetWorkloadCreateClearsMigrationConditionAfterSuccess(t *testing.T) {
+ task, management, local, r := migrationTestReconciler(t, nil)
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) {}); err != nil {
+ t.Fatal(err)
+ }
+ assertMigrationCondition(t, management, task, false)
+ var target appsv1.Deployment
+ if err := local.Get(context.Background(), types.NamespacedName{Name: "task", Namespace: "rlark-system"}, &target); err != nil {
+ t.Fatalf("target was not created: %v", err)
+ }
+}
+
+func TestTargetWorkloadFailuresRetainMigrationCondition(t *testing.T) {
+ t.Run("conflict", func(t *testing.T) {
+ conflict := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "rlark-system", Annotations: map[string]string{ManagementTaskUIDAnnotation: "other"}}}
+ task, management, _, r := migrationTestReconciler(t, conflict)
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) {}); err == nil {
+ t.Fatal("expected ownership conflict")
+ }
+ assertMigrationCondition(t, management, task, true)
+ })
+
+ t.Run("update", func(t *testing.T) {
+ existing := buildDeployment(&rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 1}}, &rlarkv1alpha1.KubernetesWorkloadSpec{})
+ task, management, local, r := migrationTestReconciler(t, existing)
+ r.c.LocalKubeClient = &failingWriteClient{Client: local, updateErr: fmt.Errorf("update failed")}
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(existing, desired client.Object) {
+ existing.(*appsv1.Deployment).Spec = desired.(*appsv1.Deployment).Spec
+ }); err == nil {
+ t.Fatal("expected update failure")
+ }
+ assertMigrationCondition(t, management, task, true)
+ })
+
+ t.Run("create", func(t *testing.T) {
+ task, management, local, r := migrationTestReconciler(t, nil)
+ r.c.LocalKubeClient = &failingWriteClient{Client: local, createErr: fmt.Errorf("create failed")}
+ if _, err := r.createOrUpdateWorkload(context.Background(), task, "Deployment", &appsv1.Deployment{}, buildDeployment(task, &rlarkv1alpha1.KubernetesWorkloadSpec{}), func(client.Object, client.Object) {}); err == nil {
+ t.Fatal("expected create failure")
+ }
+ assertMigrationCondition(t, management, task, true)
+ })
+}
+
+func migrationTestReconciler(t *testing.T, workload client.Object) (*rlarkv1alpha1.Task, client.Client, client.Client, *pullReconciler) {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ _ = appsv1.AddToScheme(scheme)
+ _ = rlarkv1alpha1.AddToScheme(scheme)
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid", Generation: 2}, Status: rlarkv1alpha1.TaskStatus{
+ Phase: rlarkv1alpha1.TaskPhasePending,
+ Conditions: []metav1.Condition{{Type: WorkloadProgressingCondition, Status: metav1.ConditionTrue, Reason: WorkloadKindMigrationReason}},
+ }}
+ management := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&rlarkv1alpha1.Task{}).WithObjects(task).Build()
+ builder := fake.NewClientBuilder().WithScheme(scheme)
+ if workload != nil {
+ builder = builder.WithObjects(workload)
+ }
+ local := builder.Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{ManagementClient: management, LocalKubeClient: local})}
+ return task, management, local, r
+}
+
+func assertMigrationCondition(t *testing.T, management client.Client, task *rlarkv1alpha1.Task, want bool) {
+ t.Helper()
+ var got rlarkv1alpha1.Task
+ if err := management.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, condition := range got.Status.Conditions {
+ found = found || condition.Type == WorkloadProgressingCondition && condition.Reason == WorkloadKindMigrationReason
+ }
+ if found != want {
+ t.Fatalf("migration condition present = %v, want %v: %#v", found, want, got.Status)
+ }
+}
+
+func TestCleanupWorkloadWaitsForStatefulSetBeforeDeletingPVC(t *testing.T) {
scheme := runtime.NewScheme()
if err := appsv1.AddToScheme(scheme); err != nil {
t.Fatal(err)
@@ -26,20 +448,22 @@ func TestCleanupWorkloadWaitsForStatefulSetAndPVC(t *testing.T) {
sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{
Name: "task", Namespace: "rlark-system", Finalizers: []string{"test/finalizer"},
+ Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid"},
}}
pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{
Name: "data", Namespace: "rlark-system", Finalizers: []string{"test/finalizer"},
- Labels: map[string]string{PVCTaskLabel: "task"},
+ Labels: map[string]string{PVCTaskLabel: "task"},
+ Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid"},
}}
localClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sts, pvc).Build()
r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: localClient})}
- pending, err := r.cleanupWorkload(context.Background(), "task", "rlark-system")
+ pending, err := r.cleanupWorkload(context.Background(), &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}, "rlark-system")
if err != nil {
t.Fatal(err)
}
if !pending {
- t.Fatal("cleanup should remain pending while StatefulSet/PVC still exist")
+ t.Fatal("cleanup should remain pending while StatefulSet exists")
}
var remainingSTS appsv1.StatefulSet
@@ -48,7 +472,44 @@ func TestCleanupWorkloadWaitsForStatefulSetAndPVC(t *testing.T) {
}
var remainingPVC corev1.PersistentVolumeClaim
if err := localClient.Get(context.Background(), types.NamespacedName{Name: "data", Namespace: "rlark-system"}, &remainingPVC); err != nil {
- t.Fatalf("PVC should still exist until its finalizer is cleared: %v", err)
+ t.Fatalf("PVC should not be deleted before the StatefulSet and its Pods are gone: %v", err)
+ }
+ if !remainingPVC.DeletionTimestamp.IsZero() {
+ t.Fatal("PVC deletion started before StatefulSet foreground deletion completed")
+ }
+ if remainingSTS.DeletionTimestamp.IsZero() {
+ t.Fatal("StatefulSet deletion was not requested")
+ }
+}
+
+func TestCleanupWorkloadDeletesPVCAfterWorkloadIsGone(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := appsv1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{
+ Name: "data", Namespace: "rlark-system", Finalizers: []string{"test/finalizer"},
+ Labels: map[string]string{PVCTaskLabel: "task"},
+ Annotations: map[string]string{ManagementTaskNameAnnotation: "task", ManagementTaskNamespaceAnnotation: "default", ManagementTaskUIDAnnotation: "uid"},
+ }}
+ localClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: localClient})}
+
+ pending, err := r.cleanupWorkload(context.Background(), &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}, "rlark-system")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pending {
+ t.Fatal("cleanup should remain pending while PVC exists")
+ }
+ if err := localClient.Get(context.Background(), types.NamespacedName{Name: "data", Namespace: "rlark-system"}, pvc); err != nil {
+ t.Fatal(err)
+ }
+ if pvc.DeletionTimestamp.IsZero() {
+ t.Fatal("PVC deletion was not requested after workloads disappeared")
}
}
@@ -60,6 +521,7 @@ func TestEnsurePVCsUsesConfiguredSize(t *testing.T) {
localClient := fake.NewClientBuilder().WithScheme(scheme).Build()
r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: localClient})}
mgmtTask := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default"}}
+ //nolint:staticcheck // Support for deprecated PVC storage class mapping
workload := &rlarkv1alpha1.KubernetesWorkloadSpec{
PvcSizeGbMap: map[string]int32{"data": 20},
Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Volumes: []corev1.Volume{{
@@ -82,6 +544,58 @@ func TestEnsurePVCsUsesConfiguredSize(t *testing.T) {
}
}
+func TestEnsurePVCsReusesClaimFromPreviousTask(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := corev1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{
+ Name: "data", Namespace: "rlark-system",
+ Annotations: map[string]string{ManagementTaskUIDAnnotation: "old-uid"},
+ }}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "new-uid"}}
+ workload := &rlarkv1alpha1.KubernetesWorkloadSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Volumes: []corev1.Volume{{
+ Name: "data", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "data"}},
+ }}}}}
+
+ if err := r.ensurePVCs(context.Background(), task, workload); err != nil {
+ t.Fatalf("reuse PVC: %v", err)
+ }
+}
+
+func TestEnsurePVCsChecksImmutableStorageClassBeforeAdoption(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = corev1.AddToScheme(scheme)
+ oldClass := "old"
+ pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "rlark-system", Labels: map[string]string{PVCTaskLabel: "task"}, Annotations: map[string]string{PVCOwnerAnnotation: "task", PVCOwnerTaskAnnotation: "task"}}, Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: &oldClass}}
+ local := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build()
+ r := &pullReconciler{c: NewTaskController(base.Controller{LocalKubeClient: local})}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task", Namespace: "default", UID: "uid"}}
+ //nolint:staticcheck // Support for deprecated PVC storage class mapping
+ workload := &rlarkv1alpha1.KubernetesWorkloadSpec{
+ PvcStorageMap: map[string]string{"data": "new"},
+ Template: corev1.PodTemplateSpec{
+ Spec: corev1.PodSpec{
+ Volumes: []corev1.Volume{
+ {Name: "data", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "data"}}},
+ },
+ },
+ },
+ }
+ if err := r.ensurePVCs(context.Background(), task, workload); err == nil {
+ t.Fatal("expected immutable storage class conflict")
+ }
+ var got corev1.PersistentVolumeClaim
+ if err := local.Get(context.Background(), client.ObjectKeyFromObject(pvc), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Spec.StorageClassName == nil || *got.Spec.StorageClassName != oldClass {
+ t.Fatalf("PVC was modified before conflict: annotations=%v storageClass=%v", got.Annotations, got.Spec.StorageClassName)
+ }
+}
+
func TestRestartCleanupRequiredAndAnnotationPropagation(t *testing.T) {
scheme := runtime.NewScheme()
if err := appsv1.AddToScheme(scheme); err != nil {
@@ -122,3 +636,59 @@ func TestRestartCleanupRequiredAndAnnotationPropagation(t *testing.T) {
t.Fatal("matching restart annotation should make rebuild idempotent")
}
}
+
+func TestApplyRLarkToolsAlwaysInjectsWithoutSSHKey(t *testing.T) {
+ template := &corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main"}}}}
+
+ applyRLarkTools(template, "rlinf/rlark:test")
+
+ if len(template.Spec.InitContainers) != 1 {
+ t.Fatalf("init containers = %d, want 1", len(template.Spec.InitContainers))
+ }
+ init := template.Spec.InitContainers[0]
+ if init.Name != rlarkToolsInitContainerName {
+ t.Fatalf("init container name = %q, want %q", init.Name, rlarkToolsInitContainerName)
+ }
+ if len(init.Command) != 3 || init.Command[1] != rlarkToolsBinPath || init.Command[2] != rlarkToolsBinDst {
+ t.Fatalf("init container command = %v", init.Command)
+ }
+ if len(template.Spec.Containers[0].VolumeMounts) != 1 || template.Spec.Containers[0].VolumeMounts[0].MountPath != rlarkToolsDstDir {
+ t.Fatalf("main container volume mounts = %v", template.Spec.Containers[0].VolumeMounts)
+ }
+}
+
+func TestApplyTemplateMutationsUnsafeTaskPrivileges(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ privileged bool
+ hostNetwork bool
+ }{
+ {name: "disabled by default"},
+ {name: "only exact true enables", env: "TRUE"},
+ {name: "enabled", env: "true", privileged: true, hostNetwork: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv(UnsafeTaskPrivilegesEnv, tt.env)
+ template := &corev1.PodTemplateSpec{Spec: corev1.PodSpec{
+ Containers: []corev1.Container{{Name: "main"}},
+ InitContainers: []corev1.Container{{Name: "init"}},
+ }}
+ mgmtTask := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "task"}}
+
+ applyTemplateMutations(template, mgmtTask, "")
+
+ if template.Spec.HostNetwork != tt.hostNetwork {
+ t.Fatalf("hostNetwork = %v, want %v", template.Spec.HostNetwork, tt.hostNetwork)
+ }
+ for _, container := range append(template.Spec.Containers, template.Spec.InitContainers...) {
+ got := container.SecurityContext != nil && container.SecurityContext.Privileged != nil && *container.SecurityContext.Privileged
+ if got != tt.privileged {
+ t.Fatalf("container %q privileged = %v, want %v", container.Name, got, tt.privileged)
+ }
+ }
+ })
+ }
+}
diff --git a/apps/rlark/pkg/agent/controllers/task/push_daemonset.go b/apps/rlark/pkg/agent/controllers/task/push_daemonset.go
index 80f7666..4590bfc 100644
--- a/apps/rlark/pkg/agent/controllers/task/push_daemonset.go
+++ b/apps/rlark/pkg/agent/controllers/task/push_daemonset.go
@@ -2,9 +2,7 @@ package task
import (
"context"
- "fmt"
- "github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
@@ -35,8 +33,6 @@ func (r *pushDaemonSetReconciler) Reconcile(ctx context.Context, req reconcile.R
taskName := ds.Annotations[ManagementTaskNameAnnotation]
taskNamespace := ds.Annotations[ManagementTaskNamespaceAnnotation]
- taskUID := ds.Annotations[ManagementTaskUIDAnnotation]
-
if taskName == "" || taskNamespace == "" {
logger.V(1).Info("DaemonSet has no management-task annotation, skipping")
return reconcile.Result{}, nil
@@ -52,37 +48,35 @@ func (r *pushDaemonSetReconciler) Reconcile(ctx context.Context, req reconcile.R
return reconcile.Result{}, nil
}
- if mgmtTask.Spec.AgentType != rlarkv1alpha1.AgentType(r.c.AgentType) {
- logger.Info(fmt.Sprintf("Task AgentType %s does not match controller AgentType %s, skipping", mgmtTask.Spec.AgentType, r.c.AgentType))
+ if !pushOwnsTask(&mgmtTask, r.c.AgentType, &ds, rlarkv1alpha1.KubernetesWorkloadDaemonSet) {
+ logger.Info("DaemonSet no longer owns management Task status, skipping")
return reconcile.Result{}, nil
}
- if string(mgmtTask.UID) != taskUID {
- logger.Info("management Task UID mismatch with annotation, skipping")
- return reconcile.Result{}, nil
+ phase, message, pods, err := daemonSetPhase(ctx, r.c.LocalKubeClient, &ds)
+ if err != nil {
+ return reconcile.Result{}, err
}
-
- phase, message, pods := daemonSetPhase(ctx, logger, r.c.LocalKubeClient, &ds)
observedNodes := podNodeNames(pods)
return updateMgmtTaskStatus(ctx, logger, r.c.ManagementClient, &mgmtTask, phase, message, observedNodes)
}
-func daemonSetPhase(ctx context.Context, logger logr.Logger, localClient client.Client, ds *appsv1.DaemonSet) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod) {
+func daemonSetPhase(ctx context.Context, localClient client.Client, ds *appsv1.DaemonSet) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod, error) {
var phase rlarkv1alpha1.TaskPhase
var message string
switch {
- case ds.Status.NumberReady >= ds.Status.DesiredNumberScheduled && ds.Status.DesiredNumberScheduled > 0:
+ case ds.Status.ObservedGeneration >= ds.Generation && ds.Status.DesiredNumberScheduled > 0 &&
+ ds.Status.UpdatedNumberScheduled == ds.Status.DesiredNumberScheduled &&
+ ds.Status.NumberReady == ds.Status.DesiredNumberScheduled &&
+ ds.Status.NumberAvailable == ds.Status.DesiredNumberScheduled:
phase = rlarkv1alpha1.TaskPhaseRunning
- case ds.Status.NumberUnavailable > 0:
- phase = rlarkv1alpha1.TaskPhaseFailed
- message = "daemonset pods unavailable"
default:
phase = rlarkv1alpha1.TaskPhasePending
}
- pods, err := listTaskPods(ctx, localClient, ds.Namespace, ds.Spec.Selector.MatchLabels)
+ pods, err := listTaskPods(ctx, localClient, ds, ds.Spec.Selector.MatchLabels)
if err != nil {
- logger.Error(err, "failed to list pods")
+ return "", "", nil, err
}
// Override to Failed when any pod container is in an abnormal state
// (CrashLoopBackOff, ImagePullBackOff, OOMKilled, etc.) so operators
@@ -93,10 +87,10 @@ func daemonSetPhase(ctx context.Context, logger logr.Logger, localClient client.
}
if phase == rlarkv1alpha1.TaskPhasePending {
if found, err := hasFailedSchedulingEvent(ctx, localClient, ds.Namespace, pods); err != nil {
- logger.Error(err, "failed to list pod scheduling events")
+ return "", "", nil, err
} else if found {
message = "FailedScheduling"
}
}
- return phase, message, pods
+ return phase, message, pods, nil
}
diff --git a/apps/rlark/pkg/agent/controllers/task/push_deployment.go b/apps/rlark/pkg/agent/controllers/task/push_deployment.go
index 902ad18..3699d66 100644
--- a/apps/rlark/pkg/agent/controllers/task/push_deployment.go
+++ b/apps/rlark/pkg/agent/controllers/task/push_deployment.go
@@ -3,6 +3,8 @@ package task
import (
"context"
"fmt"
+ "reflect"
+ "sort"
"github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
@@ -36,7 +38,6 @@ func (r *pushDeploymentReconciler) Reconcile(ctx context.Context, req reconcile.
taskName := deploy.Annotations[ManagementTaskNameAnnotation]
taskNamespace := deploy.Annotations[ManagementTaskNamespaceAnnotation]
- taskUID := deploy.Annotations[ManagementTaskUIDAnnotation]
if taskName == "" || taskNamespace == "" {
logger.V(1).Info("Deployment has no management-task annotation, skipping")
@@ -53,19 +54,15 @@ func (r *pushDeploymentReconciler) Reconcile(ctx context.Context, req reconcile.
return reconcile.Result{}, nil
}
- // AgentType check
- if mgmtTask.Spec.AgentType != rlarkv1alpha1.AgentType(r.c.AgentType) {
- logger.Info(fmt.Sprintf("Task AgentType %s does not match controller AgentType %s, skipping", mgmtTask.Spec.AgentType, r.c.AgentType))
+ if !pushOwnsTask(&mgmtTask, r.c.AgentType, &deploy, rlarkv1alpha1.KubernetesWorkloadDeployment) {
+ logger.Info("Deployment no longer owns management Task status, skipping")
return reconcile.Result{}, nil
}
- // UID match check
- if string(mgmtTask.UID) != taskUID {
- logger.Info("management Task UID mismatch with annotation, skipping")
- return reconcile.Result{}, nil
+ phase, message, pods, err := deploymentPhase(ctx, r.c.LocalKubeClient, &deploy)
+ if err != nil {
+ return reconcile.Result{}, err
}
-
- phase, message, pods := deploymentPhase(ctx, logger, r.c.LocalKubeClient, &deploy)
observedNodes := podNodeNames(pods)
// pullProgress aggregation is now performed by the control-plane Task
// reconciler from Node.status.pullProgress, so the cluster-agent no
@@ -73,11 +70,11 @@ func (r *pushDeploymentReconciler) Reconcile(ctx context.Context, req reconcile.
return updateMgmtTaskStatus(ctx, logger, r.c.ManagementClient, &mgmtTask, phase, message, observedNodes)
}
-func deploymentPhase(ctx context.Context, logger logr.Logger, localClient client.Client, deploy *appsv1.Deployment) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod) {
+func deploymentPhase(ctx context.Context, localClient client.Client, deploy *appsv1.Deployment) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod, error) {
phase, message := deploymentStatusPhase(deploy)
- pods, err := listTaskPods(ctx, localClient, deploy.Namespace, deploy.Spec.Selector.MatchLabels)
+ pods, err := listTaskPods(ctx, localClient, deploy, deploy.Spec.Selector.MatchLabels)
if err != nil {
- logger.Error(err, "failed to list pods")
+ return "", "", nil, err
}
// Override to Failed when any pod container is in an abnormal state
// (CrashLoopBackOff, ImagePullBackOff, OOMKilled, etc.) so operators
@@ -88,12 +85,12 @@ func deploymentPhase(ctx context.Context, logger logr.Logger, localClient client
}
if phase == rlarkv1alpha1.TaskPhasePending {
if found, err := hasFailedSchedulingEvent(ctx, localClient, deploy.Namespace, pods); err != nil {
- logger.Error(err, "failed to list pod scheduling events")
+ return "", "", nil, err
} else if found {
message = "FailedScheduling"
}
}
- return phase, message, pods
+ return phase, message, pods, nil
}
func deploymentStatusPhase(deploy *appsv1.Deployment) (rlarkv1alpha1.TaskPhase, string) {
@@ -101,17 +98,21 @@ func deploymentStatusPhase(deploy *appsv1.Deployment) (rlarkv1alpha1.TaskPhase,
if desired == 0 {
return rlarkv1alpha1.TaskPhaseStopped, ""
}
+ if deploy.Status.ObservedGeneration < deploy.Generation {
+ return rlarkv1alpha1.TaskPhasePending, ""
+ }
for _, cond := range deploy.Status.Conditions {
+ if cond.Type == appsv1.DeploymentProgressing && cond.Status == corev1.ConditionFalse && cond.Reason == "ProgressDeadlineExceeded" {
+ return rlarkv1alpha1.TaskPhaseFailed, cond.Message
+ }
if cond.Type == appsv1.DeploymentReplicaFailure && cond.Status == corev1.ConditionTrue {
return rlarkv1alpha1.TaskPhaseFailed, cond.Message
}
}
- if deploy.Status.ReadyReplicas >= desired && deploy.Status.Replicas >= desired {
+ if deploy.Status.UpdatedReplicas == desired && deploy.Status.ReadyReplicas == desired &&
+ deploy.Status.AvailableReplicas == desired && deploy.Status.Replicas == desired {
return rlarkv1alpha1.TaskPhaseRunning, ""
}
- if deploy.Status.UnavailableReplicas > 0 {
- return rlarkv1alpha1.TaskPhaseFailed, fmt.Sprintf("deployment replicas unavailable %d", deploy.Status.UnavailableReplicas)
- }
return rlarkv1alpha1.TaskPhasePending, ""
}
@@ -183,27 +184,83 @@ func hasFailedSchedulingEvent(ctx context.Context, localClient client.Client, na
if event.InvolvedObject.Kind != "Pod" || event.Reason != "FailedScheduling" {
continue
}
- if _, ok := podNames[event.InvolvedObject.Name]; ok {
+ if _, ok := podNames[event.InvolvedObject.Name]; ok && (event.InvolvedObject.UID == "" || event.InvolvedObject.UID == podUID(pods, event.InvolvedObject.Name)) {
return true, nil
}
}
return false, nil
}
+func podUID(pods []corev1.Pod, name string) types.UID {
+ for i := range pods {
+ if pods[i].Name == name {
+ return pods[i].UID
+ }
+ }
+ return ""
+}
+
// --- shared helper functions for push reconcilers ---
// listTaskPods lists the local pods backing a workload via its selector labels.
-func listTaskPods(ctx context.Context, localClient client.Client, namespace string, labels map[string]string) ([]corev1.Pod, error) {
+func listTaskPods(ctx context.Context, localClient client.Client, workload client.Object, labels map[string]string) ([]corev1.Pod, error) {
var podList corev1.PodList
labelSelector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{MatchLabels: labels})
if err != nil {
return nil, fmt.Errorf("failed to build label selector: %w", err)
}
- if err := localClient.List(ctx, &podList, client.InNamespace(namespace), client.MatchingLabelsSelector{Selector: labelSelector}); err != nil {
+ if err := localClient.List(ctx, &podList, client.InNamespace(workload.GetNamespace()), client.MatchingLabelsSelector{Selector: labelSelector}); err != nil {
return nil, fmt.Errorf("failed to list Pods: %w", err)
}
- return podList.Items, nil
+ pods := podList.Items[:0]
+ for i := range podList.Items {
+ pod := &podList.Items[i]
+ owner := metav1.GetControllerOf(pod)
+ if owner == nil {
+ continue
+ }
+ if owner.Kind == workloadKind(workload) && owner.Name == workload.GetName() && owner.UID == workload.GetUID() {
+ pods = append(pods, *pod)
+ continue
+ }
+ if _, ok := workload.(*appsv1.Deployment); !ok || owner.Kind != "ReplicaSet" {
+ continue
+ }
+ var rs appsv1.ReplicaSet
+ if err := localClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: pod.Namespace}, &rs); err != nil {
+ if client.IgnoreNotFound(err) != nil {
+ return nil, err
+ }
+ continue
+ }
+ if rs.UID != owner.UID {
+ continue
+ }
+ depOwner := metav1.GetControllerOf(&rs)
+ if depOwner != nil && depOwner.Kind == "Deployment" && depOwner.Name == workload.GetName() && depOwner.UID == workload.GetUID() {
+ pods = append(pods, *pod)
+ }
+ }
+ return pods, nil
+}
+
+func workloadKind(obj client.Object) string {
+ switch obj.(type) {
+ case *appsv1.Deployment:
+ return "Deployment"
+ case *appsv1.StatefulSet:
+ return "StatefulSet"
+ case *appsv1.DaemonSet:
+ return "DaemonSet"
+ default:
+ return ""
+ }
+}
+
+func pushOwnsTask(task *rlarkv1alpha1.Task, agentType string, workload client.Object, kind rlarkv1alpha1.KubernetesWorkloadKind) bool {
+ return task.Spec.AgentType == rlarkv1alpha1.AgentType(agentType) && workloadOwnershipForTask(workload, task) != workloadConflict &&
+ task.Spec.Kubernetes != nil && task.Spec.Kubernetes.Workload != nil && task.Spec.Kubernetes.Workload.Kind == kind
}
// podNodeNames returns the node names where the given pods are scheduled.
@@ -214,9 +271,24 @@ func podNodeNames(pods []corev1.Pod) []string {
nodes = append(nodes, pod.Spec.NodeName)
}
}
+ sort.Strings(nodes)
+ nodes = slicesCompact(nodes)
return nodes
}
+func slicesCompact(values []string) []string {
+ if len(values) == 0 {
+ return values
+ }
+ out := values[:1]
+ for _, value := range values[1:] {
+ if value != out[len(out)-1] {
+ out = append(out, value)
+ }
+ }
+ return out
+}
+
// updateMgmtTaskStatus reports the workload phase/message/observedNodes to the
// management Task status. Pull progress and events are aggregated by the
// control-plane Task reconciler, and are cleared when the task stops.
@@ -227,6 +299,7 @@ func podNodeNames(pods []corev1.Pod) []string {
func updateMgmtTaskStatus(ctx context.Context, logger logr.Logger, mgmtClient client.Client, mgmtTask *rlarkv1alpha1.Task, phase rlarkv1alpha1.TaskPhase, message string, observedNodes []string) (reconcile.Result, error) {
stopped := phase == rlarkv1alpha1.TaskPhaseStopped
unchanged := mgmtTask.Status.Phase == phase && mgmtTask.Status.Message == message &&
+ reflect.DeepEqual(mgmtTask.Status.ObservedNodes, observedNodes) &&
(!stopped || (len(mgmtTask.Status.PullProgress) == 0 && len(mgmtTask.Status.Events) == 0))
if unchanged {
@@ -241,9 +314,7 @@ func updateMgmtTaskStatus(ctx context.Context, logger logr.Logger, mgmtClient cl
mgmtTask.Status.PullProgress = nil
mgmtTask.Status.Events = nil
}
- if len(observedNodes) > 0 {
- mgmtTask.Status.ObservedNodes = observedNodes
- }
+ mgmtTask.Status.ObservedNodes = observedNodes
if err := mgmtClient.Status().Patch(ctx, mgmtTask, client.MergeFrom(original)); err != nil {
logger.Error(err, "failed to report Task status to management cluster")
diff --git a/apps/rlark/pkg/agent/controllers/task/push_statefulset.go b/apps/rlark/pkg/agent/controllers/task/push_statefulset.go
index 3e784ba..8bee2c2 100644
--- a/apps/rlark/pkg/agent/controllers/task/push_statefulset.go
+++ b/apps/rlark/pkg/agent/controllers/task/push_statefulset.go
@@ -2,7 +2,6 @@ package task
import (
"context"
- "fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
@@ -11,7 +10,6 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
- "github.com/go-logr/logr"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
)
@@ -35,8 +33,6 @@ func (r *pushStatefulSetReconciler) Reconcile(ctx context.Context, req reconcile
taskName := sts.Annotations[ManagementTaskNameAnnotation]
taskNamespace := sts.Annotations[ManagementTaskNamespaceAnnotation]
- taskUID := sts.Annotations[ManagementTaskUIDAnnotation]
-
if taskName == "" || taskNamespace == "" {
logger.V(1).Info("StatefulSet has no management-task annotation, skipping")
return reconcile.Result{}, nil
@@ -52,36 +48,36 @@ func (r *pushStatefulSetReconciler) Reconcile(ctx context.Context, req reconcile
return reconcile.Result{}, nil
}
- if mgmtTask.Spec.AgentType != rlarkv1alpha1.AgentType(r.c.AgentType) {
- logger.Info(fmt.Sprintf("Task AgentType %s does not match controller AgentType %s, skipping", mgmtTask.Spec.AgentType, r.c.AgentType))
+ if !pushOwnsTask(&mgmtTask, r.c.AgentType, &sts, rlarkv1alpha1.KubernetesWorkloadStatefulSet) {
+ logger.Info("StatefulSet no longer owns management Task status, skipping")
return reconcile.Result{}, nil
}
- if string(mgmtTask.UID) != taskUID {
- logger.Info("management Task UID mismatch with annotation, skipping")
- return reconcile.Result{}, nil
+ phase, message, pods, err := statefulSetPhase(ctx, r.c.LocalKubeClient, &sts)
+ if err != nil {
+ return reconcile.Result{}, err
}
-
- phase, message, pods := statefulSetPhase(ctx, logger, r.c.LocalKubeClient, &sts)
observedNodes := podNodeNames(pods)
return updateMgmtTaskStatus(ctx, logger, r.c.ManagementClient, &mgmtTask, phase, message, observedNodes)
}
-func statefulSetPhase(ctx context.Context, logger logr.Logger, localClient client.Client, sts *appsv1.StatefulSet) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod) {
+func statefulSetPhase(ctx context.Context, localClient client.Client, sts *appsv1.StatefulSet) (rlarkv1alpha1.TaskPhase, string, []corev1.Pod, error) {
desired := computeDesiredReplicas(sts.Spec.Replicas)
var phase rlarkv1alpha1.TaskPhase
switch {
case desired == 0:
phase = rlarkv1alpha1.TaskPhaseStopped
- case sts.Status.ReadyReplicas >= desired:
+ case sts.Status.ObservedGeneration >= sts.Generation && sts.Status.UpdatedReplicas == desired &&
+ sts.Status.ReadyReplicas == desired && sts.Status.CurrentReplicas == desired &&
+ sts.Status.CurrentRevision != "" && sts.Status.CurrentRevision == sts.Status.UpdateRevision:
phase = rlarkv1alpha1.TaskPhaseRunning
default:
phase = rlarkv1alpha1.TaskPhasePending
}
- pods, err := listTaskPods(ctx, localClient, sts.Namespace, sts.Spec.Selector.MatchLabels)
+ pods, err := listTaskPods(ctx, localClient, sts, sts.Spec.Selector.MatchLabels)
if err != nil {
- logger.Error(err, "failed to list pods")
+ return "", "", nil, err
}
// Override to Failed when any pod container is in an abnormal state
// (CrashLoopBackOff, ImagePullBackOff, OOMKilled, etc.) so operators
@@ -93,10 +89,10 @@ func statefulSetPhase(ctx context.Context, logger logr.Logger, localClient clien
}
if phase == rlarkv1alpha1.TaskPhasePending {
if found, err := hasFailedSchedulingEvent(ctx, localClient, sts.Namespace, pods); err != nil {
- logger.Error(err, "failed to list pod scheduling events")
+ return "", "", nil, err
} else if found {
message = "FailedScheduling"
}
}
- return phase, message, pods
+ return phase, message, pods, nil
}
diff --git a/apps/rlark/pkg/agent/controllers/task/ssh_init.go b/apps/rlark/pkg/agent/controllers/task/ssh_init.go
index 24a5883..88dad80 100644
--- a/apps/rlark/pkg/agent/controllers/task/ssh_init.go
+++ b/apps/rlark/pkg/agent/controllers/task/ssh_init.go
@@ -1,25 +1,24 @@
package task
import (
- rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
corev1 "k8s.io/api/core/v1"
)
const (
- sshServerInitContainerName = "rlark-sshd-init"
- sshServerVolumeName = "rlark-sshd"
- sshServerBinPath = "/usr/local/bin/rlark-sshd"
- sshServerBinDst = "/sshd/rlark-sshd"
- sshServerDstDir = "/sshd"
+ rlarkToolsInitContainerName = "rlark-tools-init"
+ rlarkToolsVolumeName = "rlark-tools"
+ rlarkToolsBinPath = "/usr/local/bin/rlark-tools"
+ rlarkToolsBinDst = "/rlark-tools/rlark-tools"
+ rlarkToolsDstDir = "/rlark-tools"
)
-func applySSHServer(template *corev1.PodTemplateSpec, mgmtTask *rlarkv1alpha1.Task, image string) {
- if image == "" || mgmtTask.Spec.SSHPublicKey == "" {
+func applyRLarkTools(template *corev1.PodTemplateSpec, image string) {
+ if image == "" {
return
}
template.Spec.Volumes = append(template.Spec.Volumes, corev1.Volume{
- Name: sshServerVolumeName,
+ Name: rlarkToolsVolumeName,
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
@@ -31,21 +30,21 @@ func applySSHServer(template *corev1.PodTemplateSpec, mgmtTask *rlarkv1alpha1.Ta
continue
}
c.VolumeMounts = append(c.VolumeMounts, corev1.VolumeMount{
- Name: sshServerVolumeName,
- MountPath: sshServerDstDir,
+ Name: rlarkToolsVolumeName,
+ MountPath: rlarkToolsDstDir,
})
break
}
template.Spec.InitContainers = append(template.Spec.InitContainers, corev1.Container{
- Name: sshServerInitContainerName,
+ Name: rlarkToolsInitContainerName,
Image: image,
ImagePullPolicy: corev1.PullIfNotPresent,
- Command: []string{"cp", sshServerBinPath, sshServerBinDst},
+ Command: []string{"cp", rlarkToolsBinPath, rlarkToolsBinDst},
VolumeMounts: []corev1.VolumeMount{
{
- Name: sshServerVolumeName,
- MountPath: sshServerDstDir,
+ Name: rlarkToolsVolumeName,
+ MountPath: rlarkToolsDstDir,
},
},
})
diff --git a/apps/rlark/pkg/agent/local_http.go b/apps/rlark/pkg/agent/local_http.go
index b0a2163..32b7daa 100644
--- a/apps/rlark/pkg/agent/local_http.go
+++ b/apps/rlark/pkg/agent/local_http.go
@@ -4,19 +4,18 @@ import (
"context"
"net/http"
"net/http/httputil"
+ "net/http/pprof"
"net/url"
"strings"
"github.com/gin-gonic/gin"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
)
func (a *Agent) runLocalHTTPServer(ctx context.Context) error {
logger := log.FromContext(ctx)
- r := gin.Default()
- r.Any("/api/kubernetes/*path", a.handleKubernetesProxy)
- r.GET("/api/terminal/:namespace/:pod", a.handleTerminal)
- r.Any("/api/proxy/*path", a.handleProxy)
+ r := a.localHTTPRouter()
server := http.Server{
Handler: r,
@@ -34,6 +33,45 @@ func (a *Agent) runLocalHTTPServer(ctx context.Context) error {
return server.Serve(a.localListener)
}
+func (a *Agent) localHTTPRouter() http.Handler {
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.Any("/api/kubernetes/*path", a.handleKubernetesProxy)
+ r.GET("/api/terminal/:namespace/:pod", a.handleTerminal)
+ r.Any("/api/proxy/*path", a.handleProxy)
+ return r
+}
+
+func (a *Agent) healthRouter() http.Handler {
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.GET("/livez", func(ctx *gin.Context) {
+ ctx.Status(http.StatusOK)
+ })
+ r.GET("/readyz", func(ctx *gin.Context) {
+ if !a.ready.Load() {
+ ctx.String(http.StatusServiceUnavailable, "not ready")
+ return
+ }
+ ctx.Status(http.StatusOK)
+ })
+ r.POST("/drain", func(ctx *gin.Context) {
+ a.startDrain()
+ ctx.Status(http.StatusOK)
+ })
+ r.GET("/metrics", gin.WrapH(promhttp.Handler()))
+ r.GET("/debug/pprof/", gin.WrapF(pprof.Index))
+ r.GET("/debug/pprof/cmdline", gin.WrapF(pprof.Cmdline))
+ r.GET("/debug/pprof/profile", gin.WrapF(pprof.Profile))
+ r.POST("/debug/pprof/symbol", gin.WrapF(pprof.Symbol))
+ r.GET("/debug/pprof/symbol", gin.WrapF(pprof.Symbol))
+ r.GET("/debug/pprof/trace", gin.WrapF(pprof.Trace))
+ r.GET("/debug/pprof/:profile", func(ctx *gin.Context) {
+ pprof.Handler(ctx.Param("profile")).ServeHTTP(ctx.Writer, ctx.Request)
+ })
+ return r
+}
+
func (a *Agent) handleKubernetesProxy(ctx *gin.Context) {
if a.localKubeHandler == nil {
ctx.AbortWithStatus(http.StatusServiceUnavailable)
diff --git a/apps/rlark/pkg/agent/local_http_test.go b/apps/rlark/pkg/agent/local_http_test.go
new file mode 100644
index 0000000..260169b
--- /dev/null
+++ b/apps/rlark/pkg/agent/local_http_test.go
@@ -0,0 +1,47 @@
+package agent
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHealthEndpoints(t *testing.T) {
+ a := NewAgent(DefaultConfig())
+ router := a.healthRouter()
+
+ assertStatus(t, router, http.MethodGet, "/livez", http.StatusOK)
+ assertStatus(t, router, http.MethodGet, "/readyz", http.StatusServiceUnavailable)
+ a.SetReady(true)
+ assertStatus(t, router, http.MethodGet, "/readyz", http.StatusOK)
+ assertStatus(t, router, http.MethodGet, "/metrics", http.StatusOK)
+ assertStatus(t, router, http.MethodGet, "/debug/pprof/", http.StatusOK)
+ assertStatus(t, router, http.MethodGet, "/debug/pprof/goroutine", http.StatusOK)
+}
+
+func TestDrainMarksAgentNotReadyAndCancels(t *testing.T) {
+ a := NewAgent(DefaultConfig())
+ drainCtx, cancel := context.WithCancel(context.Background())
+ a.drainMu.Lock()
+ a.drain = cancel
+ a.drainMu.Unlock()
+ a.SetReady(true)
+
+ assertStatus(t, a.healthRouter(), http.MethodPost, "/drain", http.StatusOK)
+ assertStatus(t, a.healthRouter(), http.MethodGet, "/readyz", http.StatusServiceUnavailable)
+ select {
+ case <-drainCtx.Done():
+ default:
+ t.Fatal("drain did not cancel agent context")
+ }
+}
+
+func assertStatus(t *testing.T, handler http.Handler, method, path string, want int) {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
+ if recorder.Code != want {
+ t.Fatalf("%s %s status = %d, want %d", method, path, recorder.Code, want)
+ }
+}
diff --git a/apps/rlark/pkg/agent/node_agent.go b/apps/rlark/pkg/agent/node_agent.go
index 2581b1c..81f571d 100644
--- a/apps/rlark/pkg/agent/node_agent.go
+++ b/apps/rlark/pkg/agent/node_agent.go
@@ -4,10 +4,8 @@ import (
"context"
"encoding/json"
"net/http"
- _ "net/http/pprof" // 注册 /debug/pprof 到 DefaultServeMux
"time"
- "github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -166,10 +164,12 @@ func (n *nodeAgent) Run(ctx context.Context) error {
managementPodLister,
n.a.config.RLarkServerSSHAddress,
n.a.config.RLarkServerSSHHostKey,
+ n.a.config.SSHMaxConnectionsPerDomain,
n.a.config.EnableSameClusterDirect,
n.a.config.EnableCrossClusterDirect,
n.a.config.KubeletDir,
)
+ defer func() { _ = networkAdapter.Close() }()
nodeserver := nodeserver.NewNodeServer(
n.a.config.NodeServerConfig,
networkAdapter.GetContainerNetworkCred,
@@ -181,13 +181,11 @@ func (n *nodeAgent) Run(ctx context.Context) error {
var eg errgroup.Group
eg.Go(func() error {
- return nodeserver.Run(ctx)
+ return nodeserver.Run(ctx, n.a)
})
- // metrics/pprof HTTP server,复用 --metrics-bind-address(默认 :8081)
+ // Health, metrics, and pprof HTTP server, using --metrics-bind-address (default :8081).
eg.Go(func() error {
- mux := http.DefaultServeMux
- mux.Handle("/metrics", promhttp.Handler())
- srv := &http.Server{Addr: n.a.config.MetricsBindAddress, Handler: mux}
+ srv := &http.Server{Addr: n.a.config.MetricsBindAddress, Handler: n.a.healthRouter()}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
diff --git a/apps/rlark/pkg/agent/tunnel.go b/apps/rlark/pkg/agent/tunnel.go
index 62caaa3..b245f2a 100644
--- a/apps/rlark/pkg/agent/tunnel.go
+++ b/apps/rlark/pkg/agent/tunnel.go
@@ -2,14 +2,14 @@ package agent
import (
"context"
+ "math/rand"
"net"
"net/http"
"time"
- "github.com/rancher/remotedialer"
-
"github.com/rlinf/rlark/apps/rlark/pkg/apis"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
)
func (a *Agent) runTunnel(ctx context.Context, role string) error {
@@ -29,6 +29,7 @@ func (a *Agent) runTunnel(ctx context.Context, role string) error {
header.Set(apis.RemoteDialerRoleHeader, role)
}
connect := func() error {
+ startedAt := time.Now()
ws, _, err := a.serverClient.DialWebsocket(ctx, header)
if err != nil {
return err
@@ -42,6 +43,9 @@ func (a *Agent) runTunnel(ctx context.Context, role string) error {
defer session.Close()
_, err = session.Serve(sessCtx)
+ stage, duration := session.Diagnostics()
+ logger.Info("tunnel session ended", "role", role, "duration", duration, "stage", stage)
+ _ = startedAt
return err
}
@@ -52,8 +56,7 @@ func (a *Agent) runTunnel(ctx context.Context, role string) error {
select {
case <-ctx.Done():
return nil
- default:
- time.Sleep(5 * time.Second)
+ case <-time.After(time.Duration(4_000+rand.Intn(2_001)) * time.Millisecond):
}
}
}
diff --git a/apps/rlark/pkg/common/constants.go b/apps/rlark/pkg/common/constants.go
index ea83afe..844ee2b 100644
--- a/apps/rlark/pkg/common/constants.go
+++ b/apps/rlark/pkg/common/constants.go
@@ -8,7 +8,10 @@ const (
SSHUserKeySecretName = "rlark-ssh-keys"
// UIAuthSecretName is the KCP Secret holding UI auth credentials.
- UIAuthSecretName = "rlark-ui-auth"
+ UIAuthSecretName = "rlark-ui-auth"
+ UIAuthAdminPasswordKey = "admin-password"
+ UIAuthUserPasswordKey = "user-password"
+ UIAuthJWTSigningKey = "jwt-signing-key"
// AdminCertSecretName is the KCP Secret holding the admin signing cert.
AdminCertSecretName = "rlark-admin-cert"
@@ -25,8 +28,10 @@ const (
// AgentCertSecretPrefix is the prefix for per-cluster agent cert secrets.
AgentCertSecretPrefix = "rlark-agent-cert-"
- // ImageRegistrySecretLabel is the label on Secrets that hold image registry credentials.
- ImageRegistrySecretLabel = "rlark.io/image-registry"
+ ImageRegistryReplicationLabel = "rlark.io/image-registry-replication"
+ ImageRegistryDeliveryLabel = "rlark.io/image-registry-delivery"
+ ImageRegistryCredentialLabel = "rlark.io/image-registry-credential"
+ ImageRegistryCredentialDataKey = "credential.json"
// ImageRegistryAnnotationRegistry is the annotation storing the registry URL.
ImageRegistryAnnotationRegistry = "rlark.io/registry"
@@ -46,6 +51,8 @@ const (
SystemConfigKeySSH = "ssh"
// SystemConfigKeyLog stores log backend config as a JSON object.
SystemConfigKeyLog = "log"
+ // SystemConfigKeyDeployment stores data-plane deployment YAML defaults as a JSON object.
+ SystemConfigKeyDeployment = "deployment"
)
// Constants used by the package.
diff --git a/apps/rlark/pkg/configs/configs.go b/apps/rlark/pkg/configs/kubernetes_client.go
similarity index 100%
rename from apps/rlark/pkg/configs/configs.go
rename to apps/rlark/pkg/configs/kubernetes_client.go
diff --git a/apps/rlark/pkg/configs/leader_election.go b/apps/rlark/pkg/configs/leader_election.go
new file mode 100644
index 0000000..fc90713
--- /dev/null
+++ b/apps/rlark/pkg/configs/leader_election.go
@@ -0,0 +1,132 @@
+package configs
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/spf13/pflag"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/tools/leaderelection"
+ "k8s.io/client-go/tools/leaderelection/resourcelock"
+)
+
+// LeaderElectionConfig configures a Kubernetes Lease-based leader election.
+type LeaderElectionConfig struct {
+ Enabled bool
+ Key string
+ Identity string
+ LeaseDuration time.Duration
+ RenewDeadline time.Duration
+ RetryPeriod time.Duration
+}
+
+// ResourceLock creates the Lease lock described by the configuration.
+func (c LeaderElectionConfig) ResourceLock(client kubernetes.Interface, defaultNamespace string) (resourcelock.Interface, error) {
+ if err := c.Validate(defaultNamespace); err != nil {
+ return nil, err
+ }
+ if c.Identity == "" {
+ return nil, fmt.Errorf("leader election identity is required")
+ }
+ namespace, name, _ := c.NamespaceAndName(defaultNamespace)
+ lock, err := resourcelock.New(
+ resourcelock.LeasesResourceLock,
+ namespace,
+ name,
+ client.CoreV1(),
+ client.CoordinationV1(),
+ resourcelock.ResourceLockConfig{Identity: c.Identity},
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create leader election resource lock: %w", err)
+ }
+ return lock, nil
+}
+
+// DefaultLeaderElectionConfig returns the standard leader election timings.
+func DefaultLeaderElectionConfig() LeaderElectionConfig {
+ return LeaderElectionConfig{
+ LeaseDuration: 30 * time.Second,
+ RenewDeadline: 10 * time.Second,
+ RetryPeriod: 5 * time.Second,
+ }
+}
+
+// SetupFlags registers leader election flags with an optional prefix.
+func (c *LeaderElectionConfig) SetupFlags(fs *pflag.FlagSet, prefix string) {
+ prefix = strings.TrimSuffix(prefix, "-")
+ if prefix != "" {
+ prefix += "-"
+ }
+ fs.BoolVar(&c.Enabled, prefix+"leader-election", c.Enabled, "Enable leader election")
+ fs.StringVar(&c.Key, prefix+"leader-election-key", c.Key, "Leader election lock key (name or namespace/name)")
+ fs.StringVar(&c.Identity, prefix+"leader-election-id", c.Identity, "Unique identity for this leader election participant")
+}
+
+// NamespaceAndName resolves a key in name or namespace/name form.
+func (c LeaderElectionConfig) NamespaceAndName(defaultNamespace string) (string, string, error) {
+ parts := strings.Split(c.Key, "/")
+ switch len(parts) {
+ case 1:
+ if parts[0] == "" {
+ return "", "", fmt.Errorf("leader election key name is required")
+ }
+ return defaultNamespace, parts[0], nil
+ case 2:
+ if parts[0] == "" || parts[1] == "" {
+ return "", "", fmt.Errorf("leader election key must be name or namespace/name, got %q", c.Key)
+ }
+ return parts[0], parts[1], nil
+ default:
+ return "", "", fmt.Errorf("leader election key must be name or namespace/name, got %q", c.Key)
+ }
+}
+
+// Validate validates the lock key and any explicitly configured timings.
+func (c LeaderElectionConfig) Validate(defaultNamespace string) error {
+ if _, _, err := c.NamespaceAndName(defaultNamespace); err != nil {
+ return err
+ }
+ durations := []time.Duration{c.LeaseDuration, c.RenewDeadline, c.RetryPeriod}
+ configured := 0
+ for _, duration := range durations {
+ if duration < 0 {
+ return fmt.Errorf("leader election durations must not be negative")
+ }
+ if duration > 0 {
+ configured++
+ }
+ }
+ if configured != 0 && configured != len(durations) {
+ return fmt.Errorf("leader election timings must be configured together")
+ }
+ if configured > 0 && c.LeaseDuration <= c.RenewDeadline {
+ return fmt.Errorf("leader election lease duration must be greater than renew deadline")
+ }
+ if configured > 0 && c.RenewDeadline <= c.RetryPeriod {
+ return fmt.Errorf("leader election renew deadline must be greater than retry period")
+ }
+ return nil
+}
+
+// Build creates a client-go leader election configuration using a Lease lock.
+func (c LeaderElectionConfig) Build(client kubernetes.Interface, defaultNamespace string, callbacks leaderelection.LeaderCallbacks) (leaderelection.LeaderElectionConfig, error) {
+ if err := c.Validate(defaultNamespace); err != nil {
+ return leaderelection.LeaderElectionConfig{}, err
+ }
+ if c.LeaseDuration == 0 {
+ return leaderelection.LeaderElectionConfig{}, fmt.Errorf("leader election timings are required")
+ }
+ lock, err := c.ResourceLock(client, defaultNamespace)
+ if err != nil {
+ return leaderelection.LeaderElectionConfig{}, err
+ }
+ return leaderelection.LeaderElectionConfig{
+ Lock: lock,
+ LeaseDuration: c.LeaseDuration,
+ RenewDeadline: c.RenewDeadline,
+ RetryPeriod: c.RetryPeriod,
+ Callbacks: callbacks,
+ }, nil
+}
diff --git a/apps/rlark/pkg/configs/leader_election_test.go b/apps/rlark/pkg/configs/leader_election_test.go
new file mode 100644
index 0000000..fd4fb5d
--- /dev/null
+++ b/apps/rlark/pkg/configs/leader_election_test.go
@@ -0,0 +1,108 @@
+package configs
+
+import (
+ "testing"
+ "time"
+
+ "github.com/spf13/pflag"
+ "k8s.io/client-go/kubernetes/fake"
+ "k8s.io/client-go/tools/leaderelection"
+)
+
+func TestLeaderElectionConfigSetupFlags(t *testing.T) {
+ cfg := LeaderElectionConfig{}
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ cfg.SetupFlags(fs, "")
+ if err := fs.Parse([]string{
+ "--leader-election=true",
+ "--leader-election-key=rlark-system/test",
+ "--leader-election-id=instance-1",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !cfg.Enabled || cfg.Key != "rlark-system/test" || cfg.Identity != "instance-1" {
+ t.Fatalf("leader election config = %+v", cfg)
+ }
+}
+
+func TestLeaderElectionConfigSetupFlagsPrefix(t *testing.T) {
+ cfg := LeaderElectionConfig{}
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ cfg.SetupFlags(fs, "controller")
+ if fs.Lookup("controller-leader-election") == nil || fs.Lookup("controller-leader-election-key") == nil || fs.Lookup("controller-leader-election-id") == nil {
+ t.Fatal("SetupFlags did not register prefixed flags")
+ }
+}
+
+func TestDefaultLeaderElectionConfig(t *testing.T) {
+ cfg := DefaultLeaderElectionConfig()
+ if cfg.LeaseDuration != 30*time.Second || cfg.RenewDeadline != 10*time.Second || cfg.RetryPeriod != 5*time.Second {
+ t.Fatalf("default leader election config = %+v", cfg)
+ }
+}
+
+func TestLeaderElectionConfigNamespaceAndName(t *testing.T) {
+ tests := []struct {
+ key string
+ wantNamespace string
+ wantName string
+ wantErr bool
+ }{
+ {key: "lock", wantNamespace: "default", wantName: "lock"},
+ {key: "rlark-system/lock", wantNamespace: "rlark-system", wantName: "lock"},
+ {key: "", wantErr: true},
+ {key: "/lock", wantErr: true},
+ {key: "namespace/", wantErr: true},
+ {key: "a/b/c", wantErr: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.key, func(t *testing.T) {
+ namespace, name, err := (LeaderElectionConfig{Key: tt.key}).NamespaceAndName("default")
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("NamespaceAndName() expected an error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("NamespaceAndName() error = %v", err)
+ }
+ if namespace != tt.wantNamespace || name != tt.wantName {
+ t.Fatalf("NamespaceAndName() = %s/%s, want %s/%s", namespace, name, tt.wantNamespace, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestLeaderElectionConfigBuild(t *testing.T) {
+ cfg := LeaderElectionConfig{
+ Key: "rlark-system/test-lock",
+ Identity: "test-instance",
+ LeaseDuration: 30 * time.Second,
+ RenewDeadline: 10 * time.Second,
+ RetryPeriod: 5 * time.Second,
+ }
+ built, err := cfg.Build(fake.NewSimpleClientset(), "default", leaderelection.LeaderCallbacks{})
+ if err != nil {
+ t.Fatalf("Build() error = %v", err)
+ }
+ if built.Lock.Describe() != "rlark-system/test-lock" {
+ t.Fatalf("lock = %q, want rlark-system/test-lock", built.Lock.Describe())
+ }
+ if built.LeaseDuration != cfg.LeaseDuration || built.RenewDeadline != cfg.RenewDeadline || built.RetryPeriod != cfg.RetryPeriod {
+ t.Fatalf("unexpected timings: %#v", built)
+ }
+}
+
+func TestLeaderElectionConfigValidateTimings(t *testing.T) {
+ cfg := LeaderElectionConfig{
+ Key: "lock",
+ Identity: "instance",
+ LeaseDuration: 5 * time.Second,
+ RenewDeadline: 5 * time.Second,
+ RetryPeriod: time.Second,
+ }
+ if err := cfg.Validate("default"); err == nil {
+ t.Fatal("Validate() expected lease duration error")
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/config.go b/apps/rlark/pkg/controllermanager/config.go
index 7245694..05402ba 100644
--- a/apps/rlark/pkg/controllermanager/config.go
+++ b/apps/rlark/pkg/controllermanager/config.go
@@ -1,12 +1,66 @@
package controllermanager
import (
+ "fmt"
+
"github.com/spf13/pflag"
"github.com/rlinf/rlark/apps/rlark/pkg/configs"
- "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/sync"
)
+const defaultMaxConcurrentReconciles = 8
+
+// ControllerConcurrencyConfig configures worker concurrency for each core controller.
+type ControllerConcurrencyConfig struct {
+ Job int
+ Task int
+ Workflow int
+ Node int
+ Domain int
+ JobSync int
+ TaskSync int
+ WorkflowSync int
+ NodeSync int
+}
+
+func defaultControllerConcurrencyConfig() ControllerConcurrencyConfig {
+ return ControllerConcurrencyConfig{
+ Job: defaultMaxConcurrentReconciles,
+ Task: defaultMaxConcurrentReconciles,
+ Workflow: defaultMaxConcurrentReconciles,
+ Node: defaultMaxConcurrentReconciles,
+ Domain: defaultMaxConcurrentReconciles,
+ JobSync: defaultMaxConcurrentReconciles,
+ TaskSync: defaultMaxConcurrentReconciles,
+ WorkflowSync: defaultMaxConcurrentReconciles,
+ NodeSync: defaultMaxConcurrentReconciles,
+ }
+}
+
+func (c *ControllerConcurrencyConfig) SetupFlags(fs *pflag.FlagSet) {
+ fs.IntVar(&c.Job, "job-controller-workers", c.Job, "Maximum concurrent Job reconciles")
+ fs.IntVar(&c.Task, "task-controller-workers", c.Task, "Maximum concurrent Task reconciles")
+ fs.IntVar(&c.Workflow, "workflow-controller-workers", c.Workflow, "Maximum concurrent Workflow reconciles")
+ fs.IntVar(&c.Node, "node-controller-workers", c.Node, "Maximum concurrent Node reconciles")
+ fs.IntVar(&c.Domain, "domain-controller-workers", c.Domain, "Maximum concurrent Domain reconciles")
+ fs.IntVar(&c.JobSync, "job-sync-controller-workers", c.JobSync, "Maximum concurrent Job sync reconciles")
+ fs.IntVar(&c.TaskSync, "task-sync-controller-workers", c.TaskSync, "Maximum concurrent Task sync reconciles")
+ fs.IntVar(&c.WorkflowSync, "workflow-sync-controller-workers", c.WorkflowSync, "Maximum concurrent Workflow sync reconciles")
+ fs.IntVar(&c.NodeSync, "node-sync-controller-workers", c.NodeSync, "Maximum concurrent Node sync reconciles")
+}
+
+func (c ControllerConcurrencyConfig) Validate() error {
+ for name, workers := range map[string]int{
+ "job": c.Job, "task": c.Task, "workflow": c.Workflow, "node": c.Node, "domain": c.Domain,
+ "job-sync": c.JobSync, "task-sync": c.TaskSync, "workflow-sync": c.WorkflowSync, "node-sync": c.NodeSync,
+ } {
+ if workers <= 0 {
+ return fmt.Errorf("%s controller workers must be positive", name)
+ }
+ }
+ return nil
+}
+
// Config holds configuration options.
type Config struct {
// Kubernetes client configuration.
@@ -18,13 +72,12 @@ type Config struct {
// DBConfigPath is the file path to the database configuration (e.g., YAML or JSON).
DBConfigPath string
- LeaderElection bool
- LeaderElectionID string
+ LeaderElection configs.LeaderElectionConfig
MetricsBindAddress string
ProbeBindAddress string
- SyncConfig sync.Config
+ ControllerConcurrency ControllerConcurrencyConfig
}
// DefaultConfig returns the default config.
@@ -34,13 +87,17 @@ func DefaultConfig() Config {
ServerAddress: "https://rlark-server.rlark-system.svc:8443",
DBConfigPath: "",
- LeaderElection: true,
- LeaderElectionID: "rlark-controller-manager",
+ LeaderElection: func() configs.LeaderElectionConfig {
+ config := configs.DefaultLeaderElectionConfig()
+ config.Enabled = true
+ config.Key = "rlark-controller-manager"
+ return config
+ }(),
MetricsBindAddress: ":8080",
ProbeBindAddress: ":8081",
- SyncConfig: sync.DefaultConfig(),
+ ControllerConcurrency: defaultControllerConcurrencyConfig(),
}
}
@@ -50,10 +107,9 @@ func (c *Config) SetupFlags(fs *pflag.FlagSet) {
fs.StringVar(&c.ServerAddress, "server-address", c.ServerAddress, "The address for the RLark server to listen on (e.g., https://:8443)")
fs.StringVar(&c.DBConfigPath, "db-config", c.DBConfigPath, "Path to database configuration file")
- fs.BoolVar(&c.LeaderElection, "leader-elect", c.LeaderElection, "Enable leader election for controller manager")
- fs.StringVar(&c.LeaderElectionID, "leader-election-id", c.LeaderElectionID, "Leader election ID for controller manager")
+ c.LeaderElection.SetupFlags(fs, "")
fs.StringVar(&c.MetricsBindAddress, "metrics-bind-address", c.MetricsBindAddress, "The address the metric endpoint binds to.")
fs.StringVar(&c.ProbeBindAddress, "health-probe-bind-address", c.ProbeBindAddress, "The address the probe endpoint binds to.")
- c.SyncConfig.SetupFlags(fs)
+ c.ControllerConcurrency.SetupFlags(fs)
}
diff --git a/apps/rlark/pkg/controllermanager/config_test.go b/apps/rlark/pkg/controllermanager/config_test.go
new file mode 100644
index 0000000..7f0eb20
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/config_test.go
@@ -0,0 +1,98 @@
+package controllermanager
+
+import (
+ "testing"
+
+ "github.com/spf13/pflag"
+ "k8s.io/client-go/rest"
+)
+
+func TestDefaultControllerConcurrency(t *testing.T) {
+ config := DefaultConfig()
+ got := config.ControllerConcurrency
+ if got.Job != 8 || got.Task != 8 || got.Workflow != 8 || got.Node != 8 || got.Domain != 8 ||
+ got.JobSync != 8 || got.TaskSync != 8 || got.WorkflowSync != 8 || got.NodeSync != 8 {
+ t.Fatalf("default controller concurrency = %+v, want all 8", got)
+ }
+ if err := got.Validate(); err != nil {
+ t.Fatalf("default controller concurrency is invalid: %v", err)
+ }
+}
+
+func TestControllerConcurrencyFlags(t *testing.T) {
+ config := DefaultConfig()
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ config.SetupFlags(fs)
+ if err := fs.Parse([]string{
+ "--job-controller-workers=1",
+ "--task-controller-workers=2",
+ "--workflow-controller-workers=3",
+ "--node-controller-workers=4",
+ "--domain-controller-workers=5",
+ "--job-sync-controller-workers=6",
+ "--task-sync-controller-workers=7",
+ "--workflow-sync-controller-workers=8",
+ "--node-sync-controller-workers=9",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ want := ControllerConcurrencyConfig{
+ Job: 1, Task: 2, Workflow: 3, Node: 4, Domain: 5,
+ JobSync: 6, TaskSync: 7, WorkflowSync: 8, NodeSync: 9,
+ }
+ if config.ControllerConcurrency != want {
+ t.Fatalf("controller concurrency = %+v, want %+v", config.ControllerConcurrency, want)
+ }
+}
+
+func TestControllerConcurrencyValidation(t *testing.T) {
+ config := defaultControllerConcurrencyConfig()
+ config.Domain = 0
+ if err := config.Validate(); err == nil {
+ t.Fatal("expected validation error for zero Domain workers")
+ }
+}
+
+func TestLeaderElectionFlags(t *testing.T) {
+ config := DefaultConfig()
+ fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ config.SetupFlags(fs)
+ if err := fs.Parse([]string{"--leader-election=false", "--leader-election-key=custom-lock"}); err != nil {
+ t.Fatal(err)
+ }
+ if config.LeaderElection.Enabled || config.LeaderElection.Key != "custom-lock" {
+ t.Fatalf("leader election config = %+v", config.LeaderElection)
+ }
+}
+
+func TestManagerOptionsDisabledLeaderElectionAllowsEmptyKey(t *testing.T) {
+ config := DefaultConfig()
+ config.LeaderElection.Enabled = false
+ config.LeaderElection.Key = ""
+ options, err := managerOptions(config, &rest.Config{Host: "https://127.0.0.1"})
+ if err != nil {
+ t.Fatalf("managerOptions() error = %v", err)
+ }
+ if options.LeaderElection {
+ t.Fatal("leader election unexpectedly enabled")
+ }
+}
+
+func TestManagerOptionsUsesLeaderElectionIdentity(t *testing.T) {
+ config := DefaultConfig()
+ config.KubeClientConfig.Namespace = "rlark-system"
+ config.LeaderElection.Identity = "controller-1"
+ options, err := managerOptions(config, &rest.Config{Host: "https://127.0.0.1"})
+ if err != nil {
+ t.Fatalf("managerOptions() error = %v", err)
+ }
+ if options.LeaderElectionResourceLockInterface == nil {
+ t.Fatal("custom leader election lock was not configured")
+ }
+ if options.LeaderElectionResourceLockInterface.Identity() != "controller-1" {
+ t.Fatalf("lock identity = %q, want controller-1", options.LeaderElectionResourceLockInterface.Identity())
+ }
+ if options.LeaderElectionResourceLockInterface.Describe() != "rlark-system/rlark-controller-manager" {
+ t.Fatalf("lock = %q", options.LeaderElectionResourceLockInterface.Describe())
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/controller/base.go b/apps/rlark/pkg/controllermanager/controller/base.go
index d5c90cb..bd0e74b 100644
--- a/apps/rlark/pkg/controllermanager/controller/base.go
+++ b/apps/rlark/pkg/controllermanager/controller/base.go
@@ -2,8 +2,10 @@ package controller
import (
"context"
+ "errors"
"time"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -18,6 +20,12 @@ type Reconciler interface {
IsTerminal(obj client.Object) bool
}
+// ErrRequeueAfterChildCleanup asks the generic reconciler to retry after a
+// child resource has had time to finish stopping or deletion.
+var ErrRequeueAfterChildCleanup = errors.New("requeue after child cleanup")
+
+const ChildCleanupRequeue = time.Second
+
// ReconcileWith reconciles the resource.
func ReconcileWith(
ctx context.Context,
@@ -40,14 +48,24 @@ func ReconcileWith(
changed, err := r.ReconcileStateMachine(ctx, obj)
if err != nil {
+ if errors.Is(err, ErrRequeueAfterChildCleanup) {
+ if changed {
+ if err := r.Status().Update(ctx, obj); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ return ctrl.Result{RequeueAfter: ChildCleanupRequeue}, nil
+ }
logger.Error(err, "reconcile failed")
return ctrl.Result{}, err
}
if changed {
if err := r.Status().Update(ctx, obj); err != nil {
- logger.V(1).Info("status update conflict, requeuing", "error", err.Error())
- return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
+ if apierrors.IsConflict(err) {
+ logger.V(1).Info("status update conflict", "error", err.Error())
+ }
+ return ctrl.Result{}, err
}
}
diff --git a/apps/rlark/pkg/controllermanager/controller/base_test.go b/apps/rlark/pkg/controllermanager/controller/base_test.go
new file mode 100644
index 0000000..20a1f69
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/controller/base_test.go
@@ -0,0 +1,41 @@
+package controller
+
+import (
+ "context"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+type cleanupWaitReconciler struct {
+ client.Client
+}
+
+func (r *cleanupWaitReconciler) ReconcileStateMachine(context.Context, client.Object) (bool, error) {
+ return false, ErrRequeueAfterChildCleanup
+}
+
+func (*cleanupWaitReconciler) IsTerminal(client.Object) bool { return false }
+
+func TestReconcileWithRequeuesChildCleanupWithoutError(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "workflow"}}
+ r := &cleanupWaitReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(wf).Build()}
+
+ result, err := ReconcileWith(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(wf)}, &rlarkv1alpha1.Workflow{}, "workflow", r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.RequeueAfter != ChildCleanupRequeue {
+ t.Fatalf("RequeueAfter = %v, want %v", result.RequeueAfter, ChildCleanupRequeue)
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/controller_manager.go b/apps/rlark/pkg/controllermanager/controller_manager.go
index 08ff184..ca3fd7c 100644
--- a/apps/rlark/pkg/controllermanager/controller_manager.go
+++ b/apps/rlark/pkg/controllermanager/controller_manager.go
@@ -7,7 +7,9 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ "k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/manager"
@@ -17,6 +19,7 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/domain"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/job"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/node"
+ "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/replication"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/sync"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/task"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/workflow"
@@ -42,42 +45,53 @@ func init() {
func New(config Config) (manager.Manager, error) {
logger := log.GetLogger()
ctrl.SetLogger(logger)
+ if err := config.ControllerConcurrency.Validate(); err != nil {
+ return nil, fmt.Errorf("validate controller concurrency: %w", err)
+ }
restConfig, err := config.KubeClientConfig.BuildRestConfig()
if err != nil {
return nil, fmt.Errorf("build Kubernetes client config: %w", err)
}
- mgr, err := ctrl.NewManager(restConfig, ctrl.Options{
- Scheme: scheme,
- Metrics: metricsserver.Options{BindAddress: config.MetricsBindAddress},
- HealthProbeBindAddress: config.ProbeBindAddress,
- LeaderElection: config.LeaderElection,
- LeaderElectionID: config.LeaderElectionID,
- })
+ options, err := managerOptions(config, restConfig)
+ if err != nil {
+ return nil, err
+ }
+ mgr, err := ctrl.NewManager(restConfig, options)
if err != nil {
return nil, fmt.Errorf("create manager: %w", err)
}
+ replicationReconciler, err := replication.New(restConfig, mgr.GetClient())
+ if err != nil {
+ return nil, fmt.Errorf("create replication controller: %w", err)
+ }
reconcilers := []Reconciler{
+ replicationReconciler,
&job.Reconciler{
- Client: mgr.GetClient(),
- Scheme: scheme,
+ Client: mgr.GetClient(),
+ Scheme: scheme,
+ MaxConcurrentReconciles: config.ControllerConcurrency.Job,
},
&task.Reconciler{
- Client: mgr.GetClient(),
- Scheme: scheme,
+ Client: mgr.GetClient(),
+ Scheme: scheme,
+ MaxConcurrentReconciles: config.ControllerConcurrency.Task,
},
&workflow.Reconciler{
- Client: mgr.GetClient(),
- Scheme: scheme,
+ Client: mgr.GetClient(),
+ Scheme: scheme,
+ MaxConcurrentReconciles: config.ControllerConcurrency.Workflow,
},
&node.Reconciler{
- Client: mgr.GetClient(),
- Scheme: scheme,
+ Client: mgr.GetClient(),
+ Scheme: scheme,
+ MaxConcurrentReconciles: config.ControllerConcurrency.Node,
},
&domain.Reconciler{
- Client: mgr.GetClient(),
- Scheme: scheme,
+ Client: mgr.GetClient(),
+ Scheme: scheme,
+ MaxConcurrentReconciles: config.ControllerConcurrency.Domain,
KubeClientConfig: config.KubeClientConfig,
ServerAddress: config.ServerAddress,
@@ -103,10 +117,10 @@ func New(config Config) (manager.Manager, error) {
}
logger.Info("database connected and migrated")
reconcilers = append(reconcilers,
- sync.NewJobReconciler(config.SyncConfig, mgr.GetClient(), database.DB),
- sync.NewTaskReconciler(config.SyncConfig, mgr.GetClient(), database.DB),
- sync.NewWorkflowReconciler(config.SyncConfig, mgr.GetClient(), database.DB),
- sync.NewNodeReconciler(config.SyncConfig, mgr.GetClient(), database.DB),
+ sync.NewJobReconciler(config.ControllerConcurrency.JobSync, mgr.GetClient(), database.DB),
+ sync.NewTaskReconciler(config.ControllerConcurrency.TaskSync, mgr.GetClient(), database.DB),
+ sync.NewWorkflowReconciler(config.ControllerConcurrency.WorkflowSync, mgr.GetClient(), database.DB),
+ sync.NewNodeReconciler(config.ControllerConcurrency.NodeSync, mgr.GetClient(), database.DB),
)
} else {
logger.Error(nil, "RLark controller manager is running without persistent storage.")
@@ -127,3 +141,42 @@ func New(config Config) (manager.Manager, error) {
return mgr, nil
}
+
+func managerOptions(config Config, restConfig *rest.Config) (ctrl.Options, error) {
+ options := ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsserver.Options{BindAddress: config.MetricsBindAddress},
+ HealthProbeBindAddress: config.ProbeBindAddress,
+ LeaderElection: config.LeaderElection.Enabled,
+ }
+ if !config.LeaderElection.Enabled {
+ return options, nil
+ }
+
+ leNamespace, leName, err := config.LeaderElection.NamespaceAndName(config.KubeClientConfig.DefaultNamespace())
+ if err != nil {
+ return ctrl.Options{}, fmt.Errorf("resolve leader election key: %w", err)
+ }
+ if err := config.LeaderElection.Validate(config.KubeClientConfig.DefaultNamespace()); err != nil {
+ return ctrl.Options{}, fmt.Errorf("validate leader election config: %w", err)
+ }
+ options.LeaderElectionID = leName
+ options.LeaderElectionNamespace = leNamespace
+ if config.LeaderElection.Identity != "" {
+ clientset, err := kubernetes.NewForConfig(restConfig)
+ if err != nil {
+ return ctrl.Options{}, fmt.Errorf("create leader election client: %w", err)
+ }
+ lock, err := config.LeaderElection.ResourceLock(clientset, config.KubeClientConfig.DefaultNamespace())
+ if err != nil {
+ return ctrl.Options{}, fmt.Errorf("create leader election lock: %w", err)
+ }
+ options.LeaderElectionResourceLockInterface = lock
+ }
+ if config.LeaderElection.LeaseDuration > 0 {
+ options.LeaseDuration = &config.LeaderElection.LeaseDuration
+ options.RenewDeadline = &config.LeaderElection.RenewDeadline
+ options.RetryPeriod = &config.LeaderElection.RetryPeriod
+ }
+ return options, nil
+}
diff --git a/apps/rlark/pkg/controllermanager/domain/domain_controller.go b/apps/rlark/pkg/controllermanager/domain/domain_controller.go
index 984bc79..4dff796 100644
--- a/apps/rlark/pkg/controllermanager/domain/domain_controller.go
+++ b/apps/rlark/pkg/controllermanager/domain/domain_controller.go
@@ -2,15 +2,20 @@ package domain
import (
"context"
+ "reflect"
+ "sort"
"github.com/go-logr/logr"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
@@ -18,6 +23,8 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/configs"
)
+const podDomainField = "spec.domain"
+
// Reconciler watches Domain and Pod CRs, and generates DomainPeer
// (one per cluster/namespace per domain) containing the pod list.
//
@@ -26,7 +33,8 @@ import (
// field records all pods belonging to that domain in that cluster.
type Reconciler struct {
client.Client
- Scheme *runtime.Scheme
+ Scheme *runtime.Scheme
+ MaxConcurrentReconciles int
// Kubernetes client configuration.
KubeClientConfig configs.KubernetesClientConfig
@@ -57,10 +65,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
logger.Error(err, "invalid CIDR in Domain spec, skip")
return ctrl.Result{}, nil
}
+ originalAllocations := append([]rlarkv1alpha1.DomainIPAllocation(nil), domain.Status.IPAllocations...)
- // 2. List all Pod CRs across all namespaces that belong to this domain
+ // 2. List Pod CRs belonging to this domain through the cache index.
var podList rlarkv1alpha1.PodList
- if err := r.List(ctx, &podList); err != nil {
+ if err := r.List(ctx, &podList, client.MatchingFields{podDomainField: domain.Name}); err != nil {
logger.Error(err, "failed to list Pods")
return ctrl.Result{}, err
}
@@ -76,20 +85,30 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
// 4. Group pods by namespace (each namespace = one cluster).
- // Terminal pods (Succeeded/Failed) are skipped: their UID-named CRs may
+ // Terminal and not-yet-scheduled pods are skipped. A newly created
+ // management Pod receives status in a second API write; waiting for Node and
+ // local IP avoids allocating an address and rewriting every DomainPeer twice.
+ // Terminal pods' UID-named CRs may
// coexist with the recreated pod's CR, and processing both would produce
- // duplicate entries and incorrect IP allocation.
+ // duplicate entries and incorrect IP allocation. Their namespaces remain
+ // known, however, so a transient container restart does not delete the
+ // DomainPeer and break the still-running network sidecar.
podsByNamespace := make(map[string][]rlarkv1alpha1.DomainPodInfo)
nonAllocPodsByNamespace := make(map[string][]rlarkv1alpha1.DomainPodInfo)
+ knownNamespaces := make(map[string]struct{})
reusedIPs := make(map[string]bool) // IPs already reclaimed in this pass
reusedAlloc := make(map[string]string) // podKey -> reclaimed IP
for _, pod := range podList.Items {
if pod.Spec.Domain != domain.Name {
continue
}
+ knownNamespaces[pod.Namespace] = struct{}{}
if pod.Status.Phase == rlarkv1alpha1.PodPhaseSucceeded || pod.Status.Phase == rlarkv1alpha1.PodPhaseFailed {
continue
}
+ if pod.Status.Node == "" || pod.Status.IP == "" {
+ continue
+ }
ns := pod.Namespace
podKey := ns + "/" + pod.Spec.PodNamespace + "/" + pod.Spec.PodName
if alloc, ok := oldIPAllocMap[podKey]; ok && !reusedIPs[alloc.IP] {
@@ -173,11 +192,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
})
}
}
+ sort.Slice(domain.Status.IPAllocations, func(i, j int) bool {
+ return domain.Status.IPAllocations[i].Pod < domain.Status.IPAllocations[j].Pod
+ })
// 7. Update Domain.Status.IPAllocations with the new allocations
- if err := r.Status().Update(ctx, &domain); err != nil {
- logger.Error(err, "failed to update Domain status")
- return ctrl.Result{}, err
+ if !reflect.DeepEqual(originalAllocations, domain.Status.IPAllocations) {
+ if err := r.Status().Update(ctx, &domain); err != nil {
+ logger.Error(err, "failed to update Domain status")
+ return ctrl.Result{}, err
+ }
}
// 8. Create or update DomainPeer per namespace
@@ -186,6 +210,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
for _, pods := range podsByNamespace {
allPods = append(allPods, pods...)
}
+ sort.Slice(allPods, func(i, j int) bool {
+ if allPods[i].GlobalNamespace != allPods[j].GlobalNamespace {
+ return allPods[i].GlobalNamespace < allPods[j].GlobalNamespace
+ }
+ if allPods[i].Namespace != allPods[j].Namespace {
+ return allPods[i].Namespace < allPods[j].Namespace
+ }
+ return allPods[i].Name < allPods[j].Name
+ })
for ns := range podsByNamespace {
if err := r.createOrUpdateDomainPeer(ctx, logger, domain.Name, ns, allPods, signer, ippool.PrefixLength()); err != nil {
return ctrl.Result{}, err
@@ -193,7 +226,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
}
// 9. Delete DomainPeers in namespaces that no longer have pods for this domain
- if err := r.cleanupStaleDomainPeers(ctx, logger, domain.Name, podsByNamespace); err != nil {
+ if err := r.cleanupStaleDomainPeers(ctx, logger, domain.Name, knownNamespaces); err != nil {
return ctrl.Result{}, err
}
@@ -204,8 +237,18 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
// It watches Domain as the primary resource and Pod as a secondary resource
// (pod changes trigger reconciliation of the associated domain).
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(), &rlarkv1alpha1.Pod{}, podDomainField, func(obj client.Object) []string {
+ pod, ok := obj.(*rlarkv1alpha1.Pod)
+ if !ok || pod.Spec.Domain == "" {
+ return nil
+ }
+ return []string{pod.Spec.Domain}
+ }); err != nil {
+ return err
+ }
+
return ctrl.NewControllerManagedBy(mgr).
- For(&rlarkv1alpha1.Domain{}).
+ For(&rlarkv1alpha1.Domain{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("domain").
Watches(
&rlarkv1alpha1.Pod{},
@@ -219,6 +262,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
}}
}),
).
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.MaxConcurrentReconciles}).
Complete(r)
}
@@ -257,6 +301,8 @@ func (r *Reconciler) createOrUpdateDomainPeer(
return r.Create(ctx, desiredPeer)
}
+ changed := existingPeer.Spec.PrefixLen != prefixLen || !reflect.DeepEqual(existingPeer.Spec.Pods, pods)
+ existingPeer.Spec.PrefixLen = prefixLen
existingPeer.Spec.Pods = pods
certData, err := cert.LoadData([]byte(existingPeer.Spec.Cert), []byte(existingPeer.Spec.Key))
if err != nil || certData.SSHCert == nil || !certData.IsValid() {
@@ -269,6 +315,10 @@ func (r *Reconciler) createOrUpdateDomainPeer(
}
existingPeer.Spec.Cert = string(cert)
existingPeer.Spec.Key = string(key)
+ changed = true
+ }
+ if !changed {
+ return nil
}
if err := r.Update(ctx, &existingPeer); err != nil {
logger.Error(err, "failed to update DomainPeer", "namespace", namespace)
@@ -298,7 +348,7 @@ func (r *Reconciler) deleteDomainPeers(ctx context.Context, logger logr.Logger,
return ctrl.Result{}, nil
}
-func (r *Reconciler) cleanupStaleDomainPeers(ctx context.Context, logger logr.Logger, domainName string, activeNamespaces map[string][]rlarkv1alpha1.DomainPodInfo) error {
+func (r *Reconciler) cleanupStaleDomainPeers(ctx context.Context, logger logr.Logger, domainName string, knownNamespaces map[string]struct{}) error {
var peerList rlarkv1alpha1.DomainPeerList
if err := r.List(ctx, &peerList); err != nil {
return err
@@ -307,7 +357,7 @@ func (r *Reconciler) cleanupStaleDomainPeers(ctx context.Context, logger logr.Lo
if peer.Name != domainName {
continue
}
- if _, ok := activeNamespaces[peer.Namespace]; ok {
+ if _, ok := knownNamespaces[peer.Namespace]; ok {
continue // still has pods, skip
}
// Namespace no longer has pods for this domain — delete DomainPeer
diff --git a/apps/rlark/pkg/controllermanager/domain/domain_controller_test.go b/apps/rlark/pkg/controllermanager/domain/domain_controller_test.go
index c2f66b1..c6ddcc4 100644
--- a/apps/rlark/pkg/controllermanager/domain/domain_controller_test.go
+++ b/apps/rlark/pkg/controllermanager/domain/domain_controller_test.go
@@ -19,6 +19,31 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/auth/cert"
)
+type countingClient struct {
+ client.Client
+ updates int
+ statusUpdates int
+}
+
+func (c *countingClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
+ c.updates++
+ return c.Client.Update(ctx, obj, opts...)
+}
+
+func (c *countingClient) Status() client.StatusWriter {
+ return &countingStatusWriter{SubResourceWriter: c.Client.Status(), parent: c}
+}
+
+type countingStatusWriter struct {
+ client.SubResourceWriter
+ parent *countingClient
+}
+
+func (w *countingStatusWriter) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error {
+ w.parent.statusUpdates++
+ return w.SubResourceWriter.Update(ctx, obj, opts...)
+}
+
func newTestScheme(t *testing.T) *runtime.Scheme {
t.Helper()
scheme := runtime.NewScheme()
@@ -28,6 +53,19 @@ func newTestScheme(t *testing.T) *runtime.Scheme {
return scheme
}
+func newTestClientBuilder(t *testing.T) *fake.ClientBuilder {
+ t.Helper()
+ return fake.NewClientBuilder().
+ WithScheme(newTestScheme(t)).
+ WithIndex(&rlarkv1alpha1.Pod{}, podDomainField, func(obj client.Object) []string {
+ pod := obj.(*rlarkv1alpha1.Pod)
+ if pod.Spec.Domain == "" {
+ return nil
+ }
+ return []string{pod.Spec.Domain}
+ })
+}
+
func validCertPEM(t *testing.T) (string, string) {
t.Helper()
ca, err := cert.GenerateCA(cert.GenerateTemplateCA())
@@ -93,8 +131,7 @@ func makeDomainPeer(name, namespace, certPEM, keyPEM string) *rlarkv1alpha1.Doma
func doReconcile(t *testing.T, objs ...client.Object) (rlarkv1alpha1.Domain, map[string][]rlarkv1alpha1.DomainPeer) {
t.Helper()
scheme := newTestScheme(t)
- cl := fake.NewClientBuilder().
- WithScheme(scheme).
+ cl := newTestClientBuilder(t).
WithObjects(objs...).
WithStatusSubresource(&rlarkv1alpha1.Domain{}).
Build()
@@ -142,6 +179,93 @@ func allocMap(t *testing.T, d rlarkv1alpha1.Domain) map[string]string {
return m
}
+func TestReconcileSkipsUnchangedDomainAndPeerUpdates(t *testing.T) {
+ const (
+ domainName = "test-domain"
+ namespace = "cluster-a"
+ )
+ certPEM, keyPEM := validCertPEM(t)
+ domain := &rlarkv1alpha1.Domain{
+ ObjectMeta: metav1.ObjectMeta{Name: domainName},
+ Spec: rlarkv1alpha1.DomainSpec{CIDR: "10.244.0.0/29"},
+ Status: rlarkv1alpha1.DomainStatus{IPAllocations: []rlarkv1alpha1.DomainIPAllocation{{
+ IP: "10.244.0.1", Pod: namespace + "/rlark-system/pod-a",
+ }}},
+ }
+ pod := makePod("uid-a", namespace, domainName, "rlark-system", "pod-a", "node-a", "10.0.0.1", rlarkv1alpha1.PodPhaseRunning)
+ peer := makeDomainPeer(domainName, namespace, certPEM, keyPEM)
+ peer.Spec.PrefixLen = 29
+ peer.Spec.Pods = []rlarkv1alpha1.DomainPodInfo{{
+ GlobalNamespace: namespace,
+ Namespace: "rlark-system",
+ Name: "pod-a",
+ UID: "uid-a",
+ Node: "node-a",
+ IP: "10.244.0.1",
+ LocalIP: "10.0.0.1",
+ }}
+ scheme := newTestScheme(t)
+ wrapped := &countingClient{Client: newTestClientBuilder(t).
+ WithObjects(domain, pod, peer).
+ WithStatusSubresource(&rlarkv1alpha1.Domain{}).
+ Build()}
+ r := &Reconciler{Client: wrapped, Scheme: scheme}
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: domainName}}); err != nil {
+ t.Fatal(err)
+ }
+ if wrapped.statusUpdates != 0 || wrapped.updates != 0 {
+ t.Fatalf("unchanged Domain caused writes: status=%d peers=%d", wrapped.statusUpdates, wrapped.updates)
+ }
+}
+
+func TestReconcileSkipsPodWithoutNetworkStatus(t *testing.T) {
+ const (
+ domainName = "test-domain"
+ namespace = "cluster-a"
+ )
+ certPEM, keyPEM := validCertPEM(t)
+ domain := &rlarkv1alpha1.Domain{
+ ObjectMeta: metav1.ObjectMeta{Name: domainName},
+ Spec: rlarkv1alpha1.DomainSpec{CIDR: "10.244.0.0/29"},
+ }
+ pod := makePod("uid-a", namespace, domainName, "rlark-system", "pod-a", "", "", rlarkv1alpha1.PodPhasePending)
+ peer := makeDomainPeer(domainName, namespace, certPEM, keyPEM)
+
+ updated, peersByNS := doReconcile(t, domain, pod, peer)
+ if len(updated.Status.IPAllocations) != 0 {
+ t.Fatalf("expected no allocation for incomplete Pod status, got %d", len(updated.Status.IPAllocations))
+ }
+ if len(peersByNS[namespace]) != 1 {
+ t.Fatalf("expected existing DomainPeer to be retained, got %d", len(peersByNS[namespace]))
+ }
+ if len(peersByNS[namespace][0].Spec.Pods) != 0 {
+ t.Fatalf("expected incomplete Pod to be excluded from DomainPeer, got %d pods", len(peersByNS[namespace][0].Spec.Pods))
+ }
+}
+
+func TestReconcileListsOnlyPodsInDomain(t *testing.T) {
+ certPEM, keyPEM := validCertPEM(t)
+ domain := &rlarkv1alpha1.Domain{
+ ObjectMeta: metav1.ObjectMeta{Name: "domain-a"},
+ Spec: rlarkv1alpha1.DomainSpec{CIDR: "10.244.0.0/29"},
+ }
+ podA := makePod("uid-a", "cluster-a", domain.Name, "rlark-system", "pod-a", "node-a", "10.0.0.1", rlarkv1alpha1.PodPhaseRunning)
+ podB := makePod("uid-b", "cluster-b", "domain-b", "rlark-system", "pod-b", "node-b", "10.0.0.2", rlarkv1alpha1.PodPhaseRunning)
+ peer := makeDomainPeer(domain.Name, podA.Namespace, certPEM, keyPEM)
+
+ updated, peersByNS := doReconcile(t, domain, podA, podB, peer)
+ if len(updated.Status.IPAllocations) != 1 {
+ t.Fatalf("expected one allocation, got %d", len(updated.Status.IPAllocations))
+ }
+ if got := updated.Status.IPAllocations[0].Pod; got != "cluster-a/rlark-system/pod-a" {
+ t.Fatalf("unexpected allocation for indexed Domain query: %s", got)
+ }
+ if _, exists := peersByNS[podB.Namespace]; exists {
+ t.Fatal("Pod from another Domain created a DomainPeer")
+ }
+}
+
// TestReconcile_PodRestartReusesIP verifies that when a pod is recreated
// (new UID) while its old terminal CR still exists, the new pod reuses the
// original IP and the terminal pod is skipped.
@@ -204,6 +328,73 @@ func TestReconcile_PodRestartReusesIP(t *testing.T) {
}
}
+// TestReconcile_TerminalPodKeepsDomainPeer verifies that the controller does
+// not remove a cluster's DomainPeer during the gap between a container exit
+// and its workload becoming active again. The Pod CR still exists throughout
+// that gap, and its network sidecar may still be serving connections.
+func TestReconcile_TerminalPodKeepsDomainPeer(t *testing.T) {
+ const (
+ domainName = "test-domain"
+ ns = "cluster-1"
+ )
+ certPEM, keyPEM := validCertPEM(t)
+ peer := makeDomainPeer(domainName, ns, certPEM, keyPEM)
+ peer.Spec.PrefixLen = 29
+ peer.Spec.Pods = []rlarkv1alpha1.DomainPodInfo{{
+ GlobalNamespace: ns,
+ Namespace: "rlark-system",
+ Name: "env-0",
+ UID: "uid-env0",
+ Node: "node-1",
+ IP: "10.244.0.1",
+ LocalIP: "10.42.0.1",
+ }}
+
+ domain := &rlarkv1alpha1.Domain{
+ ObjectMeta: metav1.ObjectMeta{Name: domainName},
+ Spec: rlarkv1alpha1.DomainSpec{CIDR: "10.244.0.0/29"},
+ Status: rlarkv1alpha1.DomainStatus{IPAllocations: []rlarkv1alpha1.DomainIPAllocation{{
+ IP: "10.244.0.1",
+ Pod: ns + "/rlark-system/env-0",
+ }}},
+ }
+
+ _, peersByNS := doReconcile(t,
+ domain,
+ peer,
+ makePod("uid-env0", ns, domainName, "rlark-system", "env-0", "node-1", "10.42.0.1", rlarkv1alpha1.PodPhaseSucceeded),
+ )
+
+ peers := peersByNS[ns]
+ if len(peers) != 1 {
+ t.Fatalf("expected DomainPeer to be retained while terminal Pod CR exists, got %d", len(peers))
+ }
+ if len(peers[0].Spec.Pods) != 1 || peers[0].Spec.Pods[0].IP != "10.244.0.1" {
+ t.Fatalf("expected existing route to be retained, got %+v", peers[0].Spec.Pods)
+ }
+}
+
+func TestReconcile_RemovedPodDeletesDomainPeer(t *testing.T) {
+ const (
+ domainName = "test-domain"
+ ns = "cluster-1"
+ )
+ certPEM, keyPEM := validCertPEM(t)
+ domain := &rlarkv1alpha1.Domain{
+ ObjectMeta: metav1.ObjectMeta{Name: domainName},
+ Spec: rlarkv1alpha1.DomainSpec{CIDR: "10.244.0.0/29"},
+ }
+
+ _, peersByNS := doReconcile(t,
+ domain,
+ makeDomainPeer(domainName, ns, certPEM, keyPEM),
+ )
+
+ if peers := peersByNS[ns]; len(peers) != 0 {
+ t.Fatalf("expected stale DomainPeer to be deleted after all Pod CRs are removed, got %d", len(peers))
+ }
+}
+
// TestReconcile_DuplicateIPConflictResolved verifies that when two different
// pods have allocations pointing to the same IP (historical corruption), only
// the first one reuses it; the other gets a fresh IP.
diff --git a/apps/rlark/pkg/controllermanager/domain/ippool.go b/apps/rlark/pkg/controllermanager/domain/ippool.go
index 9914040..6a1f1f8 100644
--- a/apps/rlark/pkg/controllermanager/domain/ippool.go
+++ b/apps/rlark/pkg/controllermanager/domain/ippool.go
@@ -13,8 +13,8 @@ import (
// prior allocations from DomainStatus on reconciliation).
//
// Usable IP range logic:
-// - IPv4 /31 and /32: all IPs usable (RFC 3021 PtP).
-// - Other IPv4: first (network) and last (broadcast) excluded.
+// - IPv4: addresses ending in .0 or .255 are always excluded.
+// - Other IPv4 network and broadcast addresses are also excluded.
// - IPv6 /127 and /128: all IPs usable.
// - Other IPv6: first (subnet-router anycast) excluded.
type IPPool struct {
@@ -79,7 +79,7 @@ func (p *IPPool) MarkAllocated(ip string) {
func (p *IPPool) Allocate() (string, error) {
for cur := p.first; ; cur = cur.Next() {
ipStr := cur.String()
- if !p.allocated[ipStr] {
+ if isAutoAssignable(cur) && !p.allocated[ipStr] {
p.allocated[ipStr] = true
return ipStr, nil
}
@@ -90,6 +90,14 @@ func (p *IPPool) Allocate() (string, error) {
return "", fmt.Errorf("no available IP in pool %s", p.cidr)
}
+func isAutoAssignable(addr netip.Addr) bool {
+ if !addr.Is4() {
+ return true
+ }
+ lastOctet := addr.As4()[3]
+ return lastOctet != 0 && lastOctet != 255
+}
+
// PrefixLength returns the prefix length.
func (p *IPPool) PrefixLength() int {
return p.prefix.Bits()
diff --git a/apps/rlark/pkg/controllermanager/domain/ippool_test.go b/apps/rlark/pkg/controllermanager/domain/ippool_test.go
index c65a132..5721d2a 100644
--- a/apps/rlark/pkg/controllermanager/domain/ippool_test.go
+++ b/apps/rlark/pkg/controllermanager/domain/ippool_test.go
@@ -1,6 +1,7 @@
package domain
import (
+ "fmt"
"testing"
)
@@ -34,7 +35,7 @@ func TestNewIPPool_Allocate(t *testing.T) {
}
func TestNewIPPool_Allocate_31(t *testing.T) {
- pool, err := NewIPPool("10.0.0.0/31")
+ pool, err := NewIPPool("10.0.0.2/31")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -43,16 +44,16 @@ func TestNewIPPool_Allocate_31(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if ip1 != "10.0.0.0" {
- t.Fatalf("expected 10.0.0.0, got %s", ip1)
+ if ip1 != "10.0.0.2" {
+ t.Fatalf("expected 10.0.0.2, got %s", ip1)
}
ip2, err := pool.Allocate()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if ip2 != "10.0.0.1" {
- t.Fatalf("expected 10.0.0.1, got %s", ip2)
+ if ip2 != "10.0.0.3" {
+ t.Fatalf("expected 10.0.0.3, got %s", ip2)
}
_, err = pool.Allocate()
@@ -61,6 +62,25 @@ func TestNewIPPool_Allocate_31(t *testing.T) {
}
}
+func TestNewIPPool_Allocate_31EndingAtZero(t *testing.T) {
+ pool, err := NewIPPool("10.0.1.0/31")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ip, err := pool.Allocate()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ip != "10.0.1.1" {
+ t.Fatalf("expected 10.0.1.1, got %s", ip)
+ }
+
+ if _, err = pool.Allocate(); err == nil {
+ t.Fatal("expected error (pool exhausted), got nil")
+ }
+}
+
func TestNewIPPool_Allocate_32(t *testing.T) {
pool, err := NewIPPool("10.0.0.5/32")
if err != nil {
@@ -81,6 +101,20 @@ func TestNewIPPool_Allocate_32(t *testing.T) {
}
}
+func TestNewIPPool_Allocate_32EndingAtZeroOr255(t *testing.T) {
+ for _, cidr := range []string{"10.0.0.0/32", "10.0.0.255/32"} {
+ t.Run(cidr, func(t *testing.T) {
+ pool, err := NewIPPool(cidr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, err = pool.Allocate(); err == nil {
+ t.Fatal("expected error (pool exhausted), got nil")
+ }
+ })
+ }
+}
+
func TestNewIPPool_MarkAllocated(t *testing.T) {
pool, err := NewIPPool("10.0.0.0/29")
if err != nil {
@@ -165,6 +199,25 @@ func TestNewIPPool_AllocateSequential(t *testing.T) {
}
}
+func TestNewIPPool_AllocateSkipsZeroAnd255Across24Boundary(t *testing.T) {
+ pool, err := NewIPPool("10.244.0.0/23")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ for i := 1; i <= 254; i++ {
+ pool.MarkAllocated(fmt.Sprintf("10.244.0.%d", i))
+ }
+
+ ip, err := pool.Allocate()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ip != "10.244.1.1" {
+ t.Fatalf("expected 10.244.1.1 after skipping .255 and .0, got %s", ip)
+ }
+}
+
func TestNewIPPool_V6(t *testing.T) {
pool, err := NewIPPool("2001:db8::/126")
if err != nil {
diff --git a/apps/rlark/pkg/controllermanager/job/build.go b/apps/rlark/pkg/controllermanager/job/build.go
index c0bba1f..63a1804 100644
--- a/apps/rlark/pkg/controllermanager/job/build.go
+++ b/apps/rlark/pkg/controllermanager/job/build.go
@@ -2,6 +2,9 @@ package job
import (
"context"
+ "fmt"
+ "reflect"
+ "sort"
"strconv"
"strings"
@@ -14,9 +17,9 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
-func (r *Reconciler) resolveTaskNamespace(ctx context.Context, t *rlarkv1alpha1.JobTaskTemplate) string {
+func (r *Reconciler) resolveTaskNamespace(ctx context.Context, t *rlarkv1alpha1.JobTaskTemplate) (string, error) {
if len(t.NodeSelector) == 0 {
- return "default"
+ return "default", nil
}
simpleSelector := make(map[string]string)
@@ -28,14 +31,20 @@ func (r *Reconciler) resolveTaskNamespace(ctx context.Context, t *rlarkv1alpha1.
var nodeList rlarkv1alpha1.NodeList
if err := r.List(ctx, &nodeList, &client.ListOptions{LabelSelector: selector}); err != nil {
- return "default"
+ return "", fmt.Errorf("list Nodes matching selector for Task template %q: %w", t.Name, err)
}
if len(nodeList.Items) == 0 {
- return "default"
+ return "", fmt.Errorf("no Nodes match selector for Task template %q", t.Name)
}
- return nodeList.Items[0].Namespace
+ sort.Slice(nodeList.Items, func(i, j int) bool {
+ if nodeList.Items[i].Namespace == nodeList.Items[j].Namespace {
+ return nodeList.Items[i].Name < nodeList.Items[j].Name
+ }
+ return nodeList.Items[i].Namespace < nodeList.Items[j].Namespace
+ })
+ return nodeList.Items[0].Namespace, nil
}
func buildTaskStatusMap(job *rlarkv1alpha1.Job) map[string]*rlarkv1alpha1.JobTaskStatus {
@@ -54,6 +63,13 @@ func buildTask(
taskSpec := t.TaskSpec
taskSpec.Domain = job.Spec.Domain
taskSpec.SSHPublicKey = job.Spec.SSHPublicKey
+ taskSpec.Tags = make([]rlarkv1alpha1.JobTag, len(job.Spec.Tags))
+ for i, tag := range job.Spec.Tags {
+ taskSpec.Tags[i] = rlarkv1alpha1.JobTag{
+ Key: tag.Key,
+ Values: append([]string(nil), tag.Values...),
+ }
+ }
if job.Spec.Stopped && taskSpec.Kubernetes != nil && taskSpec.Kubernetes.Workload != nil {
taskSpec.Kubernetes.Workload.Replicas = ptr.To(int32(0))
@@ -66,12 +82,31 @@ func buildTask(
Labels: map[string]string{
"rlinf.io/job": job.Name,
},
- Annotations: buildRayAnnotations(job, t),
+ Annotations: utils.MergeAnnotations(buildRayAnnotations(job, t), map[string]string{
+ utils.ParentUIDAnnotation: string(job.UID),
+ utils.ChildTemplateAnnotation: t.Name,
+ }),
},
Spec: taskSpec,
}
}
+func syncTaskStatusSnapshot(job *rlarkv1alpha1.Job) bool {
+ existing := buildTaskStatusMap(job)
+ next := make([]rlarkv1alpha1.JobTaskStatus, len(job.Spec.Tasks))
+ for i, template := range job.Spec.Tasks {
+ next[i].Name = template.Name
+ if status := existing[template.Name]; status != nil {
+ next[i] = *status
+ }
+ }
+ if reflect.DeepEqual(job.Status.Tasks, next) {
+ return false
+ }
+ job.Status.Tasks = next
+ return true
+}
+
func buildRayAnnotations(job *rlarkv1alpha1.Job, t rlarkv1alpha1.JobTaskTemplate) map[string]string {
annotations := map[string]string{
rlarkv1alpha1.RayTotalNodesAnnotation: strconv.Itoa(totalNodeCount(job.Spec.Tasks)),
@@ -118,6 +153,37 @@ func findHeadTaskName(job *rlarkv1alpha1.Job) string {
return ""
}
+// taskReplicas 返回任务模板的副本数(pod 数量),未显式设置时默认为 1。
+func taskReplicas(t rlarkv1alpha1.JobTaskTemplate) int32 {
+ if t.Kubernetes != nil && t.Kubernetes.Workload != nil && t.Kubernetes.Workload.Replicas != nil {
+ return *t.Kubernetes.Workload.Replicas
+ }
+ return 1
+}
+
+// validateHeadTask 校验 Job 中被标记为 ray head 的任务。
+// ray head 任务只能有一个 pod(replicas == 1),且最多只能有一个 head 任务。
+// 校验失败返回描述性错误。
+func validateHeadTask(job *rlarkv1alpha1.Job) error {
+ headCount := 0
+ for _, t := range job.Spec.Tasks {
+ if !t.Head {
+ continue
+ }
+ headCount++
+ if replicas := taskReplicas(t); replicas != 1 {
+ return fmt.Errorf(
+ "ray head task %q must have exactly one pod (replicas == 1), got %d",
+ t.Name, replicas,
+ )
+ }
+ }
+ if headCount > 1 {
+ return fmt.Errorf("only one ray head task is allowed, got %d", headCount)
+ }
+ return nil
+}
+
func totalNodeCount(tasks []rlarkv1alpha1.JobTaskTemplate) int {
total := 0
for _, t := range tasks {
diff --git a/apps/rlark/pkg/controllermanager/job/job_controller.go b/apps/rlark/pkg/controllermanager/job/job_controller.go
index 18b4c0f..55e420f 100644
--- a/apps/rlark/pkg/controllermanager/job/job_controller.go
+++ b/apps/rlark/pkg/controllermanager/job/job_controller.go
@@ -2,19 +2,35 @@ package job
import (
"context"
+ "fmt"
+ "reflect"
+ "time"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
)
+const (
+ CleanupFinalizer = "jobs.rlinf.io/task-cleanup"
+ cleanupRequeue = time.Second
+)
+
// Reconciler reconciles Job resources.
type Reconciler struct {
client.Client
- Scheme *runtime.Scheme
+ Scheme *runtime.Scheme
+ MaxConcurrentReconciles int
}
// +kubebuilder:rbac:groups=rlinf.io,resources=jobs,verbs=get;list;watch;create;update;patch;delete
@@ -25,7 +41,134 @@ type Reconciler struct {
// Reconcile handles a Job reconciliation request.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- return controller.ReconcileWith(ctx, req, &rlarkv1alpha1.Job{}, "job", r)
+ job := &rlarkv1alpha1.Job{}
+ if err := r.Get(ctx, req.NamespacedName, job); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if job.DeletionTimestamp.IsZero() {
+ if !controllerutil.ContainsFinalizer(job, CleanupFinalizer) {
+ controllerutil.AddFinalizer(job, CleanupFinalizer)
+ if err := r.Update(ctx, job); err != nil {
+ return ctrl.Result{}, fmt.Errorf("add Job cleanup finalizer: %w", err)
+ }
+ return ctrl.Result{RequeueAfter: time.Nanosecond}, nil
+ }
+ return controller.ReconcileWith(ctx, req, &rlarkv1alpha1.Job{}, "job", r)
+ }
+ if !controllerutil.ContainsFinalizer(job, CleanupFinalizer) {
+ return ctrl.Result{}, nil
+ }
+
+ stopped, err := r.stopOwnedTasks(ctx, job)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if !stopped {
+ return ctrl.Result{RequeueAfter: cleanupRequeue}, nil
+ }
+ statusChanged := false
+ if !jobTaskStatusesStopped(job) {
+ statusChanged, err = r.syncTaskStatuses(ctx, job)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ terminal := job.Status.Phase == rlarkv1alpha1.JobPhaseSucceeded ||
+ job.Status.Phase == rlarkv1alpha1.JobPhaseFailed
+ if job.Status.Phase != rlarkv1alpha1.JobPhaseStopped && !terminal {
+ job.Status.Phase = rlarkv1alpha1.JobPhaseStopped
+ statusChanged = true
+ }
+ if job.Status.EndTime == nil && !terminal {
+ now := metav1.Now()
+ job.Status.EndTime = &now
+ statusChanged = true
+ }
+ if statusChanged {
+ if err := r.Status().Update(ctx, job); err != nil {
+ return ctrl.Result{}, fmt.Errorf("update stopped Job status: %w", err)
+ }
+ return ctrl.Result{RequeueAfter: time.Nanosecond}, nil
+ }
+ pending, err := r.deleteOwnedTasks(ctx, job)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if pending {
+ return ctrl.Result{RequeueAfter: cleanupRequeue}, nil
+ }
+ controllerutil.RemoveFinalizer(job, CleanupFinalizer)
+ if err := r.Update(ctx, job); err != nil {
+ return ctrl.Result{}, fmt.Errorf("remove Job cleanup finalizer: %w", err)
+ }
+ return ctrl.Result{}, nil
+}
+
+func jobTaskStatusesStopped(job *rlarkv1alpha1.Job) bool {
+ if len(job.Status.Tasks) != len(job.Spec.Tasks) {
+ return false
+ }
+ for _, task := range job.Status.Tasks {
+ if task.Phase != rlarkv1alpha1.TaskPhaseStopped {
+ return false
+ }
+ }
+ return true
+}
+
+func (r *Reconciler) stopOwnedTasks(ctx context.Context, job *rlarkv1alpha1.Job) (bool, error) {
+ var tasks rlarkv1alpha1.TaskList
+ if err := r.List(ctx, &tasks); err != nil {
+ return false, fmt.Errorf("list Tasks owned by Job %s: %w", job.Name, err)
+ }
+
+ allStopped := true
+ for i := range tasks.Items {
+ task := &tasks.Items[i]
+ owner := metav1.GetControllerOf(task)
+ if owner == nil || owner.UID != job.UID || owner.Kind != "Job" || owner.APIVersion != rlarkv1alpha1.GroupVersion.String() {
+ continue
+ }
+ if task.Status.Phase != rlarkv1alpha1.TaskPhaseStopped {
+ allStopped = false
+ }
+ if task.DeletionTimestamp.IsZero() && task.Annotations[StoppedAnnotation] != "true" {
+ if task.Annotations == nil {
+ task.Annotations = map[string]string{}
+ }
+ task.Annotations[StoppedAnnotation] = "true"
+ if task.Spec.Kubernetes != nil && task.Spec.Kubernetes.Workload != nil {
+ task.Spec.Kubernetes.Workload.Replicas = ptr.To(int32(0))
+ }
+ if err := r.Update(ctx, task); err != nil {
+ return false, fmt.Errorf("stop Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ }
+ }
+ return allStopped, nil
+}
+
+func (r *Reconciler) deleteOwnedTasks(ctx context.Context, job *rlarkv1alpha1.Job) (bool, error) {
+ var tasks rlarkv1alpha1.TaskList
+ if err := r.List(ctx, &tasks); err != nil {
+ return false, fmt.Errorf("list Tasks owned by Job %s: %w", job.Name, err)
+ }
+
+ pending := false
+ for i := range tasks.Items {
+ task := &tasks.Items[i]
+ owner := metav1.GetControllerOf(task)
+ if owner == nil || owner.UID != job.UID || owner.Kind != "Job" || owner.APIVersion != rlarkv1alpha1.GroupVersion.String() {
+ continue
+ }
+ pending = true
+ if task.DeletionTimestamp.IsZero() {
+ if err := client.IgnoreNotFound(r.Delete(ctx, task)); err != nil {
+ return false, fmt.Errorf("delete Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ }
+ }
+ return pending, nil
}
// IsTerminal reports whether terminal.
@@ -41,8 +184,25 @@ func (r *Reconciler) ReconcileStateMachine(ctx context.Context, obj client.Objec
// SetupWithManager registers the controller with the manager.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
- For(&rlarkv1alpha1.Job{}).
+ For(&rlarkv1alpha1.Job{}, builder.WithPredicates(jobSpecChangedPredicate())).
Owns(&rlarkv1alpha1.Task{}).
Named("job").
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.MaxConcurrentReconciles}).
Complete(r)
}
+
+func jobSpecChangedPredicate() predicate.Predicate {
+ return predicate.Funcs{
+ CreateFunc: func(event.CreateEvent) bool { return true },
+ DeleteFunc: func(event.DeleteEvent) bool { return true },
+ GenericFunc: func(event.GenericEvent) bool { return true },
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldJob, oldOK := e.ObjectOld.(*rlarkv1alpha1.Job)
+ newJob, newOK := e.ObjectNew.(*rlarkv1alpha1.Job)
+ return !oldOK || !newOK || oldJob.Generation != newJob.Generation ||
+ !reflect.DeepEqual(oldJob.Annotations, newJob.Annotations) ||
+ !reflect.DeepEqual(oldJob.Finalizers, newJob.Finalizers) ||
+ !reflect.DeepEqual(oldJob.DeletionTimestamp, newJob.DeletionTimestamp)
+ },
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/job/job_controller_test.go b/apps/rlark/pkg/controllermanager/job/job_controller_test.go
new file mode 100644
index 0000000..9e7a16a
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/job/job_controller_test.go
@@ -0,0 +1,318 @@
+package job
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+func jobTestScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ return scheme
+}
+
+func TestReconcileAddsCleanupFinalizer(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ c := fake.NewClientBuilder().WithScheme(jobTestScheme(t)).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: jobTestScheme(t)}
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if !contains(job.Finalizers, CleanupFinalizer) {
+ t.Fatalf("cleanup finalizer not added: %v", job.Finalizers)
+ }
+}
+
+func TestDeleteOwnedTasksWaitsForCleanupAndFiltersOwnerUID(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ owned := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "owned",
+ Namespace: "workers",
+ Finalizers: []string{"rlark.io/agent-cleanup"},
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID,
+ Controller: ptrBool(true),
+ }},
+ }}
+ unrelated := owned.DeepCopy()
+ unrelated.Name = "unrelated"
+ unrelated.UID = types.UID("unrelated")
+ unrelated.OwnerReferences[0].UID = types.UID("other-job")
+ c := fake.NewClientBuilder().WithScheme(jobTestScheme(t)).WithObjects(owned, unrelated).Build()
+ r := &Reconciler{Client: c, Scheme: jobTestScheme(t)}
+
+ pending, err := r.deleteOwnedTasks(context.Background(), job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pending {
+ t.Fatal("owned Task should keep Job cleanup pending")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), owned); err != nil {
+ t.Fatal(err)
+ }
+ if owned.DeletionTimestamp.IsZero() {
+ t.Fatal("owned Task deletion was not requested")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(unrelated), unrelated); err != nil {
+ t.Fatalf("unrelated Task should not be deleted: %v", err)
+ }
+}
+
+func TestStoppedJobDeletesTasksAndWaitsForCleanup(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")},
+ Spec: rlarkv1alpha1.JobSpec{Stopped: true, Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}},
+ Status: rlarkv1alpha1.JobStatus{Phase: rlarkv1alpha1.JobPhaseRunning, Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "worker", Phase: rlarkv1alpha1.TaskPhaseRunning}}},
+ }
+ owned := &rlarkv1alpha1.Task{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job-worker",
+ Namespace: "default",
+ Finalizers: []string{"rlark.io/agent-cleanup"},
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID,
+ Controller: ptrBool(true),
+ }},
+ },
+ Status: rlarkv1alpha1.TaskStatus{Phase: rlarkv1alpha1.TaskPhaseRunning},
+ }
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owned).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), job); err == nil {
+ t.Fatal("Task cleanup should requeue the Job")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), owned); err != nil {
+ t.Fatal(err)
+ }
+ if owned.DeletionTimestamp.IsZero() {
+ t.Fatal("Task deletion was not requested")
+ }
+}
+
+func TestStoppedInvalidJobDeletesTasksAndWaitsForCleanup(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")},
+ Spec: rlarkv1alpha1.JobSpec{
+ Stopped: true,
+ Tasks: []rlarkv1alpha1.JobTaskTemplate{
+ {Name: "head", Head: true},
+ {Name: "another-head", Head: true},
+ },
+ },
+ Status: rlarkv1alpha1.JobStatus{Phase: rlarkv1alpha1.JobPhaseRunning},
+ }
+ owned := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "job-head", Namespace: "default", Finalizers: []string{"rlark.io/agent-cleanup"},
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID,
+ Controller: ptrBool(true),
+ }},
+ }}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owned).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), job); err == nil {
+ t.Fatal("Task cleanup should requeue the invalid stopped Job")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), owned); err != nil {
+ t.Fatal(err)
+ }
+ if owned.DeletionTimestamp.IsZero() {
+ t.Fatal("Task deletion was not requested")
+ }
+ if job.Status.Phase == rlarkv1alpha1.JobPhaseFailed {
+ t.Fatal("stopped Job should not be marked invalid before cleanup")
+ }
+}
+
+func TestStoppedJobCompletesAfterTasksDisappear(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")},
+ Spec: rlarkv1alpha1.JobSpec{Stopped: true, Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}},
+ Status: rlarkv1alpha1.JobStatus{Phase: rlarkv1alpha1.JobPhaseRunning, Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "worker", Phase: rlarkv1alpha1.TaskPhaseRunning}}},
+ }
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ changed, err := r.ReconcileStateMachine(context.Background(), job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed || job.Status.Phase != rlarkv1alpha1.JobPhaseStopped || job.Status.Tasks[0].Phase != rlarkv1alpha1.TaskPhaseStopped {
+ t.Fatalf("stopped Job status not completed: %#v", job.Status)
+ }
+}
+
+func TestStoppedJobPreservesTerminalTaskStatuses(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ Spec: rlarkv1alpha1.JobSpec{Stopped: true, Tasks: []rlarkv1alpha1.JobTaskTemplate{
+ {Name: "done"}, {Name: "failed"}, {Name: "running"},
+ }},
+ Status: rlarkv1alpha1.JobStatus{Phase: rlarkv1alpha1.JobPhaseRunning, Tasks: []rlarkv1alpha1.JobTaskStatus{
+ {Name: "done", Phase: rlarkv1alpha1.TaskPhaseSucceeded},
+ {Name: "failed", Phase: rlarkv1alpha1.TaskPhaseFailed},
+ {Name: "running", Phase: rlarkv1alpha1.TaskPhaseRunning},
+ }},
+ }
+ r := &Reconciler{Client: fake.NewClientBuilder().WithScheme(jobTestScheme(t)).Build()}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), job); err != nil {
+ t.Fatal(err)
+ }
+ if job.Status.Tasks[0].Phase != rlarkv1alpha1.TaskPhaseSucceeded ||
+ job.Status.Tasks[1].Phase != rlarkv1alpha1.TaskPhaseFailed ||
+ job.Status.Tasks[2].Phase != rlarkv1alpha1.TaskPhaseStopped {
+ t.Fatalf("unexpected Task phases after stop: %#v", job.Status.Tasks)
+ }
+}
+
+func TestDeletingJobUpdatesStoppedStatusBeforeDeletingTasks(t *testing.T) {
+ deletedAt := metav1.Now()
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job",
+ UID: types.UID("job-uid"),
+ DeletionTimestamp: &deletedAt,
+ Finalizers: []string{CleanupFinalizer},
+ },
+ Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}},
+ Status: rlarkv1alpha1.JobStatus{
+ Phase: rlarkv1alpha1.JobPhaseRunning,
+ Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "worker", Phase: rlarkv1alpha1.TaskPhaseRunning}},
+ },
+ }
+ task := &rlarkv1alpha1.Task{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job-worker",
+ Namespace: "default",
+ Finalizers: []string{"rlark.io/agent-cleanup"},
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID,
+ Controller: ptrBool(true),
+ }},
+ },
+ Status: rlarkv1alpha1.TaskStatus{Phase: rlarkv1alpha1.TaskPhaseStopped},
+ }
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(job).WithObjects(job, task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if job.Status.Phase != rlarkv1alpha1.JobPhaseStopped || job.Status.EndTime == nil {
+ t.Fatalf("deleting Job status was not stopped: %#v", job.Status)
+ }
+ if job.Status.Tasks[0].Phase != rlarkv1alpha1.TaskPhaseStopped {
+ t.Fatalf("Task status was not aggregated: %#v", job.Status.Tasks)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), task); err != nil {
+ t.Fatal(err)
+ }
+ if !task.DeletionTimestamp.IsZero() {
+ t.Fatal("Task must remain until the stopped Job status is persisted")
+ }
+}
+
+func TestDeletingStoppedJobAddsMissingEndTime(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}},
+ Status: rlarkv1alpha1.JobStatus{
+ Phase: rlarkv1alpha1.JobPhaseStopped,
+ Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "worker", Phase: rlarkv1alpha1.TaskPhaseStopped}},
+ },
+ }
+ if !jobTaskStatusesStopped(job) {
+ t.Fatal("complete stopped Task statuses should not require resync")
+ }
+ if job.Status.EndTime != nil {
+ t.Fatal("test requires a missing endTime")
+ }
+}
+
+func TestDeletingTerminalJobPreservesPhaseAndEndTime(t *testing.T) {
+ for _, phase := range []rlarkv1alpha1.JobPhase{
+ rlarkv1alpha1.JobPhaseSucceeded,
+ rlarkv1alpha1.JobPhaseFailed,
+ } {
+ t.Run(string(phase), func(t *testing.T) {
+ deletedAt := metav1.Now()
+ endTime := metav1.NewTime(time.Now().Truncate(time.Second))
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job",
+ UID: types.UID("job-uid"),
+ DeletionTimestamp: &deletedAt,
+ Finalizers: []string{CleanupFinalizer, "test/finalizer"},
+ },
+ Status: rlarkv1alpha1.JobStatus{Phase: phase, EndTime: &endTime},
+ }
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(job).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if job.Status.Phase != phase || !job.Status.EndTime.Time.Equal(endTime.Time) {
+ t.Fatalf("terminal status changed: %#v", job.Status)
+ }
+ })
+ }
+}
+
+func ptrBool(v bool) *bool { return &v }
+
+func contains(values []string, value string) bool {
+ for _, item := range values {
+ if item == value {
+ return true
+ }
+ }
+ return false
+}
+
+func TestJobSpecChangedPredicateIgnoresStatusOnlyUpdate(t *testing.T) {
+ p := jobSpecChangedPredicate()
+ oldJob := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", Generation: 1}}
+ newJob := oldJob.DeepCopy()
+ newJob.Status.Phase = rlarkv1alpha1.JobPhaseRunning
+ if p.Update(event.UpdateEvent{ObjectOld: oldJob, ObjectNew: newJob}) {
+ t.Fatal("status-only update should not enqueue the Job")
+ }
+
+ newJob = oldJob.DeepCopy()
+ newJob.Generation = 2
+ if !p.Update(event.UpdateEvent{ObjectOld: oldJob, ObjectNew: newJob}) {
+ t.Fatal("generation update should enqueue the Job")
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/job/lifecycle_test.go b/apps/rlark/pkg/controllermanager/job/lifecycle_test.go
new file mode 100644
index 0000000..6cebcb1
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/job/lifecycle_test.go
@@ -0,0 +1,348 @@
+package job
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/go-logr/logr"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/ptr"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ controllerbase "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
+)
+
+type failingNodeListClient struct{ client.Client }
+
+func (c failingNodeListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
+ if _, ok := list.(*rlarkv1alpha1.NodeList); ok {
+ return fmt.Errorf("node list failed")
+ }
+ return c.Client.List(ctx, list, opts...)
+}
+
+type failSecondNodeListClient struct {
+ client.Client
+ lists int
+}
+
+func (c *failSecondNodeListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
+ if _, ok := list.(*rlarkv1alpha1.NodeList); ok {
+ c.lists++
+ if c.lists > 1 {
+ return fmt.Errorf("unexpected second node list")
+ }
+ }
+ return c.Client.List(ctx, list, opts...)
+}
+
+func TestSyncTaskStatusSnapshotIsExactAndOrdered(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "b"}, {Name: "a"}}},
+ Status: rlarkv1alpha1.JobStatus{Tasks: []rlarkv1alpha1.JobTaskStatus{
+ {Name: "a", Phase: rlarkv1alpha1.TaskPhaseSucceeded, Message: "done"}, {Name: "removed"},
+ }},
+ }
+ if !syncTaskStatusSnapshot(job) {
+ t.Fatal("expected snapshot change")
+ }
+ if len(job.Status.Tasks) != 2 || job.Status.Tasks[0].Name != "b" || job.Status.Tasks[1].Name != "a" || job.Status.Tasks[1].Message != "done" {
+ t.Fatalf("unexpected snapshot: %#v", job.Status.Tasks)
+ }
+}
+
+func TestReconcileTaskPreservesAnnotationsAndNaturalTerminalSpec(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{RunScript: "new"}}
+ task := buildTask(job, template, "job-worker", "default")
+ task.Spec.RunScript = "old"
+ task.Annotations["user"] = "keep"
+ task.Status.Phase = rlarkv1alpha1.TaskPhaseSucceeded
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ got, err := r.reconcileTask(context.Background(), job, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Spec.RunScript != "old" || got.Annotations["user"] != "keep" {
+ t.Fatalf("terminal Task was rewritten: %#v", got)
+ }
+}
+
+func TestReconcileTaskResumesControllerStoppedTerminalTask(t *testing.T) {
+ replicas := int32(4)
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: &replicas}}}}
+ task := buildTask(job, template, "job-worker", "default")
+ task.Spec = *task.Spec.DeepCopy()
+ task.Spec.Kubernetes.Workload.Replicas = ptr.To(int32(0))
+ task.Annotations[StoppedAnnotation] = "true"
+ task.Status.Phase = rlarkv1alpha1.TaskPhaseSucceeded
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ got, err := r.reconcileTask(context.Background(), job, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *got.Spec.Kubernetes.Workload.Replicas != replicas || got.Annotations[StoppedAnnotation] != "" {
+ t.Fatalf("controller-stopped terminal Task was not resumed: replicas=%d annotations=%v", *got.Spec.Kubernetes.Workload.Replicas, got.Annotations)
+ }
+}
+
+func TestReconcileTaskWaitsForTerminatingTask(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker"}
+ task := buildTask(job, template, "job-worker", "default")
+ task.UID = types.UID("task-uid")
+ task.Finalizers = []string{"rlark.io/agent-cleanup"}
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ if err := c.Delete(context.Background(), task); err != nil {
+ t.Fatal(err)
+ }
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.reconcileTask(context.Background(), job, template, logr.Discard()); !errors.Is(err, controllerbase.ErrRequeueAfterChildCleanup) {
+ t.Fatalf("reconcile terminating Task error = %v, want cleanup requeue", err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.DeletionTimestamp.IsZero() {
+ t.Fatal("test Task is not terminating")
+ }
+}
+
+func TestReconcileTaskDoesNotResumeTerminalTaskWithoutZeroReplicas(t *testing.T) {
+ replicas := int32(4)
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: &replicas}}}}
+ task := buildTask(job, template, "job-worker", "default")
+ task.Annotations[StoppedAnnotation] = "true"
+ task.Status.Phase = rlarkv1alpha1.TaskPhaseSucceeded
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ got, err := r.reconcileTask(context.Background(), job, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[StoppedAnnotation] != "true" || *got.Spec.Kubernetes.Workload.Replicas != replicas {
+ t.Fatalf("terminal Task without zero replicas changed: replicas=%d annotations=%v", *got.Spec.Kubernetes.Workload.Replicas, got.Annotations)
+ }
+}
+
+func TestReconcileTaskAdoptsLegacyAndRejectsConflict(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}}
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker"}
+ legacy := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "job-worker", Namespace: "default", Labels: map[string]string{jobLabel: job.Name}, Annotations: map[string]string{"user": "keep"}}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(legacy).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ task, err := r.reconcileTask(context.Background(), job, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.Annotations["user"] != "keep" || task.Annotations[utils.ParentUIDAnnotation] != string(job.UID) || metav1.GetControllerOf(task).UID != job.UID {
+ t.Fatalf("legacy Task not adopted safely: %#v", task.ObjectMeta)
+ }
+ task.Annotations[utils.ParentUIDAnnotation] = "other"
+ if err := c.Update(context.Background(), task); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.reconcileTask(context.Background(), job, template, logr.Discard()); err == nil {
+ t.Fatal("expected conflicting UID to be rejected")
+ }
+}
+
+func TestSyncTaskStatusesRejectsOwnershipMismatch(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: "job-uid"}, Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}}, Status: rlarkv1alpha1.JobStatus{Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "worker", Phase: rlarkv1alpha1.TaskPhasePending}}}}
+ task := buildTask(job, job.Spec.Tasks[0], "job-worker", "default")
+ task.Status.Phase = rlarkv1alpha1.TaskPhaseSucceeded
+ task.Annotations[utils.ParentUIDAnnotation] = "other"
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.syncTaskStatuses(context.Background(), job); err == nil {
+ t.Fatal("expected ownership mismatch")
+ }
+ if job.Status.Tasks[0].Phase != rlarkv1alpha1.TaskPhasePending {
+ t.Fatal("mismatched Task changed Job status")
+ }
+}
+
+func TestPruneTasksStopsNamespaceDriftBeforeDelete(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}, Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker"}}}}
+ task := buildTask(job, job.Spec.Tasks[0], "job-worker", "old")
+ task.Spec.Kubernetes = &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: ptr.To(int32(2))}}
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ waiting, err := r.pruneTasks(context.Background(), job)
+ if err != nil || !waiting {
+ t.Fatalf("pruneTasks() = %v, %v", waiting, err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), task); err != nil {
+ t.Fatal(err)
+ }
+ if task.Annotations[StoppedAnnotation] != "true" || *task.Spec.Kubernetes.Workload.Replicas != 0 || !task.DeletionTimestamp.IsZero() {
+ t.Fatalf("drifted Task was not stopped before deletion: %#v", task)
+ }
+ task.Status.Phase = rlarkv1alpha1.TaskPhaseStopped
+ if err := c.Update(context.Background(), task); err != nil {
+ t.Fatal(err)
+ }
+ waiting, err = r.pruneTasks(context.Background(), job)
+ if err != nil || !waiting {
+ t.Fatalf("stopped pruneTasks() = %v, %v", waiting, err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), task); err == nil {
+ t.Fatal("stopped drifted Task was not deleted")
+ }
+}
+
+func TestPruneTasksStopsOwnerOnlyRemovedTask(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: "job-uid"}, Status: rlarkv1alpha1.JobStatus{Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "removed"}}}}
+ task := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "job-removed", Namespace: "default", Labels: map[string]string{jobLabel: job.Name}}, Spec: rlarkv1alpha1.TaskSpec{Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: ptr.To(int32(2))}}}}
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ waiting, err := r.pruneTasks(context.Background(), job)
+ if err != nil || !waiting {
+ t.Fatalf("pruneTasks() = %v, %v", waiting, err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[StoppedAnnotation] != "true" || *got.Spec.Kubernetes.Workload.Replicas != 0 {
+ t.Fatalf("owner-only removed Task was not stopped: %#v", got)
+ }
+}
+
+func TestJobReconcileUsesOldStatusToPruneSafely(t *testing.T) {
+ job := &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "job", UID: "job-uid"},
+ Status: rlarkv1alpha1.JobStatus{Phase: rlarkv1alpha1.JobPhaseSucceeded, Tasks: []rlarkv1alpha1.JobTaskStatus{{Name: "removed"}}},
+ }
+ controller := true
+ ownerOnly := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{
+ Name: "legacy-name", Namespace: "default", Labels: map[string]string{jobLabel: job.Name},
+ OwnerReferences: []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}},
+ }, Status: rlarkv1alpha1.TaskStatus{Phase: rlarkv1alpha1.TaskPhaseStopped}}
+ unrelated := &rlarkv1alpha1.Task{ObjectMeta: metav1.ObjectMeta{Name: "job-unrelated", Namespace: "default", Labels: map[string]string{jobLabel: job.Name}}, Status: rlarkv1alpha1.TaskStatus{Phase: rlarkv1alpha1.TaskPhaseStopped}}
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ownerOnly, unrelated).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.ReconcileStateMachine(context.Background(), job); !errors.Is(err, controllerbase.ErrRequeueAfterChildCleanup) {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(ownerOnly), &rlarkv1alpha1.Task{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("owner-only removed Task was not pruned: %v", err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(unrelated), &rlarkv1alpha1.Task{}); err != nil {
+ t.Fatalf("unrelated label-only prefixed Task was touched: %v", err)
+ }
+}
+
+func TestNamespaceResolutionFailurePreservesExistingTask(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ listFails bool
+ }{
+ {name: "list error", listFails: true},
+ {name: "no match"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}, Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{NodeSelector: map[string]string{"zone": "new"}}}}}}
+ task := buildTask(job, job.Spec.Tasks[0], "job-worker", "old")
+ task.Spec.Kubernetes = &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: ptr.To(int32(2))}}
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ scheme := jobTestScheme(t)
+ baseClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task).Build()
+ var c client.Client = baseClient
+ if tt.listFails {
+ c = failingNodeListClient{Client: c}
+ }
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.pruneTasks(context.Background(), job); err == nil {
+ t.Fatal("expected namespace resolution error")
+ }
+ var got rlarkv1alpha1.Task
+ if err := baseClient.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil || got.Annotations[StoppedAnnotation] != "" || *got.Spec.Kubernetes.Workload.Replicas != 2 {
+ t.Fatalf("healthy Task changed after resolution failure: task=%#v err=%v", got, err)
+ }
+ })
+ }
+}
+
+func TestResolvedNamespaceIsDeterministicAndMigratesTask(t *testing.T) {
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}, Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{NodeSelector: map[string]string{"zone": "new"}}}}}}
+ task := buildTask(job, job.Spec.Tasks[0], "job-worker", "old")
+ task.Spec.Kubernetes = &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: ptr.To(int32(2))}}
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ nodes := []client.Object{
+ &rlarkv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: "z", Namespace: "workers-b", Labels: map[string]string{"zone": "new"}}},
+ &rlarkv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "workers-a", Labels: map[string]string{"zone": "new"}}},
+ }
+ scheme := jobTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(append(nodes, task)...).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ namespace, err := r.resolveTaskNamespace(context.Background(), &job.Spec.Tasks[0])
+ if err != nil || namespace != "workers-a" {
+ t.Fatalf("namespace=%q err=%v", namespace, err)
+ }
+ waiting, err := r.pruneTasks(context.Background(), job)
+ if err != nil || !waiting {
+ t.Fatalf("pruneTasks()=%v, %v", waiting, err)
+ }
+ var got rlarkv1alpha1.Task
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(task), &got); err != nil || got.Annotations[StoppedAnnotation] != "true" || !reflect.DeepEqual(got.Spec.Kubernetes.Workload.Replicas, ptr.To(int32(0))) {
+ t.Fatalf("old Task was not stopped for resolved migration: %#v err=%v", got, err)
+ }
+}
+
+func TestDispatchResolvesNamespaceBeforeMutating(t *testing.T) {
+ replicas := int32(2)
+ template := rlarkv1alpha1.JobTaskTemplate{Name: "worker", TaskSpec: rlarkv1alpha1.TaskSpec{NodeSelector: map[string]string{"zone": "new"}, Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: &replicas}}}}
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", UID: types.UID("job-uid")}, Spec: rlarkv1alpha1.JobSpec{Tasks: []rlarkv1alpha1.JobTaskTemplate{template}}}
+ task := buildTask(job, template, "job-worker", "workers")
+ controller := true
+ task.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Job", Name: job.Name, UID: job.UID, Controller: &controller}}
+ node := &rlarkv1alpha1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node", Namespace: "workers", Labels: map[string]string{"zone": "new"}}}
+ scheme := jobTestScheme(t)
+ baseClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(task, node).Build()
+ c := &failSecondNodeListClient{Client: baseClient}
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.dispatchTasks(context.Background(), job, logr.Discard()); err != nil {
+ t.Fatal(err)
+ }
+ if c.lists != 1 {
+ t.Fatalf("Node lists = %d, want 1", c.lists)
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/job/statemachine.go b/apps/rlark/pkg/controllermanager/job/statemachine.go
index 2440404..2401f29 100644
--- a/apps/rlark/pkg/controllermanager/job/statemachine.go
+++ b/apps/rlark/pkg/controllermanager/job/statemachine.go
@@ -73,21 +73,15 @@ func newJobStateMachine() *fsm.FSM {
},
"enter_" + string(rlarkv1alpha1.JobPhasePending): func(ctx context.Context, e *fsm.Event) {
job := e.Args[0].(*rlarkv1alpha1.Job)
- if job.Status.StartTime == nil {
+ if e.Src != "" || job.Status.StartTime == nil {
now := metav1.Now()
job.Status.StartTime = &now
}
if job.Status.EndTime != nil {
job.Status.EndTime = nil
}
- if job.Status.Tasks == nil {
- job.Status.Tasks = make([]rlarkv1alpha1.JobTaskStatus, 0, len(job.Spec.Tasks))
- for _, t := range job.Spec.Tasks {
- job.Status.Tasks = append(job.Status.Tasks, rlarkv1alpha1.JobTaskStatus{
- Name: t.Name,
- })
- }
- }
+
+ syncTaskStatusSnapshot(job)
},
"enter_" + string(rlarkv1alpha1.JobPhaseSucceeded): func(ctx context.Context, e *fsm.Event) {
job := e.Args[0].(*rlarkv1alpha1.Job)
diff --git a/apps/rlark/pkg/controllermanager/job/statemachine_test.go b/apps/rlark/pkg/controllermanager/job/statemachine_test.go
index abd9ec4..e62e667 100644
--- a/apps/rlark/pkg/controllermanager/job/statemachine_test.go
+++ b/apps/rlark/pkg/controllermanager/job/statemachine_test.go
@@ -3,8 +3,10 @@ package job
import (
"context"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
)
@@ -23,6 +25,26 @@ func TestStoppedJobRecordsEndTime(t *testing.T) {
}
}
+func TestRestartedJobResetsRunTimes(t *testing.T) {
+ oldStart := metav1.NewTime(time.Now().Add(-time.Hour))
+ oldEnd := metav1.NewTime(time.Now().Add(-time.Minute))
+ job := &rlarkv1alpha1.Job{Status: rlarkv1alpha1.JobStatus{
+ Phase: rlarkv1alpha1.JobPhaseStopped, StartTime: &oldStart, EndTime: &oldEnd,
+ }}
+ f := newJobStateMachine()
+ f.SetState(string(job.Status.Phase))
+
+ if err := f.Event(context.Background(), EventTasksPending, job); err != nil {
+ t.Fatalf("restart job: %v", err)
+ }
+ if job.Status.StartTime == nil || !job.Status.StartTime.After(oldStart.Time) {
+ t.Fatalf("startTime was not reset: %v", job.Status.StartTime)
+ }
+ if job.Status.EndTime != nil {
+ t.Fatalf("endTime was not cleared: %v", job.Status.EndTime)
+ }
+}
+
func newJob(phase rlarkv1alpha1.JobPhase) *rlarkv1alpha1.Job {
return &rlarkv1alpha1.Job{Status: rlarkv1alpha1.JobStatus{Phase: phase}}
}
diff --git a/apps/rlark/pkg/controllermanager/job/sync.go b/apps/rlark/pkg/controllermanager/job/sync.go
index 8bd2f87..73c9b59 100644
--- a/apps/rlark/pkg/controllermanager/job/sync.go
+++ b/apps/rlark/pkg/controllermanager/job/sync.go
@@ -7,14 +7,21 @@ import (
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/errors"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
+const jobLabel = "rlinf.io/job"
+
func (r *Reconciler) syncTaskStatuses(
ctx context.Context,
job *rlarkv1alpha1.Job,
@@ -29,15 +36,21 @@ func (r *Reconciler) syncTaskStatuses(
}
taskName := utils.ChildName(job.Name, t.Name)
- taskNamespace := r.resolveTaskNamespace(ctx, &t)
+ taskNamespace, err := r.resolveTaskNamespace(ctx, &t)
+ if err != nil {
+ return false, err
+ }
var task rlarkv1alpha1.Task
- err := r.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task)
+ err = r.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task)
if err != nil {
if errors.IsNotFound(err) {
continue
}
return false, fmt.Errorf("get Task %s/%s: %w", taskNamespace, taskName, err)
}
+ if _, _, err := utils.ClassifyChild(&task, job.UID, rlarkv1alpha1.GroupVersion.String(), "Job", jobLabel, job.Name, t.Name); err != nil {
+ return false, fmt.Errorf("task %s/%s ownership conflict: %w", taskNamespace, taskName, err)
+ }
if utils.SyncStatusEntry(ts, string(task.Status.Phase), task.Status.Message) {
changed = true
}
@@ -51,13 +64,35 @@ func (r *Reconciler) dispatchTasks(
job *rlarkv1alpha1.Job,
logger logr.Logger,
) (bool, error) {
+ oldTemplates := make([]string, len(job.Status.Tasks))
+ for i := range job.Status.Tasks {
+ oldTemplates[i] = job.Status.Tasks[i].Name
+ }
+ return r.dispatchTasksWithTemplates(ctx, job, logger, oldTemplates)
+}
+
+func (r *Reconciler) dispatchTasksWithTemplates(
+ ctx context.Context,
+ job *rlarkv1alpha1.Job,
+ logger logr.Logger,
+ oldTemplates []string,
+) (bool, error) {
+ namespaces, err := r.resolveTaskNamespaces(ctx, job.Spec.Tasks)
+ if err != nil {
+ return false, err
+ }
+ if waiting, err := r.pruneTasksInNamespaces(ctx, job, namespaces, oldTemplates); err != nil {
+ return false, err
+ } else if waiting {
+ return false, controller.ErrRequeueAfterChildCleanup
+ }
statusMap := buildTaskStatusMap(job)
changed := false
- for _, t := range job.Spec.Tasks {
+ for i, t := range job.Spec.Tasks {
ts := statusMap[t.Name]
- task, err := r.reconcileTask(ctx, job, t, logger)
+ task, err := r.reconcileTaskInNamespace(ctx, job, t, namespaces[i], logger)
if err != nil {
return false, err
}
@@ -75,15 +110,41 @@ func (r *Reconciler) reconcileTask(
t rlarkv1alpha1.JobTaskTemplate,
logger logr.Logger,
) (*rlarkv1alpha1.Task, error) {
- taskNamespace := r.resolveTaskNamespace(ctx, &t)
+ taskNamespace, err := r.resolveTaskNamespace(ctx, &t)
+ if err != nil {
+ return nil, err
+ }
+ return r.reconcileTaskInNamespace(ctx, job, t, taskNamespace, logger)
+}
+
+func (r *Reconciler) reconcileTaskInNamespace(
+ ctx context.Context,
+ job *rlarkv1alpha1.Job,
+ t rlarkv1alpha1.JobTaskTemplate,
+ taskNamespace string,
+ logger logr.Logger,
+) (*rlarkv1alpha1.Task, error) {
taskName := utils.ChildName(job.Name, t.Name)
var task rlarkv1alpha1.Task
err := r.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task)
if err == nil {
- if !taskEqual(&task, job, t) {
+ if err := r.ensureTaskOwnership(ctx, job, t.Name, &task); err != nil {
+ return nil, err
+ }
+ if !task.DeletionTimestamp.IsZero() {
+ return nil, controller.ErrRequeueAfterChildCleanup
+ }
+ controllerStopped := task.Annotations[StoppedAnnotation] == "true" && task.Spec.Kubernetes != nil &&
+ task.Spec.Kubernetes.Workload != nil && task.Spec.Kubernetes.Workload.Replicas != nil &&
+ *task.Spec.Kubernetes.Workload.Replicas == 0
+ resuming := controllerStopped && !job.Spec.Stopped
+ if (!taskTerminal(&task) || resuming) && !taskEqual(&task, job, t) {
desired := buildTask(job, t, taskName, taskNamespace)
task.Spec = desired.Spec
- task.Annotations = desired.Annotations
+ task.Annotations = utils.MergeAnnotations(task.Annotations, desired.Annotations)
+ if resuming {
+ delete(task.Annotations, StoppedAnnotation)
+ }
if err := r.Update(ctx, &task); err != nil {
return nil, fmt.Errorf("update Task %s/%s: %w", taskNamespace, taskName, err)
}
@@ -99,8 +160,17 @@ func (r *Reconciler) reconcileTask(
if err := ctrl.SetControllerReference(job, newTask, r.Scheme); err != nil {
return nil, fmt.Errorf("set controller reference on Task %s: %w", taskName, err)
}
- if err := r.Create(ctx, newTask); err != nil && !errors.IsAlreadyExists(err) {
- return nil, fmt.Errorf("create Task %s/%s: %w", taskNamespace, taskName, err)
+ if err := r.Create(ctx, newTask); err != nil {
+ if !errors.IsAlreadyExists(err) {
+ return nil, fmt.Errorf("create Task %s/%s: %w", taskNamespace, taskName, err)
+ }
+ if err := r.Get(ctx, types.NamespacedName{Name: taskName, Namespace: taskNamespace}, &task); err != nil {
+ return nil, fmt.Errorf("reload Task %s/%s after create race: %w", taskNamespace, taskName, err)
+ }
+ if err := r.ensureTaskOwnership(ctx, job, t.Name, &task); err != nil {
+ return nil, err
+ }
+ return &task, nil
}
logger.Info("Created Task for job", "task", taskName, "namespace", taskNamespace)
@@ -108,6 +178,101 @@ func (r *Reconciler) reconcileTask(
return newTask, nil
}
+func taskTerminal(task *rlarkv1alpha1.Task) bool {
+ return task.Status.Phase == rlarkv1alpha1.TaskPhaseSucceeded || task.Status.Phase == rlarkv1alpha1.TaskPhaseFailed
+}
+
+func (r *Reconciler) ensureTaskOwnership(ctx context.Context, job *rlarkv1alpha1.Job, template string, task *rlarkv1alpha1.Task) error {
+ owned, legacy, err := utils.ClassifyChild(task, job.UID, rlarkv1alpha1.GroupVersion.String(), "Job", jobLabel, job.Name, template)
+ if err != nil || !owned {
+ return fmt.Errorf("task %s/%s ownership conflict: %w", task.Namespace, task.Name, err)
+ }
+ if !legacy {
+ return nil
+ }
+ task.Annotations = utils.MergeAnnotations(task.Annotations, map[string]string{
+ utils.ParentUIDAnnotation: string(job.UID), utils.ChildTemplateAnnotation: template,
+ })
+ if err := ctrl.SetControllerReference(job, task, r.Scheme); err != nil {
+ return fmt.Errorf("adopt Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ if err := r.Update(ctx, task); err != nil {
+ return fmt.Errorf("adopt Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ return nil
+}
+
+func (r *Reconciler) pruneTasks(ctx context.Context, job *rlarkv1alpha1.Job) (bool, error) {
+ oldTemplates := make([]string, len(job.Status.Tasks))
+ for i := range job.Status.Tasks {
+ oldTemplates[i] = job.Status.Tasks[i].Name
+ }
+ namespaces, err := r.resolveTaskNamespaces(ctx, job.Spec.Tasks)
+ if err != nil {
+ return false, err
+ }
+ return r.pruneTasksInNamespaces(ctx, job, namespaces, oldTemplates)
+}
+
+func (r *Reconciler) resolveTaskNamespaces(ctx context.Context, templates []rlarkv1alpha1.JobTaskTemplate) ([]string, error) {
+ namespaces := make([]string, len(templates))
+ for i := range templates {
+ namespace, err := r.resolveTaskNamespace(ctx, &templates[i])
+ if err != nil {
+ return nil, err
+ }
+ namespaces[i] = namespace
+ }
+ return namespaces, nil
+}
+
+func (r *Reconciler) pruneTasksInNamespaces(ctx context.Context, job *rlarkv1alpha1.Job, namespaces, oldTemplates []string) (bool, error) {
+ desired := make(map[string]string, len(job.Spec.Tasks))
+ for i := range job.Spec.Tasks {
+ template := &job.Spec.Tasks[i]
+ desired[namespaces[i]+"/"+utils.ChildName(job.Name, template.Name)] = template.Name
+ }
+ var tasks rlarkv1alpha1.TaskList
+ if err := r.List(ctx, &tasks, client.MatchingLabels{jobLabel: job.Name}); err != nil {
+ return false, fmt.Errorf("list Tasks for Job %s: %w", job.Name, err)
+ }
+ waiting := false
+ for i := range tasks.Items {
+ task := &tasks.Items[i]
+ template, keep := desired[task.Namespace+"/"+task.Name]
+ if keep {
+ if _, _, err := utils.ClassifyChild(task, job.UID, rlarkv1alpha1.GroupVersion.String(), "Job", jobLabel, job.Name, template); err != nil {
+ return false, fmt.Errorf("task %s/%s ownership conflict: %w", task.Namespace, task.Name, err)
+ }
+ continue
+ }
+ owned, _ := utils.ClassifyRemovedChild(task, job.UID, rlarkv1alpha1.GroupVersion.String(), "Job", jobLabel, job.Name, oldTemplates)
+ if !owned {
+ continue
+ }
+ waiting = true
+ if task.Status.Phase != rlarkv1alpha1.TaskPhaseStopped {
+ if task.Annotations == nil {
+ task.Annotations = map[string]string{}
+ }
+ task.Annotations[StoppedAnnotation] = "true"
+ if task.Spec.Kubernetes != nil && task.Spec.Kubernetes.Workload != nil {
+ task.Spec.Kubernetes.Workload.Replicas = ptr.To(int32(0))
+ }
+ if err := r.Update(ctx, task); err != nil {
+ return false, fmt.Errorf("stop pruned Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ continue
+ }
+ if task.DeletionTimestamp.IsZero() {
+ if err := r.Delete(ctx, task); client.IgnoreNotFound(err) != nil {
+ return false, fmt.Errorf("prune Task %s/%s: %w", task.Namespace, task.Name, err)
+ }
+ }
+ }
+ return waiting, nil
+}
+
func taskEqual(existing *rlarkv1alpha1.Task, job *rlarkv1alpha1.Job, t rlarkv1alpha1.JobTaskTemplate) bool {
desired := buildTask(job, t, existing.Name, existing.Namespace)
return reflect.DeepEqual(existing.Spec, desired.Spec) &&
@@ -120,10 +285,20 @@ func (r *Reconciler) reconcileWithStateMachine(
job *rlarkv1alpha1.Job,
) (bool, error) {
logger := log.FromContext(ctx).WithValues("job", job.Name)
+
+ if job.Spec.Stopped {
+ return r.reconcileStoppedJob(ctx, job)
+ }
+
+ if err := validateHeadTask(job); err != nil {
+ logger.Error(err, "invalid ray head task configuration")
+ return markJobInvalid(job, err.Error()), nil
+ }
+
f := newJobStateMachine()
f.SetState(string(job.Status.Phase))
- changed := false
+ changed := syncTaskStatusSnapshot(job)
if f.Can(EventInit) {
if err := f.Event(ctx, EventInit, job); err != nil {
@@ -139,7 +314,6 @@ func (r *Reconciler) reconcileWithStateMachine(
if syncChanged {
changed = true
}
-
dispatchChanged, err := r.dispatchTasks(ctx, job, logger)
if err != nil {
return false, err
@@ -159,6 +333,86 @@ func (r *Reconciler) reconcileWithStateMachine(
return changed, nil
}
+func (r *Reconciler) reconcileStoppedJob(ctx context.Context, job *rlarkv1alpha1.Job) (bool, error) {
+ f := newJobStateMachine()
+ f.SetState(string(job.Status.Phase))
+ changed := syncTaskStatusSnapshot(job)
+
+ if f.Can(EventInit) {
+ if err := f.Event(ctx, EventInit, job); err != nil {
+ return false, err
+ }
+ changed = true
+ }
+ if syncChanged, err := r.syncTaskStatuses(ctx, job); err != nil {
+ return false, err
+ } else if syncChanged {
+ changed = true
+ }
+ waiting, err := r.deleteOwnedTasks(ctx, job)
+ if err != nil {
+ return false, err
+ }
+ if waiting {
+ return changed, controller.ErrRequeueAfterChildCleanup
+ }
+ if markTaskStatusesStopped(job) {
+ changed = true
+ }
+ if f.Can(EventJobStopped) {
+ if err := f.Event(ctx, EventJobStopped, job); err != nil {
+ return false, err
+ }
+ changed = true
+ }
+ return changed, nil
+}
+
+func markTaskStatusesStopped(job *rlarkv1alpha1.Job) bool {
+ changed := syncTaskStatusSnapshot(job)
+ for i := range job.Status.Tasks {
+ switch job.Status.Tasks[i].Phase {
+ case rlarkv1alpha1.TaskPhaseSucceeded, rlarkv1alpha1.TaskPhaseFailed, rlarkv1alpha1.TaskPhaseStopped:
+ continue
+ default:
+ job.Status.Tasks[i].Phase = rlarkv1alpha1.TaskPhaseStopped
+ changed = true
+ }
+ }
+ return changed
+}
+
+// jobConditionValidated is the type of the condition recording spec validation.
+const jobConditionValidated = "Validated"
+
+// markJobInvalid marks the Job as Failed due to an invalid spec and records the
+// reason in a "Validated" condition. It returns whether the Job object changed.
+func markJobInvalid(job *rlarkv1alpha1.Job, message string) bool {
+ changed := false
+
+ if job.Status.Phase != rlarkv1alpha1.JobPhaseFailed {
+ job.Status.Phase = rlarkv1alpha1.JobPhaseFailed
+ changed = true
+ }
+ if job.Status.EndTime == nil {
+ now := metav1.Now()
+ job.Status.EndTime = &now
+ changed = true
+ }
+
+ if apimeta.SetStatusCondition(&job.Status.Conditions, metav1.Condition{
+ Type: jobConditionValidated,
+ Status: metav1.ConditionFalse,
+ Reason: "InvalidRayHeadTask",
+ Message: message,
+ ObservedGeneration: job.Generation,
+ }) {
+ changed = true
+ }
+
+ return changed
+}
+
func (r *Reconciler) evaluateJobEvent(job *rlarkv1alpha1.Job) string {
phases := make([]string, len(job.Status.Tasks))
for i, ts := range job.Status.Tasks {
diff --git a/apps/rlark/pkg/controllermanager/job/sync_test.go b/apps/rlark/pkg/controllermanager/job/sync_test.go
index 3652f91..6d00011 100644
--- a/apps/rlark/pkg/controllermanager/job/sync_test.go
+++ b/apps/rlark/pkg/controllermanager/job/sync_test.go
@@ -1,9 +1,11 @@
package job
import (
+ "reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/utils/ptr"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
)
@@ -103,6 +105,125 @@ func TestBuildTaskCarriesRestartAnnotation(t *testing.T) {
}
}
+func TestBuildTaskCarriesJobTags(t *testing.T) {
+ job := &rlarkv1alpha1.Job{Spec: rlarkv1alpha1.JobSpec{
+ Tags: []rlarkv1alpha1.JobTag{{Key: "team", Values: []string{"research", "platform"}}},
+ }}
+ task := buildTask(job, rlarkv1alpha1.JobTaskTemplate{Name: "worker"}, "job-worker", "default")
+
+ if !reflect.DeepEqual(task.Spec.Tags, job.Spec.Tags) {
+ t.Fatalf("task tags = %#v, want %#v", task.Spec.Tags, job.Spec.Tags)
+ }
+
+ job.Spec.Tags[0].Values[0] = "infrastructure"
+ if task.Spec.Tags[0].Values[0] != "research" {
+ t.Fatalf("task tags should not share backing storage with job tags: %#v", task.Spec.Tags)
+ }
+}
+
+func TestValidateHeadTask(t *testing.T) {
+ tmpl := func(name string, head bool, replicas *int32) rlarkv1alpha1.JobTaskTemplate {
+ return rlarkv1alpha1.JobTaskTemplate{
+ Name: name,
+ Head: head,
+ TaskSpec: rlarkv1alpha1.TaskSpec{
+ Kubernetes: &rlarkv1alpha1.KubernetesTaskSpec{
+ Workload: &rlarkv1alpha1.KubernetesWorkloadSpec{Replicas: replicas},
+ },
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ tasks []rlarkv1alpha1.JobTaskTemplate
+ wantErr bool
+ }{
+ {
+ name: "head with single pod is valid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{
+ tmpl("head", true, ptr.To(int32(1))),
+ tmpl("worker", false, ptr.To(int32(4))),
+ },
+ },
+ {
+ name: "head with unset replicas defaults to one and is valid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{
+ {Name: "head", Head: true},
+ },
+ },
+ {
+ name: "head with multiple pods is invalid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{
+ tmpl("head", true, ptr.To(int32(2))),
+ },
+ wantErr: true,
+ },
+ {
+ name: "head with zero pods is invalid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{
+ tmpl("head", true, ptr.To(int32(0))),
+ },
+ wantErr: true,
+ },
+ {
+ name: "multiple head tasks are invalid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{
+ tmpl("head-a", true, ptr.To(int32(1))),
+ tmpl("head-b", true, ptr.To(int32(1))),
+ },
+ wantErr: true,
+ },
+ {
+ name: "no head task is valid",
+ tasks: []rlarkv1alpha1.JobTaskTemplate{tmpl("worker", false, ptr.To(int32(3)))},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ job := &rlarkv1alpha1.Job{Spec: rlarkv1alpha1.JobSpec{Tasks: tt.tasks}}
+ err := validateHeadTask(job)
+ if tt.wantErr && err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !tt.wantErr && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ })
+ }
+}
+
+func TestMarkJobInvalid(t *testing.T) {
+ job := &rlarkv1alpha1.Job{}
+ if !markJobInvalid(job, "boom") {
+ t.Fatal("expected the job to change on first invalidation")
+ }
+ if job.Status.Phase != rlarkv1alpha1.JobPhaseFailed {
+ t.Fatalf("phase = %q, want %q", job.Status.Phase, rlarkv1alpha1.JobPhaseFailed)
+ }
+ if job.Status.EndTime == nil {
+ t.Fatal("expected EndTime to be set")
+ }
+ cond := findCondition(job.Status.Conditions, jobConditionValidated)
+ if cond == nil || cond.Status != metav1.ConditionFalse || cond.Message != "boom" {
+ t.Fatalf("unexpected validated condition: %+v", cond)
+ }
+
+ if markJobInvalid(job, "boom") {
+ t.Fatal("expected no change when the invalid state is unchanged")
+ }
+}
+
+func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition {
+ for i := range conditions {
+ if conditions[i].Type == condType {
+ return &conditions[i]
+ }
+ }
+ return nil
+}
+
func jobWithTaskPhases(stopped bool, phase rlarkv1alpha1.JobPhase, phases ...rlarkv1alpha1.TaskPhase) *rlarkv1alpha1.Job {
job := &rlarkv1alpha1.Job{Spec: rlarkv1alpha1.JobSpec{Stopped: stopped}, Status: rlarkv1alpha1.JobStatus{Phase: phase}}
for i, taskPhase := range phases {
diff --git a/apps/rlark/pkg/controllermanager/node/node_controller.go b/apps/rlark/pkg/controllermanager/node/node_controller.go
index bdcd19f..3201a76 100644
--- a/apps/rlark/pkg/controllermanager/node/node_controller.go
+++ b/apps/rlark/pkg/controllermanager/node/node_controller.go
@@ -7,6 +7,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/log"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
@@ -15,7 +16,8 @@ import (
// Reconciler reconciles Node resources (cluster-scoped).
type Reconciler struct {
client.Client
- Scheme *runtime.Scheme
+ Scheme *runtime.Scheme
+ MaxConcurrentReconciles int
}
// +kubebuilder:rbac:groups=rlinf.io,resources=nodes,verbs=get;list;watch;create;update;patch;delete
@@ -49,5 +51,6 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Node{}).
Named("node").
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.MaxConcurrentReconciles}).
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/replication/controller.go b/apps/rlark/pkg/controllermanager/replication/controller.go
new file mode 100644
index 0000000..d432be8
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/replication/controller.go
@@ -0,0 +1,123 @@
+package replication
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/discovery"
+ "k8s.io/client-go/discovery/cached/memory"
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/restmapper"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+)
+
+const resyncInterval = 5 * time.Minute
+
+type Reconciler struct {
+ client.Client
+ Engine *distribution.Engine
+}
+
+func New(config *rest.Config, declarationClient client.Client) (*Reconciler, error) {
+ targetClient, err := dynamic.NewForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("create dynamic client: %w", err)
+ }
+ discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("create discovery client: %w", err)
+ }
+ mode := distribution.ReplicationMode
+ return &Reconciler{
+ Client: declarationClient,
+ Engine: &distribution.Engine{
+ DeclarationClient: declarationClient,
+ TargetClient: targetClient,
+ TargetMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
+ Namespaces: namespaceResolver{client: declarationClient},
+ InventoryStore: distribution.ConfigMapInventoryStore{Client: declarationClient, Mode: mode},
+ Policy: restrictedPolicy{},
+ Mode: mode,
+ MaxTargets: 1000,
+ },
+ }, nil
+}
+
+func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
+ declaration := &corev1.Secret{}
+ if err := r.Get(ctx, request.NamespacedName, declaration); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if declaration.Type != distribution.ReplicationSecretType {
+ return ctrl.Result{}, nil
+ }
+ result, err := r.Engine.Reconcile(ctx, declaration)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if result.Requeue {
+ return ctrl.Result{RequeueAfter: time.Nanosecond}, nil
+ }
+ return ctrl.Result{RequeueAfter: resyncInterval}, nil
+}
+
+func (r *Reconciler) SetupWithManager(manager ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(manager).
+ Named("resource-replication").
+ For(&corev1.Secret{}, builder.WithPredicates(secretPredicate(distribution.ReplicationSecretType))).
+ Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespace)).
+ Complete(r)
+}
+
+func (r *Reconciler) mapNamespace(ctx context.Context, _ client.Object) []reconcile.Request {
+ var secrets corev1.SecretList
+ if err := r.List(ctx, &secrets); err != nil {
+ return nil
+ }
+ requests := make([]reconcile.Request, 0)
+ for i := range secrets.Items {
+ if secrets.Items[i].Type == distribution.ReplicationSecretType {
+ requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: secrets.Items[i].Namespace, Name: secrets.Items[i].Name}})
+ }
+ }
+ return requests
+}
+
+type namespaceResolver struct{ client client.Client }
+
+func (r namespaceResolver) List(ctx context.Context) ([]distribution.NamespaceIdentity, error) {
+ var namespaces corev1.NamespaceList
+ if err := r.client.List(ctx, &namespaces); err != nil {
+ return nil, err
+ }
+ result := make([]distribution.NamespaceIdentity, 0, len(namespaces.Items))
+ for _, namespace := range namespaces.Items {
+ result = append(result, distribution.NamespaceIdentity{Name: namespace.Name, UID: namespace.UID, Labels: namespace.Labels, Phase: namespace.Status.Phase})
+ }
+ return result, nil
+}
+
+func secretPredicate(secretType corev1.SecretType) predicate.Predicate {
+ matches := func(object client.Object) bool {
+ secret, ok := object.(*corev1.Secret)
+ return ok && secret.Type == secretType
+ }
+ return predicate.Funcs{
+ CreateFunc: func(e event.CreateEvent) bool { return matches(e.Object) },
+ UpdateFunc: func(e event.UpdateEvent) bool { return matches(e.ObjectOld) || matches(e.ObjectNew) },
+ DeleteFunc: func(e event.DeleteEvent) bool { return matches(e.Object) },
+ GenericFunc: func(e event.GenericEvent) bool { return matches(e.Object) },
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/replication/controller_test.go b/apps/rlark/pkg/controllermanager/replication/controller_test.go
new file mode 100644
index 0000000..e2804ca
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/replication/controller_test.go
@@ -0,0 +1,73 @@
+package replication
+
+import (
+ "context"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSecretPredicate(t *testing.T) {
+ p := secretPredicate(distribution.ReplicationSecretType)
+ replication := &corev1.Secret{Type: distribution.ReplicationSecretType}
+ delivery := &corev1.Secret{Type: distribution.DeliverySecretType}
+
+ assert.True(t, p.Create(event.CreateEvent{Object: replication}))
+ assert.False(t, p.Create(event.CreateEvent{Object: delivery}))
+ assert.True(t, p.Update(event.UpdateEvent{ObjectOld: replication, ObjectNew: delivery}))
+ assert.True(t, p.Delete(event.DeleteEvent{Object: replication}))
+ assert.True(t, p.Generic(event.GenericEvent{Object: replication}))
+}
+
+func TestRestrictedPolicy(t *testing.T) {
+ policy := restrictedPolicy{}
+ configMap := &meta.RESTMapping{}
+ object := &unstructured.Unstructured{}
+
+ require.NoError(t, policy.Validate(context.Background(), &corev1.Secret{}, configMap, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{}))
+ assert.ErrorContains(t, policy.Validate(context.Background(), &corev1.Secret{}, configMap, distribution.NamespaceIdentity{}, object, distribution.ApplyPolicy{AdoptExisting: true}), "disabled")
+}
+
+func TestMapNamespaceReturnsOnlyReplicationSecrets(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "platform"}, Type: distribution.ReplicationSecretType},
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "platform"}, Type: distribution.DeliverySecretType},
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "other"}, Type: distribution.ReplicationSecretType},
+ ).Build()
+ r := &Reconciler{Client: c}
+
+ requests := r.mapNamespace(context.Background(), &corev1.Namespace{})
+ require.Len(t, requests, 2)
+ assert.ElementsMatch(t, []string{"platform/a", "other/c"}, []string{
+ requests[0].Namespace + "/" + requests[0].Name,
+ requests[1].Namespace + "/" + requests[1].Name,
+ })
+}
+
+func TestNamespaceResolver(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: "namespace-uid", Labels: map[string]string{"managed": "true"}},
+ Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive},
+ }).Build()
+
+ items, err := (namespaceResolver{client: c}).List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+ assert.Equal(t, "tenant-a", items[0].Name)
+ assert.Equal(t, "namespace-uid", string(items[0].UID))
+ assert.Equal(t, "true", items[0].Labels["managed"])
+}
diff --git a/apps/rlark/pkg/controllermanager/replication/policy.go b/apps/rlark/pkg/controllermanager/replication/policy.go
new file mode 100644
index 0000000..5114f3c
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/replication/policy.go
@@ -0,0 +1,27 @@
+package replication
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+)
+
+type restrictedPolicy struct{}
+
+func (restrictedPolicy) Validate(_ context.Context, _ *corev1.Secret, _ *meta.RESTMapping, _ distribution.NamespaceIdentity, object *unstructured.Unstructured, apply distribution.ApplyPolicy) error {
+ if apply.AdoptExisting || apply.ConflictPolicy == "Force" {
+ return fmt.Errorf("adoption and force apply are disabled")
+ }
+ if object.GetKind() == "Secret" {
+ secretType, _, _ := unstructured.NestedString(object.Object, "type")
+ if secretType == string(corev1.SecretTypeServiceAccountToken) {
+ return fmt.Errorf("service account token secrets are denied")
+ }
+ }
+ return nil
+}
diff --git a/apps/rlark/pkg/controllermanager/sync/config.go b/apps/rlark/pkg/controllermanager/sync/config.go
deleted file mode 100644
index 1a80c31..0000000
--- a/apps/rlark/pkg/controllermanager/sync/config.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package sync
-
-import (
- "fmt"
-
- "github.com/spf13/pflag"
-
- "sigs.k8s.io/controller-runtime/pkg/controller"
- "sigs.k8s.io/controller-runtime/pkg/reconcile"
-)
-
-// Config holds configuration for the persistencer.
-type Config struct {
- // Workers is the number of concurrent workers for syncing.
- Workers int
-}
-
-// DefaultConfig returns the default persistencer configuration.
-func DefaultConfig() Config {
- return Config{
- Workers: 5,
- }
-}
-
-// SetupFlags sets the upFlags.
-func (c *Config) SetupFlags(fs *pflag.FlagSet) {
- fs.IntVar(&c.Workers, "sync-workers", c.Workers, "Number of concurrent workers for syncing")
-}
-
-// Validate validates the configuration.
-func (c Config) Validate() error {
- if c.Workers <= 0 {
- return fmt.Errorf("sync workers must be positive")
- }
- return nil
-}
-
-// ToControllerOptions is an exported method.
-func (c Config) ToControllerOptions() controller.TypedOptions[reconcile.Request] {
- return controller.TypedOptions[reconcile.Request]{
- MaxConcurrentReconciles: c.Workers,
- }
-}
diff --git a/apps/rlark/pkg/controllermanager/sync/generic_handler.go b/apps/rlark/pkg/controllermanager/sync/generic_handler.go
index 1fd4e27..60f054c 100644
--- a/apps/rlark/pkg/controllermanager/sync/generic_handler.go
+++ b/apps/rlark/pkg/controllermanager/sync/generic_handler.go
@@ -3,12 +3,18 @@ package sync
import (
"encoding/json"
"fmt"
+ "slices"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
)
+func readyForFinalDeletion(obj client.Object) bool {
+ return obj.GetDeletionTimestamp() != nil &&
+ len(obj.GetFinalizers()) == 1 && slices.Contains(obj.GetFinalizers(), SyncFinalizer)
+}
+
// Handler defines the interface for syncing resources to the database.
type Handler interface {
GetTableName() string
@@ -72,7 +78,7 @@ func (h *genericSyncHandler) ToPersistedModelObject(obj client.Object) (db.Resou
CreatedAt: obj.GetCreationTimestamp().Time,
Raw: rawData,
}
- if obj.GetDeletionTimestamp() != nil {
+ if readyForFinalDeletion(obj) {
deletedAt := obj.GetDeletionTimestamp().Time
m.DeletedAt = &deletedAt
}
@@ -94,7 +100,7 @@ func (h *genericSyncHandler) ToPersistedLastestModelObject(obj client.Object) (d
CreatedAt: obj.GetCreationTimestamp().Time,
Raw: rawData,
}
- if obj.GetDeletionTimestamp() != nil {
+ if readyForFinalDeletion(obj) {
deletedAt := obj.GetDeletionTimestamp().Time
m.DeletedAt = &deletedAt
}
diff --git a/apps/rlark/pkg/controllermanager/sync/generic_handler_test.go b/apps/rlark/pkg/controllermanager/sync/generic_handler_test.go
new file mode 100644
index 0000000..e98f2cd
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/sync/generic_handler_test.go
@@ -0,0 +1,46 @@
+package sync
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+func TestDeletionTimestampOnlySetsDeletedAtWhenPersistFinalizerIsLast(t *testing.T) {
+ deletedAt := metav1.NewTime(time.Now())
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{
+ Name: "job",
+ DeletionTimestamp: &deletedAt,
+ Finalizers: []string{"jobs.rlinf.io/task-cleanup", SyncFinalizer},
+ }}
+ handler := newJobSyncHandler()
+
+ model, err := handler.ToPersistedLastestModelObject(job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if model.GetBase().DeletedAt != nil {
+ t.Fatal("resource should remain visible while cleanup finalizers are present")
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(model.GetBase().Raw, &raw); err != nil {
+ t.Fatal(err)
+ }
+ metadata := raw["metadata"].(map[string]any)
+ if metadata["deletionTimestamp"] == nil {
+ t.Fatal("raw resource must expose deletionTimestamp during cleanup")
+ }
+
+ job.Finalizers = []string{SyncFinalizer}
+ model, err = handler.ToPersistedLastestModelObject(job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if model.GetBase().DeletedAt == nil || !model.GetBase().DeletedAt.Equal(deletedAt.Time) {
+ t.Fatalf("deleted_at was not set at final deletion: %v", model.GetBase().DeletedAt)
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/sync/generic_reconciler.go b/apps/rlark/pkg/controllermanager/sync/generic_reconciler.go
index 31e7e59..d1bb09a 100644
--- a/apps/rlark/pkg/controllermanager/sync/generic_reconciler.go
+++ b/apps/rlark/pkg/controllermanager/sync/generic_reconciler.go
@@ -3,6 +3,7 @@ package sync
import (
"context"
"fmt"
+ "slices"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -16,6 +17,7 @@ type genericReconciler[T client.Object] struct {
db *bun.DB
handler Handler
newObj func() T
+ syncFn func(context.Context, T) error
}
func (r *genericReconciler[T]) saveToDatabase(ctx context.Context, m db.ResourceModel) error {
@@ -67,16 +69,46 @@ func (r *genericReconciler[T]) handleFinalizer(ctx context.Context, obj T) error
return r.client.Update(ctx, obj)
}
+func (r *genericReconciler[T]) ensureFinalizer(ctx context.Context, obj T) error {
+ if slices.Contains(obj.GetFinalizers(), SyncFinalizer) {
+ return nil
+ }
+ obj.SetFinalizers(append(obj.GetFinalizers(), SyncFinalizer))
+ return r.client.Update(ctx, obj)
+}
+
+func (r *genericReconciler[T]) removeFinalizer(ctx context.Context, obj T) error {
+ if !slices.Contains(obj.GetFinalizers(), SyncFinalizer) {
+ return nil
+ }
+ finalizers := slices.Clone(obj.GetFinalizers())
+ obj.SetFinalizers(slices.DeleteFunc(finalizers, func(finalizer string) bool {
+ return finalizer == SyncFinalizer
+ }))
+ return r.client.Update(ctx, obj)
+}
+
// Reconcile reconciles the resource.
func (r *genericReconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
obj := r.newObj()
if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
- if err := r.syncResource(ctx, obj); err != nil {
+ if r.handler != nil && !r.handler.ShouldSyncObject(obj) {
+ return ctrl.Result{}, r.removeFinalizer(ctx, obj)
+ }
+ syncResource := r.syncResource
+ if r.syncFn != nil {
+ syncResource = r.syncFn
+ }
+ if err := syncResource(ctx, obj); err != nil {
return ctrl.Result{}, err
}
- if obj.GetDeletionTimestamp() != nil {
+ if obj.GetDeletionTimestamp() == nil {
+ if err := r.ensureFinalizer(ctx, obj); err != nil {
+ return ctrl.Result{}, err
+ }
+ } else {
if err := r.handleFinalizer(ctx, obj); err != nil {
return ctrl.Result{}, err
}
diff --git a/apps/rlark/pkg/controllermanager/sync/generic_reconciler_test.go b/apps/rlark/pkg/controllermanager/sync/generic_reconciler_test.go
new file mode 100644
index 0000000..fd93cfb
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/sync/generic_reconciler_test.go
@@ -0,0 +1,149 @@
+package sync
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+func TestReconcileAddsSyncFinalizerAfterPersistence(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job"}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &genericReconciler[*rlarkv1alpha1.Job]{
+ client: c,
+ handler: &genericSyncHandler{},
+ newObj: func() *rlarkv1alpha1.Job { return &rlarkv1alpha1.Job{} },
+ syncFn: func(context.Context, *rlarkv1alpha1.Job) error { return nil },
+ }
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if len(job.Finalizers) != 1 || job.Finalizers[0] != SyncFinalizer {
+ t.Fatalf("sync finalizer not added: %v", job.Finalizers)
+ }
+}
+
+func TestReconcileDoesNotAddSyncFinalizerWhenPersistenceFails(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job"}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &genericReconciler[*rlarkv1alpha1.Job]{
+ client: c,
+ handler: &genericSyncHandler{},
+ newObj: func() *rlarkv1alpha1.Job { return &rlarkv1alpha1.Job{} },
+ syncFn: func(context.Context, *rlarkv1alpha1.Job) error { return errors.New("database unavailable") },
+ }
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err == nil {
+ t.Fatal("expected persistence error")
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if len(job.Finalizers) != 0 {
+ t.Fatalf("sync finalizer added before persistence succeeded: %v", job.Finalizers)
+ }
+}
+
+func TestReconcileSkipsPersistenceAndFinalizerForSkippedObject(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "job", Annotations: map[string]string{"skip-sync": ""}}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ called := false
+ r := &genericReconciler[*rlarkv1alpha1.Job]{
+ client: c,
+ handler: &genericSyncHandler{},
+ newObj: func() *rlarkv1alpha1.Job { return &rlarkv1alpha1.Job{} },
+ syncFn: func(context.Context, *rlarkv1alpha1.Job) error {
+ called = true
+ return nil
+ },
+ }
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if called {
+ t.Fatal("skipped object was persisted")
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if len(job.Finalizers) != 0 {
+ t.Fatalf("sync finalizer added to skipped object: %v", job.Finalizers)
+ }
+}
+
+func TestReconcileRemovesOnlySyncFinalizerFromSkippedObject(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{
+ Name: "job",
+ Annotations: map[string]string{"skip-sync": "true"},
+ Finalizers: []string{"example.com/cleanup", SyncFinalizer},
+ }}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &genericReconciler[*rlarkv1alpha1.Job]{
+ client: c,
+ handler: &genericSyncHandler{},
+ newObj: func() *rlarkv1alpha1.Job { return &rlarkv1alpha1.Job{} },
+ syncFn: func(context.Context, *rlarkv1alpha1.Job) error {
+ return errors.New("must not persist")
+ },
+ }
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: job.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: job.Name}, job); err != nil {
+ t.Fatal(err)
+ }
+ if len(job.Finalizers) != 1 || job.Finalizers[0] != "example.com/cleanup" {
+ t.Fatalf("unexpected finalizers after skip: %v", job.Finalizers)
+ }
+}
+
+func TestRemoveFinalizerDoesNotMutateInputSlice(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{
+ Name: "job",
+ Finalizers: []string{SyncFinalizer, "example.com/cleanup"},
+ }}
+ original := job.Finalizers
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &genericReconciler[*rlarkv1alpha1.Job]{client: c}
+
+ if err := r.removeFinalizer(context.Background(), job); err != nil {
+ t.Fatal(err)
+ }
+ if original[0] != SyncFinalizer || original[1] != "example.com/cleanup" {
+ t.Fatalf("input finalizer backing array was mutated: %v", original)
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/sync/job_sync.go b/apps/rlark/pkg/controllermanager/sync/job_sync.go
index 5751b27..c8ec93d 100644
--- a/apps/rlark/pkg/controllermanager/sync/job_sync.go
+++ b/apps/rlark/pkg/controllermanager/sync/job_sync.go
@@ -4,6 +4,7 @@ import (
"github.com/uptrace/bun"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
@@ -26,7 +27,7 @@ func newJobSyncHandler() Handler {
// JobReconciler reconciles Job resources.
type JobReconciler struct {
- config Config
+ maxConcurrentReconciles int
*genericReconciler[*rlarkv1alpha1.Job]
}
@@ -35,9 +36,9 @@ type JobReconciler struct {
// +kubebuilder:rbac:groups=rlinf.io,resources=jobs/finalizers,verbs=update
// NewJobReconciler creates a new JobReconciler.
-func NewJobReconciler(config Config, client client.Client, db *bun.DB) *JobReconciler {
+func NewJobReconciler(maxConcurrentReconciles int, client client.Client, db *bun.DB) *JobReconciler {
return &JobReconciler{
- config: config,
+ maxConcurrentReconciles: maxConcurrentReconciles,
genericReconciler: &genericReconciler[*rlarkv1alpha1.Job]{
client: client,
db: db,
@@ -51,7 +52,7 @@ func NewJobReconciler(config Config, client client.Client, db *bun.DB) *JobRecon
func (r *JobReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Job{}).
- WithOptions(r.config.ToControllerOptions()).
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}).
Named("job-sync").
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/sync/node_sync.go b/apps/rlark/pkg/controllermanager/sync/node_sync.go
index 18ede15..ca9cb88 100644
--- a/apps/rlark/pkg/controllermanager/sync/node_sync.go
+++ b/apps/rlark/pkg/controllermanager/sync/node_sync.go
@@ -4,6 +4,7 @@ import (
"github.com/uptrace/bun"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
@@ -26,7 +27,7 @@ func newNodeSyncHandler() Handler {
// NodeReconciler reconciles Node resources.
type NodeReconciler struct {
- config Config
+ maxConcurrentReconciles int
*genericReconciler[*rlarkv1alpha1.Node]
}
@@ -35,9 +36,9 @@ type NodeReconciler struct {
// +kubebuilder:rbac:groups=rlinf.io,resources=nodes/finalizers,verbs=update
// NewNodeReconciler creates a new NodeReconciler.
-func NewNodeReconciler(config Config, client client.Client, db *bun.DB) *NodeReconciler {
+func NewNodeReconciler(maxConcurrentReconciles int, client client.Client, db *bun.DB) *NodeReconciler {
return &NodeReconciler{
- config: config,
+ maxConcurrentReconciles: maxConcurrentReconciles,
genericReconciler: &genericReconciler[*rlarkv1alpha1.Node]{
client: client,
db: db,
@@ -51,7 +52,7 @@ func NewNodeReconciler(config Config, client client.Client, db *bun.DB) *NodeRec
func (r *NodeReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Node{}).
- WithOptions(r.config.ToControllerOptions()).
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}).
Named("node-sync").
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/sync/task_sync.go b/apps/rlark/pkg/controllermanager/sync/task_sync.go
index 075d815..6345b08 100644
--- a/apps/rlark/pkg/controllermanager/sync/task_sync.go
+++ b/apps/rlark/pkg/controllermanager/sync/task_sync.go
@@ -4,6 +4,7 @@ import (
"github.com/uptrace/bun"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
@@ -26,7 +27,7 @@ func newTaskSyncHandler() Handler {
// TaskReconciler reconciles Task resources.
type TaskReconciler struct {
- config Config
+ maxConcurrentReconciles int
*genericReconciler[*rlarkv1alpha1.Task]
}
@@ -35,9 +36,9 @@ type TaskReconciler struct {
// +kubebuilder:rbac:groups=rlinf.io,resources=tasks/finalizers,verbs=update
// NewTaskReconciler creates a new TaskReconciler.
-func NewTaskReconciler(config Config, client client.Client, db *bun.DB) *TaskReconciler {
+func NewTaskReconciler(maxConcurrentReconciles int, client client.Client, db *bun.DB) *TaskReconciler {
return &TaskReconciler{
- config: config,
+ maxConcurrentReconciles: maxConcurrentReconciles,
genericReconciler: &genericReconciler[*rlarkv1alpha1.Task]{
client: client,
db: db,
@@ -51,7 +52,7 @@ func NewTaskReconciler(config Config, client client.Client, db *bun.DB) *TaskRec
func (r *TaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Task{}).
- WithOptions(r.config.ToControllerOptions()).
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}).
Named("task-sync").
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/sync/workflow_sync.go b/apps/rlark/pkg/controllermanager/sync/workflow_sync.go
index 68b9979..62bbbe2 100644
--- a/apps/rlark/pkg/controllermanager/sync/workflow_sync.go
+++ b/apps/rlark/pkg/controllermanager/sync/workflow_sync.go
@@ -4,6 +4,7 @@ import (
"github.com/uptrace/bun"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
@@ -26,7 +27,7 @@ func newWorkflowSyncHandler() Handler {
// WorkflowReconciler reconciles Workflow resources.
type WorkflowReconciler struct {
- config Config
+ maxConcurrentReconciles int
*genericReconciler[*rlarkv1alpha1.Workflow]
}
@@ -35,9 +36,9 @@ type WorkflowReconciler struct {
// +kubebuilder:rbac:groups=rlinf.io,resources=workflows/finalizers,verbs=update
// NewWorkflowReconciler creates a new WorkflowReconciler.
-func NewWorkflowReconciler(config Config, client client.Client, db *bun.DB) *WorkflowReconciler {
+func NewWorkflowReconciler(maxConcurrentReconciles int, client client.Client, db *bun.DB) *WorkflowReconciler {
return &WorkflowReconciler{
- config: config,
+ maxConcurrentReconciles: maxConcurrentReconciles,
genericReconciler: &genericReconciler[*rlarkv1alpha1.Workflow]{
client: client,
db: db,
@@ -51,7 +52,7 @@ func NewWorkflowReconciler(config Config, client client.Client, db *bun.DB) *Wor
func (r *WorkflowReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Workflow{}).
- WithOptions(r.config.ToControllerOptions()).
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}).
Named("workflow-sync").
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/task/task_controller.go b/apps/rlark/pkg/controllermanager/task/task_controller.go
index 2ae65d7..c257a00 100644
--- a/apps/rlark/pkg/controllermanager/task/task_controller.go
+++ b/apps/rlark/pkg/controllermanager/task/task_controller.go
@@ -10,6 +10,7 @@ import (
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/log"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
@@ -45,7 +46,8 @@ const maxAggregatedEvents = 100
// writes by the agent are preserved.
type Reconciler struct {
client.Client
- Scheme *runtime.Scheme
+ Scheme *runtime.Scheme
+ MaxConcurrentReconciles int
}
// +kubebuilder:rbac:groups=rlinf.io,resources=tasks,verbs=get;list;watch;create;update;patch;delete
@@ -267,5 +269,6 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rlarkv1alpha1.Task{}).
Named("task").
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.MaxConcurrentReconciles}).
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/workflow/build.go b/apps/rlark/pkg/controllermanager/workflow/build.go
index 315b30c..c7a407f 100644
--- a/apps/rlark/pkg/controllermanager/workflow/build.go
+++ b/apps/rlark/pkg/controllermanager/workflow/build.go
@@ -1,9 +1,12 @@
package workflow
import (
+ "reflect"
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
func buildJobStatusMap(wf *rlarkv1alpha1.Workflow) map[string]*rlarkv1alpha1.WorkflowJobStatus {
@@ -21,7 +24,27 @@ func buildJob(wf *rlarkv1alpha1.Workflow, jt rlarkv1alpha1.WorkflowJobTemplate,
Labels: map[string]string{
"rlinf.io/workflow": wf.Name,
},
+ Annotations: map[string]string{
+ utils.ParentUIDAnnotation: string(wf.UID),
+ utils.ChildTemplateAnnotation: jt.Name,
+ },
},
Spec: jt.Spec,
}
}
+
+func syncJobStatusSnapshot(wf *rlarkv1alpha1.Workflow) bool {
+ existing := buildJobStatusMap(wf)
+ next := make([]rlarkv1alpha1.WorkflowJobStatus, len(wf.Spec.JobTemplates))
+ for i, template := range wf.Spec.JobTemplates {
+ next[i].Name = template.Name
+ if status := existing[template.Name]; status != nil {
+ next[i] = *status
+ }
+ }
+ if reflect.DeepEqual(wf.Status.Jobs, next) {
+ return false
+ }
+ wf.Status.Jobs = next
+ return true
+}
diff --git a/apps/rlark/pkg/controllermanager/workflow/lifecycle_test.go b/apps/rlark/pkg/controllermanager/workflow/lifecycle_test.go
new file mode 100644
index 0000000..3493f3f
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/workflow/lifecycle_test.go
@@ -0,0 +1,351 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/go-logr/logr"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
+)
+
+func TestSyncJobStatusSnapshotIsExactAndOrdered(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ Spec: rlarkv1alpha1.WorkflowSpec{JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{{Name: "b"}, {Name: "a"}}},
+ Status: rlarkv1alpha1.WorkflowStatus{Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "a", Phase: rlarkv1alpha1.JobPhaseSucceeded}, {Name: "removed", Phase: rlarkv1alpha1.JobPhaseFailed},
+ }},
+ }
+ if !syncJobStatusSnapshot(wf) {
+ t.Fatal("expected snapshot change")
+ }
+ if len(wf.Status.Jobs) != 2 || wf.Status.Jobs[0].Name != "b" || wf.Status.Jobs[1].Name != "a" || wf.Status.Jobs[1].Phase != rlarkv1alpha1.JobPhaseSucceeded {
+ t.Fatalf("unexpected snapshot: %#v", wf.Status.Jobs)
+ }
+}
+
+func TestReconcileJobAdoptsLegacyAndRejectsConflict(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")}}
+ template := rlarkv1alpha1.WorkflowJobTemplate{Name: "train"}
+ legacy := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "wf-train", Labels: map[string]string{workflowLabel: wf.Name}, Annotations: map[string]string{"user": "keep"}}}
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(legacy).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ job, err := r.reconcileJob(context.Background(), wf, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if job.Annotations["user"] != "keep" || job.Annotations[utils.ParentUIDAnnotation] != string(wf.UID) || metav1.GetControllerOf(job).UID != wf.UID {
+ t.Fatalf("legacy Job not adopted safely: %#v", job.ObjectMeta)
+ }
+
+ conflict := job.DeepCopy()
+ conflict.Annotations[utils.ParentUIDAnnotation] = "other"
+ if err := c.Update(context.Background(), conflict); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.reconcileJob(context.Background(), wf, template, logr.Discard()); err == nil {
+ t.Fatal("expected conflicting UID to be rejected")
+ }
+}
+
+func TestReconcileJobBackfillsMatchingOwnerReference(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")}}
+ template := rlarkv1alpha1.WorkflowJobTemplate{Name: "train"}
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "wf-train", Annotations: map[string]string{"user": "keep"}}}
+ if err := setWorkflowOwner(wf, job); err != nil {
+ t.Fatal(err)
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ got, err := r.reconcileJob(context.Background(), wf, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Annotations[utils.ParentUIDAnnotation] != string(wf.UID) || got.Annotations[utils.ChildTemplateAnnotation] != template.Name || got.Annotations["user"] != "keep" {
+ t.Fatalf("owner-only Job was not backfilled: %#v", got.Annotations)
+ }
+}
+
+func TestReconcileJobSyncsMutableSpecAndPreservesTerminalJob(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")}}
+ template := rlarkv1alpha1.WorkflowJobTemplate{Name: "train", Spec: rlarkv1alpha1.JobSpec{Domain: "new"}}
+ for _, phase := range []rlarkv1alpha1.JobPhase{rlarkv1alpha1.JobPhaseRunning, rlarkv1alpha1.JobPhaseSucceeded} {
+ t.Run(string(phase), func(t *testing.T) {
+ job := buildJob(wf, template, "wf-train")
+ job.Spec.Domain = "old"
+ job.Status.Phase = phase
+ job.Annotations["user"] = "keep"
+ if err := setWorkflowOwner(wf, job); err != nil {
+ t.Fatal(err)
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ got, err := r.reconcileJob(context.Background(), wf, template, logr.Discard())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "new"
+ if phase == rlarkv1alpha1.JobPhaseSucceeded {
+ want = "old"
+ }
+ if got.Spec.Domain != want || got.Annotations["user"] != "keep" {
+ t.Fatalf("Job sync = domain %q, annotations %#v", got.Spec.Domain, got.Annotations)
+ }
+ })
+ }
+}
+
+func TestStoppedWorkflowCompletesAfterJobsDisappear(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")},
+ Spec: rlarkv1alpha1.WorkflowSpec{
+ Stopped: true,
+ JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{{Name: "train"}},
+ },
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseStopping, Jobs: []rlarkv1alpha1.WorkflowJobStatus{{Name: "train", Phase: rlarkv1alpha1.JobPhaseRunning}}},
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ changed, err := r.ReconcileStateMachine(context.Background(), wf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed || wf.Status.Phase != rlarkv1alpha1.WorkflowPhaseStopped || wf.Status.Jobs[0].Phase != rlarkv1alpha1.JobPhaseStopped {
+ t.Fatalf("stopped Workflow status not completed: %#v", wf.Status)
+ }
+}
+
+func TestWorkflowResumeRerunsDAGFromBeginning(t *testing.T) {
+ oldStart := metav1.NewTime(time.Now().Add(-time.Hour))
+ oldEnd := metav1.NewTime(time.Now().Add(-time.Minute))
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")},
+ Spec: rlarkv1alpha1.WorkflowSpec{JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{
+ {Name: "prepare"},
+ {Name: "train", Dependencies: []string{"prepare"}},
+ }},
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseStopped, StartTime: &oldStart, EndTime: &oldEnd, Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "prepare", Phase: rlarkv1alpha1.JobPhaseStopped},
+ {Name: "train", Phase: rlarkv1alpha1.JobPhaseStopped},
+ }},
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Phase != rlarkv1alpha1.WorkflowPhasePending || len(wf.Status.Jobs) != 0 {
+ t.Fatalf("resumed Workflow did not reset its run: %#v", wf.Status)
+ }
+ if wf.Status.StartTime == nil || !wf.Status.StartTime.After(oldStart.Time) || wf.Status.EndTime != nil {
+ t.Fatalf("resumed Workflow did not reset run times: %#v", wf.Status)
+ }
+ if _, err := r.ReconcileStateMachine(context.Background(), wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Phase != rlarkv1alpha1.WorkflowPhaseRunning {
+ t.Fatalf("second resume reconcile phase = %s, want Running", wf.Status.Phase)
+ }
+ var prepare rlarkv1alpha1.Job
+ if err := c.Get(context.Background(), client.ObjectKey{Name: "wf-prepare"}, &prepare); err != nil {
+ t.Fatalf("first DAG Job was not recreated: %v", err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: "wf-train"}, &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("dependent Job should wait for the new prepare run: %v", err)
+ }
+}
+
+func TestWorkflowResumeWaitsForTerminatingJob(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")},
+ Spec: rlarkv1alpha1.WorkflowSpec{JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{{Name: "prepare"}}},
+ Status: rlarkv1alpha1.WorkflowStatus{
+ Phase: rlarkv1alpha1.WorkflowPhaseRunning,
+ Jobs: []rlarkv1alpha1.WorkflowJobStatus{{Name: "prepare", Phase: rlarkv1alpha1.JobPhasePending}},
+ },
+ }
+ job := buildJob(wf, wf.Spec.JobTemplates[0], "wf-prepare")
+ deletedAt := metav1.Now()
+ job.DeletionTimestamp = &deletedAt
+ job.Finalizers = []string{"example.com/cleanup"}
+ if err := setWorkflowOwner(wf, job); err != nil {
+ t.Fatal(err)
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.reconcileJob(context.Background(), wf, wf.Spec.JobTemplates[0], logr.Discard()); !errors.Is(err, controller.ErrRequeueAfterChildCleanup) {
+ t.Fatalf("resume with terminating Job error = %v, want cleanup requeue", err)
+ }
+ if wf.Status.Jobs[0].Phase != rlarkv1alpha1.JobPhasePending {
+ t.Fatalf("terminating Job advanced Workflow status: %#v", wf.Status.Jobs)
+ }
+}
+
+func TestStoppedWorkflowPreservesTerminalJobStatuses(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ Spec: rlarkv1alpha1.WorkflowSpec{Stopped: true, JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{
+ {Name: "done"}, {Name: "failed"}, {Name: "running"},
+ }},
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseStopping, Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "done", Phase: rlarkv1alpha1.JobPhaseSucceeded},
+ {Name: "failed", Phase: rlarkv1alpha1.JobPhaseFailed},
+ {Name: "running", Phase: rlarkv1alpha1.JobPhaseRunning},
+ }},
+ }
+ r := &Reconciler{Client: fake.NewClientBuilder().WithScheme(workflowTestScheme(t)).Build()}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Jobs[0].Phase != rlarkv1alpha1.JobPhaseSucceeded ||
+ wf.Status.Jobs[1].Phase != rlarkv1alpha1.JobPhaseFailed ||
+ wf.Status.Jobs[2].Phase != rlarkv1alpha1.JobPhaseStopped {
+ t.Fatalf("unexpected Job phases after stop: %#v", wf.Status.Jobs)
+ }
+}
+
+func TestTerminalWorkflowReconcilePrunesWithoutDispatch(t *testing.T) {
+ for _, phase := range []rlarkv1alpha1.WorkflowPhase{rlarkv1alpha1.WorkflowPhaseSucceeded, rlarkv1alpha1.WorkflowPhaseFailed} {
+ t.Run(string(phase), func(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")},
+ Spec: rlarkv1alpha1.WorkflowSpec{JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{{Name: "kept"}, {Name: "added"}}},
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: phase, Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "removed", Phase: rlarkv1alpha1.JobPhaseSucceeded},
+ {Name: "kept", Phase: rlarkv1alpha1.JobPhaseSucceeded},
+ }},
+ }
+ removed := buildJob(wf, rlarkv1alpha1.WorkflowJobTemplate{Name: "removed"}, "wf-removed")
+ removed.Status.Phase = rlarkv1alpha1.JobPhaseSucceeded
+ if err := setWorkflowOwner(wf, removed); err != nil {
+ t.Fatal(err)
+ }
+ kept := buildJob(wf, wf.Spec.JobTemplates[0], "wf-kept")
+ kept.Status.Phase = rlarkv1alpha1.JobPhaseSucceeded
+ if err := setWorkflowOwner(wf, kept); err != nil {
+ t.Fatal(err)
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(removed, kept).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if r.IsTerminal(wf) {
+ t.Fatal("Workflow must remain eligible for lifecycle reconciliation")
+ }
+ changed, err := r.ReconcileStateMachine(context.Background(), wf)
+ if !errors.Is(err, controller.ErrRequeueAfterChildCleanup) {
+ t.Fatalf("terminal reconciliation returned an FSM error: %v", err)
+ }
+ changedAgain, err := r.ReconcileStateMachine(context.Background(), wf)
+ if err != nil {
+ t.Fatalf("terminal reconciliation after pruning returned an FSM error: %v", err)
+ }
+ if !changed || wf.Status.Phase != phase {
+ t.Fatalf("terminal status was not normalized safely: changed=%v status=%#v", changed, wf.Status)
+ }
+ if changedAgain {
+ t.Fatal("stable terminal reconciliation unexpectedly changed status")
+ }
+ if len(wf.Status.Jobs) != 2 || wf.Status.Jobs[0].Name != "kept" || wf.Status.Jobs[1].Name != "added" {
+ t.Fatalf("unexpected terminal status snapshot: %#v", wf.Status.Jobs)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: removed.Name}, &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("removed terminal child still exists: %v", err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: "wf-added"}, &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("new template was dispatched for terminal Workflow: %v", err)
+ }
+ })
+ }
+}
+
+func TestWorkflowReconcileUsesOldStatusToPruneSafely(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: "wf-uid"},
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseSucceeded, Jobs: []rlarkv1alpha1.WorkflowJobStatus{{Name: "removed"}}},
+ }
+ ownerOnly := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "legacy-name", Labels: map[string]string{workflowLabel: wf.Name}}}
+ if err := setWorkflowOwner(wf, ownerOnly); err != nil {
+ t.Fatal(err)
+ }
+ unrelated := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "wf-unrelated", Labels: map[string]string{workflowLabel: wf.Name}}}
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ownerOnly, unrelated).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.ReconcileStateMachine(context.Background(), wf); !errors.Is(err, controller.ErrRequeueAfterChildCleanup) {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(ownerOnly), &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("owner-only removed Job was not pruned: %v", err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(unrelated), &rlarkv1alpha1.Job{}); err != nil {
+ t.Fatalf("unrelated label-only prefixed Job was touched: %v", err)
+ }
+}
+
+func TestPruneJobsDeletesOnlyOwned(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: types.UID("wf-uid")}}
+ owned := buildJob(wf, rlarkv1alpha1.WorkflowJobTemplate{Name: "old"}, "wf-old")
+ if err := setWorkflowOwner(wf, owned); err != nil {
+ t.Fatal(err)
+ }
+ conflict := owned.DeepCopy()
+ conflict.Name = "wf-conflict"
+ conflict.UID = ""
+ conflict.OwnerReferences[0].UID = "other"
+ conflict.Annotations[utils.ParentUIDAnnotation] = "other"
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owned, conflict).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ if _, err := r.pruneJobs(context.Background(), wf); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: owned.Name}, &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("owned removed Job still exists: %v", err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: conflict.Name}, &rlarkv1alpha1.Job{}); err != nil {
+ t.Fatalf("conflicting Job was touched: %v", err)
+ }
+}
+
+func TestPruneJobsDeletesOwnerOnlyRemovedJob(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "wf", UID: "wf-uid"}, Status: rlarkv1alpha1.WorkflowStatus{Jobs: []rlarkv1alpha1.WorkflowJobStatus{{Name: "removed"}}}}
+ job := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{Name: "wf-removed", Labels: map[string]string{workflowLabel: wf.Name}}}
+ if err := setWorkflowOwner(wf, job); err != nil {
+ t.Fatal(err)
+ }
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(job).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+ waiting, err := r.pruneJobs(context.Background(), wf)
+ if err != nil || !waiting {
+ t.Fatalf("pruneJobs() = %v, %v", waiting, err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(job), &rlarkv1alpha1.Job{}); !apierrors.IsNotFound(err) {
+ t.Fatalf("owner-only removed Job still exists: %v", err)
+ }
+}
+
+func setWorkflowOwner(wf *rlarkv1alpha1.Workflow, job *rlarkv1alpha1.Job) error {
+ controller := true
+ job.OwnerReferences = []metav1.OwnerReference{{APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Workflow", Name: wf.Name, UID: wf.UID, Controller: &controller}}
+ return nil
+}
diff --git a/apps/rlark/pkg/controllermanager/workflow/statemachine.go b/apps/rlark/pkg/controllermanager/workflow/statemachine.go
index a871f75..f0c1969 100644
--- a/apps/rlark/pkg/controllermanager/workflow/statemachine.go
+++ b/apps/rlark/pkg/controllermanager/workflow/statemachine.go
@@ -13,6 +13,9 @@ import (
const (
EventInit = "init"
EventStart = "start"
+ EventStop = "stop"
+ EventAllJobsStopped = "all-jobs-stopped"
+ EventResume = "resume"
EventAllJobsSucceeded = "all-jobs-succeeded"
EventAnyJobFailed = "any-job-failed"
)
@@ -28,6 +31,27 @@ var workflowEvents = fsm.Events{
Src: []string{string(rlarkv1alpha1.WorkflowPhasePending)},
Dst: string(rlarkv1alpha1.WorkflowPhaseRunning),
},
+ {
+ Name: EventStop,
+ Src: []string{
+ string(rlarkv1alpha1.WorkflowPhasePending),
+ string(rlarkv1alpha1.WorkflowPhaseRunning),
+ },
+ Dst: string(rlarkv1alpha1.WorkflowPhaseStopping),
+ },
+ {
+ Name: EventAllJobsStopped,
+ Src: []string{string(rlarkv1alpha1.WorkflowPhaseStopping)},
+ Dst: string(rlarkv1alpha1.WorkflowPhaseStopped),
+ },
+ {
+ Name: EventResume,
+ Src: []string{
+ string(rlarkv1alpha1.WorkflowPhaseStopping),
+ string(rlarkv1alpha1.WorkflowPhaseStopped),
+ },
+ Dst: string(rlarkv1alpha1.WorkflowPhasePending),
+ },
{
Name: EventAllJobsSucceeded,
Src: []string{string(rlarkv1alpha1.WorkflowPhaseRunning)},
@@ -48,19 +72,20 @@ func newWorkflowStateMachine() *fsm.FSM {
},
"enter_" + string(rlarkv1alpha1.WorkflowPhasePending): func(ctx context.Context, e *fsm.Event) {
wf := e.Args[0].(*rlarkv1alpha1.Workflow)
- if wf.Status.Jobs == nil {
- wf.Status.Jobs = make([]rlarkv1alpha1.WorkflowJobStatus, 0, len(wf.Spec.JobTemplates))
- for _, jt := range wf.Spec.JobTemplates {
- wf.Status.Jobs = append(wf.Status.Jobs, rlarkv1alpha1.WorkflowJobStatus{
- Name: jt.Name,
- })
- }
+ if e.Src != "" {
+ now := metav1.Now()
+ wf.Status.StartTime = &now
+ wf.Status.EndTime = nil
}
+ syncJobStatusSnapshot(wf)
},
"enter_" + string(rlarkv1alpha1.WorkflowPhaseRunning): func(ctx context.Context, e *fsm.Event) {
wf := e.Args[0].(*rlarkv1alpha1.Workflow)
- now := metav1.Now()
- wf.Status.StartTime = &now
+ if wf.Status.StartTime == nil {
+ now := metav1.Now()
+ wf.Status.StartTime = &now
+ }
+ wf.Status.EndTime = nil
},
"enter_" + string(rlarkv1alpha1.WorkflowPhaseSucceeded): func(ctx context.Context, e *fsm.Event) {
wf := e.Args[0].(*rlarkv1alpha1.Workflow)
diff --git a/apps/rlark/pkg/controllermanager/workflow/statemachine_test.go b/apps/rlark/pkg/controllermanager/workflow/statemachine_test.go
new file mode 100644
index 0000000..46416c3
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/workflow/statemachine_test.go
@@ -0,0 +1,55 @@
+package workflow
+
+import (
+ "context"
+ "testing"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+func TestWorkflowStopAndResumeTransitions(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseRunning}}
+ f := newWorkflowStateMachine()
+ f.SetState(string(wf.Status.Phase))
+
+ if err := f.Event(context.Background(), EventStop, wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Phase != rlarkv1alpha1.WorkflowPhaseStopping {
+ t.Fatalf("phase = %s, want Stopping", wf.Status.Phase)
+ }
+ if err := f.Event(context.Background(), EventAllJobsStopped, wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Phase != rlarkv1alpha1.WorkflowPhaseStopped {
+ t.Fatalf("phase = %s, want Stopped", wf.Status.Phase)
+ }
+ if err := f.Event(context.Background(), EventResume, wf); err != nil {
+ t.Fatal(err)
+ }
+ if wf.Status.Phase != rlarkv1alpha1.WorkflowPhasePending {
+ t.Fatalf("phase = %s, want Pending", wf.Status.Phase)
+ }
+}
+
+func TestEvaluateStoppedWorkflow(t *testing.T) {
+ r := &Reconciler{}
+ wf := &rlarkv1alpha1.Workflow{
+ Spec: rlarkv1alpha1.WorkflowSpec{Stopped: true},
+ Status: rlarkv1alpha1.WorkflowStatus{Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "one", Phase: rlarkv1alpha1.JobPhaseStopped},
+ {Name: "two", Phase: rlarkv1alpha1.JobPhaseStopped},
+ }},
+ }
+ if event := r.evaluateWorkflowEvent(wf); event != EventAllJobsStopped {
+ t.Fatalf("event = %q, want %q", event, EventAllJobsStopped)
+ }
+}
+
+func TestEvaluateStoppedWorkflowWithoutDispatchedJobs(t *testing.T) {
+ r := &Reconciler{}
+ wf := &rlarkv1alpha1.Workflow{Spec: rlarkv1alpha1.WorkflowSpec{Stopped: true}}
+ if event := r.evaluateWorkflowEvent(wf); event != EventAllJobsStopped {
+ t.Fatalf("event = %q, want %q", event, EventAllJobsStopped)
+ }
+}
diff --git a/apps/rlark/pkg/controllermanager/workflow/sync.go b/apps/rlark/pkg/controllermanager/workflow/sync.go
index 4bdee31..19a6e91 100644
--- a/apps/rlark/pkg/controllermanager/workflow/sync.go
+++ b/apps/rlark/pkg/controllermanager/workflow/sync.go
@@ -3,17 +3,22 @@ package workflow
import (
"context"
"fmt"
+ "reflect"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+ "github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
+const workflowLabel = "rlinf.io/workflow"
+
func (r *Reconciler) syncJobStatuses(
ctx context.Context,
wf *rlarkv1alpha1.Workflow,
@@ -35,6 +40,9 @@ func (r *Reconciler) syncJobStatuses(
}
return false, fmt.Errorf("get Job %s: %w", jt.Name, err)
}
+ if _, _, err := utils.ClassifyChild(&job, wf.UID, rlarkv1alpha1.GroupVersion.String(), "Workflow", workflowLabel, wf.Name, jt.Name); err != nil {
+ return false, fmt.Errorf("job %s ownership conflict: %w", job.Name, err)
+ }
if utils.SyncStatusEntry(js, string(job.Status.Phase), "") {
changed = true
}
@@ -89,6 +97,30 @@ func (r *Reconciler) reconcileJob(
var job rlarkv1alpha1.Job
err := r.Get(ctx, types.NamespacedName{Name: jobName}, &job)
if err == nil {
+ if err := r.ensureJobOwnership(ctx, wf, jt.Name, &job); err != nil {
+ return nil, err
+ }
+ if !job.DeletionTimestamp.IsZero() {
+ return nil, controller.ErrRequeueAfterChildCleanup
+ }
+ if !jobTerminal(&job) {
+ desired := buildJob(wf, jt, jobName)
+ marker := string(wf.UID)
+ workflowStopped := wf.Spec.Stopped || job.Annotations[rlarkv1alpha1.WorkflowStoppedByAnnotation] == marker
+ if workflowStopped {
+ desired.Spec.Stopped = true
+ desired.Annotations[rlarkv1alpha1.WorkflowStoppedByAnnotation] = marker
+ }
+ if !reflect.DeepEqual(job.Spec, desired.Spec) ||
+ (workflowStopped && job.Annotations[rlarkv1alpha1.WorkflowStoppedByAnnotation] != marker) {
+ job.Spec = desired.Spec
+ job.Annotations = utils.MergeAnnotations(job.Annotations, desired.Annotations)
+ if err := r.Update(ctx, &job); err != nil {
+ return nil, fmt.Errorf("update Job %s: %w", jobName, err)
+ }
+ logger.Info("Updated Job for workflow", "job", jobName, "workflowJob", jt.Name)
+ }
+ }
return &job, nil
}
if !errors.IsNotFound(err) {
@@ -99,8 +131,17 @@ func (r *Reconciler) reconcileJob(
if err := ctrl.SetControllerReference(wf, newJob, r.Scheme); err != nil {
return nil, fmt.Errorf("set controller reference on Job %s: %w", jobName, err)
}
- if err := r.Create(ctx, newJob); err != nil && !errors.IsAlreadyExists(err) {
- return nil, fmt.Errorf("create Job %s: %w", jobName, err)
+ if err := r.Create(ctx, newJob); err != nil {
+ if !errors.IsAlreadyExists(err) {
+ return nil, fmt.Errorf("create Job %s: %w", jobName, err)
+ }
+ if err := r.Get(ctx, types.NamespacedName{Name: jobName}, &job); err != nil {
+ return nil, fmt.Errorf("reload Job %s after create race: %w", jobName, err)
+ }
+ if err := r.ensureJobOwnership(ctx, wf, jt.Name, &job); err != nil {
+ return nil, err
+ }
+ return &job, nil
}
logger.Info("Created Job for workflow", "job", jobName, "workflowJob", jt.Name)
@@ -108,6 +149,30 @@ func (r *Reconciler) reconcileJob(
return newJob, nil
}
+func jobTerminal(job *rlarkv1alpha1.Job) bool {
+ return job.Status.Phase == rlarkv1alpha1.JobPhaseSucceeded || job.Status.Phase == rlarkv1alpha1.JobPhaseFailed
+}
+
+func (r *Reconciler) ensureJobOwnership(ctx context.Context, wf *rlarkv1alpha1.Workflow, template string, job *rlarkv1alpha1.Job) error {
+ owned, legacy, err := utils.ClassifyChild(job, wf.UID, rlarkv1alpha1.GroupVersion.String(), "Workflow", workflowLabel, wf.Name, template)
+ if err != nil || !owned {
+ return fmt.Errorf("job %s ownership conflict: %w", job.Name, err)
+ }
+ if !legacy {
+ return nil
+ }
+ job.Annotations = utils.MergeAnnotations(job.Annotations, map[string]string{
+ utils.ParentUIDAnnotation: string(wf.UID), utils.ChildTemplateAnnotation: template,
+ })
+ if err := ctrl.SetControllerReference(wf, job, r.Scheme); err != nil {
+ return fmt.Errorf("adopt Job %s: %w", job.Name, err)
+ }
+ if err := r.Update(ctx, job); err != nil {
+ return fmt.Errorf("adopt Job %s: %w", job.Name, err)
+ }
+ return nil
+}
+
func (r *Reconciler) reconcileWithStateMachine(
ctx context.Context,
wf *rlarkv1alpha1.Workflow,
@@ -115,7 +180,18 @@ func (r *Reconciler) reconcileWithStateMachine(
f := newWorkflowStateMachine()
f.SetState(string(wf.Status.Phase))
- changed := false
+ oldTemplates := make([]string, len(wf.Status.Jobs))
+ for i := range wf.Status.Jobs {
+ oldTemplates[i] = wf.Status.Jobs[i].Name
+ }
+ changed := syncJobStatusSnapshot(wf)
+ waiting, err := r.pruneJobsWithTemplates(ctx, wf, oldTemplates)
+ if err != nil {
+ return false, err
+ }
+ if waiting {
+ return changed, controller.ErrRequeueAfterChildCleanup
+ }
if f.Can(EventInit) {
if err := f.Event(ctx, EventInit, wf); err != nil {
@@ -124,7 +200,24 @@ func (r *Reconciler) reconcileWithStateMachine(
changed = true
}
- if f.Can(EventStart) {
+ if wf.Spec.Stopped && f.Can(EventStop) {
+ if err := f.Event(ctx, EventStop, wf); err != nil {
+ return false, err
+ }
+ changed = true
+ }
+
+ resuming := !wf.Spec.Stopped && f.Can(EventResume)
+ if resuming {
+ if err := f.Event(ctx, EventResume, wf); err != nil {
+ return false, err
+ }
+ wf.Status.Jobs = nil
+ changed = true
+ return changed, nil
+ }
+
+ if !wf.Spec.Stopped && f.Can(EventStart) {
if err := f.Event(ctx, EventStart, wf); err != nil {
return false, err
}
@@ -138,13 +231,34 @@ func (r *Reconciler) reconcileWithStateMachine(
if syncChanged {
changed = true
}
-
- dagChanged, err := r.reconcileDAG(ctx, wf, log.FromContext(ctx))
- if err != nil {
- return false, err
+ if wf.Spec.Stopped {
+ waiting, err := r.deleteOwnedJobs(ctx, wf)
+ if err != nil {
+ return false, err
+ }
+ if waiting {
+ return changed, controller.ErrRequeueAfterChildCleanup
+ }
+ if markJobStatusesStopped(wf) {
+ changed = true
+ }
+ if f.Can(EventAllJobsStopped) {
+ if err := f.Event(ctx, EventAllJobsStopped, wf); err != nil {
+ return false, err
+ }
+ changed = true
+ }
+ return changed, nil
}
- if dagChanged {
- changed = true
+
+ if !wf.Spec.Stopped && wf.Status.Phase == rlarkv1alpha1.WorkflowPhaseRunning {
+ dagChanged, err := r.reconcileDAG(ctx, wf, log.FromContext(ctx))
+ if err != nil {
+ return false, err
+ }
+ if dagChanged {
+ changed = true
+ }
}
event := r.evaluateWorkflowEvent(wf)
@@ -158,6 +272,58 @@ func (r *Reconciler) reconcileWithStateMachine(
return changed, nil
}
+func markJobStatusesStopped(wf *rlarkv1alpha1.Workflow) bool {
+ changed := syncJobStatusSnapshot(wf)
+ for i := range wf.Status.Jobs {
+ switch wf.Status.Jobs[i].Phase {
+ case rlarkv1alpha1.JobPhaseSucceeded, rlarkv1alpha1.JobPhaseFailed, rlarkv1alpha1.JobPhaseStopped:
+ continue
+ default:
+ wf.Status.Jobs[i].Phase = rlarkv1alpha1.JobPhaseStopped
+ changed = true
+ }
+ }
+ return changed
+}
+
+func (r *Reconciler) pruneJobs(ctx context.Context, wf *rlarkv1alpha1.Workflow) (bool, error) {
+ oldTemplates := make([]string, len(wf.Status.Jobs))
+ for i := range wf.Status.Jobs {
+ oldTemplates[i] = wf.Status.Jobs[i].Name
+ }
+ return r.pruneJobsWithTemplates(ctx, wf, oldTemplates)
+}
+
+func (r *Reconciler) pruneJobsWithTemplates(ctx context.Context, wf *rlarkv1alpha1.Workflow, oldTemplates []string) (bool, error) {
+ desired := make(map[string]string, len(wf.Spec.JobTemplates))
+ for _, template := range wf.Spec.JobTemplates {
+ desired[utils.ChildName(wf.Name, template.Name)] = template.Name
+ }
+ var jobs rlarkv1alpha1.JobList
+ if err := r.List(ctx, &jobs, client.MatchingLabels{workflowLabel: wf.Name}); err != nil {
+ return false, fmt.Errorf("list Jobs for Workflow %s: %w", wf.Name, err)
+ }
+ waiting := false
+ for i := range jobs.Items {
+ job := &jobs.Items[i]
+ template, keep := desired[job.Name]
+ if keep {
+ if _, _, err := utils.ClassifyChild(job, wf.UID, rlarkv1alpha1.GroupVersion.String(), "Workflow", workflowLabel, wf.Name, template); err != nil {
+ return false, fmt.Errorf("job %s ownership conflict: %w", job.Name, err)
+ }
+ continue
+ }
+ owned, _ := utils.ClassifyRemovedChild(job, wf.UID, rlarkv1alpha1.GroupVersion.String(), "Workflow", workflowLabel, wf.Name, oldTemplates)
+ if owned {
+ waiting = true
+ if err := r.Delete(ctx, job); client.IgnoreNotFound(err) != nil {
+ return false, fmt.Errorf("prune Job %s: %w", job.Name, err)
+ }
+ }
+ }
+ return waiting, nil
+}
+
func (r *Reconciler) evaluateWorkflowEvent(wf *rlarkv1alpha1.Workflow) string {
phases := make([]string, len(wf.Status.Jobs))
for i, js := range wf.Status.Jobs {
@@ -169,6 +335,9 @@ func (r *Reconciler) evaluateWorkflowEvent(wf *rlarkv1alpha1.Workflow) string {
string(rlarkv1alpha1.JobPhaseRunning),
string(rlarkv1alpha1.JobPhaseStopped),
)
+ if wf.Spec.Stopped && (!s.HasItems || s.AllStopped) {
+ return EventAllJobsStopped
+ }
if s.AnyFailed {
return EventAnyJobFailed
}
diff --git a/apps/rlark/pkg/controllermanager/workflow/workflow_controller.go b/apps/rlark/pkg/controllermanager/workflow/workflow_controller.go
index 039c768..5d2382d 100644
--- a/apps/rlark/pkg/controllermanager/workflow/workflow_controller.go
+++ b/apps/rlark/pkg/controllermanager/workflow/workflow_controller.go
@@ -2,19 +2,30 @@ package workflow
import (
"context"
+ "fmt"
+ "time"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ controllerconfig "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/controllermanager/controller"
)
+const (
+ CleanupFinalizer = "workflows.rlinf.io/job-cleanup"
+ cleanupRequeue = time.Second
+)
+
// Reconciler reconciles Workflow resources.
type Reconciler struct {
client.Client
- Scheme *runtime.Scheme
+ Scheme *runtime.Scheme
+ MaxConcurrentReconciles int
}
// +kubebuilder:rbac:groups=rlinf.io,resources=workflows,verbs=get;list;watch;create;update;patch;delete
@@ -25,16 +36,65 @@ type Reconciler struct {
// Reconcile handles a Workflow reconciliation request.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- return controller.ReconcileWith(ctx, req, &rlarkv1alpha1.Workflow{}, "workflow", r)
+ wf := &rlarkv1alpha1.Workflow{}
+ if err := r.Get(ctx, req.NamespacedName, wf); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if wf.DeletionTimestamp.IsZero() {
+ if !controllerutil.ContainsFinalizer(wf, CleanupFinalizer) {
+ controllerutil.AddFinalizer(wf, CleanupFinalizer)
+ if err := r.Update(ctx, wf); err != nil {
+ return ctrl.Result{}, fmt.Errorf("add Workflow cleanup finalizer: %w", err)
+ }
+ return ctrl.Result{RequeueAfter: time.Nanosecond}, nil
+ }
+ return controller.ReconcileWith(ctx, req, &rlarkv1alpha1.Workflow{}, "workflow", r)
+ }
+ if !controllerutil.ContainsFinalizer(wf, CleanupFinalizer) {
+ return ctrl.Result{}, nil
+ }
+
+ pending, err := r.deleteOwnedJobs(ctx, wf)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ if pending {
+ return ctrl.Result{RequeueAfter: cleanupRequeue}, nil
+ }
+ controllerutil.RemoveFinalizer(wf, CleanupFinalizer)
+ if err := r.Update(ctx, wf); err != nil {
+ return ctrl.Result{}, fmt.Errorf("remove Workflow cleanup finalizer: %w", err)
+ }
+ return ctrl.Result{}, nil
}
-// IsTerminal reports whether terminal.
-func (r *Reconciler) IsTerminal(obj client.Object) bool {
- wf := obj.(*rlarkv1alpha1.Workflow)
- return wf.Status.Phase == rlarkv1alpha1.WorkflowPhaseSucceeded ||
- wf.Status.Phase == rlarkv1alpha1.WorkflowPhaseFailed
+func (r *Reconciler) deleteOwnedJobs(ctx context.Context, wf *rlarkv1alpha1.Workflow) (bool, error) {
+ var jobs rlarkv1alpha1.JobList
+ if err := r.List(ctx, &jobs); err != nil {
+ return false, fmt.Errorf("list Jobs owned by Workflow %s: %w", wf.Name, err)
+ }
+
+ pending := false
+ for i := range jobs.Items {
+ job := &jobs.Items[i]
+ owner := metav1.GetControllerOf(job)
+ if owner == nil || owner.UID != wf.UID || owner.Kind != "Workflow" || owner.APIVersion != rlarkv1alpha1.GroupVersion.String() {
+ continue
+ }
+ pending = true
+ if job.DeletionTimestamp.IsZero() {
+ if err := client.IgnoreNotFound(r.Delete(ctx, job)); err != nil {
+ return false, fmt.Errorf("delete Job %s: %w", job.Name, err)
+ }
+ }
+ }
+ return pending, nil
}
+// IsTerminal reports whether the generic controller may stop reconciling.
+// Completed Workflows still need child pruning and status normalization.
+func (r *Reconciler) IsTerminal(client.Object) bool { return false }
+
// ReconcileStateMachine reconciles the resource.
func (r *Reconciler) ReconcileStateMachine(ctx context.Context, obj client.Object) (bool, error) {
return r.reconcileWithStateMachine(ctx, obj.(*rlarkv1alpha1.Workflow))
@@ -46,5 +106,6 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&rlarkv1alpha1.Workflow{}).
Owns(&rlarkv1alpha1.Job{}).
Named("workflow").
+ WithOptions(controllerconfig.Options{MaxConcurrentReconciles: r.MaxConcurrentReconciles}).
Complete(r)
}
diff --git a/apps/rlark/pkg/controllermanager/workflow/workflow_controller_test.go b/apps/rlark/pkg/controllermanager/workflow/workflow_controller_test.go
new file mode 100644
index 0000000..89b4337
--- /dev/null
+++ b/apps/rlark/pkg/controllermanager/workflow/workflow_controller_test.go
@@ -0,0 +1,125 @@
+package workflow
+
+import (
+ "context"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
+)
+
+func workflowTestScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := rlarkv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatal(err)
+ }
+ return scheme
+}
+
+func TestReconcileAddsCleanupFinalizer(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "workflow", UID: types.UID("workflow-uid")}}
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(wf).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKey{Name: wf.Name}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Get(context.Background(), client.ObjectKey{Name: wf.Name}, wf); err != nil {
+ t.Fatal(err)
+ }
+ if !hasFinalizer(wf.Finalizers, CleanupFinalizer) {
+ t.Fatalf("cleanup finalizer not added: %v", wf.Finalizers)
+ }
+}
+
+func TestDeleteOwnedJobsWaitsForCleanupAndFiltersOwnerUID(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{ObjectMeta: metav1.ObjectMeta{Name: "workflow", UID: types.UID("workflow-uid")}}
+ owned := &rlarkv1alpha1.Job{ObjectMeta: metav1.ObjectMeta{
+ Name: "owned",
+ Finalizers: []string{"jobs.rlinf.io/task-cleanup"},
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Workflow", Name: wf.Name, UID: wf.UID,
+ Controller: boolPtr(true),
+ }},
+ }}
+ unrelated := owned.DeepCopy()
+ unrelated.Name = "unrelated"
+ unrelated.UID = types.UID("unrelated")
+ unrelated.OwnerReferences[0].UID = types.UID("other-workflow")
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owned, unrelated).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ pending, err := r.deleteOwnedJobs(context.Background(), wf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pending {
+ t.Fatal("owned Job should keep Workflow cleanup pending")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), owned); err != nil {
+ t.Fatal(err)
+ }
+ if owned.DeletionTimestamp.IsZero() {
+ t.Fatal("owned Job deletion was not requested")
+ }
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(unrelated), unrelated); err != nil {
+ t.Fatalf("unrelated Job should not be deleted: %v", err)
+ }
+}
+
+func TestStoppedWorkflowDeletesAllJobsIncludingTerminal(t *testing.T) {
+ wf := &rlarkv1alpha1.Workflow{
+ ObjectMeta: metav1.ObjectMeta{Name: "workflow", UID: types.UID("workflow-uid")},
+ Spec: rlarkv1alpha1.WorkflowSpec{Stopped: true, JobTemplates: []rlarkv1alpha1.WorkflowJobTemplate{
+ {Name: "running"}, {Name: "succeeded"},
+ }},
+ Status: rlarkv1alpha1.WorkflowStatus{Phase: rlarkv1alpha1.WorkflowPhaseRunning, Jobs: []rlarkv1alpha1.WorkflowJobStatus{
+ {Name: "running", Phase: rlarkv1alpha1.JobPhaseRunning}, {Name: "succeeded", Phase: rlarkv1alpha1.JobPhaseSucceeded},
+ }},
+ }
+ owned := func(name string, phase rlarkv1alpha1.JobPhase, stopped bool) *rlarkv1alpha1.Job {
+ return &rlarkv1alpha1.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Finalizers: []string{"jobs.rlinf.io/task-cleanup"}, OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: rlarkv1alpha1.GroupVersion.String(), Kind: "Workflow", Name: wf.Name, UID: wf.UID, Controller: boolPtr(true),
+ }}},
+ Spec: rlarkv1alpha1.JobSpec{Stopped: stopped}, Status: rlarkv1alpha1.JobStatus{Phase: phase},
+ }
+ }
+ running := owned("running", rlarkv1alpha1.JobPhaseRunning, false)
+ succeeded := owned("succeeded", rlarkv1alpha1.JobPhaseSucceeded, false)
+ scheme := workflowTestScheme(t)
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(running, succeeded).Build()
+ r := &Reconciler{Client: c, Scheme: scheme}
+
+ if _, err := r.ReconcileStateMachine(context.Background(), wf); err == nil {
+ t.Fatal("Job cleanup should requeue the Workflow")
+ }
+ for _, job := range []*rlarkv1alpha1.Job{running, succeeded} {
+ if err := c.Get(context.Background(), client.ObjectKeyFromObject(job), job); err != nil {
+ t.Fatal(err)
+ }
+ if job.DeletionTimestamp.IsZero() {
+ t.Fatalf("Job %s deletion was not requested", job.Name)
+ }
+ }
+}
+
+func boolPtr(v bool) *bool { return &v }
+
+func hasFinalizer(values []string, value string) bool {
+ for _, item := range values {
+ if item == value {
+ return true
+ }
+ }
+ return false
+}
diff --git a/apps/rlark/pkg/db/resource_store.go b/apps/rlark/pkg/db/resource_store.go
index c18ee8b..88b6805 100644
--- a/apps/rlark/pkg/db/resource_store.go
+++ b/apps/rlark/pkg/db/resource_store.go
@@ -20,6 +20,8 @@ type ListOptions struct {
FieldSelector []FieldSelector
// LabelSelector filters by raw JSONB labels, e.g. "tenant=acme".
LabelSelector []LabelSelector
+ // TagSelector filters Job spec.tags by key/value pairs.
+ TagSelector []TagSelector
// OrderBy specifies sorting, e.g. "created_at", "name", "created_at desc".
OrderBy []string
// Limit limits the number of results (0 means no limit).
@@ -42,6 +44,12 @@ type LabelSelector struct {
Value string
}
+// TagSelector represents a Job spec.tags key/value filter.
+type TagSelector struct {
+ Key string
+ Value string
+}
+
// ListResult holds the result of a list query.
type ListResult struct {
Items []map[string]any `json:"items"`
@@ -124,6 +132,18 @@ func (q *ResourceStore) List(ctx context.Context, opts ListOptions) (*ListResult
baseQuery = applySelector(baseQuery, colExpr, ls.Op, ls.Value)
}
+ // Tag selectors: values of the same key are ORed; different keys are ANDed.
+ tagValuesByKey := make(map[string][]string)
+ for _, ts := range opts.TagSelector {
+ tagValuesByKey[ts.Key] = append(tagValuesByKey[ts.Key], ts.Value)
+ }
+ for key, values := range tagValuesByKey {
+ baseQuery = baseQuery.Where(
+ "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(?TableAlias.raw->'spec'->'tags', '[]'::jsonb)) AS tag WHERE tag->>'key' = ? AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tag->'values', '[]'::jsonb)) AS value WHERE value IN (?)))",
+ bun.Ident(q.tableAlias), key, bun.List(values),
+ )
+ }
+
// Count total before pagination
total, err := baseQuery.Count(ctx)
if err != nil {
diff --git a/apps/rlark/pkg/distribution/engine.go b/apps/rlark/pkg/distribution/engine.go
new file mode 100644
index 0000000..ae938ff
--- /dev/null
+++ b/apps/rlark/pkg/distribution/engine.go
@@ -0,0 +1,355 @@
+package distribution
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "slices"
+ "sort"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/dynamic"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type Engine struct {
+ DeclarationClient client.Client
+ TargetClient dynamic.Interface
+ TargetMapper meta.RESTMapper
+ Namespaces NamespaceResolver
+ InventoryStore InventoryStore
+ Policy Policy
+ Mode Mode
+ MaxTargets int
+}
+
+type desiredObject struct {
+ object *unstructured.Unstructured
+ mapping *meta.RESTMapping
+ target NamespaceIdentity
+ config Config
+}
+
+func (e *Engine) Reconcile(ctx context.Context, declaration *corev1.Secret) (Result, error) {
+ if declaration.Type != e.Mode.SecretType {
+ return Result{}, nil
+ }
+ inventory, err := e.InventoryStore.Load(ctx, declaration)
+ if err != nil {
+ return Result{}, err
+ }
+ if declaration.DeletionTimestamp != nil {
+ return e.cleanup(ctx, declaration, inventory)
+ }
+ definition, err := Parse(declaration.Data)
+ if err != nil {
+ return e.fail(ctx, declaration, inventory, "InvalidDefinition", err)
+ }
+ desiredObjects, phase, err := e.expand(ctx, declaration, definition)
+ if err != nil {
+ return e.fail(ctx, declaration, inventory, phase, err)
+ }
+ if e.MaxTargets > 0 && len(desiredObjects) > e.MaxTargets {
+ return e.fail(ctx, declaration, inventory, "ExpansionLimitExceeded", fmt.Errorf("expanded to %d resources, limit is %d", len(desiredObjects), e.MaxTargets))
+ }
+ if len(desiredObjects) == 0 && len(inventory.Items) == 0 {
+ inventory.ObservedDeclarationResourceVersion = declaration.ResourceVersion
+ inventory.ObservedRevision = inventoryRevision(nil)
+ inventory.Phase = "NoTargetsMatched"
+ inventory.Message = ""
+ if err := e.InventoryStore.Save(ctx, declaration, inventory); err != nil {
+ return Result{}, err
+ }
+ return Result{Inventory: inventory}, nil
+ }
+ if !slices.Contains(declaration.Finalizers, e.Mode.CleanupFinalizer) {
+ base := declaration.DeepCopy()
+ declaration.Finalizers = append(declaration.Finalizers, e.Mode.CleanupFinalizer)
+ if err := e.DeclarationClient.Patch(ctx, declaration, client.MergeFrom(base)); err != nil {
+ return Result{}, err
+ }
+ return Result{Requeue: true}, nil
+ }
+
+ desired := make([]InventoryItem, 0, len(desiredObjects))
+ for _, desiredObject := range desiredObjects {
+ item, err := e.apply(ctx, declaration, desiredObject)
+ if err != nil {
+ return e.fail(ctx, declaration, inventory, "ApplyFailed", err)
+ }
+ desired = append(desired, item)
+ }
+ if err := e.prune(ctx, declaration, inventory.Items, desired, definition.Config.Apply.DeletionPolicy); err != nil {
+ return e.fail(ctx, declaration, inventory, "PruneFailed", err)
+ }
+ newInventory := Inventory{ObservedDeclarationResourceVersion: declaration.ResourceVersion, Phase: "Applied", Items: desired}
+ newInventory.ObservedRevision = inventoryRevision(desired)
+ if err := e.InventoryStore.Save(ctx, declaration, newInventory); err != nil {
+ return Result{}, err
+ }
+ return Result{Inventory: newInventory}, nil
+}
+
+func (e *Engine) expand(ctx context.Context, declaration *corev1.Secret, definition Definition) ([]desiredObject, string, error) {
+ var selectedNamespaces []NamespaceIdentity
+ var namespaceSnapshot []NamespaceIdentity
+
+ desired := make([]desiredObject, 0, len(definition.Objects))
+ identities := map[string]struct{}{}
+ for index, object := range definition.Objects {
+ mapping, err := e.TargetMapper.RESTMapping(object.GroupVersionKind().GroupKind(), object.GroupVersionKind().Version)
+ if err != nil {
+ return nil, "DiscoveryFailed", fmt.Errorf("manifest %d: %w", index, err)
+ }
+ namespaced := mapping.Scope.Name() == meta.RESTScopeNameNamespace
+ if !namespaced {
+ if object.GetNamespace() != "" {
+ return nil, "InvalidDefinition", fmt.Errorf("manifest %d: cluster-scoped resource must not set metadata.namespace", index)
+ }
+ if !e.Mode.AllowClusterScoped || !definition.Config.Security.AllowClusterScoped {
+ return nil, "TargetMustBeNamespaced", fmt.Errorf("manifest %d: cluster-scoped target is not allowed", index)
+ }
+ desired = append(desired, desiredObject{object: object, mapping: mapping, config: definition.Config})
+ continue
+ }
+
+ if definition.Config.Targets.Namespaces != nil {
+ if object.GetNamespace() == "" {
+ if selectedNamespaces == nil {
+ selectedNamespaces, err = e.resolveTargets(ctx, declaration, definition.Config)
+ if err != nil {
+ return nil, "TargetsResolutionFailed", err
+ }
+ }
+ for _, target := range selectedNamespaces {
+ desired = append(desired, desiredObject{object: object, mapping: mapping, target: target, config: definition.Config})
+ }
+ continue
+ }
+ } else if object.GetNamespace() == "" {
+ return nil, "InvalidDefinition", fmt.Errorf("manifest %d: namespaced resource requires metadata.namespace when targets.namespaces is not configured", index)
+ }
+ if object.GetNamespace() != "" {
+ if namespaceSnapshot == nil {
+ namespaceSnapshot, err = e.Namespaces.List(ctx)
+ if err != nil {
+ return nil, "TargetsResolutionFailed", err
+ }
+ }
+ target, ok := namespaceByName(namespaceSnapshot, object.GetNamespace())
+ if !ok {
+ return nil, "TargetNamespaceNotFound", fmt.Errorf("manifest %d: namespace %q is not active or does not exist", index, object.GetNamespace())
+ }
+ desired = append(desired, desiredObject{object: object, mapping: mapping, target: target, config: definition.Config})
+ }
+ }
+ for _, item := range desired {
+ key := item.mapping.Resource.String() + "/" + item.target.Name + "/" + item.object.GetName()
+ if _, exists := identities[key]; exists {
+ return nil, "InvalidDefinition", fmt.Errorf("duplicate desired resource %s", key)
+ }
+ identities[key] = struct{}{}
+ if err := e.Policy.Validate(ctx, declaration, item.mapping, item.target, item.object.DeepCopy(), definition.Config.Apply); err != nil {
+ return nil, "PolicyDenied", err
+ }
+ }
+ return desired, "", nil
+}
+
+func namespaceByName(namespaces []NamespaceIdentity, name string) (NamespaceIdentity, bool) {
+ for _, namespace := range namespaces {
+ if namespace.Name == name && namespace.Phase == corev1.NamespaceActive {
+ return namespace, true
+ }
+ }
+ return NamespaceIdentity{}, false
+}
+
+func (e *Engine) resolveTargets(ctx context.Context, declaration *corev1.Secret, config Config) ([]NamespaceIdentity, error) {
+ namespaces, err := e.Namespaces.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var targets []NamespaceIdentity
+ for _, namespace := range namespaces {
+ if namespace.Phase != corev1.NamespaceActive || (!config.IncludeDefinitionNamespace && namespace.Name == declaration.Namespace) {
+ continue
+ }
+ if !Matches(*config.Targets.Namespaces, namespace.Name) || !labelsMatch(config.RequireTargetLabels, namespace.Labels) {
+ continue
+ }
+ targets = append(targets, namespace)
+ }
+ sort.Slice(targets, func(i, j int) bool { return targets[i].Name < targets[j].Name })
+ return targets, nil
+}
+
+func (e *Engine) apply(ctx context.Context, declaration *corev1.Secret, desired desiredObject) (InventoryItem, error) {
+ object := desired.object.DeepCopy()
+ if desired.mapping.Scope.Name() == meta.RESTScopeNameNamespace {
+ object.SetNamespace(desired.target.Name)
+ }
+ revision, err := objectRevision(object, desired.config.Apply, desired.target)
+ if err != nil {
+ return InventoryItem{}, err
+ }
+ labels := object.GetLabels()
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ labels[e.Mode.OwnershipPrefix+"managed"] = "true"
+ labels[e.Mode.OwnershipPrefix+"definition-name"] = declaration.Name
+ object.SetLabels(labels)
+ annotations := object.GetAnnotations()
+ if annotations == nil {
+ annotations = map[string]string{}
+ }
+ annotations[e.Mode.OwnershipPrefix+"definition-namespace"] = declaration.Namespace
+ annotations[e.Mode.OwnershipPrefix+"definition-uid"] = string(declaration.UID)
+ annotations[e.Mode.OwnershipPrefix+"revision"] = revision
+ if desired.target.UID != "" {
+ annotations[e.Mode.OwnershipPrefix+"target-namespace-uid"] = string(desired.target.UID)
+ }
+ object.SetAnnotations(annotations)
+
+ resource := e.TargetClient.Resource(desired.mapping.Resource)
+ var targetResource dynamic.ResourceInterface = resource
+ if object.GetNamespace() != "" {
+ targetResource = resource.Namespace(object.GetNamespace())
+ }
+ existing, err := targetResource.Get(ctx, object.GetName(), metav1.GetOptions{})
+ if err == nil {
+ owner := existing.GetAnnotations()[e.Mode.OwnershipPrefix+"definition-uid"]
+ if owner != string(declaration.UID) && !desired.config.Apply.AdoptExisting {
+ return InventoryItem{}, fmt.Errorf("ownership conflict for %s/%s", object.GetNamespace(), object.GetName())
+ }
+ } else if !apierrors.IsNotFound(err) {
+ return InventoryItem{}, err
+ }
+ data, err := json.Marshal(object.Object)
+ if err != nil {
+ return InventoryItem{}, err
+ }
+ force := desired.config.Apply.ConflictPolicy == "Force"
+ applied, err := targetResource.Patch(ctx, object.GetName(), types.ApplyPatchType, data, metav1.PatchOptions{
+ FieldManager: e.Mode.FieldManagerPrefix + shortHash(string(declaration.UID)), Force: &force,
+ })
+ if err != nil {
+ return InventoryItem{}, err
+ }
+ return InventoryItem{Group: desired.mapping.Resource.Group, Version: desired.mapping.Resource.Version, Resource: desired.mapping.Resource.Resource, Namespace: object.GetNamespace(), NamespaceUID: desired.target.UID, Name: object.GetName(), UID: applied.GetUID()}, nil
+}
+
+func (e *Engine) prune(ctx context.Context, declaration *corev1.Secret, previous, desired []InventoryItem, policy string) error {
+ desiredSet := map[string]struct{}{}
+ for _, item := range desired {
+ desiredSet[itemKey(item)] = struct{}{}
+ }
+ for _, item := range previous {
+ if _, ok := desiredSet[itemKey(item)]; ok {
+ continue
+ }
+ if policy == "Orphan" {
+ continue
+ }
+ if err := e.deleteOwned(ctx, declaration, item); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *Engine) cleanup(ctx context.Context, declaration *corev1.Secret, inventory Inventory) (Result, error) {
+ for _, item := range inventory.Items {
+ if err := e.deleteOwned(ctx, declaration, item); err != nil {
+ return Result{}, err
+ }
+ }
+ if err := e.InventoryStore.Delete(ctx, declaration); err != nil {
+ return Result{}, err
+ }
+ current := &corev1.Secret{}
+ if err := e.DeclarationClient.Get(ctx, client.ObjectKeyFromObject(declaration), current); err != nil {
+ return Result{}, client.IgnoreNotFound(err)
+ }
+ base := current.DeepCopy()
+ current.Finalizers = slices.DeleteFunc(current.Finalizers, func(value string) bool { return value == e.Mode.CleanupFinalizer })
+ if err := e.DeclarationClient.Patch(ctx, current, client.MergeFrom(base)); err != nil {
+ return Result{}, err
+ }
+ return Result{}, nil
+}
+
+func (e *Engine) deleteOwned(ctx context.Context, declaration *corev1.Secret, item InventoryItem) error {
+ resource := e.TargetClient.Resource(schema.GroupVersionResource{Group: item.Group, Version: item.Version, Resource: item.Resource})
+ var target dynamic.ResourceInterface = resource
+ if item.Namespace != "" {
+ target = resource.Namespace(item.Namespace)
+ }
+ existing, err := target.Get(ctx, item.Name, metav1.GetOptions{})
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if existing.GetUID() != item.UID || existing.GetAnnotations()[e.Mode.OwnershipPrefix+"definition-uid"] != string(declaration.UID) {
+ return fmt.Errorf("ownership lost for %s/%s", item.Namespace, item.Name)
+ }
+ uid := existing.GetUID()
+ return target.Delete(ctx, item.Name, metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &uid}})
+}
+
+func (e *Engine) fail(ctx context.Context, declaration *corev1.Secret, inventory Inventory, phase string, cause error) (Result, error) {
+ inventory.Phase = phase
+ inventory.Message = cause.Error()
+ if err := e.InventoryStore.Save(ctx, declaration, inventory); err != nil {
+ return Result{}, err
+ }
+ return Result{Inventory: inventory}, cause
+}
+
+func labelsMatch(required, labels map[string]string) bool {
+ for key, value := range required {
+ if labels[key] != value {
+ return false
+ }
+ }
+ return true
+}
+
+func itemKey(item InventoryItem) string {
+ return item.Group + "/" + item.Version + "/" + item.Resource + "/" + item.Namespace + "/" + item.Name + "/" + string(item.NamespaceUID)
+}
+
+func objectRevision(object *unstructured.Unstructured, policy ApplyPolicy, target NamespaceIdentity) (string, error) {
+ data, err := json.Marshal(struct {
+ Object any
+ Policy ApplyPolicy
+ Target NamespaceIdentity
+ }{object.Object, policy, target})
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func inventoryRevision(items []InventoryItem) string {
+ data, _ := json.Marshal(items)
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func shortHash(value string) string {
+ sum := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(sum[:8])
+}
diff --git a/apps/rlark/pkg/distribution/engine_test.go b/apps/rlark/pkg/distribution/engine_test.go
new file mode 100644
index 0000000..66a126b
--- /dev/null
+++ b/apps/rlark/pkg/distribution/engine_test.go
@@ -0,0 +1,575 @@
+package distribution
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ dynamicfake "k8s.io/client-go/dynamic/fake"
+ clientgotesting "k8s.io/client-go/testing"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type staticNamespaces struct {
+ items []NamespaceIdentity
+ err error
+}
+
+func (s staticNamespaces) List(context.Context) ([]NamespaceIdentity, error) { return s.items, s.err }
+
+type memoryInventoryStore struct {
+ inventory Inventory
+ saves []Inventory
+ deleted bool
+}
+
+func (s *memoryInventoryStore) Load(context.Context, *corev1.Secret) (Inventory, error) {
+ return s.inventory, nil
+}
+
+func (s *memoryInventoryStore) Save(_ context.Context, _ *corev1.Secret, inventory Inventory) error {
+ s.inventory = inventory
+ s.saves = append(s.saves, inventory)
+ return nil
+}
+
+func (s *memoryInventoryStore) Delete(context.Context, *corev1.Secret) error {
+ s.deleted = true
+ return nil
+}
+
+func newTestEngine(t *testing.T, mode Mode, declaration *corev1.Secret, namespaces staticNamespaces) (*Engine, *dynamicfake.FakeDynamicClient, *memoryInventoryStore, client.Client) {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ declarationClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration).Build()
+ targetClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
+ {Version: "v1", Resource: "configmaps"}: "ConfigMapList",
+ {Version: "v1", Resource: "namespaces"}: "NamespaceList",
+ })
+ installApplyReactor(t, targetClient)
+ mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{corev1.SchemeGroupVersion})
+ mapper.Add(corev1.SchemeGroupVersion.WithKind("ConfigMap"), meta.RESTScopeNamespace)
+ mapper.Add(corev1.SchemeGroupVersion.WithKind("Namespace"), meta.RESTScopeRoot)
+ store := &memoryInventoryStore{}
+ return &Engine{
+ DeclarationClient: declarationClient,
+ TargetClient: targetClient,
+ TargetMapper: mapper,
+ Namespaces: namespaces,
+ InventoryStore: store,
+ Policy: NoopPolicy{},
+ Mode: mode,
+ }, targetClient, store, declarationClient
+}
+
+func installApplyReactor(t *testing.T, targetClient *dynamicfake.FakeDynamicClient) {
+ t.Helper()
+ targetClient.PrependReactor("patch", "*", func(action clientgotesting.Action) (bool, runtime.Object, error) {
+ patch := action.(clientgotesting.PatchAction)
+ object := &unstructured.Unstructured{}
+ require.NoError(t, json.Unmarshal(patch.GetPatch(), &object.Object))
+ object.SetNamespace(action.GetNamespace())
+ object.SetUID(types.UID("uid-" + action.GetNamespace() + "-" + object.GetName()))
+ gvr := action.GetResource()
+ current, err := targetClient.Tracker().Get(gvr, object.GetNamespace(), object.GetName())
+ if err == nil {
+ object.SetResourceVersion(current.(metav1.Object).GetResourceVersion())
+ require.NoError(t, targetClient.Tracker().Update(gvr, object, object.GetNamespace()))
+ } else {
+ require.NoError(t, targetClient.Tracker().Create(gvr, object, object.GetNamespace()))
+ }
+ return true, object, nil
+ })
+}
+
+func testDeclaration(secretType corev1.SecretType, mode Mode) *corev1.Secret {
+ return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "definition", Namespace: "platform", UID: "definition-uid", Finalizers: []string{mode.CleanupFinalizer}}, Type: secretType, Data: validData()}
+}
+
+func TestEngineAppliesAndPrunesNamespacedObjects(t *testing.T) {
+ ctx := context.Background()
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ declaration := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "replicate", Namespace: "platform", UID: "definition-uid", Finalizers: []string{ReplicationMode.CleanupFinalizer}}, Type: ReplicationSecretType, Data: validData()}
+ declarationClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration).Build()
+ targetClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
+ {Version: "v1", Resource: "configmaps"}: "ConfigMapList",
+ })
+ targetClient.PrependReactor("patch", "configmaps", func(action clientgotesting.Action) (bool, runtime.Object, error) {
+ patch := action.(clientgotesting.PatchAction)
+ object := &corev1.ConfigMap{}
+ require.NoError(t, json.Unmarshal(patch.GetPatch(), object))
+ object.Namespace = action.GetNamespace()
+ object.UID = types.UID("local-uid")
+ gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}
+ current, err := targetClient.Tracker().Get(gvr, object.Namespace, object.Name)
+ if err == nil {
+ object.ResourceVersion = current.(*corev1.ConfigMap).ResourceVersion
+ require.NoError(t, targetClient.Tracker().Update(gvr, object, object.Namespace))
+ } else {
+ require.NoError(t, targetClient.Tracker().Create(gvr, object, object.Namespace))
+ }
+ return true, object, nil
+ })
+ mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{corev1.SchemeGroupVersion})
+ mapper.Add(corev1.SchemeGroupVersion.WithKind("ConfigMap"), meta.RESTScopeNamespace)
+ store := ConfigMapInventoryStore{Client: declarationClient, Mode: ReplicationMode}
+ engine := Engine{
+ DeclarationClient: declarationClient,
+ TargetClient: targetClient,
+ TargetMapper: mapper,
+ Namespaces: staticNamespaces{items: []NamespaceIdentity{
+ {Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive},
+ {Name: "other", UID: "ns-b", Phase: corev1.NamespaceActive},
+ }},
+ InventoryStore: store,
+ Policy: NoopPolicy{},
+ Mode: ReplicationMode,
+ }
+
+ result, err := engine.Reconcile(ctx, declaration.DeepCopy())
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ object, err := targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(ctx, "runtime-config", metav1.GetOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, "definition-uid", object.GetAnnotations()["replication.rlinf.io/definition-uid"])
+
+ current := &corev1.Secret{}
+ require.NoError(t, declarationClient.Get(ctx, types.NamespacedName{Namespace: "platform", Name: "replicate"}, current))
+ data := validData()
+ data["config"] = []byte("targets:\n namespaces:\n include: ['new-*']")
+ current.Data = data
+ require.NoError(t, declarationClient.Update(ctx, current))
+ _, err = engine.Reconcile(ctx, current)
+ require.NoError(t, err)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(ctx, "runtime-config", metav1.GetOptions{})
+ assert.Error(t, err)
+}
+
+func TestEngineRejectsClusterScopedTargetInReplicationMode(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ data := validData()
+ data["manifests"] = []byte("- apiVersion: v1\n kind: Namespace\n metadata:\n name: distributed")
+ data["config"] = []byte("security:\n allowClusterScoped: true")
+ declaration := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "replicate", Namespace: "platform", UID: "definition-uid", Finalizers: []string{ReplicationMode.CleanupFinalizer}}, Type: ReplicationSecretType, Data: data}
+ declarationClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration).Build()
+ mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{corev1.SchemeGroupVersion})
+ mapper.Add(corev1.SchemeGroupVersion.WithKind("Namespace"), meta.RESTScopeRoot)
+ engine := Engine{DeclarationClient: declarationClient, TargetClient: dynamicfake.NewSimpleDynamicClient(scheme), TargetMapper: mapper, InventoryStore: ConfigMapInventoryStore{Client: declarationClient, Mode: ReplicationMode}, Policy: NoopPolicy{}, Mode: ReplicationMode}
+
+ _, err := engine.Reconcile(context.Background(), declaration.DeepCopy())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cluster-scoped target is not allowed")
+}
+
+func TestEngineIgnoresWrongSecretType(t *testing.T) {
+ declaration := testDeclaration(corev1.SecretTypeOpaque, ReplicationMode)
+ engine, targetClient, store, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ assert.Empty(t, result.Inventory.Items)
+ assert.Empty(t, store.saves)
+ assert.Empty(t, targetClient.Actions())
+}
+
+func TestEngineAddsFinalizerBeforeApplying(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ declaration.Finalizers = nil
+ engine, targetClient, store, declarationClient := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{items: []NamespaceIdentity{{Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive}}})
+
+ result, err := engine.Reconcile(context.Background(), declaration.DeepCopy())
+ require.NoError(t, err)
+ assert.True(t, result.Requeue)
+ assert.Empty(t, targetClient.Actions())
+ assert.Empty(t, store.saves)
+ current := &corev1.Secret{}
+ require.NoError(t, declarationClient.Get(context.Background(), client.ObjectKeyFromObject(declaration), current))
+ assert.Contains(t, current.Finalizers, ReplicationMode.CleanupFinalizer)
+}
+
+func TestEngineDoesNotAddFinalizerWhenNoResourcesAreManaged(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ declaration.Finalizers = nil
+ engine, targetClient, store, declarationClient := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+
+ result, err := engine.Reconcile(context.Background(), declaration.DeepCopy())
+ require.NoError(t, err)
+ assert.False(t, result.Requeue)
+ assert.Equal(t, "NoTargetsMatched", result.Inventory.Phase)
+ assert.Empty(t, targetClient.Actions())
+ require.Len(t, store.saves, 1)
+ current := &corev1.Secret{}
+ require.NoError(t, declarationClient.Get(context.Background(), client.ObjectKeyFromObject(declaration), current))
+ assert.NotContains(t, current.Finalizers, ReplicationMode.CleanupFinalizer)
+}
+
+func TestEngineKeepsFinalizerWhenPreviousInventoryNeedsPruning(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ declaration.Finalizers = nil
+ engine, targetClient, store, declarationClient := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+ owned := ownedConfigMap(declaration, ReplicationMode, "tenant-a", "old-uid")
+ require.NoError(t, targetClient.Tracker().Create(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, owned, "tenant-a"))
+ store.inventory = Inventory{Items: []InventoryItem{{Version: "v1", Resource: "configmaps", Namespace: "tenant-a", Name: "runtime-config", UID: "old-uid"}}}
+
+ result, err := engine.Reconcile(context.Background(), declaration.DeepCopy())
+ require.NoError(t, err)
+ assert.True(t, result.Requeue)
+ current := &corev1.Secret{}
+ require.NoError(t, declarationClient.Get(context.Background(), client.ObjectKeyFromObject(declaration), current))
+ assert.Contains(t, current.Finalizers, ReplicationMode.CleanupFinalizer)
+}
+
+func TestEngineFiltersNamespaces(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ data := validData()
+ data["config"] = []byte(`targets:
+ namespaces:
+ include: ["tenant-*"]
+ exclude: ["tenant-disabled"]
+requireTargetNamespaceLabels:
+ managed: "true"
+`)
+ declaration.Data = data
+ engine, _, _, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{items: []NamespaceIdentity{
+ {Name: "tenant-a", UID: "a", Labels: map[string]string{"managed": "true"}, Phase: corev1.NamespaceActive},
+ {Name: "tenant-disabled", UID: "b", Labels: map[string]string{"managed": "true"}, Phase: corev1.NamespaceActive},
+ {Name: "tenant-unmanaged", UID: "c", Phase: corev1.NamespaceActive},
+ {Name: "tenant-terminating", UID: "d", Labels: map[string]string{"managed": "true"}, Phase: corev1.NamespaceTerminating},
+ {Name: "platform", UID: "e", Labels: map[string]string{"managed": "true"}, Phase: corev1.NamespaceActive},
+ }})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ assert.Equal(t, "tenant-a", result.Inventory.Items[0].Namespace)
+}
+
+func TestEngineFailsClosedWhenNamespaceListFails(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ engine, targetClient, store, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{err: errors.New("list failed")})
+ store.inventory = Inventory{Items: []InventoryItem{{Version: "v1", Resource: "configmaps", Namespace: "tenant-a", Name: "runtime-config", UID: "local-uid"}}}
+
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.ErrorContains(t, err, "list failed")
+ assert.Equal(t, "TargetsResolutionFailed", store.inventory.Phase)
+ assert.Len(t, store.inventory.Items, 1)
+ for _, action := range targetClient.Actions() {
+ assert.NotEqual(t, "delete", action.GetVerb())
+ }
+}
+
+func TestEngineRejectsOwnershipConflict(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ engine, targetClient, store, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{items: []NamespaceIdentity{{Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive}}})
+ existing := &unstructured.Unstructured{Object: map[string]any{
+ "apiVersion": "v1", "kind": "ConfigMap",
+ "metadata": map[string]any{"name": "runtime-config", "namespace": "tenant-a"},
+ }}
+ require.NoError(t, targetClient.Tracker().Create(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, existing, "tenant-a"))
+
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.ErrorContains(t, err, "ownership conflict")
+ assert.Equal(t, "ApplyFailed", store.inventory.Phase)
+}
+
+func TestEngineOrphansRemovedTarget(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ data := validData()
+ data["config"] = []byte("targets:\n namespaces:\n include: ['new-*']\napply:\n deletionPolicy: Orphan")
+ declaration.Data = data
+ engine, targetClient, store, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+ old := ownedConfigMap(declaration, ReplicationMode, "tenant-a", "old-uid")
+ require.NoError(t, targetClient.Tracker().Create(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, old, "tenant-a"))
+ store.inventory = Inventory{Items: []InventoryItem{{Version: "v1", Resource: "configmaps", Namespace: "tenant-a", NamespaceUID: "ns-a", Name: "runtime-config", UID: "old-uid"}}}
+
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(context.Background(), "runtime-config", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineAppliesClusterScopedDelivery(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("security:\n allowClusterScoped: true"),
+ "manifests": []byte("- apiVersion: v1\n kind: Namespace\n metadata:\n name: delivered"),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{err: errors.New("must not list")})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ assert.Empty(t, result.Inventory.Items[0].Namespace)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}).Get(context.Background(), "delivered", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineClusterScopedBundleIgnoresNamespaceSelector(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte(`targets:
+ namespaces:
+ include: ["tenant-*"]
+security:
+ allowClusterScoped: true
+`),
+ "manifests": []byte("- apiVersion: v1\n kind: Namespace\n metadata:\n name: delivered"),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{err: errors.New("must not list")})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}).Get(context.Background(), "delivered", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineExplicitNamespaceBundleDoesNotExpandSelector(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("targets:\n namespaces:\n include: ['tenant-*']"),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: fixed
+ namespace: shared
+`),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: []NamespaceIdentity{{Name: "shared", UID: "ns-shared", Phase: corev1.NamespaceActive}}})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("shared").Get(context.Background(), "fixed", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineAppliesMixedScopeBundleWithNamespaceSelector(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte(`targets:
+ namespaces:
+ include: ["tenant-*"]
+security:
+ allowClusterScoped: true
+`),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: runtime-config
+- apiVersion: v1
+ kind: Namespace
+ metadata:
+ name: shared
+`),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: []NamespaceIdentity{
+ {Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive},
+ {Name: "tenant-b", UID: "ns-b", Phase: corev1.NamespaceActive},
+ {Name: "other", UID: "ns-c", Phase: corev1.NamespaceActive},
+ }})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 3)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(context.Background(), "runtime-config", metav1.GetOptions{})
+ require.NoError(t, err)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-b").Get(context.Background(), "runtime-config", metav1.GetOptions{})
+ require.NoError(t, err)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}).Get(context.Background(), "shared", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineAppliesNamespacedBundleWithoutSelector(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("{}"),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: runtime-config
+ namespace: tenant-a
+`),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: []NamespaceIdentity{{Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive}}})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 1)
+ assert.Equal(t, types.UID("ns-a"), result.Inventory.Items[0].NamespaceUID)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(context.Background(), "runtime-config", metav1.GetOptions{})
+ require.NoError(t, err)
+}
+
+func TestEngineExplicitNamespaceOverridesSelector(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("targets:\n namespaces:\n include: ['tenant-*']"),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: expanded
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: fixed
+ namespace: shared
+`),
+ }
+ engine, targetClient, _, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: []NamespaceIdentity{
+ {Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive},
+ {Name: "tenant-b", UID: "ns-b", Phase: corev1.NamespaceActive},
+ {Name: "shared", UID: "ns-shared", Phase: corev1.NamespaceActive},
+ }})
+
+ result, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, result.Inventory.Items, 3)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("shared").Get(context.Background(), "fixed", metav1.GetOptions{})
+ require.NoError(t, err)
+ for _, namespace := range []string{"tenant-a", "tenant-b"} {
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace(namespace).Get(context.Background(), "expanded", metav1.GetOptions{})
+ require.NoError(t, err)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace(namespace).Get(context.Background(), "fixed", metav1.GetOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+ }
+}
+
+func TestEngineRejectsInvalidBundleNamespaceRules(t *testing.T) {
+ tests := []struct {
+ name string
+ config string
+ manifests string
+ want string
+ namespaces []NamespaceIdentity
+ }{
+ {
+ name: "missing explicit namespace", config: "{}",
+ manifests: "- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: one", want: "requires metadata.namespace",
+ },
+ {
+ name: "unknown explicit namespace", config: "{}",
+ manifests: "- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: one\n namespace: missing", want: "not active or does not exist",
+ },
+ {
+ name: "cluster resource with namespace", config: "security:\n allowClusterScoped: true",
+ manifests: "- apiVersion: v1\n kind: Namespace\n metadata:\n name: one\n namespace: invalid", want: "must not set metadata.namespace",
+ },
+ {
+ name: "duplicate identity", config: "{}",
+ manifests: "- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: one\n namespace: tenant-a\n- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: one\n namespace: tenant-a", want: "duplicate desired resource",
+ namespaces: []NamespaceIdentity{{Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive}},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{"config": []byte(tt.config), "manifests": []byte(tt.manifests)}
+ engine, targetClient, store, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: tt.namespaces})
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.ErrorContains(t, err, tt.want)
+ assert.Empty(t, targetClient.Actions())
+ assert.Empty(t, store.inventory.Items)
+ })
+ }
+}
+
+func TestEngineRejectsClusterScopedResourceInsideReplicationBundle(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("security:\n allowClusterScoped: true"),
+ "manifests": []byte("- apiVersion: v1\n kind: Namespace\n metadata:\n name: shared"),
+ }
+ engine, _, _, _ := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.ErrorContains(t, err, "cluster-scoped target is not allowed")
+}
+
+func TestEnginePrunesRemovedBundleMember(t *testing.T) {
+ declaration := testDeclaration(DeliverySecretType, DeliveryMode)
+ declaration.Data = map[string][]byte{
+ "config": []byte("{}"),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: retained
+ namespace: tenant-a
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: removed
+ namespace: tenant-a
+`),
+ }
+ engine, targetClient, store, _ := newTestEngine(t, DeliveryMode, declaration, staticNamespaces{items: []NamespaceIdentity{{Name: "tenant-a", UID: "ns-a", Phase: corev1.NamespaceActive}}})
+ first, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, first.Inventory.Items, 2)
+
+ declaration.Data["manifests"] = []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: retained
+ namespace: tenant-a
+`)
+ second, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ require.Len(t, second.Inventory.Items, 1)
+ assert.Equal(t, "retained", second.Inventory.Items[0].Name)
+ assert.Len(t, store.inventory.Items, 1)
+ _, err = targetClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("tenant-a").Get(context.Background(), "removed", metav1.GetOptions{})
+ assert.True(t, apierrors.IsNotFound(err))
+}
+
+func TestEngineCleanupVerifiesOwnershipAndRemovesFinalizer(t *testing.T) {
+ declaration := testDeclaration(ReplicationSecretType, ReplicationMode)
+ now := metav1.Now()
+ declaration.DeletionTimestamp = &now
+ engine, targetClient, store, declarationClient := newTestEngine(t, ReplicationMode, declaration, staticNamespaces{})
+ owned := ownedConfigMap(declaration, ReplicationMode, "tenant-a", "old-uid")
+ require.NoError(t, targetClient.Tracker().Create(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, owned, "tenant-a"))
+ store.inventory = Inventory{Items: []InventoryItem{{Version: "v1", Resource: "configmaps", Namespace: "tenant-a", Name: "runtime-config", UID: "old-uid"}}}
+
+ _, err := engine.Reconcile(context.Background(), declaration)
+ require.NoError(t, err)
+ assert.True(t, store.deleted)
+ current := &corev1.Secret{}
+ err = declarationClient.Get(context.Background(), client.ObjectKeyFromObject(declaration), current)
+ if err == nil {
+ assert.NotContains(t, current.Finalizers, ReplicationMode.CleanupFinalizer)
+ } else {
+ assert.True(t, apierrors.IsNotFound(err))
+ }
+}
+
+func ownedConfigMap(declaration *corev1.Secret, mode Mode, namespace string, uid types.UID) *unstructured.Unstructured {
+ return &unstructured.Unstructured{Object: map[string]any{
+ "apiVersion": "v1", "kind": "ConfigMap",
+ "metadata": map[string]any{
+ "name": "runtime-config", "namespace": namespace, "uid": string(uid),
+ "annotations": map[string]any{mode.OwnershipPrefix + "definition-uid": string(declaration.UID)},
+ },
+ }}
+}
diff --git a/apps/rlark/pkg/distribution/parser.go b/apps/rlark/pkg/distribution/parser.go
new file mode 100644
index 0000000..6447d44
--- /dev/null
+++ b/apps/rlark/pkg/distribution/parser.go
@@ -0,0 +1,132 @@
+package distribution
+
+import (
+ "fmt"
+ "path"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/util/yaml"
+)
+
+const (
+ maxPayloadSize = 512 * 1024
+ maxBundleItems = 100
+)
+
+func Parse(data map[string][]byte) (Definition, error) {
+ if len(data["config"]) == 0 {
+ return Definition{}, fmt.Errorf("data.config is required")
+ }
+ if len(data["manifests"]) == 0 {
+ return Definition{}, fmt.Errorf("data.manifests is required")
+ }
+ if len(data["manifests"]) > maxPayloadSize {
+ return Definition{}, fmt.Errorf("manifests exceeds %d bytes", maxPayloadSize)
+ }
+
+ var config Config
+ if err := yaml.Unmarshal(data["config"], &config); err != nil {
+ return Definition{}, fmt.Errorf("parse config: %w", err)
+ }
+ if config.Apply.Mode == "" {
+ config.Apply.Mode = "ServerSideApply"
+ }
+ if config.Apply.ConflictPolicy == "" {
+ config.Apply.ConflictPolicy = "Fail"
+ }
+ if config.Apply.DeletionPolicy == "" {
+ config.Apply.DeletionPolicy = "Delete"
+ }
+ if config.Apply.Mode != "ServerSideApply" {
+ return Definition{}, fmt.Errorf("unsupported apply mode %q", config.Apply.Mode)
+ }
+ if config.Apply.ConflictPolicy != "Fail" && config.Apply.ConflictPolicy != "Force" {
+ return Definition{}, fmt.Errorf("unsupported conflict policy %q", config.Apply.ConflictPolicy)
+ }
+ if config.Apply.DeletionPolicy != "Delete" && config.Apply.DeletionPolicy != "Orphan" {
+ return Definition{}, fmt.Errorf("unsupported deletion policy %q", config.Apply.DeletionPolicy)
+ }
+ if config.Targets.Namespaces != nil {
+ if err := validateSelector(*config.Targets.Namespaces); err != nil {
+ return Definition{}, err
+ }
+ }
+
+ var raw []map[string]any
+ if err := yaml.Unmarshal(data["manifests"], &raw); err != nil {
+ return Definition{}, fmt.Errorf("parse manifests: %w", err)
+ }
+ if len(raw) == 0 {
+ return Definition{}, fmt.Errorf("manifests must contain at least one object")
+ }
+ if len(raw) > maxBundleItems {
+ return Definition{}, fmt.Errorf("manifests contains %d objects, limit is %d", len(raw), maxBundleItems)
+ }
+ objects := make([]*unstructured.Unstructured, 0, len(raw))
+ for index, content := range raw {
+ object := &unstructured.Unstructured{Object: content}
+ if err := validateObject(object); err != nil {
+ return Definition{}, fmt.Errorf("manifest %d: %w", index, err)
+ }
+ objects = append(objects, object)
+ }
+ return Definition{Config: config, Objects: objects}, nil
+}
+
+func validateObject(object *unstructured.Unstructured) error {
+ if object.GetAPIVersion() == "" {
+ return fmt.Errorf("apiVersion is required")
+ }
+ if object.GetKind() == "" {
+ return fmt.Errorf("kind is required")
+ }
+ if object.GetName() == "" {
+ return fmt.Errorf("metadata.name is required")
+ }
+ if object.GetGenerateName() != "" {
+ return fmt.Errorf("metadata.generateName is forbidden")
+ }
+ metadata, _, _ := unstructured.NestedMap(object.Object, "metadata")
+ for _, key := range []string{"uid", "resourceVersion", "managedFields", "ownerReferences", "finalizers", "deletionTimestamp"} {
+ if _, ok := metadata[key]; ok {
+ return fmt.Errorf("metadata.%s is forbidden", key)
+ }
+ }
+ if _, ok := object.Object["status"]; ok {
+ return fmt.Errorf("status is forbidden")
+ }
+ return nil
+}
+
+func validateSelector(selector GlobSelector) error {
+ if len(selector.Include) == 0 {
+ return fmt.Errorf("namespace include must not be empty")
+ }
+ for _, pattern := range append(append([]string{}, selector.Include...), selector.Exclude...) {
+ if _, err := path.Match(pattern, ""); err != nil {
+ return fmt.Errorf("invalid namespace pattern %q: %w", pattern, err)
+ }
+ }
+ return nil
+}
+
+func Matches(selector GlobSelector, name string) bool {
+ include := false
+ for _, pattern := range selector.Include {
+ matched, _ := path.Match(pattern, name)
+ if matched {
+ include = true
+ break
+ }
+ }
+ if !include {
+ return false
+ }
+ for _, pattern := range selector.Exclude {
+ matched, _ := path.Match(pattern, name)
+ if matched {
+ return false
+ }
+ }
+ return true
+}
diff --git a/apps/rlark/pkg/distribution/parser_test.go b/apps/rlark/pkg/distribution/parser_test.go
new file mode 100644
index 0000000..3922d81
--- /dev/null
+++ b/apps/rlark/pkg/distribution/parser_test.go
@@ -0,0 +1,134 @@
+package distribution
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMatches(t *testing.T) {
+ tests := []struct {
+ name string
+ selector GlobSelector
+ namespace string
+ want bool
+ }{
+ {"all", GlobSelector{Include: []string{"*"}}, "tenant-a", true},
+ {"prefix", GlobSelector{Include: []string{"tenant-*"}}, "tenant-a", true},
+ {"whole string", GlobSelector{Include: []string{"tenant-*"}}, "a-tenant-a", false},
+ {"single", GlobSelector{Include: []string{"prod-?"}}, "prod-a", true},
+ {"range", GlobSelector{Include: []string{"prod-[a-c]"}}, "prod-b", true},
+ {"escaped", GlobSelector{Include: []string{`tenant-\*`}}, "tenant-*", true},
+ {"excluded", GlobSelector{Include: []string{"tenant-*"}, Exclude: []string{"tenant-system"}}, "tenant-system", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, Matches(tt.selector, tt.namespace))
+ })
+ }
+}
+
+func TestParse(t *testing.T) {
+ definition, err := Parse(validData())
+ require.NoError(t, err)
+ require.Len(t, definition.Objects, 1)
+ assert.Equal(t, "ConfigMap", definition.Objects[0].GetKind())
+ assert.Equal(t, "runtime-config", definition.Objects[0].GetName())
+ assert.Equal(t, "ServerSideApply", definition.Config.Apply.Mode)
+ assert.Equal(t, "Fail", definition.Config.Apply.ConflictPolicy)
+ assert.Equal(t, "Delete", definition.Config.Apply.DeletionPolicy)
+}
+
+func TestParseRejectsInvalidDeclarations(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(map[string][]byte)
+ match string
+ }{
+ {"empty include", func(data map[string][]byte) { data["config"] = []byte("targets:\n namespaces:\n include: []") }, "include"},
+ {"invalid glob", func(data map[string][]byte) { data["config"] = []byte("targets:\n namespaces:\n include: ['[']") }, "invalid namespace pattern"},
+ {"missing api version", func(data map[string][]byte) {
+ data["manifests"] = []byte("- kind: ConfigMap\n metadata:\n name: invalid")
+ }, "apiVersion"},
+ {"resource version", func(data map[string][]byte) {
+ data["manifests"] = []byte("- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: invalid\n resourceVersion: '1'")
+ }, "resourceVersion"},
+ {"owner reference", func(data map[string][]byte) {
+ data["manifests"] = []byte("- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: invalid\n ownerReferences: []")
+ }, "ownerReferences"},
+ {"status", func(data map[string][]byte) {
+ data["manifests"] = []byte("- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: invalid\n status: {}")
+ }, "status is forbidden"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ data := validData()
+ tt.mutate(data)
+ _, err := Parse(data)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.match)
+ })
+ }
+}
+
+func TestParseBundle(t *testing.T) {
+ definition, err := Parse(map[string][]byte{
+ "config": []byte("security:\n allowClusterScoped: true"),
+ "manifests": []byte(`
+- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: runtime-config
+ namespace: tenant-a
+ data:
+ mode: production
+- apiVersion: v1
+ kind: Namespace
+ metadata:
+ name: shared
+`),
+ })
+ require.NoError(t, err)
+ require.Len(t, definition.Objects, 2)
+ assert.Equal(t, "ConfigMap", definition.Objects[0].GetKind())
+ assert.Equal(t, "tenant-a", definition.Objects[0].GetNamespace())
+ assert.Equal(t, "Namespace", definition.Objects[1].GetKind())
+}
+
+func TestParseRejectsInvalidBundle(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string][]byte
+ want string
+ }{
+ {"missing manifests", map[string][]byte{"config": []byte("{}"), "manifest": []byte("metadata:\n name: one")}, "data.manifests is required"},
+ {"empty bundle", map[string][]byte{"config": []byte("{}"), "manifests": []byte("[]")}, "at least one"},
+ {"missing api version", map[string][]byte{"config": []byte("{}"), "manifests": []byte("- kind: ConfigMap\n metadata:\n name: one")}, "apiVersion"},
+ {"runtime metadata", map[string][]byte{"config": []byte("{}"), "manifests": []byte("- apiVersion: v1\n kind: ConfigMap\n metadata:\n name: one\n uid: old")}, "uid"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := Parse(tt.data)
+ require.ErrorContains(t, err, tt.want)
+ })
+ }
+}
+
+func validData() map[string][]byte {
+ return map[string][]byte{
+ "config": []byte(`targets:
+ namespaces:
+ include: ["tenant-*"]
+apply:
+ mode: ServerSideApply
+`),
+ "manifests": []byte(`- apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: runtime-config
+ data:
+ mode: production
+`),
+ }
+}
diff --git a/apps/rlark/pkg/distribution/store.go b/apps/rlark/pkg/distribution/store.go
new file mode 100644
index 0000000..7e7a1e3
--- /dev/null
+++ b/apps/rlark/pkg/distribution/store.go
@@ -0,0 +1,110 @@
+package distribution
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "slices"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type ConfigMapInventoryStore struct {
+ Client client.Client
+ Mode Mode
+}
+
+func (s ConfigMapInventoryStore) Load(ctx context.Context, declaration *corev1.Secret) (Inventory, error) {
+ raw := declaration.Annotations[InventoryAnnotation]
+ if raw == "" {
+ var status corev1.ConfigMap
+ if err := s.Client.Get(ctx, types.NamespacedName{Namespace: declaration.Namespace, Name: statusName(declaration)}, &status); err != nil {
+ if apierrors.IsNotFound(err) {
+ return Inventory{}, nil
+ }
+ return Inventory{}, err
+ }
+ raw = status.Data["status.json"]
+ } else {
+ decoded, err := base64.RawStdEncoding.DecodeString(raw)
+ if err != nil {
+ return Inventory{}, fmt.Errorf("decode inventory annotation: %w", err)
+ }
+ raw = string(decoded)
+ }
+ var inventory Inventory
+ if err := json.Unmarshal([]byte(raw), &inventory); err != nil {
+ return Inventory{}, fmt.Errorf("decode inventory: %w", err)
+ }
+ return inventory, nil
+}
+
+func (s ConfigMapInventoryStore) Save(ctx context.Context, declaration *corev1.Secret, inventory Inventory) error {
+ data, err := json.Marshal(inventory)
+ if err != nil {
+ return err
+ }
+ status := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{
+ Name: statusName(declaration),
+ Namespace: declaration.Namespace,
+ Labels: map[string]string{
+ s.Mode.OwnershipPrefix + "status": "true",
+ s.Mode.OwnershipPrefix + "definition-uid": string(declaration.UID),
+ },
+ OwnerReferences: []metav1.OwnerReference{ownerReference(declaration)},
+ }, Data: map[string]string{"status.json": string(data)}}
+ var existing corev1.ConfigMap
+ err = s.Client.Get(ctx, client.ObjectKeyFromObject(status), &existing)
+ if apierrors.IsNotFound(err) {
+ if err := s.Client.Create(ctx, status); err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ } else {
+ status.ResourceVersion = existing.ResourceVersion
+ if err := s.Client.Update(ctx, status); err != nil {
+ return err
+ }
+ }
+
+ current := &corev1.Secret{}
+ if err := s.Client.Get(ctx, client.ObjectKeyFromObject(declaration), current); err != nil {
+ return err
+ }
+ if current.UID != declaration.UID {
+ return fmt.Errorf("declaration UID changed")
+ }
+ base := current.DeepCopy()
+ if current.Annotations == nil {
+ current.Annotations = map[string]string{}
+ }
+ current.Annotations[InventoryAnnotation] = base64.RawStdEncoding.EncodeToString(data)
+ return s.Client.Patch(ctx, current, client.MergeFrom(base))
+}
+
+func (s ConfigMapInventoryStore) Delete(ctx context.Context, declaration *corev1.Secret) error {
+ status := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: statusName(declaration), Namespace: declaration.Namespace}}
+ if err := s.Client.Delete(ctx, status); client.IgnoreNotFound(err) != nil {
+ return err
+ }
+ current := &corev1.Secret{}
+ if err := s.Client.Get(ctx, client.ObjectKeyFromObject(declaration), current); err != nil {
+ return client.IgnoreNotFound(err)
+ }
+ base := current.DeepCopy()
+ delete(current.Annotations, InventoryAnnotation)
+ if slices.EqualFunc(base.Finalizers, current.Finalizers, func(a, b string) bool { return a == b }) && len(base.Annotations) == len(current.Annotations) {
+ return nil
+ }
+ return s.Client.Patch(ctx, current, client.MergeFrom(base))
+}
+
+func statusName(declaration *corev1.Secret) string {
+ return declaration.Name + "-status"
+}
diff --git a/apps/rlark/pkg/distribution/store_test.go b/apps/rlark/pkg/distribution/store_test.go
new file mode 100644
index 0000000..96ae1af
--- /dev/null
+++ b/apps/rlark/pkg/distribution/store_test.go
@@ -0,0 +1,82 @@
+package distribution
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigMapInventoryStoreSaveLoadAndDelete(t *testing.T) {
+ ctx := context.Background()
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ declaration := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "definition", Namespace: "platform", UID: "definition-uid", Finalizers: []string{ReplicationMode.CleanupFinalizer}}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration).Build()
+ store := ConfigMapInventoryStore{Client: c, Mode: ReplicationMode}
+ want := Inventory{Phase: "Applied", Items: []InventoryItem{{Version: "v1", Resource: "configmaps", Namespace: "tenant-a", Name: "runtime-config", UID: "local-uid"}}}
+
+ require.NoError(t, store.Save(ctx, declaration, want))
+ current := &corev1.Secret{}
+ require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(declaration), current))
+ assert.NotEmpty(t, current.Annotations[InventoryAnnotation])
+ status := &corev1.ConfigMap{}
+ require.NoError(t, c.Get(ctx, client.ObjectKey{Namespace: "platform", Name: "definition-status"}, status))
+ assert.Equal(t, "true", status.Labels["replication.rlinf.io/status"])
+ assert.Equal(t, "definition-uid", status.Labels["replication.rlinf.io/definition-uid"])
+
+ got, err := store.Load(ctx, current)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+
+ require.NoError(t, store.Delete(ctx, current))
+ err = c.Get(ctx, client.ObjectKey{Namespace: "platform", Name: "definition-status"}, status)
+ assert.True(t, apierrors.IsNotFound(err))
+ require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(declaration), current))
+ assert.Empty(t, current.Annotations[InventoryAnnotation])
+}
+
+func TestConfigMapInventoryStoreLoadsStatusFallback(t *testing.T) {
+ ctx := context.Background()
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ declaration := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "definition", Namespace: "platform", UID: "definition-uid"}}
+ want := Inventory{Phase: "Applied"}
+ raw, err := json.Marshal(want)
+ require.NoError(t, err)
+ status := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "definition-status", Namespace: "platform"}, Data: map[string]string{"status.json": string(raw)}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration, status).Build()
+
+ got, err := (ConfigMapInventoryStore{Client: c, Mode: ReplicationMode}).Load(ctx, declaration)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestConfigMapInventoryStoreRejectsCorruptOrStaleInventory(t *testing.T) {
+ ctx := context.Background()
+ scheme := runtime.NewScheme()
+ require.NoError(t, corev1.AddToScheme(scheme))
+ declaration := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "definition", Namespace: "platform", UID: "new-uid", Annotations: map[string]string{InventoryAnnotation: "not-base64"}}}
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(declaration).Build()
+ store := ConfigMapInventoryStore{Client: c, Mode: ReplicationMode}
+ _, err := store.Load(ctx, declaration)
+ require.ErrorContains(t, err, "decode inventory annotation")
+
+ delete(declaration.Annotations, InventoryAnnotation)
+ raw, err := json.Marshal(Inventory{})
+ require.NoError(t, err)
+ declaration.Annotations[InventoryAnnotation] = base64.RawStdEncoding.EncodeToString(raw)
+ stale := declaration.DeepCopy()
+ stale.UID = "old-uid"
+ require.ErrorContains(t, store.Save(ctx, stale, Inventory{}), "UID changed")
+}
diff --git a/apps/rlark/pkg/distribution/types.go b/apps/rlark/pkg/distribution/types.go
new file mode 100644
index 0000000..77553ff
--- /dev/null
+++ b/apps/rlark/pkg/distribution/types.go
@@ -0,0 +1,130 @@
+package distribution
+
+import (
+ "context"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/types"
+)
+
+const (
+ ReplicationSecretType corev1.SecretType = "rlinf.io/resource-replication"
+ DeliverySecretType corev1.SecretType = "rlinf.io/resource-delivery"
+
+ InventoryAnnotation = "distribution.rlinf.io/inventory"
+)
+
+type Mode struct {
+ SecretType corev1.SecretType
+ AllowClusterScoped bool
+ FieldManagerPrefix string
+ OwnershipPrefix string
+ CleanupFinalizer string
+}
+
+var ReplicationMode = Mode{
+ SecretType: ReplicationSecretType,
+ FieldManagerPrefix: "rlark-replication-",
+ OwnershipPrefix: "replication.rlinf.io/",
+ CleanupFinalizer: "replication.rlinf.io/control-plane-cleanup",
+}
+
+var DeliveryMode = Mode{
+ SecretType: DeliverySecretType,
+ AllowClusterScoped: true,
+ FieldManagerPrefix: "rlark-delivery-",
+ OwnershipPrefix: "delivery.rlinf.io/",
+ CleanupFinalizer: "delivery.rlinf.io/agent-cleanup",
+}
+
+type GlobSelector struct {
+ Include []string `json:"include" yaml:"include"`
+ Exclude []string `json:"exclude,omitempty" yaml:"exclude,omitempty"`
+}
+
+type ApplyPolicy struct {
+ Mode string `json:"mode" yaml:"mode"`
+ ConflictPolicy string `json:"conflictPolicy" yaml:"conflictPolicy"`
+ DeletionPolicy string `json:"deletionPolicy" yaml:"deletionPolicy"`
+ AdoptExisting bool `json:"adoptExisting,omitempty" yaml:"adoptExisting,omitempty"`
+}
+
+type Config struct {
+ Targets struct {
+ Namespaces *GlobSelector `json:"namespaces,omitempty" yaml:"namespaces,omitempty"`
+ } `json:"targets,omitempty" yaml:"targets,omitempty"`
+ IncludeDefinitionNamespace bool `json:"includeDefinitionNamespace,omitempty" yaml:"includeDefinitionNamespace,omitempty"`
+ RequireTargetLabels map[string]string `json:"requireTargetNamespaceLabels,omitempty" yaml:"requireTargetNamespaceLabels,omitempty"`
+ Security struct {
+ AllowClusterScoped bool `json:"allowClusterScoped,omitempty" yaml:"allowClusterScoped,omitempty"`
+ } `json:"security,omitempty" yaml:"security,omitempty"`
+ Apply ApplyPolicy `json:"apply" yaml:"apply"`
+}
+
+type Definition struct {
+ Config Config
+ Objects []*unstructured.Unstructured
+}
+
+type NamespaceIdentity struct {
+ Name string
+ UID types.UID
+ Labels map[string]string
+ Phase corev1.NamespacePhase
+}
+
+type NamespaceResolver interface {
+ List(context.Context) ([]NamespaceIdentity, error)
+}
+
+type Policy interface {
+ Validate(context.Context, *corev1.Secret, *meta.RESTMapping, NamespaceIdentity, *unstructured.Unstructured, ApplyPolicy) error
+}
+
+type PolicyFunc func(context.Context, *corev1.Secret, *meta.RESTMapping, NamespaceIdentity, *unstructured.Unstructured, ApplyPolicy) error
+
+func (f PolicyFunc) Validate(ctx context.Context, declaration *corev1.Secret, mapping *meta.RESTMapping, target NamespaceIdentity, object *unstructured.Unstructured, apply ApplyPolicy) error {
+ return f(ctx, declaration, mapping, target, object, apply)
+}
+
+type Inventory struct {
+ ObservedDeclarationResourceVersion string `json:"observedDeclarationResourceVersion,omitempty"`
+ ObservedRevision string `json:"observedRevision,omitempty"`
+ Phase string `json:"phase,omitempty"`
+ Message string `json:"message,omitempty"`
+ Items []InventoryItem `json:"items,omitempty"`
+}
+
+type InventoryItem struct {
+ Group string `json:"group,omitempty"`
+ Version string `json:"version"`
+ Resource string `json:"resource"`
+ Namespace string `json:"namespace,omitempty"`
+ NamespaceUID types.UID `json:"namespaceUID,omitempty"`
+ Name string `json:"name"`
+ UID types.UID `json:"uid,omitempty"`
+}
+
+type InventoryStore interface {
+ Load(context.Context, *corev1.Secret) (Inventory, error)
+ Save(context.Context, *corev1.Secret, Inventory) error
+ Delete(context.Context, *corev1.Secret) error
+}
+
+type NoopPolicy struct{}
+
+func (NoopPolicy) Validate(context.Context, *corev1.Secret, *meta.RESTMapping, NamespaceIdentity, *unstructured.Unstructured, ApplyPolicy) error {
+ return nil
+}
+
+type Result struct {
+ Inventory Inventory
+ Requeue bool
+}
+
+func ownerReference(secret *corev1.Secret) metav1.OwnerReference {
+ return *metav1.NewControllerRef(secret, corev1.SchemeGroupVersion.WithKind("Secret"))
+}
diff --git a/apps/rlark/pkg/gateway/api_reference.go b/apps/rlark/pkg/gateway/api_reference.go
new file mode 100644
index 0000000..abc8aa8
--- /dev/null
+++ b/apps/rlark/pkg/gateway/api_reference.go
@@ -0,0 +1,164 @@
+package gateway
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+type localizedText struct {
+ ZH string `json:"zh"`
+ EN string `json:"en"`
+}
+
+type apiReferenceEndpoint struct {
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Description localizedText `json:"description"`
+ Example map[string]any `json:"example"`
+}
+
+type apiReferenceSection struct {
+ ID string `json:"id"`
+ Title localizedText `json:"title"`
+ Description localizedText `json:"description"`
+ Endpoints []apiReferenceEndpoint `json:"endpoints"`
+}
+
+type apiReferenceResponse struct {
+ Title localizedText `json:"title"`
+ Description localizedText `json:"description"`
+ Sections []apiReferenceSection `json:"sections"`
+}
+
+func (g *Gateway) handleAPIReference(c *gin.Context) {
+ c.JSON(http.StatusOK, apiReferenceResponse{
+ Title: localizedText{ZH: "接口参考", EN: "API Reference"},
+ Description: localizedText{
+ ZH: "Gateway 提供的认证、集群与工作负载资源 API。",
+ EN: "Authentication, cluster, and workload resource APIs exposed by Gateway.",
+ },
+ Sections: []apiReferenceSection{
+ {
+ ID: "overview",
+ Title: localizedText{ZH: "概览", EN: "Overview"},
+ Description: localizedText{
+ ZH: "工作流、任务、子任务、节点和 Pod 使用 /api/v1/rlinf.io/v1alpha1 资源路径。带命名空间的资源需要提供 namespace 查询参数。",
+ EN: "Workflows, jobs, tasks, nodes, and pods use the /api/v1/rlinf.io/v1alpha1 resource path. Namespaced resources require the namespace query parameter.",
+ },
+ },
+ {
+ ID: "authentication",
+ Title: localizedText{ZH: "认证", EN: "Authentication"},
+ Description: localizedText{
+ ZH: "调用登录接口获取 JWT。除登录接口外,请在请求中携带 Authorization: Bearer 。",
+ EN: "Obtain a JWT from the login endpoint. Send Authorization: Bearer with every request except login.",
+ },
+ Endpoints: []apiReferenceEndpoint{
+ {
+ Method: "POST",
+ Path: "/api/v1/auth/login",
+ Description: localizedText{
+ ZH: "使用用户名和密码登录",
+ EN: "Log in with a username and password",
+ },
+ Example: map[string]any{"ok": true, "role": "user", "token": "", "expiresAt": "2026-09-23T18:00:00Z"},
+ },
+ },
+ },
+ {
+ ID: "clusters",
+ Title: localizedText{ZH: "集群", EN: "Clusters"},
+ Description: localizedText{ZH: "查询已连接到控制平面的数据面集群。", EN: "List data-plane clusters connected to the control plane."},
+ Endpoints: []apiReferenceEndpoint{
+ {
+ Method: "GET",
+ Path: "/api/v1/clusters",
+ Description: localizedText{ZH: "查询集群列表", EN: "List clusters"},
+ Example: map[string]any{"data": []any{map[string]any{"id": "cluster-beijing", "name": "Beijing GPU Cluster", "status": "Ready"}}},
+ },
+ {
+ Method: "GET",
+ Path: "/api/v1/clusters/{cluster_id}",
+ Description: localizedText{ZH: "查询集群详情", EN: "Get cluster details"},
+ Example: map[string]any{"data": map[string]any{"id": "cluster-beijing", "name": "Beijing GPU Cluster", "status": "Ready"}},
+ },
+ },
+ },
+ resourceReferenceSection("nodes", "节点", "Nodes", "查询集群中的节点资源;节点写操作仅允许管理员执行。", "List cluster nodes; node write operations require an administrator.", "Node", "NodeList"),
+ resourceReferenceSection("workflows", "工作流", "Workflows", "创建和管理由多个任务组成的工作流。", "Create and manage workflows composed of multiple jobs.", "Workflow", "WorkflowList"),
+ {
+ ID: "jobs",
+ Title: localizedText{ZH: "任务", EN: "Jobs"},
+ Description: localizedText{ZH: "创建、查询和控制任务,并读取任务运行日志。", EN: "Create, inspect, and control jobs, and read their runtime logs."},
+ Endpoints: append(resourceEndpoints("jobs", "任务", "job", "Job", "JobList"), apiReferenceEndpoint{
+ Method: "GET",
+ Path: "/api/v1/rlinf.io/v1alpha1/jobs/{name}/logs",
+ Description: localizedText{ZH: "查看任务日志", EN: "Get job logs"},
+ Example: map[string]any{"source": "pod", "pods": []any{map[string]any{"taskName": "training-job-worker", "podName": "training-job-worker-0", "phase": "Running", "logs": "Worker started"}}},
+ }),
+ },
+ resourceReferenceSection("tasks", "子任务", "Tasks", "查询和管理任务生成的子任务。", "List and manage tasks generated by jobs.", "Task", "TaskList"),
+ {
+ ID: "pods",
+ Title: localizedText{ZH: "Pod", EN: "Pods"},
+ Description: localizedText{ZH: "查询子任务对应的 Pod 运行实例和事件。", EN: "Inspect pod instances and events created for tasks."},
+ Endpoints: []apiReferenceEndpoint{
+ {
+ Method: "GET",
+ Path: "/api/v1/rlinf.io/v1alpha1/pods?namespace={namespace}",
+ Description: localizedText{ZH: "查询 Pod 列表", EN: "List pods"},
+ Example: map[string]any{"apiVersion": "v1", "kind": "PodList", "items": []any{}},
+ },
+ {
+ Method: "GET",
+ Path: "/api/v1/rlinf.io/v1alpha1/pods/{name}/events?namespace={namespace}",
+ Description: localizedText{ZH: "查询 Pod 事件", EN: "List pod events"},
+ Example: map[string]any{"events": []any{}},
+ },
+ },
+ },
+ },
+ })
+}
+
+func resourceReferenceSection(id, zhTitle, enTitle, zhDescription, enDescription, kind, listKind string) apiReferenceSection {
+ return apiReferenceSection{
+ ID: id,
+ Title: localizedText{ZH: zhTitle, EN: enTitle},
+ Description: localizedText{ZH: zhDescription, EN: enDescription},
+ Endpoints: resourceEndpoints(id, zhTitle, stringsToLower(enTitle), kind, listKind),
+ }
+}
+
+func resourceEndpoints(resource, zhName, enName, kind, listKind string) []apiReferenceEndpoint {
+ path := "/api/v1/rlinf.io/v1alpha1/" + resource
+ query := ""
+ if resource == "nodes" || resource == "tasks" {
+ query = "?namespace={namespace}"
+ }
+ return []apiReferenceEndpoint{
+ {
+ Method: "GET",
+ Path: path + query,
+ Description: localizedText{ZH: "查询" + zhName + "列表", EN: "List " + enName},
+ Example: map[string]any{"apiVersion": "rlinf.io/v1alpha1", "kind": listKind, "items": []any{}},
+ },
+ {
+ Method: "POST",
+ Path: path + query,
+ Description: localizedText{ZH: "创建" + zhName, EN: "Create " + enName},
+ Example: map[string]any{"apiVersion": "rlinf.io/v1alpha1", "kind": kind, "metadata": map[string]any{"name": "example"}},
+ },
+ }
+}
+
+func stringsToLower(value string) string {
+ if value == "Nodes" {
+ return "nodes"
+ }
+ if value == "Workflows" {
+ return "workflows"
+ }
+ return "tasks"
+}
diff --git a/apps/rlark/pkg/gateway/api_reference_test.go b/apps/rlark/pkg/gateway/api_reference_test.go
new file mode 100644
index 0000000..af8e1ba
--- /dev/null
+++ b/apps/rlark/pkg/gateway/api_reference_test.go
@@ -0,0 +1,52 @@
+package gateway
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestAPIReferenceReturnsGroupedRegisteredEndpoints(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.GET("/api/v1/api-reference", (&Gateway{}).handleAPIReference)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/api-reference", nil)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+
+ var body apiReferenceResponse
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Title.ZH == "" || body.Title.EN == "" || len(body.Sections) == 0 {
+ t.Fatalf("incomplete API reference: %#v", body)
+ }
+
+ registered := map[string]struct{}{}
+ registeredRouter := gin.New()
+ (&Gateway{}).RegisterRoutes(registeredRouter)
+ for _, route := range registeredRouter.Routes() {
+ registered[route.Method+" "+openAPIPath(route.Path)] = struct{}{}
+ }
+ for _, section := range body.Sections {
+ for _, endpoint := range section.Endpoints {
+ path := endpoint.Path
+ for i, char := range path {
+ if char == '?' {
+ path = path[:i]
+ break
+ }
+ }
+ if _, ok := registered[endpoint.Method+" "+path]; !ok {
+ t.Errorf("API reference endpoint is not registered: %s %s", endpoint.Method, path)
+ }
+ }
+ }
+}
diff --git a/apps/rlark/pkg/gateway/auth_handler.go b/apps/rlark/pkg/gateway/auth_handler.go
index d9aee3f..423eb32 100644
--- a/apps/rlark/pkg/gateway/auth_handler.go
+++ b/apps/rlark/pkg/gateway/auth_handler.go
@@ -2,11 +2,14 @@ package gateway
import (
"context"
+ "crypto/subtle"
"fmt"
"net/http"
"strings"
+ "time"
"github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -16,6 +19,11 @@ type loginRequest struct {
Password string `json:"password"`
}
+type authClaims struct {
+ Role string `json:"role"`
+ jwt.RegisteredClaims
+}
+
func (g *Gateway) handleLogin(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -48,14 +56,31 @@ func (g *Gateway) handleLogin(c *gin.Context) {
return
}
- if req.Password != expectedPW {
+ if subtle.ConstantTimeCompare([]byte(req.Password), []byte(expectedPW)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
+ token, expiresAt, err := g.issueJWT(req.Username, role)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue access token"})
+ return
+ }
+
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: "rlark_access_token",
+ Value: token,
+ Path: "/api/",
+ HttpOnly: true,
+ Secure: c.Request.TLS != nil,
+ SameSite: http.SameSiteStrictMode,
+ Expires: expiresAt,
+ })
c.JSON(http.StatusOK, gin.H{
- "ok": true,
- "role": role,
+ "ok": true,
+ "role": role,
+ "token": token,
+ "expiresAt": expiresAt.UTC().Format(time.RFC3339),
})
}
@@ -65,12 +90,48 @@ func (g *Gateway) readUIAuthSecret() (adminPW, userPW string, err error) {
}
ctx := context.Background()
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
+ secret, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
if err != nil {
return "", "", err
}
- adminPW = strings.TrimSpace(string(secret.Data["admin-password"]))
- userPW = strings.TrimSpace(string(secret.Data["user-password"]))
+ adminPW = strings.TrimSpace(string(secret.Data[common.UIAuthAdminPasswordKey]))
+ userPW = strings.TrimSpace(string(secret.Data[common.UIAuthUserPasswordKey]))
return adminPW, userPW, nil
}
+
+func (g *Gateway) loadJWTSigningKey(ctx context.Context) error {
+ secret, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
+ if err != nil {
+ return err
+ }
+ key := secret.Data[common.UIAuthJWTSigningKey]
+ if len(key) < 32 {
+ return fmt.Errorf("%s must contain at least 32 bytes", common.UIAuthJWTSigningKey)
+ }
+ g.jwtSigningKey = append([]byte(nil), key...)
+ return nil
+}
+
+func (g *Gateway) issueJWT(username, role string) (string, time.Time, error) {
+ if len(g.jwtSigningKey) < 32 {
+ return "", time.Time{}, fmt.Errorf("JWT signing key is not initialized")
+ }
+ ttl := g.config.JWTTokenTTL
+ if ttl <= 0 {
+ ttl = 8 * time.Hour
+ }
+ now := time.Now()
+ expiresAt := now.Add(ttl)
+ claims := authClaims{
+ Role: role,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: "rlark-gateway",
+ Subject: username,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(expiresAt),
+ },
+ }
+ token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(g.jwtSigningKey)
+ return token, expiresAt, err
+}
diff --git a/apps/rlark/pkg/gateway/auth_handler_test.go b/apps/rlark/pkg/gateway/auth_handler_test.go
new file mode 100644
index 0000000..3163a12
--- /dev/null
+++ b/apps/rlark/pkg/gateway/auth_handler_test.go
@@ -0,0 +1,183 @@
+package gateway
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+func TestLoginIssuesJWTAndProtectedRoutesValidateIt(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ key := []byte("01234567890123456789012345678901")
+ g := &Gateway{
+ config: Config{
+ KubeClientConfig: configs.KubernetesClientConfig{Namespace: "test"},
+ JWTTokenTTL: time.Hour,
+ },
+ rawClient: fake.NewSimpleClientset(&corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: common.UIAuthSecretName, Namespace: "test"},
+ Data: map[string][]byte{
+ common.UIAuthAdminPasswordKey: []byte("admin-password"),
+ common.UIAuthUserPasswordKey: []byte("user-password"),
+ common.UIAuthJWTSigningKey: key,
+ },
+ }),
+ jwtSigningKey: key,
+ }
+
+ router := gin.New()
+ router.POST("/api/v1/auth/login", g.handleLogin)
+ router.GET("/api/v1/protected", g.requireJWT(), func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"subject": c.GetString(authSubjectKey), "role": c.GetString(authRoleKey)})
+ })
+
+ login := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"user","password":"user-password"}`))
+ login.Header.Set("Content-Type", "application/json")
+ loginResponse := httptest.NewRecorder()
+ router.ServeHTTP(loginResponse, login)
+ if loginResponse.Code != http.StatusOK {
+ t.Fatalf("login status = %d, body = %s", loginResponse.Code, loginResponse.Body.String())
+ }
+ var body struct {
+ Token string `json:"token"`
+ Role string `json:"role"`
+ }
+ if err := json.Unmarshal(loginResponse.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Token == "" || body.Role != "user" {
+ t.Fatalf("unexpected login response: %#v", body)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/protected", nil)
+ request.Header.Set("Authorization", "Bearer "+body.Token)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"subject":"user"`) {
+ t.Fatalf("protected status = %d, body = %s", response.Code, response.Body.String())
+ }
+}
+
+func TestJWTMiddlewareRejectsMissingExpiredAndWrongAlgorithmTokens(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ key := []byte("01234567890123456789012345678901")
+ g := &Gateway{jwtSigningKey: key}
+ router := gin.New()
+ router.GET("/protected", g.requireJWT(), func(c *gin.Context) { c.Status(http.StatusOK) })
+
+ expiredClaims := authClaims{
+ Role: "user",
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: "rlark-gateway", Subject: "user", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
+ },
+ }
+ expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ none := jwt.NewWithClaims(jwt.SigningMethodNone, expiredClaims)
+ unsigned, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for name, authorization := range map[string]string{
+ "missing": "",
+ "malformed header": "Token abc",
+ "expired": "Bearer " + expired,
+ "wrong algorithm": "Bearer " + unsigned,
+ } {
+ t.Run(name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ request.Header.Set("Authorization", authorization)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", response.Code)
+ }
+ })
+ }
+}
+
+func TestAdminMiddlewareEnforcesRole(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ key := []byte("01234567890123456789012345678901")
+ g := &Gateway{config: Config{JWTTokenTTL: time.Hour}, jwtSigningKey: key}
+ router := gin.New()
+ router.GET("/admin", g.requireJWT(), requireAdmin(), func(c *gin.Context) { c.Status(http.StatusOK) })
+
+ for role, want := range map[string]int{"user": http.StatusForbidden, "admin": http.StatusOK} {
+ t.Run(role, func(t *testing.T) {
+ token, _, err := g.issueJWT(role, role)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ request.Header.Set("Authorization", "Bearer "+token)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != want {
+ t.Fatalf("status = %d, want %d", response.Code, want)
+ }
+ })
+ }
+}
+
+func TestRegisteredRoutePermissions(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ key := []byte("01234567890123456789012345678901")
+ g := &Gateway{config: Config{JWTTokenTTL: time.Hour}, jwtSigningKey: key, rawClient: fake.NewSimpleClientset()}
+ router := gin.New()
+ g.RegisterRoutes(router)
+ userToken, _, err := g.issueJWT("user", "user")
+ if err != nil {
+ t.Fatal(err)
+ }
+ adminToken, _, err := g.issueJWT("admin", "admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ name string
+ method string
+ path string
+ user int
+ admin int
+ }{
+ {name: "user route", method: http.MethodGet, path: "/api/v1/rlinf.io/v1alpha1/jobs/anything/metrics", user: http.StatusNotImplemented, admin: http.StatusNotImplemented},
+ {name: "shared system config read", method: http.MethodGet, path: "/api/v1/system-config", user: http.StatusOK, admin: http.StatusOK},
+ {name: "admin system config write", method: http.MethodPut, path: "/api/v1/system-config", user: http.StatusForbidden, admin: http.StatusBadRequest},
+ {name: "shared ssh keys", method: http.MethodGet, path: "/api/v1/ssh-user-keys", user: http.StatusOK, admin: http.StatusOK},
+ {name: "admin write on shared resource", method: http.MethodPost, path: "/api/v1/storage/storageclass", user: http.StatusForbidden, admin: http.StatusBadRequest},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ for role, tc := range map[string]struct {
+ token string
+ want int
+ }{"user": {userToken, test.user}, "admin": {adminToken, test.admin}} {
+ t.Run(role, func(t *testing.T) {
+ request := httptest.NewRequest(test.method, test.path, nil)
+ request.Header.Set("Authorization", "Bearer "+tc.token)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != tc.want {
+ t.Fatalf("status = %d, want %d, body = %s", response.Code, tc.want, response.Body.String())
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/apps/rlark/pkg/gateway/auth_middleware.go b/apps/rlark/pkg/gateway/auth_middleware.go
new file mode 100644
index 0000000..14a6007
--- /dev/null
+++ b/apps/rlark/pkg/gateway/auth_middleware.go
@@ -0,0 +1,57 @@
+package gateway
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const (
+ authSubjectKey = "auth.subject"
+ authRoleKey = "auth.role"
+)
+
+func (g *Gateway) requireJWT() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ tokenString := ""
+ if header := c.GetHeader("Authorization"); header != "" {
+ parts := strings.SplitN(header, " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
+ return
+ }
+ tokenString = parts[1]
+ } else if cookie, err := c.Cookie("rlark_access_token"); err == nil {
+ tokenString = cookie
+ }
+ if tokenString == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
+ return
+ }
+
+ claims := &authClaims{}
+ token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) {
+ return g.jwtSigningKey, nil
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer("rlark-gateway"))
+ if err != nil || !token.Valid || claims.Subject == "" || (claims.Role != "admin" && claims.Role != "user") {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired access token"})
+ return
+ }
+
+ c.Set(authSubjectKey, claims.Subject)
+ c.Set(authRoleKey, claims.Role)
+ c.Next()
+ }
+}
+
+func requireAdmin() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.GetString(authRoleKey) != "admin" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "administrator access required"})
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/apps/rlark/pkg/gateway/cert_handler.go b/apps/rlark/pkg/gateway/cert_handler.go
index 4641b44..4bdf14a 100644
--- a/apps/rlark/pkg/gateway/cert_handler.go
+++ b/apps/rlark/pkg/gateway/cert_handler.go
@@ -79,10 +79,11 @@ func (g *Gateway) storeAgentCertSecret(ctx context.Context, clusterID string, ca
return fmt.Errorf("raw kubernetes client not initialized")
}
secretName := "rlark-agent-cert-" + clusterID
+ namespace := g.managementNamespace()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
- Namespace: common.SecretNamespace,
+ Namespace: namespace,
Labels: map[string]string{
common.AgentCertLabelKey: common.AgentCertLabelValue,
},
@@ -96,9 +97,9 @@ func (g *Gateway) storeAgentCertSecret(ctx context.Context, clusterID string, ca
"tls.key": agentKey,
},
}
- _, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
+ _, err := g.rawClient.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
- _, updateErr := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Update(ctx, secret, metav1.UpdateOptions{})
+ _, updateErr := g.rawClient.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
if updateErr != nil {
return fmt.Errorf("create/update secret %s: %w", secretName, updateErr)
}
@@ -119,7 +120,7 @@ func (g *Gateway) handleListAgentCerts(c *gin.Context) {
return
}
ctx := c.Request.Context()
- secretList, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).List(ctx, metav1.ListOptions{
+ secretList, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).List(ctx, metav1.ListOptions{
LabelSelector: labels.FormatLabels(map[string]string{common.AgentCertLabelKey: common.AgentCertLabelValue}),
})
if err != nil {
@@ -153,7 +154,7 @@ func (g *Gateway) handleGetAgentCert(c *gin.Context) {
}
ctx := c.Request.Context()
secretName := "rlark-agent-cert-" + clusterID
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, secretName, metav1.GetOptions{})
+ secret, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Get(ctx, secretName, metav1.GetOptions{})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("secret not found: %v", err)})
return
@@ -174,12 +175,13 @@ func (g *Gateway) getKCPAdminCerts() (certPEM, keyPEM, caPEM []byte, err error)
ctx := context.Background()
- adminSecret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.AdminCertSecretName, metav1.GetOptions{})
+ namespace := g.managementNamespace()
+ adminSecret, err := g.rawClient.CoreV1().Secrets(namespace).Get(ctx, common.AdminCertSecretName, metav1.GetOptions{})
if err != nil {
return nil, nil, nil, fmt.Errorf("get secret %s: %w", common.AdminCertSecretName, err)
}
- caSecret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.TLSCASecretName, metav1.GetOptions{})
+ caSecret, err := g.rawClient.CoreV1().Secrets(namespace).Get(ctx, common.TLSCASecretName, metav1.GetOptions{})
if err != nil {
return nil, nil, nil, fmt.Errorf("get secret %s: %w", common.TLSCASecretName, err)
}
diff --git a/apps/rlark/pkg/gateway/clusters_handler.go b/apps/rlark/pkg/gateway/clusters_handler.go
index b15ab19..fed5e3a 100644
--- a/apps/rlark/pkg/gateway/clusters_handler.go
+++ b/apps/rlark/pkg/gateway/clusters_handler.go
@@ -3,6 +3,7 @@ package gateway
import (
"net/http"
"sort"
+ "strings"
"github.com/gin-gonic/gin"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -39,6 +40,43 @@ type ClusterDetail struct {
Nodes []rlarkv1alpha1.Node `json:"nodes"`
}
+// nodeCategories 返回节点所属的分类集合,支持两种 label 形式:
+// 1. 枚举值:rlark.io/node-category = "cloud,edge"(逗号分隔)
+// 2. 布尔位:rlark.io/node-category-cloud = "true"(前端批量编辑使用)
+// 两者任一命中即返回对应分类,去重后返回。
+func nodeCategories(node rlarkv1alpha1.Node) []rlarkv1alpha1.NodeCategory {
+ seen := make(map[rlarkv1alpha1.NodeCategory]struct{})
+ var result []rlarkv1alpha1.NodeCategory
+ add := func(c rlarkv1alpha1.NodeCategory) {
+ if _, ok := seen[c]; !ok {
+ seen[c] = struct{}{}
+ result = append(result, c)
+ }
+ }
+ if v := node.Labels[rlarkv1alpha1.LabelNodeCategory]; v != "" {
+ for _, part := range strings.Split(v, ",") {
+ switch rlarkv1alpha1.NodeCategory(strings.TrimSpace(part)) {
+ case rlarkv1alpha1.NodeCategoryCloud:
+ add(rlarkv1alpha1.NodeCategoryCloud)
+ case rlarkv1alpha1.NodeCategoryEdge:
+ add(rlarkv1alpha1.NodeCategoryEdge)
+ case rlarkv1alpha1.NodeCategoryRobot:
+ add(rlarkv1alpha1.NodeCategoryRobot)
+ }
+ }
+ }
+ for _, c := range []rlarkv1alpha1.NodeCategory{
+ rlarkv1alpha1.NodeCategoryCloud,
+ rlarkv1alpha1.NodeCategoryEdge,
+ rlarkv1alpha1.NodeCategoryRobot,
+ } {
+ if node.Labels[rlarkv1alpha1.LabelNodeCategory+"-"+string(c)] == "true" {
+ add(c)
+ }
+ }
+ return result
+}
+
func buildClusterInfo(clusterID string, nodes []rlarkv1alpha1.Node) ClusterInfo {
info := ClusterInfo{
ID: clusterID,
@@ -58,18 +96,20 @@ func buildClusterInfo(clusterID string, nodes []rlarkv1alpha1.Node) ClusterInfo
info.OfflineNodes++
}
- switch node.Labels[rlarkv1alpha1.LabelNodeCategory] {
- case string(rlarkv1alpha1.NodeCategoryCloud):
- info.CloudNodes++
- if model := node.Labels["rlark.io/model"]; model != "" {
- gpuModels[model] = struct{}{}
- }
- case string(rlarkv1alpha1.NodeCategoryEdge):
- info.EmbodiedNodes++
- case string(rlarkv1alpha1.NodeCategoryRobot):
- info.Robots++
- if model := node.Labels["rlark.io/model"]; model != "" {
- robotModels[model] = struct{}{}
+ for _, category := range nodeCategories(node) {
+ switch category {
+ case rlarkv1alpha1.NodeCategoryCloud:
+ info.CloudNodes++
+ if model := node.Labels["rlark.io/model"]; model != "" {
+ gpuModels[model] = struct{}{}
+ }
+ case rlarkv1alpha1.NodeCategoryEdge:
+ info.EmbodiedNodes++
+ case rlarkv1alpha1.NodeCategoryRobot:
+ info.Robots++
+ if model := node.Labels["rlark.io/model"]; model != "" {
+ robotModels[model] = struct{}{}
+ }
}
}
@@ -98,9 +138,11 @@ func buildClusterInfo(clusterID string, nodes []rlarkv1alpha1.Node) ClusterInfo
default:
info.Phase = "Degraded"
}
- if info.CloudNodes > 0 && info.EmbodiedNodes == 0 && info.Robots == 0 {
+
+ hybrid := info.TotalNodes != info.CloudNodes+info.EmbodiedNodes+info.Robots // 有无标签的节点则显示为混合集群
+ if info.CloudNodes > 0 && info.EmbodiedNodes == 0 && info.Robots == 0 && !hybrid {
info.Type = "Cloud"
- } else if info.CloudNodes == 0 && (info.EmbodiedNodes > 0 || info.Robots > 0) {
+ } else if info.CloudNodes == 0 && (info.EmbodiedNodes > 0 || info.Robots > 0) && !hybrid {
info.Type = "Embodied"
} else {
info.Type = "Hybrid"
diff --git a/apps/rlark/pkg/gateway/clusters_handler_test.go b/apps/rlark/pkg/gateway/clusters_handler_test.go
index c300c87..30aa1f0 100644
--- a/apps/rlark/pkg/gateway/clusters_handler_test.go
+++ b/apps/rlark/pkg/gateway/clusters_handler_test.go
@@ -42,3 +42,64 @@ func TestBuildClusterInfoFallsBackToCityLabel(t *testing.T) {
t.Fatalf("expected label city, got %q", info.Location)
}
}
+
+// 回归:批量编辑使用布尔位 label(rlark.io/node-category-cloud=true),
+// 而不是枚举值 label(rlark.io/node-category=cloud)。buildClusterInfo
+// 必须识别布尔位,否则 Type 会错判为 Hybrid。
+func TestBuildClusterInfoBooleanCategoryLabel(t *testing.T) {
+ nodes := []rlarkv1alpha1.Node{
+ {
+ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
+ rlarkv1alpha1.LabelClusterID: "cluster-a",
+ rlarkv1alpha1.LabelNodeCategory + "-cloud": "true",
+ }},
+ Status: rlarkv1alpha1.NodeStatus{Phase: rlarkv1alpha1.NodeOnline},
+ },
+ {
+ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
+ rlarkv1alpha1.LabelClusterID: "cluster-a",
+ rlarkv1alpha1.LabelNodeCategory + "-cloud": "true",
+ }},
+ Status: rlarkv1alpha1.NodeStatus{Phase: rlarkv1alpha1.NodeOnline},
+ },
+ }
+
+ info := buildClusterInfo("cluster-a", nodes)
+ if info.Type != "Cloud" {
+ t.Fatalf("expected Cloud, got %q (cloud=%d embodied=%d robots=%d)",
+ info.Type, info.CloudNodes, info.EmbodiedNodes, info.Robots)
+ }
+ if info.CloudNodes != 2 {
+ t.Fatalf("expected 2 cloud nodes, got %d", info.CloudNodes)
+ }
+}
+
+// 两种 label 形式同时存在时也要合并识别(去重)
+func TestBuildClusterInfoMixedCategoryLabelForms(t *testing.T) {
+ nodes := []rlarkv1alpha1.Node{
+ {
+ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
+ rlarkv1alpha1.LabelClusterID: "cluster-a",
+ rlarkv1alpha1.LabelNodeCategory: "cloud,edge",
+ }},
+ Status: rlarkv1alpha1.NodeStatus{Phase: rlarkv1alpha1.NodeOnline},
+ },
+ {
+ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
+ rlarkv1alpha1.LabelClusterID: "cluster-a",
+ rlarkv1alpha1.LabelNodeCategory + "-robot": "true",
+ }},
+ Status: rlarkv1alpha1.NodeStatus{Phase: rlarkv1alpha1.NodeOnline},
+ },
+ }
+
+ info := buildClusterInfo("cluster-a", nodes)
+ if info.Type != "Hybrid" {
+ t.Fatalf("expected Hybrid, got %q (cloud=%d embodied=%d robots=%d)",
+ info.Type, info.CloudNodes, info.EmbodiedNodes, info.Robots)
+ }
+ if info.CloudNodes != 1 || info.EmbodiedNodes != 1 || info.Robots != 1 {
+ t.Fatalf("expected 1/1/1, got %d/%d/%d",
+ info.CloudNodes, info.EmbodiedNodes, info.Robots)
+ }
+}
diff --git a/apps/rlark/pkg/gateway/config.go b/apps/rlark/pkg/gateway/config.go
index 1c586eb..f8f9773 100644
--- a/apps/rlark/pkg/gateway/config.go
+++ b/apps/rlark/pkg/gateway/config.go
@@ -1,6 +1,8 @@
package gateway
import (
+ "time"
+
"github.com/spf13/pflag"
"github.com/rlinf/rlark/apps/rlark/pkg/configs"
@@ -19,6 +21,9 @@ type Config struct {
// ServerAddress is the address of the rlark-server for certificate signing.
ServerAddress string
+
+ // JWTTokenTTL is the lifetime of a UI access token.
+ JWTTokenTTL time.Duration
}
// DefaultConfig returns a Config with sensible defaults.
@@ -26,6 +31,7 @@ func DefaultConfig() Config {
return Config{
Address: ":8080",
ServerAddress: "https://rlark-server.rlark-system.svc:8443",
+ JWTTokenTTL: 8 * time.Hour,
KubeClientConfig: configs.DefaultKubernetesClientConfig(),
}
}
@@ -35,6 +41,7 @@ func (c *Config) SetupFlags(fs *pflag.FlagSet) {
fs.StringVar(&c.Address, "addr", c.Address, "The address the API gateway binds to.")
fs.StringVar(&c.DBConfigPath, "db-config", c.DBConfigPath, "The file path to the database configuration (e.g., YAML or JSON).")
fs.StringVar(&c.ServerAddress, "server-address", c.ServerAddress, "The address of the rlark-server for certificate signing.")
+ fs.DurationVar(&c.JWTTokenTTL, "jwt-token-ttl", c.JWTTokenTTL, "The lifetime of UI JWT access tokens.")
c.KubeClientConfig.SetupFlags(fs)
}
diff --git a/apps/rlark/pkg/gateway/gateway.go b/apps/rlark/pkg/gateway/gateway.go
index dc7301a..0b44e50 100644
--- a/apps/rlark/pkg/gateway/gateway.go
+++ b/apps/rlark/pkg/gateway/gateway.go
@@ -50,6 +50,11 @@ type Gateway struct {
images map[string]imageUsage
serverTransport *http.Transport
+ jwtSigningKey []byte
+}
+
+func (g *Gateway) managementNamespace() string {
+ return g.config.KubeClientConfig.DefaultNamespace()
}
// NewGateway creates a Gateway with database-backed stores for read operations
@@ -95,6 +100,9 @@ func (g *Gateway) init(ctx context.Context) error {
if err != nil {
return fmt.Errorf("create raw Kubernetes client: %w", err)
}
+ if err := g.loadJWTSigningKey(ctx); err != nil {
+ return fmt.Errorf("load JWT signing key: %w", err)
+ }
// init pod informer/lister for cached Pod lookups (used by the
// TensorBoard proxy handler).
diff --git a/apps/rlark/pkg/gateway/handler.go b/apps/rlark/pkg/gateway/handler.go
index 15e9e94..edbe19b 100644
--- a/apps/rlark/pkg/gateway/handler.go
+++ b/apps/rlark/pkg/gateway/handler.go
@@ -1,12 +1,15 @@
package gateway
import (
+ "encoding/json"
"net/http"
+ "sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
+ rlarkiov1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
)
@@ -57,6 +60,14 @@ func (g *Gateway) parseListOptions(c *gin.Context) db.ListOptions {
}
}
+ if tsStr := c.Query("tagSelector"); tsStr != "" {
+ for _, pair := range strings.Split(tsStr, ",") {
+ if ts, err := parseTagSelector(pair); err == nil {
+ opts.TagSelector = append(opts.TagSelector, ts)
+ }
+ }
+ }
+
return opts
}
@@ -86,6 +97,14 @@ func parseLabelSelector(s string) (db.LabelSelector, error) {
return db.LabelSelector{}, strconv.ErrSyntax
}
+func parseTagSelector(s string) (db.TagSelector, error) {
+ parts := strings.SplitN(s, "=", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return db.TagSelector{}, strconv.ErrSyntax
+ }
+ return db.TagSelector{Key: parts[0], Value: parts[1]}, nil
+}
+
// --- Read handler implementations (database or Kubernetes API-backed) ---
func (g *Gateway) handleList(resource string) gin.HandlerFunc {
@@ -112,6 +131,31 @@ func (g *Gateway) handleGet(resource string) gin.HandlerFunc {
}
}
+func (g *Gateway) handleListJobs(c *gin.Context) {
+ if g.dbClient != nil {
+ if _, ok := g.stores["jobs"]; ok {
+ g.handleListDB(c, "jobs")
+ return
+ }
+ }
+ g.handleListJobsKube(c)
+}
+
+type jobTag struct {
+ Key string `json:"key"`
+ Values []string `json:"values"`
+}
+
+func (g *Gateway) handleListJobTags(c *gin.Context) {
+ if g.dbClient != nil {
+ if _, ok := g.stores["jobs"]; ok {
+ g.handleListJobTagsDB(c)
+ return
+ }
+ }
+ g.handleListJobTagsKube(c)
+}
+
// --- Database-backed read handlers ---
func (g *Gateway) handleListDB(c *gin.Context, resource string) {
@@ -130,6 +174,83 @@ func (g *Gateway) handleListDB(c *gin.Context, resource string) {
c.JSON(http.StatusOK, result)
}
+func collectJobTags(jobs []rlarkiov1alpha1.Job) []jobTag {
+ seen := make(map[string]map[string]struct{})
+ for _, job := range jobs {
+ for _, tag := range job.Spec.Tags {
+ if seen[tag.Key] == nil {
+ seen[tag.Key] = make(map[string]struct{})
+ }
+ for _, value := range tag.Values {
+ seen[tag.Key][value] = struct{}{}
+ }
+ }
+ }
+
+ tags := make([]jobTag, 0, len(seen))
+ for key, values := range seen {
+ tag := jobTag{Key: key, Values: make([]string, 0, len(values))}
+ for value := range values {
+ tag.Values = append(tag.Values, value)
+ }
+ sort.Strings(tag.Values)
+ tags = append(tags, tag)
+ }
+ sort.Slice(tags, func(i, j int) bool {
+ return tags[i].Key < tags[j].Key
+ })
+ return tags
+}
+
+func (g *Gateway) handleListJobTagsDB(c *gin.Context) {
+ store, ok := g.stores["jobs"]
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "resource not configured"})
+ return
+ }
+
+ result, err := store.List(c.Request.Context(), db.ListOptions{Namespace: c.Query("namespace")})
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ seen := make(map[string]map[string]struct{})
+ for _, item := range result.Items {
+ var job struct {
+ Spec struct {
+ Tags []jobTag `json:"tags"`
+ } `json:"spec"`
+ }
+ data, _ := json.Marshal(item)
+ if json.Unmarshal(data, &job) != nil {
+ continue
+ }
+ for _, tag := range job.Spec.Tags {
+ if seen[tag.Key] == nil {
+ seen[tag.Key] = make(map[string]struct{})
+ }
+ for _, value := range tag.Values {
+ seen[tag.Key][value] = struct{}{}
+ }
+ }
+ }
+
+ tags := make([]jobTag, 0, len(seen))
+ for key, values := range seen {
+ tag := jobTag{Key: key, Values: make([]string, 0, len(values))}
+ for value := range values {
+ tag.Values = append(tag.Values, value)
+ }
+ sort.Strings(tag.Values)
+ tags = append(tags, tag)
+ }
+ sort.Slice(tags, func(i, j int) bool {
+ return tags[i].Key < tags[j].Key
+ })
+ c.JSON(http.StatusOK, gin.H{"items": tags})
+}
+
func (g *Gateway) handleGetDB(c *gin.Context, resource string) {
store, ok := g.stores[resource]
if !ok {
diff --git a/apps/rlark/pkg/gateway/imageregistry_handler.go b/apps/rlark/pkg/gateway/imageregistry_handler.go
index 02c2280..28ccb41 100644
--- a/apps/rlark/pkg/gateway/imageregistry_handler.go
+++ b/apps/rlark/pkg/gateway/imageregistry_handler.go
@@ -6,40 +6,277 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/util/retry"
+ "sigs.k8s.io/yaml"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
)
+const (
+ imageRegistryCredentialVersion = "v1"
+ imageRegistryCreateAttempts = 3
+ imageRegistryNoTargetsPattern = "/"
+ imageRegistryNamespacePrefix = "rlark-"
+)
+
+var imageRegistryIDPattern = regexp.MustCompile(`^ir-[0-9a-f]{16}$`)
+
+type clusterSelectionMode string
+
+const (
+ clusterSelectionNone clusterSelectionMode = "None"
+ clusterSelectionSelected clusterSelectionMode = "Selected"
+ clusterSelectionAll clusterSelectionMode = "All"
+)
+
+type clusterSelection struct {
+ Mode clusterSelectionMode `json:"mode"`
+ Clusters []string `json:"clusters"`
+}
+
+type storedImageRegistryCredential struct {
+ Version string `json:"version"`
+ Name string `json:"name"`
+ Registry string `json:"registry"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ ClusterSelection clusterSelection `json:"clusterSelection"`
+}
+
type imageRegistryItem struct {
- Name string `json:"name"`
- Registry string `json:"registry"`
- Username string `json:"username"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Registry string `json:"registry"`
+ Username string `json:"username"`
+ ClusterSelection clusterSelection `json:"clusterSelection"`
}
type createImageRegistryRequest struct {
- Name string `json:"name" binding:"required"`
- Registry string `json:"registry" binding:"required"`
- Username string `json:"username" binding:"required"`
- Password string `json:"password" binding:"required"`
+ Name string `json:"name"`
+ Registry string `json:"registry"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ ClusterSelection clusterSelection `json:"clusterSelection"`
}
type updateImageRegistryRequest struct {
- Registry string `json:"registry" binding:"required"`
- Username string `json:"username" binding:"required"`
- Password string `json:"password,omitempty"`
+ Name string `json:"name"`
+ Registry string `json:"registry"`
+ Username string `json:"username"`
+ Password *string `json:"password,omitempty"`
+ ClusterSelection clusterSelection `json:"clusterSelection"`
+}
+
+func newImageRegistryID() string {
+ return "ir-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:16]
}
-func listImageRegistrySecrets(ctx context.Context, rawClient kubernetes.Interface) ([]corev1.Secret, error) {
- secretList, err := rawClient.CoreV1().Secrets(common.SecretNamespace).List(ctx, metav1.ListOptions{
- LabelSelector: labels.Set{common.ImageRegistrySecretLabel: "true"}.AsSelector().String(),
+func imageRegistryReplicationName(id string) string { return id + "-replication" }
+func imageRegistryDeliveryName(id string) string { return id + "-delivery" }
+func imageRegistryCredentialName(id string) string { return id }
+
+func parseImageRegistryID(name string) (string, bool) {
+ const suffix = "-replication"
+ if !strings.HasSuffix(name, suffix) {
+ return "", false
+ }
+ id := strings.TrimSuffix(name, suffix)
+ return id, imageRegistryIDPattern.MatchString(id)
+}
+
+func normalizeClusterSelection(selection clusterSelection) (clusterSelection, error) {
+ clusters := make([]string, 0, len(selection.Clusters))
+ for _, cluster := range selection.Clusters {
+ cluster = strings.TrimSpace(cluster)
+ cluster = strings.TrimPrefix(cluster, imageRegistryNamespacePrefix)
+ if cluster == "" {
+ return clusterSelection{}, fmt.Errorf("cluster names must not be empty")
+ }
+ if problems := validation.IsDNS1123Label(imageRegistryNamespacePrefix + cluster); len(problems) != 0 {
+ return clusterSelection{}, fmt.Errorf("invalid cluster name %q: %s", cluster, strings.Join(problems, "; "))
+ }
+ clusters = append(clusters, cluster)
+ }
+ sort.Strings(clusters)
+ clusters = slices.Compact(clusters)
+ selection.Clusters = clusters
+
+ switch selection.Mode {
+ case clusterSelectionNone, clusterSelectionAll:
+ if len(clusters) != 0 {
+ return clusterSelection{}, fmt.Errorf("clusters must be empty for mode %s", selection.Mode)
+ }
+ case clusterSelectionSelected:
+ if len(clusters) == 0 {
+ return clusterSelection{}, fmt.Errorf("at least one cluster is required for mode Selected")
+ }
+ default:
+ return clusterSelection{}, fmt.Errorf("invalid cluster selection mode %q", selection.Mode)
+ }
+ return selection, nil
+}
+
+func normalizeImageRegistryCredential(name, registry, username, password string, selection clusterSelection) (storedImageRegistryCredential, error) {
+ name = strings.TrimSpace(name)
+ registry = common.NormalizeRegistry(registry)
+ username = strings.TrimSpace(username)
+ if name == "" || registry == "" || username == "" || password == "" {
+ return storedImageRegistryCredential{}, fmt.Errorf("name, registry, username, and password are required")
+ }
+ selection, err := normalizeClusterSelection(selection)
+ if err != nil {
+ return storedImageRegistryCredential{}, err
+ }
+ return storedImageRegistryCredential{
+ Version: imageRegistryCredentialVersion,
+ Name: name,
+ Registry: registry,
+ Username: username,
+ Password: password,
+ ClusterSelection: selection,
+ }, nil
+}
+
+func buildDockerConfigJSON(registry, username, password string) ([]byte, error) {
+ dockerConfig := map[string]map[string]map[string]string{
+ "auths": {
+ common.NormalizeRegistry(registry): {
+ "auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + password)),
+ },
+ },
+ }
+ return json.Marshal(dockerConfig)
+}
+
+func buildImageRegistryReplicationSecret(namespace, id string, credential storedImageRegistryCredential) (*corev1.Secret, error) {
+ credentialJSON, err := json.Marshal(credential)
+ if err != nil {
+ return nil, fmt.Errorf("marshal credential: %w", err)
+ }
+ dockerConfigJSON, err := buildDockerConfigJSON(credential.Registry, credential.Username, credential.Password)
+ if err != nil {
+ return nil, fmt.Errorf("marshal docker config: %w", err)
+ }
+
+ dockerSecret := corev1.Secret{
+ TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
+ ObjectMeta: metav1.ObjectMeta{
+ Name: imageRegistryCredentialName(id),
+ Namespace: "rlark-system",
+ Labels: map[string]string{common.ImageRegistryCredentialLabel: "true"},
+ Annotations: map[string]string{
+ common.ImageRegistryAnnotationRegistry: credential.Registry,
+ common.ImageRegistryAnnotationUsername: credential.Username,
+ },
+ },
+ Type: corev1.SecretTypeDockerConfigJson,
+ Data: map[string][]byte{corev1.DockerConfigJsonKey: dockerConfigJSON},
+ }
+ dockerManifest, err := yaml.Marshal([]corev1.Secret{dockerSecret})
+ if err != nil {
+ return nil, fmt.Errorf("marshal docker secret manifest: %w", err)
+ }
+
+ deliveryConfig := distribution.Config{Apply: distribution.ApplyPolicy{
+ Mode: "ServerSideApply", ConflictPolicy: "Fail", DeletionPolicy: "Delete",
+ }}
+ deliveryConfigYAML, err := yaml.Marshal(deliveryConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshal delivery config: %w", err)
+ }
+ deliverySecret := corev1.Secret{
+ TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
+ ObjectMeta: metav1.ObjectMeta{
+ Name: imageRegistryDeliveryName(id),
+ Labels: map[string]string{common.ImageRegistryDeliveryLabel: "true"},
+ },
+ Type: distribution.DeliverySecretType,
+ Data: map[string][]byte{"config": deliveryConfigYAML, "manifests": dockerManifest},
+ }
+ deliveryManifest, err := yaml.Marshal([]corev1.Secret{deliverySecret})
+ if err != nil {
+ return nil, fmt.Errorf("marshal delivery secret manifest: %w", err)
+ }
+
+ include := []string{imageRegistryNoTargetsPattern}
+ switch credential.ClusterSelection.Mode {
+ case clusterSelectionSelected:
+ include = make([]string, 0, len(credential.ClusterSelection.Clusters))
+ for _, cluster := range credential.ClusterSelection.Clusters {
+ include = append(include, imageRegistryNamespacePrefix+cluster)
+ }
+ case clusterSelectionAll:
+ include = []string{imageRegistryNamespacePrefix + "*"}
+ }
+ replicationConfig := distribution.Config{Apply: distribution.ApplyPolicy{
+ Mode: "ServerSideApply", ConflictPolicy: "Fail", DeletionPolicy: "Delete",
+ }}
+ replicationConfig.Targets.Namespaces = &distribution.GlobSelector{
+ Include: include,
+ Exclude: []string{"rlark-system"},
+ }
+ replicationConfigYAML, err := yaml.Marshal(replicationConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshal replication config: %w", err)
+ }
+
+ return &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: imageRegistryReplicationName(id),
+ Namespace: namespace,
+ Labels: map[string]string{common.ImageRegistryReplicationLabel: "true"},
+ },
+ Type: distribution.ReplicationSecretType,
+ Data: map[string][]byte{
+ common.ImageRegistryCredentialDataKey: credentialJSON,
+ "config": replicationConfigYAML,
+ "manifests": deliveryManifest,
+ },
+ }, nil
+}
+
+func imageRegistryItemFromSecret(secret *corev1.Secret) (imageRegistryItem, error) {
+ id, ok := parseImageRegistryID(secret.Name)
+ if !ok {
+ return imageRegistryItem{}, fmt.Errorf("invalid image registry secret name %q", secret.Name)
+ }
+ if secret.Labels[common.ImageRegistryReplicationLabel] != "true" || secret.Type != distribution.ReplicationSecretType {
+ return imageRegistryItem{}, fmt.Errorf("secret %q is not an image registry replication", secret.Name)
+ }
+ var credential storedImageRegistryCredential
+ if err := json.Unmarshal(secret.Data[common.ImageRegistryCredentialDataKey], &credential); err != nil {
+ return imageRegistryItem{}, fmt.Errorf("decode credential: %w", err)
+ }
+ if credential.Version != imageRegistryCredentialVersion {
+ return imageRegistryItem{}, fmt.Errorf("unsupported credential version %q", credential.Version)
+ }
+ selection, err := normalizeClusterSelection(credential.ClusterSelection)
+ if err != nil {
+ return imageRegistryItem{}, fmt.Errorf("invalid cluster selection: %w", err)
+ }
+ return imageRegistryItem{
+ ID: id, Name: credential.Name, Registry: credential.Registry, Username: credential.Username, ClusterSelection: selection,
+ }, nil
+}
+
+func listImageRegistrySecrets(ctx context.Context, rawClient kubernetes.Interface, namespace string) ([]corev1.Secret, error) {
+ secretList, err := rawClient.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{
+ LabelSelector: labels.Set{common.ImageRegistryReplicationLabel: "true"}.AsSelector().String(),
})
if err != nil {
return nil, err
@@ -47,181 +284,199 @@ func listImageRegistrySecrets(ctx context.Context, rawClient kubernetes.Interfac
return secretList.Items, nil
}
+func validateImageRegistryID(id string) error {
+ if !imageRegistryIDPattern.MatchString(id) {
+ return fmt.Errorf("id must use the format ir- followed by 16 lowercase hexadecimal characters")
+ }
+ return nil
+}
+
+func getImageRegistrySecret(ctx context.Context, rawClient kubernetes.Interface, namespace, id string) (*corev1.Secret, error) {
+ secret, err := rawClient.CoreV1().Secrets(namespace).Get(ctx, imageRegistryReplicationName(id), metav1.GetOptions{})
+ if err != nil {
+ return nil, err
+ }
+ if _, err := imageRegistryItemFromSecret(secret); err != nil {
+ return nil, errors.NewNotFound(corev1.Resource("secrets"), secret.Name)
+ }
+ return secret, nil
+}
+
func (g *Gateway) handleListImageRegistries(c *gin.Context) {
logger := log.FromContext(c.Request.Context())
- ctx := c.Request.Context()
-
- secrets, err := listImageRegistrySecrets(ctx, g.rawClient)
+ secrets, err := listImageRegistrySecrets(c.Request.Context(), g.rawClient, g.managementNamespace())
if err != nil {
logger.Error(err, "failed to list image registry secrets")
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list image registries: %v", err)})
return
}
-
items := make([]imageRegistryItem, 0, len(secrets))
- for _, secret := range secrets {
- items = append(items, imageRegistryItem{
- Name: secret.Name,
- Registry: secret.Annotations[common.ImageRegistryAnnotationRegistry],
- Username: secret.Annotations[common.ImageRegistryAnnotationUsername],
- })
+ for i := range secrets {
+ item, err := imageRegistryItemFromSecret(&secrets[i])
+ if err != nil {
+ logger.Error(err, "invalid image registry secret", "secret", secrets[i].Name)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid image registry data"})
+ return
+ }
+ items = append(items, item)
}
-
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Name == items[j].Name {
+ return items[i].ID < items[j].ID
+ }
+ return items[i].Name < items[j].Name
+ })
c.JSON(http.StatusOK, items)
}
func (g *Gateway) handleGetImageRegistry(c *gin.Context) {
- logger := log.FromContext(c.Request.Context())
- ctx := c.Request.Context()
-
- name := c.Param("name")
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, name, metav1.GetOptions{})
+ id := c.Param("id")
+ if err := validateImageRegistryID(id); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ secret, err := getImageRegistrySecret(c.Request.Context(), g.rawClient, g.managementNamespace(), id)
if err != nil {
if errors.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "image registry not found"})
return
}
- logger.Error(err, "failed to get image registry secret")
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get image registry: %v", err)})
return
}
-
- c.JSON(http.StatusOK, imageRegistryItem{
- Name: secret.Name,
- Registry: secret.Annotations[common.ImageRegistryAnnotationRegistry],
- Username: secret.Annotations[common.ImageRegistryAnnotationUsername],
- })
-}
-
-func buildDockerConfigJSON(registry, username, password string) ([]byte, error) {
- registry = common.NormalizeRegistry(registry)
- dockerConfig := map[string]map[string]map[string]string{
- "auths": {
- registry: {
- "auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + password)),
- },
- },
+ item, err := imageRegistryItemFromSecret(secret)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid image registry data"})
+ return
}
- return json.Marshal(dockerConfig)
+ c.JSON(http.StatusOK, item)
}
func (g *Gateway) handleCreateImageRegistry(c *gin.Context) {
- logger := log.FromContext(c.Request.Context())
- ctx := c.Request.Context()
-
var req createImageRegistryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
- req.Registry = common.NormalizeRegistry(req.Registry)
-
- configJSON, err := buildDockerConfigJSON(req.Registry, req.Username, req.Password)
+ credential, err := normalizeImageRegistryCredential(req.Name, req.Registry, req.Username, req.Password, req.ClusterSelection)
if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal docker config: %v", err)})
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- secret := &corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{
- Name: req.Name,
- Namespace: common.SecretNamespace,
- Labels: map[string]string{
- common.ImageRegistrySecretLabel: "true",
- },
- Annotations: map[string]string{
- common.ImageRegistryAnnotationRegistry: req.Registry,
- common.ImageRegistryAnnotationUsername: req.Username,
- },
- },
- Type: corev1.SecretTypeDockerConfigJson,
- Data: map[string][]byte{
- corev1.DockerConfigJsonKey: configJSON,
- },
- }
-
- if _, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
- if errors.IsAlreadyExists(err) {
- c.JSON(http.StatusConflict, gin.H{"error": "image registry secret already exists"})
+ for range imageRegistryCreateAttempts {
+ id := newImageRegistryID()
+ secret, err := buildImageRegistryReplicationSecret(g.managementNamespace(), id, credential)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if _, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Create(c.Request.Context(), secret, metav1.CreateOptions{}); err != nil {
+ if errors.IsAlreadyExists(err) {
+ continue
+ }
+ log.FromContext(c.Request.Context()).Error(err, "failed to create image registry replication")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create image registry: %v", err)})
return
}
- logger.Error(err, "failed to create image registry secret")
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create image registry: %v", err)})
+ item, _ := imageRegistryItemFromSecret(secret)
+ c.JSON(http.StatusCreated, item)
return
}
-
- c.JSON(http.StatusOK, gin.H{"ok": true, "name": req.Name})
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to allocate image registry id"})
}
func (g *Gateway) handleUpdateImageRegistry(c *gin.Context) {
- logger := log.FromContext(c.Request.Context())
- ctx := c.Request.Context()
-
- name := c.Param("name")
+ id := c.Param("id")
+ if err := validateImageRegistryID(id); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
var req updateImageRegistryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
- req.Registry = common.NormalizeRegistry(req.Registry)
-
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, name, metav1.GetOptions{})
+ if req.Password != nil && *req.Password == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "password must not be empty"})
+ return
+ }
+ selection, err := normalizeClusterSelection(req.ClusterSelection)
if err != nil {
- if errors.IsNotFound(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "image registry not found"})
- return
- }
- logger.Error(err, "failed to get image registry secret")
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get image registry: %v", err)})
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
-
- if secret.Annotations == nil {
- secret.Annotations = make(map[string]string)
+ password := "placeholder"
+ if req.Password != nil {
+ password = *req.Password
+ }
+ if _, err := normalizeImageRegistryCredential(req.Name, req.Registry, req.Username, password, selection); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
}
- secret.Annotations[common.ImageRegistryAnnotationRegistry] = req.Registry
- secret.Annotations[common.ImageRegistryAnnotationUsername] = req.Username
- // Only update password if provided
- if req.Password != "" {
- configJSON, err := buildDockerConfigJSON(req.Registry, req.Username, req.Password)
+ var item imageRegistryItem
+ err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
+ secret, err := getImageRegistrySecret(c.Request.Context(), g.rawClient, g.managementNamespace(), id)
if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal docker config: %v", err)})
- return
+ return err
}
- secret.Data = map[string][]byte{
- corev1.DockerConfigJsonKey: configJSON,
+ var old storedImageRegistryCredential
+ if err := json.Unmarshal(secret.Data[common.ImageRegistryCredentialDataKey], &old); err != nil {
+ return fmt.Errorf("decode stored credential: %w", err)
+ }
+ password := old.Password
+ if req.Password != nil {
+ password = *req.Password
+ }
+ credential, err := normalizeImageRegistryCredential(req.Name, req.Registry, req.Username, password, req.ClusterSelection)
+ if err != nil {
+ return err
+ }
+ rebuilt, err := buildImageRegistryReplicationSecret(secret.Namespace, id, credential)
+ if err != nil {
+ return err
+ }
+ secret.Data = rebuilt.Data
+ updated, err := g.rawClient.CoreV1().Secrets(secret.Namespace).Update(c.Request.Context(), secret, metav1.UpdateOptions{})
+ if err != nil {
+ return err
+ }
+ item, err = imageRegistryItemFromSecret(updated)
+ return err
+ })
+ if err != nil {
+ if errors.IsNotFound(err) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "image registry not found"})
+ return
}
- }
-
- if _, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
- logger.Error(err, "failed to update image registry secret")
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update image registry: %v", err)})
return
}
-
- c.JSON(http.StatusOK, gin.H{"ok": true, "name": name})
+ c.JSON(http.StatusOK, item)
}
func (g *Gateway) handleDeleteImageRegistry(c *gin.Context) {
- logger := log.FromContext(c.Request.Context())
- ctx := c.Request.Context()
-
- name := c.Param("name")
- if name == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ id := c.Param("id")
+ if err := validateImageRegistryID(id); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
-
- if err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
+ if _, err := getImageRegistrySecret(c.Request.Context(), g.rawClient, g.managementNamespace(), id); err != nil {
+ if errors.IsNotFound(err) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "image registry not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get image registry: %v", err)})
+ return
+ }
+ if err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Delete(c.Request.Context(), imageRegistryReplicationName(id), metav1.DeleteOptions{}); err != nil {
if errors.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "image registry not found"})
return
}
- logger.Error(err, "failed to delete image registry secret")
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete image registry: %v", err)})
return
}
-
- c.JSON(http.StatusOK, gin.H{"ok": true})
+ c.JSON(http.StatusAccepted, gin.H{"ok": true})
}
diff --git a/apps/rlark/pkg/gateway/imageregistry_handler_test.go b/apps/rlark/pkg/gateway/imageregistry_handler_test.go
new file mode 100644
index 0000000..39b1895
--- /dev/null
+++ b/apps/rlark/pkg/gateway/imageregistry_handler_test.go
@@ -0,0 +1,192 @@
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/client-go/kubernetes/fake"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
+ "github.com/rlinf/rlark/apps/rlark/pkg/distribution"
+)
+
+func TestBuildImageRegistryReplicationSecret(t *testing.T) {
+ tests := []struct {
+ name string
+ selection clusterSelection
+ include []string
+ }{
+ {name: "none", selection: clusterSelection{Mode: clusterSelectionNone}, include: []string{"/"}},
+ {name: "selected", selection: clusterSelection{Mode: clusterSelectionSelected, Clusters: []string{"cluster-a", "cluster-b"}}, include: []string{"rlark-cluster-a", "rlark-cluster-b"}},
+ {name: "all", selection: clusterSelection{Mode: clusterSelectionAll}, include: []string{"rlark-*"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ credential, err := normalizeImageRegistryCredential("Harbor", "https://harbor.example.com/v2/", "robot", "secret", tt.selection)
+ require.NoError(t, err)
+ secret, err := buildImageRegistryReplicationSecret("management", "ir-0123456789abcdef", credential)
+ require.NoError(t, err)
+
+ assert.Equal(t, "ir-0123456789abcdef-replication", secret.Name)
+ assert.Equal(t, "management", secret.Namespace)
+ assert.Equal(t, distribution.ReplicationSecretType, secret.Type)
+ assert.Equal(t, "true", secret.Labels[common.ImageRegistryReplicationLabel])
+ assert.NotContains(t, string(secret.Data[common.ImageRegistryCredentialDataKey]), `"password":""`)
+
+ replication, err := distribution.Parse(secret.Data)
+ require.NoError(t, err)
+ require.NotNil(t, replication.Config.Targets.Namespaces)
+ assert.Equal(t, tt.include, replication.Config.Targets.Namespaces.Include)
+ assert.Equal(t, []string{"rlark-system"}, replication.Config.Targets.Namespaces.Exclude)
+ require.Len(t, replication.Objects, 1)
+
+ delivery := replication.Objects[0]
+ assert.Equal(t, "ir-0123456789abcdef-delivery", delivery.GetName())
+ assert.Equal(t, string(distribution.DeliverySecretType), nestedString(t, delivery.Object, "type"))
+ deliveryData, found, err := unstructured.NestedStringMap(delivery.Object, "data")
+ require.NoError(t, err)
+ require.True(t, found)
+ deliveryDefinition, err := distribution.Parse(map[string][]byte{
+ "config": decodeManifestData(t, deliveryData["config"]),
+ "manifests": decodeManifestData(t, deliveryData["manifests"]),
+ })
+ require.NoError(t, err)
+ require.Len(t, deliveryDefinition.Objects, 1)
+ dockerSecret := deliveryDefinition.Objects[0]
+ assert.Equal(t, "ir-0123456789abcdef", dockerSecret.GetName())
+ assert.Equal(t, "rlark-system", dockerSecret.GetNamespace())
+ assert.Equal(t, string(corev1.SecretTypeDockerConfigJson), nestedString(t, dockerSecret.Object, "type"))
+ assert.Equal(t, "harbor.example.com", dockerSecret.GetAnnotations()[common.ImageRegistryAnnotationRegistry])
+
+ dockerData, found, err := unstructured.NestedStringMap(dockerSecret.Object, "data")
+ require.NoError(t, err)
+ require.True(t, found)
+ var config map[string]map[string]map[string]string
+ require.NoError(t, json.Unmarshal(decodeManifestData(t, dockerData[corev1.DockerConfigJsonKey]), &config))
+ assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("robot:secret")), config["auths"]["harbor.example.com"]["auth"])
+ })
+ }
+}
+
+func TestNormalizeClusterSelection(t *testing.T) {
+ selection, err := normalizeClusterSelection(clusterSelection{Mode: clusterSelectionSelected, Clusters: []string{" rlark-b ", "a", "a"}})
+ require.NoError(t, err)
+ assert.Equal(t, []string{"a", "b"}, selection.Clusters)
+
+ for _, selection := range []clusterSelection{
+ {Mode: clusterSelectionSelected},
+ {Mode: clusterSelectionNone, Clusters: []string{"a"}},
+ {Mode: clusterSelectionAll, Clusters: []string{"a"}},
+ {Mode: "invalid"},
+ {Mode: clusterSelectionSelected, Clusters: []string{"*"}},
+ {Mode: clusterSelectionSelected, Clusters: []string{"invalid_name"}},
+ } {
+ _, err := normalizeClusterSelection(selection)
+ assert.Error(t, err)
+ }
+ matched, err := path.Match(imageRegistryNoTargetsPattern, "rlark-cluster-a")
+ require.NoError(t, err)
+ assert.False(t, matched)
+}
+
+func TestImageRegistryHandlers(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ gateway := &Gateway{
+ config: Config{KubeClientConfig: configs.KubernetesClientConfig{Namespace: "management"}},
+ rawClient: fake.NewSimpleClientset(),
+ jwtSigningKey: []byte("01234567890123456789012345678901"),
+ }
+ token, _, err := gateway.issueJWT("admin", "admin")
+ require.NoError(t, err)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Request.Header.Set("Authorization", "Bearer "+token)
+ })
+ gateway.RegisterRoutes(router)
+
+ created := performJSONRequest(t, router, http.MethodPost, "/api/v1/image-registries", map[string]any{
+ "name": "Harbor", "registry": "https://harbor.example.com/", "username": "robot", "password": "secret",
+ "clusterSelection": map[string]any{"mode": "Selected", "clusters": []string{"cluster-b", "cluster-a", "cluster-a"}},
+ })
+ assert.Equal(t, http.StatusCreated, created.Code)
+ var item imageRegistryItem
+ require.NoError(t, json.Unmarshal(created.Body.Bytes(), &item))
+ assert.Regexp(t, `^ir-[0-9a-f]{16}$`, item.ID)
+ assert.Equal(t, []string{"cluster-a", "cluster-b"}, item.ClusterSelection.Clusters)
+ assert.NotContains(t, created.Body.String(), "secret")
+
+ secret, err := gateway.rawClient.CoreV1().Secrets("management").Get(context.Background(), imageRegistryReplicationName(item.ID), metav1.GetOptions{})
+ require.NoError(t, err)
+ secret.Finalizers = []string{"example.com/finalizer"}
+ secret.Annotations = map[string]string{distribution.InventoryAnnotation: "inventory"}
+ _, err = gateway.rawClient.CoreV1().Secrets("management").Update(context.Background(), secret, metav1.UpdateOptions{})
+ require.NoError(t, err)
+
+ updated := performJSONRequest(t, router, http.MethodPut, "/api/v1/image-registries/"+item.ID, map[string]any{
+ "name": "Renamed Harbor", "registry": "harbor.example.com/team", "username": "new-robot",
+ "clusterSelection": map[string]any{"mode": "All", "clusters": []string{}},
+ })
+ assert.Equal(t, http.StatusOK, updated.Code)
+ assert.NotContains(t, updated.Body.String(), "secret")
+ secret, err = gateway.rawClient.CoreV1().Secrets("management").Get(context.Background(), imageRegistryReplicationName(item.ID), metav1.GetOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, []string{"example.com/finalizer"}, secret.Finalizers)
+ assert.Equal(t, "inventory", secret.Annotations[distribution.InventoryAnnotation])
+ var stored storedImageRegistryCredential
+ require.NoError(t, json.Unmarshal(secret.Data[common.ImageRegistryCredentialDataKey], &stored))
+ assert.Equal(t, "secret", stored.Password)
+ assert.Equal(t, "Renamed Harbor", stored.Name)
+
+ badPassword := performJSONRequest(t, router, http.MethodPut, "/api/v1/image-registries/"+item.ID, map[string]any{
+ "name": "Harbor", "registry": "harbor.example.com", "username": "robot", "password": "",
+ "clusterSelection": map[string]any{"mode": "None", "clusters": []string{}},
+ })
+ assert.Equal(t, http.StatusBadRequest, badPassword.Code)
+
+ invalidID := performJSONRequest(t, router, http.MethodGet, "/api/v1/image-registries/not-an-id", nil)
+ assert.Equal(t, http.StatusBadRequest, invalidID.Code)
+
+ deleted := performJSONRequest(t, router, http.MethodDelete, "/api/v1/image-registries/"+item.ID, nil)
+ assert.Equal(t, http.StatusAccepted, deleted.Code)
+}
+
+func nestedString(t *testing.T, object map[string]any, fields ...string) string {
+ t.Helper()
+ value, found, err := unstructured.NestedString(object, fields...)
+ require.NoError(t, err)
+ require.True(t, found)
+ return value
+}
+
+func decodeManifestData(t *testing.T, value string) []byte {
+ t.Helper()
+ data, err := base64.StdEncoding.DecodeString(value)
+ require.NoError(t, err)
+ return data
+}
+
+func performJSONRequest(t *testing.T, handler http.Handler, method, target string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ var requestBody bytes.Buffer
+ if body != nil {
+ require.NoError(t, json.NewEncoder(&requestBody).Encode(body))
+ }
+ request := httptest.NewRequest(method, target, &requestBody)
+ request.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ return response
+}
diff --git a/apps/rlark/pkg/gateway/job_logs.go b/apps/rlark/pkg/gateway/job_logs.go
index aad8090..715a5d8 100644
--- a/apps/rlark/pkg/gateway/job_logs.go
+++ b/apps/rlark/pkg/gateway/job_logs.go
@@ -98,10 +98,11 @@ func (g *Gateway) rlinfv1alpha1JobLogs(c *gin.Context) {
podName := c.Query("pod")
rawQuery := c.Query("query")
cursor := c.Query("cursor")
+ reverse := c.Query("order") != "asc"
if timeRangeProvided {
if querier := g.getLogQuerier(ctx); querier != nil {
- result, err := g.queryJobLogsFromBackend(ctx, querier, jobName, fromTime, toTime, taskName, podName, rawQuery, cursor)
+ result, err := g.queryJobLogsFromBackend(ctx, querier, jobName, fromTime, toTime, taskName, podName, rawQuery, cursor, reverse)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"source": "backend",
@@ -119,7 +120,64 @@ func (g *Gateway) rlinfv1alpha1JobLogs(c *gin.Context) {
g.serveJobPodLogs(c, jobName)
}
-func (g *Gateway) queryJobLogsFromBackend(ctx context.Context, querier logquery.Querier, jobName string, from, to time.Time, taskName, podName, rawQuery, cursor string) (*logquery.Result, error) {
+// rlinfv1alpha1JobLogLabelValues 返回指定 Job 在指定时间范围内,某个标签(如 pod)的所有唯一值。
+// 用于前端 Worker 下拉框,支持查询已停止任务的历史日志。
+func (g *Gateway) rlinfv1alpha1JobLogLabelValues(c *gin.Context) {
+ logger := log.FromContext(c.Request.Context())
+ ctx := c.Request.Context()
+ jobName := c.Param("name")
+
+ // 时间范围参数
+ var fromTime, toTime time.Time
+ if v := c.Query("from"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ fromTime = t
+ }
+ }
+ if v := c.Query("to"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ toTime = t
+ }
+ }
+
+ // 标签名参数,默认为 "pod"
+ label := c.Query("label")
+ if label == "" {
+ label = "pod"
+ }
+
+ // 可选过滤参数
+ taskName := c.Query("task")
+ podName := c.Query("pod")
+
+ // 构建过滤器
+ filters := map[string]string{}
+ if taskName != "" {
+ filters["task"] = taskName
+ }
+ if podName != "" {
+ filters["pod"] = podName
+ }
+
+ // 获取日志查询器
+ querier := g.getLogQuerier(ctx)
+ if querier == nil {
+ c.JSON(http.StatusOK, gin.H{"values": []string{}})
+ return
+ }
+
+ // 调用 LabelValues 接口
+ values, err := querier.LabelValues(ctx, label, fromTime, toTime, filters)
+ if err != nil {
+ logger.Error(err, "failed to query label values", "job", jobName, "label", label)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to query label values: %v", err)})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"values": values})
+}
+
+func (g *Gateway) queryJobLogsFromBackend(ctx context.Context, querier logquery.Querier, jobName string, from, to time.Time, taskName, podName, rawQuery, cursor string, reverse bool) (*logquery.Result, error) {
if to.IsZero() {
to = time.Now()
}
@@ -137,12 +195,13 @@ func (g *Gateway) queryJobLogsFromBackend(ctx context.Context, querier logquery.
labels["container"] = "main"
return querier.Query(ctx, logquery.Query{
- Raw: rawQuery,
- From: from,
- To: to,
- Limit: 99,
- Labels: labels,
- Cursor: cursor,
+ Raw: rawQuery,
+ From: from,
+ To: to,
+ Limit: 99,
+ Labels: labels,
+ Cursor: cursor,
+ Reverse: reverse,
})
}
diff --git a/apps/rlark/pkg/gateway/kube_handler.go b/apps/rlark/pkg/gateway/kube_handler.go
index 066ab81..421ad84 100644
--- a/apps/rlark/pkg/gateway/kube_handler.go
+++ b/apps/rlark/pkg/gateway/kube_handler.go
@@ -212,6 +212,102 @@ func registerAccessors(client versioned.Interface) map[string]*resourceAccessor
// --- Generic Kubernetes client handlers using resourceAccessor ---
+func (g *Gateway) handleListJobsKube(c *gin.Context) {
+ a, ok := g.accessors["jobs"]
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unknown resource: jobs"})
+ return
+ }
+
+ opts := metav1.ListOptions{}
+ if limit := c.Query("limit"); limit != "" {
+ if n, err := strconv.Atoi(limit); err == nil {
+ opts.Limit = int64(n)
+ }
+ }
+ if cont := c.Query("continue"); cont != "" {
+ opts.Continue = cont
+ }
+ if fs := c.Query("fieldSelector"); fs != "" {
+ opts.FieldSelector = fs
+ }
+ if ls := c.Query("labelSelector"); ls != "" {
+ opts.LabelSelector = ls
+ }
+
+ result, err := a.list(c.Request.Context(), c.Query("namespace"), opts)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ jobs := result.(*rlarkiov1alpha1.JobList)
+ selectors := parseTagSelectors(c.Query("tagSelector"))
+ if len(selectors) > 0 {
+ items := jobs.Items[:0]
+ for _, job := range jobs.Items {
+ if jobMatchesTagSelectors(job, selectors) {
+ items = append(items, job)
+ }
+ }
+ jobs.Items = items
+ }
+ c.JSON(http.StatusOK, jobs)
+}
+
+func (g *Gateway) handleListJobTagsKube(c *gin.Context) {
+ a, ok := g.accessors["jobs"]
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unknown resource: jobs"})
+ return
+ }
+ result, err := a.list(c.Request.Context(), c.Query("namespace"), metav1.ListOptions{})
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ tags := collectJobTags(result.(*rlarkiov1alpha1.JobList).Items)
+ c.JSON(http.StatusOK, gin.H{"items": tags})
+}
+
+func parseTagSelectors(raw string) map[string]map[string]struct{} {
+ selectors := make(map[string]map[string]struct{})
+ for _, pair := range strings.Split(raw, ",") {
+ parts := strings.SplitN(pair, "=", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ continue
+ }
+ if selectors[parts[0]] == nil {
+ selectors[parts[0]] = make(map[string]struct{})
+ }
+ selectors[parts[0]][parts[1]] = struct{}{}
+ }
+ return selectors
+}
+
+func jobMatchesTagSelectors(job rlarkiov1alpha1.Job, selectors map[string]map[string]struct{}) bool {
+ for key, values := range selectors {
+ matched := false
+ for _, tag := range job.Spec.Tags {
+ if tag.Key != key {
+ continue
+ }
+ for _, value := range tag.Values {
+ if _, ok := values[value]; ok {
+ matched = true
+ break
+ }
+ }
+ if matched {
+ break
+ }
+ }
+ if !matched {
+ return false
+ }
+ }
+ return true
+}
+
func (g *Gateway) handleListKube(c *gin.Context, resource string) {
a, ok := g.accessors[resource]
if !ok {
diff --git a/apps/rlark/pkg/gateway/kube_handler_test.go b/apps/rlark/pkg/gateway/kube_handler_test.go
index 112dc1c..043b900 100644
--- a/apps/rlark/pkg/gateway/kube_handler_test.go
+++ b/apps/rlark/pkg/gateway/kube_handler_test.go
@@ -52,3 +52,50 @@ func TestPrepareJobForCreateRejectsLongDisplayName(t *testing.T) {
t.Fatal("expected error for a job name longer than 50 characters")
}
}
+
+func TestPrepareJobForCreatePreservesFrontendJobRequest(t *testing.T) {
+ body := []byte(`{
+ "apiVersion":"rlinf.io/v1alpha1",
+ "kind":"Job",
+ "metadata":{"name":"jo-7bdbef36d2624b03","annotations":{"rlark.io/display-name":"tagtest"}},
+ "spec":{
+ "domain":"lky-test-domain",
+ "tags":[{"key":"user","values":["tang","yanhan"]},{"key":"usage","values":["test","tag"]}],
+ "tasks":[{
+ "name":"actor","head":true,"agentType":"Kubernetes","role":"Actor",
+ "nodeSelector":{"kubernetes.io/hostname":"hgx-049"},
+ "prepareScript":"sleep 600","runScript":"python train.py",
+ "kubernetes":{"workload":{"kind":"StatefulSet","replicas":1,"template":{"spec":{"containers":[{"name":"main","image":"example.com/rlark:latest","env":[{"name":"RLARK_TASK_ROLE","value":"Actor"}],"resources":{"requests":{},"limits":{}}}],"volumes":[]}}}}
+ }]
+ }
+ }`)
+
+ prepared, err := prepareJobForCreate(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var job rlarkiov1alpha1.Job
+ if err := json.Unmarshal(prepared, &job); err != nil {
+ t.Fatal(err)
+ }
+ if job.Spec.Domain != "lky-test-domain" {
+ t.Fatalf("domain = %q, want lky-test-domain", job.Spec.Domain)
+ }
+ if len(job.Spec.Tags) != 2 || job.Spec.Tags[0].Key != "user" || len(job.Spec.Tags[0].Values) != 2 {
+ t.Fatalf("tags = %#v, want frontend tags preserved", job.Spec.Tags)
+ }
+ if len(job.Spec.Tasks) != 1 {
+ t.Fatalf("tasks = %#v, want one task", job.Spec.Tasks)
+ }
+ task := job.Spec.Tasks[0]
+ if task.AgentType != rlarkiov1alpha1.AgentTypeKubernetes || task.Role != rlarkiov1alpha1.TaskRoleActor {
+ t.Fatalf("task = %#v, want Kubernetes Actor task", task)
+ }
+ if task.Kubernetes == nil || task.Kubernetes.Workload == nil || task.Kubernetes.Workload.Replicas == nil || *task.Kubernetes.Workload.Replicas != 1 {
+ t.Fatalf("kubernetes workload = %#v, want StatefulSet with one replica", task.Kubernetes)
+ }
+ if got := job.Annotations[jobDisplayNameAnnotation]; got != "tagtest" {
+ t.Fatalf("display name = %q, want tagtest", got)
+ }
+}
diff --git a/apps/rlark/pkg/gateway/management_namespace_test.go b/apps/rlark/pkg/gateway/management_namespace_test.go
new file mode 100644
index 0000000..209e266
--- /dev/null
+++ b/apps/rlark/pkg/gateway/management_namespace_test.go
@@ -0,0 +1,50 @@
+package gateway
+
+import (
+ "context"
+ "testing"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+func TestManagementSecretsUseConfiguredNamespace(t *testing.T) {
+ namespace := "rlark-system"
+ gateway := &Gateway{
+ config: Config{KubeClientConfig: configs.KubernetesClientConfig{Namespace: namespace}},
+ rawClient: fake.NewSimpleClientset(
+ &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: common.UIAuthSecretName, Namespace: namespace},
+ Data: map[string][]byte{
+ common.UIAuthAdminPasswordKey: []byte("admin"),
+ common.UIAuthUserPasswordKey: []byte("user"),
+ common.UIAuthJWTSigningKey: []byte("01234567890123456789012345678901"),
+ },
+ },
+ &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: common.AdminCertSecretName, Namespace: namespace},
+ Data: map[string][]byte{"client.crt": []byte("cert"), "client.key": []byte("key")},
+ },
+ &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: common.TLSCASecretName, Namespace: namespace},
+ Data: map[string][]byte{"ca.crt": []byte("ca")},
+ },
+ ),
+ }
+
+ admin, user, err := gateway.readUIAuthSecret()
+ if err != nil || admin != "admin" || user != "user" {
+ t.Fatalf("readUIAuthSecret() = %q, %q, %v", admin, user, err)
+ }
+ cert, key, ca, err := gateway.getKCPAdminCerts()
+ if err != nil || string(cert) != "cert" || string(key) != "key" || string(ca) != "ca" {
+ t.Fatalf("getKCPAdminCerts() = %q, %q, %q, %v", cert, key, ca, err)
+ }
+
+ if _, err := gateway.rawClient.CoreV1().Secrets("default").Get(context.Background(), common.UIAuthSecretName, metav1.GetOptions{}); err == nil {
+ t.Fatal("test unexpectedly found UI auth secret in default namespace")
+ }
+}
diff --git a/apps/rlark/pkg/gateway/pod_events.go b/apps/rlark/pkg/gateway/pod_events.go
index b0a4c02..e79e630 100644
--- a/apps/rlark/pkg/gateway/pod_events.go
+++ b/apps/rlark/pkg/gateway/pod_events.go
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
rlarkv1alpha1 "github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/apis"
@@ -41,7 +42,31 @@ func (g *Gateway) handlePodEvents(c *gin.Context) {
return
}
- selector := url.QueryEscape("involvedObject.kind=Pod,involvedObject.name=" + pod.Spec.PodName)
+ podPath := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s",
+ url.PathEscape(pod.Spec.PodNamespace), url.PathEscape(pod.Spec.PodName))
+ podResp, err := g.proxyKubeRequest(ctx, http.MethodGet, agentID, podPath, nil)
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("fetch pod: %v", err)})
+ return
+ }
+ podBody, readErr := io.ReadAll(podResp.Body)
+ _ = podResp.Body.Close()
+ if readErr != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read pod: %v", readErr)})
+ return
+ }
+ if podResp.StatusCode != http.StatusOK {
+ c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("agent returned HTTP %d", podResp.StatusCode)})
+ return
+ }
+ var dataPlanePod corev1.Pod
+ if err := json.Unmarshal(podBody, &dataPlanePod); err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("decode pod: %v", err)})
+ return
+ }
+
+ selector := url.QueryEscape(fmt.Sprintf("involvedObject.kind=Pod,involvedObject.name=%s,involvedObject.uid=%s",
+ pod.Spec.PodName, dataPlanePod.UID))
kubePath := fmt.Sprintf("/api/v1/namespaces/%s/events?fieldSelector=%s",
url.PathEscape(pod.Spec.PodNamespace), selector)
resp, err := g.proxyKubeRequest(ctx, http.MethodGet, agentID, kubePath, nil)
@@ -65,13 +90,16 @@ func (g *Gateway) handlePodEvents(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("decode pod events: %v", err)})
return
}
- c.JSON(http.StatusOK, gin.H{"events": relevantPodEvents(list.Items)})
+ c.JSON(http.StatusOK, gin.H{"events": relevantPodEvents(list.Items, dataPlanePod.UID)})
}
-func relevantPodEvents(events []corev1.Event) []rlarkv1alpha1.NodeEvent {
+func relevantPodEvents(events []corev1.Event, podUID types.UID) []rlarkv1alpha1.NodeEvent {
result := make([]rlarkv1alpha1.NodeEvent, 0, len(events))
for i := range events {
event := &events[i]
+ if event.InvolvedObject.UID != podUID {
+ continue
+ }
if event.Type != corev1.EventTypeWarning &&
(event.Type != corev1.EventTypeNormal ||
(event.Reason != "Pulling" && event.Reason != "Pulled")) {
diff --git a/apps/rlark/pkg/gateway/pod_events_test.go b/apps/rlark/pkg/gateway/pod_events_test.go
index a31874d..04f4fb5 100644
--- a/apps/rlark/pkg/gateway/pod_events_test.go
+++ b/apps/rlark/pkg/gateway/pod_events_test.go
@@ -4,16 +4,19 @@ import (
"testing"
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
)
func TestRelevantPodEvents(t *testing.T) {
+ currentUID := types.UID("current-pod")
events := []corev1.Event{
- {Type: corev1.EventTypeNormal, Reason: "Pulling", Message: "Pulling image x"},
- {Type: corev1.EventTypeNormal, Reason: "Created", Message: "Created container"},
- {Type: corev1.EventTypeWarning, Reason: "Failed", Message: "Failed to pull image"},
+ {Type: corev1.EventTypeNormal, Reason: "Pulling", Message: "Pulling image x", InvolvedObject: corev1.ObjectReference{UID: currentUID}},
+ {Type: corev1.EventTypeNormal, Reason: "Created", Message: "Created container", InvolvedObject: corev1.ObjectReference{UID: currentUID}},
+ {Type: corev1.EventTypeWarning, Reason: "Failed", Message: "Failed to pull image", InvolvedObject: corev1.ObjectReference{UID: currentUID}},
+ {Type: corev1.EventTypeWarning, Reason: "BackOff", Message: "Old pod failed", InvolvedObject: corev1.ObjectReference{UID: "old-pod"}},
}
- got := relevantPodEvents(events)
+ got := relevantPodEvents(events, currentUID)
if len(got) != 2 || got[0].Reason != "Pulling" || got[1].Reason != "Failed" {
- t.Fatalf("relevantPodEvents() = %+v, want Pulling and Failed", got)
+ t.Fatalf("relevantPodEvents() = %+v, want current Pod Pulling and Failed events", got)
}
}
diff --git a/apps/rlark/pkg/gateway/router.go b/apps/rlark/pkg/gateway/router.go
index 79602c0..f08990d 100644
--- a/apps/rlark/pkg/gateway/router.go
+++ b/apps/rlark/pkg/gateway/router.go
@@ -4,10 +4,20 @@ import "github.com/gin-gonic/gin"
// RegisterRoutes registers the routes.
func (g *Gateway) RegisterRoutes(r gin.IRouter) {
- rlinfv1alpha1 := r.Group("/api/v1/rlinf.io/v1alpha1")
+ auth := r.Group("/api/v1/auth")
+ {
+ auth.POST("/login", g.handleLogin)
+ }
+
+ api := r.Group("/api/v1", g.requireJWT())
+ rlinfv1alpha1 := api.Group("/rlinf.io/v1alpha1")
+ adminAPI := api.Group("", requireAdmin())
+ adminRlinfv1alpha1 := rlinfv1alpha1.Group("", requireAdmin())
+
+ api.GET("/api-reference", g.handleAPIReference)
// Clusters API
- clusters := r.Group("/api/v1/clusters")
+ clusters := api.Group("/clusters")
{
clusters.GET("", g.listClusters)
clusters.GET("/:cluster_id", g.getCluster)
@@ -16,11 +26,14 @@ func (g *Gateway) RegisterRoutes(r gin.IRouter) {
nodes := rlinfv1alpha1.Group("/nodes")
{
nodes.GET("", g.rlinfv1alpha1ListNodes)
- nodes.POST("", g.rlinfv1alpha1CreateNode)
nodes.GET("/:name", g.rlinfv1alpha1GetNode)
- nodes.PUT("/:name", g.rlinfv1alpha1UpdateNode)
- nodes.PATCH("/:name", g.rlinfv1alpha1PatchNode)
- nodes.DELETE("/:name", g.rlinfv1alpha1DeleteNode)
+ }
+ adminNodes := adminRlinfv1alpha1.Group("/nodes")
+ {
+ adminNodes.POST("", g.rlinfv1alpha1CreateNode)
+ adminNodes.PUT("/:name", g.rlinfv1alpha1UpdateNode)
+ adminNodes.PATCH("/:name", g.rlinfv1alpha1PatchNode)
+ adminNodes.DELETE("/:name", g.rlinfv1alpha1DeleteNode)
}
workflows := rlinfv1alpha1.Group("/workflows")
@@ -36,12 +49,14 @@ func (g *Gateway) RegisterRoutes(r gin.IRouter) {
jobs := rlinfv1alpha1.Group("/jobs")
{
jobs.GET("", g.rlinfv1alpha1ListJobs)
+ jobs.GET("/tags", g.rlinfv1alpha1ListJobTags)
jobs.POST("", g.rlinfv1alpha1CreateJob)
jobs.GET("/:name", g.rlinfv1alpha1GetJob)
jobs.PUT("/:name", g.rlinfv1alpha1UpdateJob)
jobs.PATCH("/:name", g.rlinfv1alpha1PatchJob)
jobs.DELETE("/:name", g.rlinfv1alpha1DeleteJob)
jobs.GET("/:name/logs", g.rlinfv1alpha1JobLogs)
+ jobs.GET("/:name/logs/label-values", g.rlinfv1alpha1JobLogLabelValues)
jobs.GET("/:name/metrics", g.rlinfv1alpha1JobMetrics)
}
@@ -68,14 +83,17 @@ func (g *Gateway) RegisterRoutes(r gin.IRouter) {
domains := rlinfv1alpha1.Group("/domains")
{
domains.GET("", g.rlinfv1alpha1ListDomains)
- domains.POST("", g.rlinfv1alpha1CreateDomain)
domains.GET("/:name", g.rlinfv1alpha1GetDomain)
- domains.PUT("/:name", g.rlinfv1alpha1UpdateDomain)
- domains.PATCH("/:name", g.rlinfv1alpha1PatchDomain)
- domains.DELETE("/:name", g.rlinfv1alpha1DeleteDomain)
+ }
+ adminDomains := adminRlinfv1alpha1.Group("/domains")
+ {
+ adminDomains.POST("", g.rlinfv1alpha1CreateDomain)
+ adminDomains.PUT("/:name", g.rlinfv1alpha1UpdateDomain)
+ adminDomains.PATCH("/:name", g.rlinfv1alpha1PatchDomain)
+ adminDomains.DELETE("/:name", g.rlinfv1alpha1DeleteDomain)
}
- certificates := r.Group("/api/v1/certificates")
+ certificates := adminAPI.Group("/certificates")
{
certificates.GET("/agent", g.handleListAgentCerts)
certificates.GET("/agent/:cluster_id", g.handleGetAgentCert)
@@ -83,48 +101,42 @@ func (g *Gateway) RegisterRoutes(r gin.IRouter) {
certificates.POST("/revoke", g.handleRevokeCertificate)
}
- sshUserKeys := r.Group("/api/v1/ssh-user-keys")
+ sshUserKeys := api.Group("/ssh-user-keys")
{
sshUserKeys.GET("", g.handleListSSHUserKeys)
sshUserKeys.POST("", g.handleCreateSSHUserKey)
sshUserKeys.DELETE("/:id", g.handleDeleteSSHUserKey)
}
- auth := r.Group("/api/v1/auth")
- {
- auth.POST("/login", g.handleLogin)
- }
-
- images := r.Group("/api/v1/images")
+ images := api.Group("/images")
{
images.GET("", g.listImages)
}
// Image Registry APIs
- imageRegistries := r.Group("/api/v1/image-registries")
+ imageRegistries := adminAPI.Group("/image-registries")
{
imageRegistries.GET("", g.handleListImageRegistries)
imageRegistries.POST("", g.handleCreateImageRegistry)
- imageRegistries.GET("/:name", g.handleGetImageRegistry)
- imageRegistries.PUT("/:name", g.handleUpdateImageRegistry)
- imageRegistries.DELETE("/:name", g.handleDeleteImageRegistry)
+ imageRegistries.GET("/:id", g.handleGetImageRegistry)
+ imageRegistries.PUT("/:id", g.handleUpdateImageRegistry)
+ imageRegistries.DELETE("/:id", g.handleDeleteImageRegistry)
}
// System Config APIs
- systemConfig := r.Group("/api/v1/system-config")
+ systemConfig := api.Group("/system-config")
{
systemConfig.GET("", g.handleGetSystemConfig)
- systemConfig.PUT("", g.handleUpdateSystemConfig)
+ }
+ adminSystemConfig := adminAPI.Group("/system-config")
+ {
+ adminSystemConfig.PUT("", g.handleUpdateSystemConfig)
}
// Storage APIs
- storage := r.Group("/api/v1/storage")
+ storage := api.Group("/storage")
{
storage.GET("/storageclass", g.listStorageClass)
- storage.POST("/storageclass", g.createStorageClass)
- storage.PUT("/storageclass/:name", g.updateStorageClass)
- storage.DELETE("/storageclass/:name", g.deleteStorageClass)
- storage.GET("/storageclass/provider", g.listProvider)
scFiles := storage.Group("/storageclass/:name/:cluster")
{
@@ -134,22 +146,29 @@ func (g *Gateway) RegisterRoutes(r gin.IRouter) {
scFiles.DELETE("/object/*key", g.deleteStorageClassObject)
}
}
+ adminStorage := adminAPI.Group("/storage")
+ {
+ adminStorage.POST("/storageclass", g.createStorageClass)
+ adminStorage.PUT("/storageclass/:name", g.updateStorageClass)
+ adminStorage.DELETE("/storageclass/:name", g.deleteStorageClass)
+ adminStorage.GET("/storageclass/provider", g.listProvider)
+ }
// Addon Catalog APIs
- addons := r.Group("/api/v1/addons")
+ addons := adminAPI.Group("/addons")
{
addons.GET("", g.listAddonCatalog)
addons.GET("/:name", g.getAddonCatalog)
}
// Installed Addons API (all clusters or filtered by ?cluster=)
- installedAddons := r.Group("/api/v1/installed-addons")
+ installedAddons := adminAPI.Group("/installed-addons")
{
installedAddons.GET("", g.listInstalledAddons)
}
// Cluster Addon APIs
- clusterAddons := r.Group("/api/v1/clusters/:cluster_id/addons")
+ clusterAddons := adminAPI.Group("/clusters/:cluster_id/addons")
{
clusterAddons.GET("", g.listClusterAddons)
clusterAddons.POST("", g.installClusterAddon)
@@ -179,12 +198,13 @@ func (g *Gateway) rlinfv1alpha1DeleteWorkflow(c *gin.Context) { g.handleKubeDele
// --- Job handlers ---
-func (g *Gateway) rlinfv1alpha1ListJobs(c *gin.Context) { g.handleList("jobs")(c) }
-func (g *Gateway) rlinfv1alpha1CreateJob(c *gin.Context) { g.handleKubeCreate("jobs")(c) }
-func (g *Gateway) rlinfv1alpha1GetJob(c *gin.Context) { g.handleGet("jobs")(c) }
-func (g *Gateway) rlinfv1alpha1UpdateJob(c *gin.Context) { g.handleKubeUpdate("jobs")(c) }
-func (g *Gateway) rlinfv1alpha1PatchJob(c *gin.Context) { g.handleKubePatch("jobs")(c) }
-func (g *Gateway) rlinfv1alpha1DeleteJob(c *gin.Context) { g.handleKubeDelete("jobs")(c) }
+func (g *Gateway) rlinfv1alpha1ListJobs(c *gin.Context) { g.handleListJobs(c) }
+func (g *Gateway) rlinfv1alpha1ListJobTags(c *gin.Context) { g.handleListJobTags(c) }
+func (g *Gateway) rlinfv1alpha1CreateJob(c *gin.Context) { g.handleKubeCreate("jobs")(c) }
+func (g *Gateway) rlinfv1alpha1GetJob(c *gin.Context) { g.handleGet("jobs")(c) }
+func (g *Gateway) rlinfv1alpha1UpdateJob(c *gin.Context) { g.handleKubeUpdate("jobs")(c) }
+func (g *Gateway) rlinfv1alpha1PatchJob(c *gin.Context) { g.handleKubePatch("jobs")(c) }
+func (g *Gateway) rlinfv1alpha1DeleteJob(c *gin.Context) { g.handleKubeDelete("jobs")(c) }
// --- Job sub-resource handlers ---
diff --git a/apps/rlark/pkg/gateway/suk_handler.go b/apps/rlark/pkg/gateway/suk_handler.go
index 9a9a4ed..04d4bf5 100644
--- a/apps/rlark/pkg/gateway/suk_handler.go
+++ b/apps/rlark/pkg/gateway/suk_handler.go
@@ -2,6 +2,7 @@ package gateway
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"strconv"
@@ -21,6 +22,10 @@ import (
const (
sshKeyMaxRetries = 5
+ // sshKeyAddedAtAnnotationPrefix 是记录每个 user 公钥添加时间的 annotation 前缀。
+ // 完整 key = prefix + "." + user,value 是 RFC3339 字符串数组,与
+ // Secret.Data[user] 里按行分割的 key 一一对应。
+ sshKeyAddedAtAnnotationPrefix = "rlark.io/ssh-key-added-at"
)
type sshUserKeyItem struct {
@@ -42,7 +47,7 @@ func (g *Gateway) getSSHKeySecret(ctx context.Context) (*corev1.Secret, error) {
return nil, fmt.Errorf("raw kubernetes client not initialized")
}
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.SSHUserKeySecretName, metav1.GetOptions{})
+ secret, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Get(ctx, common.SSHUserKeySecretName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
return nil, nil
@@ -66,12 +71,12 @@ func (g *Gateway) ensureSSHKeySecret(ctx context.Context) (*corev1.Secret, error
secret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: common.SSHUserKeySecretName,
- Namespace: common.SecretNamespace,
+ Namespace: g.managementNamespace(),
},
Data: make(map[string][]byte),
}
- created, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
+ created, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
return g.getSSHKeySecret(ctx)
@@ -102,6 +107,66 @@ func parseSSHKeysFromSecret(secret *corev1.Secret) map[string][]string {
return result
}
+// sshKeyAddedAtAnnotationKey 返回记录某 user 公钥添加时间数组的 annotation key。
+func sshKeyAddedAtAnnotationKey(user string) string {
+ return sshKeyAddedAtAnnotationPrefix + "." + user
+}
+
+// readSSHKeyAddedAts 读取某 user 所有公钥的添加时间数组。
+// 返回 nil 表示没有记录(老数据)。
+func readSSHKeyAddedAts(secret *corev1.Secret, user string) []time.Time {
+ if secret.Annotations == nil {
+ return nil
+ }
+ raw, ok := secret.Annotations[sshKeyAddedAtAnnotationKey(user)]
+ if !ok || raw == "" {
+ return nil
+ }
+ var timestamps []string
+ if err := json.Unmarshal([]byte(raw), ×tamps); err != nil {
+ return nil
+ }
+ result := make([]time.Time, 0, len(timestamps))
+ for _, ts := range timestamps {
+ if t, err := time.Parse(time.RFC3339, ts); err == nil {
+ result = append(result, t)
+ }
+ }
+ return result
+}
+
+// writeSSHKeyAddedAts 把某 user 的添加时间数组写回 annotations。
+// 长度为 0 时删除该 annotation。
+func writeSSHKeyAddedAts(secret *corev1.Secret, user string, ats []time.Time) {
+ key := sshKeyAddedAtAnnotationKey(user)
+ if len(ats) == 0 {
+ delete(secret.Annotations, key)
+ return
+ }
+ if secret.Annotations == nil {
+ secret.Annotations = make(map[string]string)
+ }
+ timestamps := make([]string, len(ats))
+ for i, t := range ats {
+ timestamps[i] = t.UTC().Format(time.RFC3339)
+ }
+ data, err := json.Marshal(timestamps)
+ if err != nil {
+ return
+ }
+ secret.Annotations[key] = string(data)
+}
+
+// sshKeyAddedAt 读取某 user 第 index 个公钥的添加时间。
+// 老数据没有 annotation 时回落到 secret.CreationTimestamp。
+func sshKeyAddedAt(secret *corev1.Secret, user string, index int) time.Time {
+ ats := readSSHKeyAddedAts(secret, user)
+ if index >= 0 && index < len(ats) {
+ return ats[index]
+ }
+ return secret.CreationTimestamp.Time
+}
+
func (g *Gateway) handleListSSHUserKeys(c *gin.Context) {
logger := log.FromContext(c.Request.Context())
secret, err := g.getSSHKeySecret(c.Request.Context())
@@ -129,7 +194,7 @@ func (g *Gateway) handleListSSHUserKeys(c *gin.Context) {
Index: i,
User: user,
PublicKey: key,
- AddedAt: secret.CreationTimestamp.Format(time.RFC3339),
+ AddedAt: sshKeyAddedAt(secret, user, i).UTC().Format(time.RFC3339),
})
}
}
@@ -137,6 +202,20 @@ func (g *Gateway) handleListSSHUserKeys(c *gin.Context) {
c.JSON(http.StatusOK, items)
}
+func findSSHKeyDuplicate(keysByUser map[string][]string, user, publicKey string) string {
+ if _, exists := keysByUser[user]; exists {
+ return "public key name already exists"
+ }
+ for _, keys := range keysByUser {
+ for _, key := range keys {
+ if key == publicKey {
+ return "public key already exists"
+ }
+ }
+ }
+ return ""
+}
+
func (g *Gateway) handleCreateSSHUserKey(c *gin.Context) {
logger := log.FromContext(c.Request.Context())
@@ -151,6 +230,12 @@ func (g *Gateway) handleCreateSSHUserKey(c *gin.Context) {
return
}
+ req.User = strings.TrimSpace(req.User)
+ if req.User == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "user is required"})
+ return
+ }
+
pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(req.PublicKey))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid public key: %v", err)})
@@ -172,22 +257,22 @@ func (g *Gateway) handleCreateSSHUserKey(c *gin.Context) {
secret.Data = make(map[string][]byte)
}
- existing := strings.TrimSpace(string(secret.Data[req.User]))
- var lines []string
- if existing != "" {
- lines = strings.Split(existing, "\n")
+ keysByUser := parseSSHKeysFromSecret(secret)
+ if duplicate := findSSHKeyDuplicate(keysByUser, req.User, normalizedKey); duplicate != "" {
+ c.JSON(http.StatusConflict, gin.H{"error": duplicate})
+ return
}
- for _, line := range lines {
- if strings.TrimSpace(line) == normalizedKey {
- c.JSON(http.StatusConflict, gin.H{"error": "public key already exists for this user"})
- return
- }
- }
- lines = append(lines, normalizedKey)
+ lines := []string{normalizedKey}
secret.Data[req.User] = []byte(strings.Join(lines, "\n"))
- _, err = g.rawClient.CoreV1().Secrets(common.SecretNamespace).Update(ctx, secret, metav1.UpdateOptions{})
+ // 记录添加时间:按 user 存 RFC3339 数组到 annotation,
+ // index 与 Data 里的行号一一对应。老数据没有 annotation 时
+ // 列表接口会回落到 secret.CreationTimestamp。
+ // 当前 create 逻辑是覆盖该 user 的所有 key,所以这里直接重置为单元素。
+ writeSSHKeyAddedAts(secret, req.User, []time.Time{time.Now()})
+
+ _, err = g.rawClient.CoreV1().Secrets(g.managementNamespace()).Update(ctx, secret, metav1.UpdateOptions{})
if err != nil {
if errors.IsConflict(err) {
logger.Info("conflict updating ssh key secret, retrying", "attempt", attempt+1)
@@ -249,7 +334,14 @@ func (g *Gateway) handleDeleteSSHUserKey(c *gin.Context) {
delete(secret.Data, user)
}
- _, err = g.rawClient.CoreV1().Secrets(common.SecretNamespace).Update(ctx, secret, metav1.UpdateOptions{})
+ // 同步删除 annotation 里对应 index 的添加时间
+ addedAts := readSSHKeyAddedAts(secret, user)
+ if index < len(addedAts) {
+ addedAts = append(addedAts[:index], addedAts[index+1:]...)
+ }
+ writeSSHKeyAddedAts(secret, user, addedAts)
+
+ _, err = g.rawClient.CoreV1().Secrets(g.managementNamespace()).Update(ctx, secret, metav1.UpdateOptions{})
if err != nil {
if errors.IsConflict(err) {
logger.Info("conflict updating ssh key secret, retrying", "attempt", attempt+1)
diff --git a/apps/rlark/pkg/gateway/suk_handler_test.go b/apps/rlark/pkg/gateway/suk_handler_test.go
new file mode 100644
index 0000000..fce6c44
--- /dev/null
+++ b/apps/rlark/pkg/gateway/suk_handler_test.go
@@ -0,0 +1,116 @@
+package gateway
+
+import (
+ "testing"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestFindSSHKeyDuplicate(t *testing.T) {
+ keysByUser := map[string][]string{
+ "alice": {"ssh-ed25519 AAAA-alice"},
+ }
+
+ tests := []struct {
+ name string
+ user string
+ publicKey string
+ want string
+ }{
+ {name: "duplicate name", user: "alice", publicKey: "ssh-rsa AAAA-other", want: "public key name already exists"},
+ {name: "duplicate key", user: "bob", publicKey: "ssh-ed25519 AAAA-alice", want: "public key already exists"},
+ {name: "unique key", user: "bob", publicKey: "ssh-rsa AAAA-bob", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := findSSHKeyDuplicate(keysByUser, tt.user, tt.publicKey); got != tt.want {
+ t.Fatalf("findSSHKeyDuplicate() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSSHKeyAddedAtRoundTrip(t *testing.T) {
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ssh-user-keys",
+ CreationTimestamp: metav1.NewTime(time.Date(2026, 8, 26, 14, 38, 32, 0, time.UTC)),
+ },
+ Data: map[string][]byte{},
+ }
+
+ // 老数据:没 annotation,回落到 CreationTimestamp
+ if got := sshKeyAddedAt(secret, "alice", 0); !got.Equal(secret.CreationTimestamp.Time) {
+ t.Fatalf("expected fallback to CreationTimestamp, got %v", got)
+ }
+
+ // 写入两个 key 的时间
+ t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
+ t2 := time.Date(2026, 9, 21, 15, 30, 0, 0, time.UTC)
+ writeSSHKeyAddedAts(secret, "alice", []time.Time{t1, t2})
+
+ if got := sshKeyAddedAt(secret, "alice", 0); !got.Equal(t1) {
+ t.Fatalf("expected t1, got %v", got)
+ }
+ if got := sshKeyAddedAt(secret, "alice", 1); !got.Equal(t2) {
+ t.Fatalf("expected t2, got %v", got)
+ }
+ // 越界回落
+ if got := sshKeyAddedAt(secret, "alice", 5); !got.Equal(secret.CreationTimestamp.Time) {
+ t.Fatalf("expected fallback for out-of-range index, got %v", got)
+ }
+ // 其他 user 不受影响
+ if got := sshKeyAddedAt(secret, "bob", 0); !got.Equal(secret.CreationTimestamp.Time) {
+ t.Fatalf("expected fallback for unknown user, got %v", got)
+ }
+}
+
+// 删除中间一条后,后续 index 的时间应正确前移
+func TestSSHKeyAddedAtDeleteShiftsIndices(t *testing.T) {
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ssh-user-keys",
+ CreationTimestamp: metav1.NewTime(time.Now()),
+ },
+ Data: map[string][]byte{},
+ }
+
+ t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
+ t2 := time.Date(2026, 9, 10, 10, 0, 0, 0, time.UTC)
+ t3 := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
+ writeSSHKeyAddedAts(secret, "alice", []time.Time{t1, t2, t3})
+
+ // 模拟 delete index=1:删除中间一条
+ ats := readSSHKeyAddedAts(secret, "alice")
+ ats = append(ats[:1], ats[2:]...)
+ writeSSHKeyAddedAts(secret, "alice", ats)
+
+ if got := sshKeyAddedAt(secret, "alice", 0); !got.Equal(t1) {
+ t.Fatalf("index 0 should still be t1, got %v", got)
+ }
+ if got := sshKeyAddedAt(secret, "alice", 1); !got.Equal(t3) {
+ t.Fatalf("index 1 should be t3 after shift, got %v", got)
+ }
+ if len(readSSHKeyAddedAts(secret, "alice")) != 2 {
+ t.Fatalf("expected 2 timestamps after delete")
+ }
+}
+
+// 清空后 annotation 应被删除,避免积累空记录
+func TestSSHKeyAddedAtClearedWhenEmpty(t *testing.T) {
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "ssh-user-keys"},
+ Data: map[string][]byte{},
+ }
+ writeSSHKeyAddedAts(secret, "alice", []time.Time{time.Now()})
+ if _, ok := secret.Annotations[sshKeyAddedAtAnnotationKey("alice")]; !ok {
+ t.Fatal("expected annotation to be set")
+ }
+ writeSSHKeyAddedAts(secret, "alice", nil)
+ if _, ok := secret.Annotations[sshKeyAddedAtAnnotationKey("alice")]; ok {
+ t.Fatal("expected annotation to be removed when empty")
+ }
+}
diff --git a/apps/rlark/pkg/gateway/swagger_test.go b/apps/rlark/pkg/gateway/swagger_test.go
index 8180381..715cbd2 100644
--- a/apps/rlark/pkg/gateway/swagger_test.go
+++ b/apps/rlark/pkg/gateway/swagger_test.go
@@ -27,8 +27,9 @@ func TestSwaggerOperationsAreRegisteredRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
(&Gateway{}).RegisterRoutes(router)
-
- registered := make(map[string]struct{})
+ registered := map[string]struct{}{
+ "GET /metrics": {},
+ }
for _, route := range router.Routes() {
registered[route.Method+" "+openAPIPath(route.Path)] = struct{}{}
}
diff --git a/apps/rlark/pkg/gateway/systemconfig_handler.go b/apps/rlark/pkg/gateway/systemconfig_handler.go
index 24c5ee0..33e25c2 100644
--- a/apps/rlark/pkg/gateway/systemconfig_handler.go
+++ b/apps/rlark/pkg/gateway/systemconfig_handler.go
@@ -5,6 +5,9 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "net/url"
+ "strconv"
+ "strings"
"github.com/gin-gonic/gin"
corev1 "k8s.io/api/core/v1"
@@ -14,14 +17,16 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/common"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/logquery"
+ rlarkadmtypes "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/types"
)
// systemConfig is the unified request/response shape for the system config
// API. Each category is a sub-struct; missing categories in a PUT body are
// left unchanged.
type systemConfig struct {
- SSH *sshConfig `json:"ssh,omitempty"`
- Log *logquery.Config `json:"log,omitempty"`
+ SSH *sshConfig `json:"ssh,omitempty"`
+ Log *logquery.Config `json:"log,omitempty"`
+ Deployment *rlarkadmtypes.DeployConfig `json:"deployment,omitempty"`
}
type sshConfig struct {
@@ -29,8 +34,97 @@ type sshConfig struct {
JumpPort string `json:"jumpPort,omitempty"`
}
+func validateDeploymentConfig(cfg *rlarkadmtypes.DeployConfig) error {
+ if cfg == nil {
+ return nil
+ }
+ cfg.ControlPlaneAddress = strings.TrimSpace(cfg.ControlPlaneAddress)
+ cfg.SSHAddress = strings.TrimSpace(cfg.SSHAddress)
+ controlPlaneAddress := cfg.ControlPlaneAddress
+ if cfg.DB != nil || cfg.Docker != nil || cfg.Raw != nil || cfg.Cert != nil {
+ return fmt.Errorf("deployment defaults only support data-plane agent fields")
+ }
+ if cfg.Kubernetes == nil {
+ return fmt.Errorf("deployment defaults must use the kubernetes environment")
+ }
+ if cfg.Kubernetes.ManagementAPI != "" ||
+ cfg.Kubernetes.GatewayImage != "" ||
+ cfg.Kubernetes.ControllerManagerImage != "" ||
+ cfg.Kubernetes.ServerImage != "" ||
+ cfg.Kubernetes.KCPImage != "" ||
+ cfg.Kubernetes.EtcdImage != "" ||
+ cfg.Kubernetes.PostgresqlImage != "" ||
+ cfg.Kubernetes.UIImage != "" ||
+ cfg.Kubernetes.Replicas != 0 ||
+ cfg.Kubernetes.Storage != nil ||
+ cfg.Kubernetes.KCP != nil ||
+ cfg.Kubernetes.Etcd != nil ||
+ cfg.Kubernetes.Postgresql != nil {
+ return fmt.Errorf("deployment defaults only support kubernetes agent fields")
+ }
+ cfg.APIVersion = "rlark.io/v1alpha1"
+ cfg.Kind = "DeployConfig"
+ cfg.Plane = rlarkadmtypes.PlaneData
+ if cfg.ControlPlaneAddress == "" {
+ cfg.ControlPlaneAddress = "https://system-config-default.invalid"
+ }
+ cfg.Cert = &rlarkadmtypes.CertConfig{CACert: "configured", AgentCert: "configured", AgentKey: "configured"}
+ if err := cfg.Validate(); err != nil {
+ cfg.ControlPlaneAddress = controlPlaneAddress
+ cfg.Cert = nil
+ return err
+ }
+ cfg.ControlPlaneAddress = controlPlaneAddress
+ cfg.Cert = nil
+ return nil
+}
+
+func validateSSHConfig(cfg *sshConfig) error {
+ if cfg == nil {
+ return nil
+ }
+ host := strings.TrimSpace(cfg.JumpHost)
+ port := strings.TrimSpace(cfg.JumpPort)
+ if host == "" {
+ if port != "" {
+ return fmt.Errorf("jumpHost is required when jumpPort is set")
+ }
+ return nil
+ }
+ if strings.ContainsAny(host, " \t\r\n/@") {
+ return fmt.Errorf("jumpHost must be a hostname or IP address without spaces, scheme, user, or path")
+ }
+ if strings.Contains(host, ":") {
+ if parsed, err := url.Parse("ssh://" + host); err != nil || parsed.Hostname() != host {
+ return fmt.Errorf("jumpHost must not include a port; use jumpPort instead")
+ }
+ }
+ if port != "" {
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > 65535 {
+ return fmt.Errorf("jumpPort must be an integer between 1 and 65535")
+ }
+ }
+ cfg.JumpHost = host
+ cfg.JumpPort = port
+ return nil
+}
+
+func preserveMaskedLogFields(incoming, existing *logquery.Config) {
+ if incoming == nil || existing == nil || incoming.Backend != existing.Backend {
+ return
+ }
+ for _, key := range []string{"accessKeyId", "accessKeySecret"} {
+ if incoming.Config[key] == "****" {
+ if value, ok := existing.Config[key]; ok {
+ incoming.Config[key] = value
+ }
+ }
+ }
+}
+
func (g *Gateway) getSystemConfigSecret(ctx context.Context) (*corev1.Secret, error) {
- secret, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.SystemConfigSecretName, metav1.GetOptions{})
+ secret, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Get(ctx, common.SystemConfigSecretName, metav1.GetOptions{})
if err != nil {
return nil, err
}
@@ -48,12 +142,12 @@ func (g *Gateway) ensureSystemConfigSecret(ctx context.Context) (*corev1.Secret,
secret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: common.SystemConfigSecretName,
- Namespace: common.SecretNamespace,
+ Namespace: g.managementNamespace(),
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{},
}
- created, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
+ created, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create system config secret: %w", err)
}
@@ -81,6 +175,12 @@ func readSystemConfig(secret *corev1.Secret) *systemConfig {
cfg.Log = &l
}
}
+ if raw, ok := secret.Data[common.SystemConfigKeyDeployment]; ok {
+ var deployment rlarkadmtypes.DeployConfig
+ if err := json.Unmarshal(raw, &deployment); err == nil {
+ cfg.Deployment = &deployment
+ }
+ }
// Legacy flat keys take lower precedence and only fill gaps.
if cfg.SSH == nil {
@@ -118,6 +218,13 @@ func writeSystemConfigToSecret(secret *corev1.Secret, cfg *systemConfig) error {
}
secret.Data[common.SystemConfigKeyLog] = raw
}
+ if cfg.Deployment != nil {
+ raw, err := json.Marshal(cfg.Deployment)
+ if err != nil {
+ return fmt.Errorf("marshal deployment config: %w", err)
+ }
+ secret.Data[common.SystemConfigKeyDeployment] = raw
+ }
return nil
}
@@ -165,14 +272,13 @@ func (g *Gateway) handleUpdateSystemConfig(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
-
- // Validate the log backend config if provided, so we fail fast on bad
- // input instead of persisting something the querier can't construct.
- if req.Log != nil {
- if err := logquery.ValidateConfig(req.Log); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid log config: %v", err)})
- return
- }
+ if err := validateSSHConfig(req.SSH); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid ssh config: %v", err)})
+ return
+ }
+ if err := validateDeploymentConfig(req.Deployment); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid deployment config: %v", err)})
+ return
}
for attempt := 0; attempt < systemConfigMaxRetries; attempt++ {
@@ -182,13 +288,20 @@ func (g *Gateway) handleUpdateSystemConfig(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to access system config: %v", err)})
return
}
+ if req.Log != nil {
+ preserveMaskedLogFields(req.Log, readSystemConfig(secret).Log)
+ if err := logquery.ValidateConfig(req.Log); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid log config: %v", err)})
+ return
+ }
+ }
if err := writeSystemConfigToSecret(secret, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- if _, err := g.rawClient.CoreV1().Secrets(common.SecretNamespace).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
+ if _, err := g.rawClient.CoreV1().Secrets(g.managementNamespace()).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
if errors.IsConflict(err) {
logger.Info("conflict updating system config secret, retrying", "attempt", attempt+1)
continue
diff --git a/apps/rlark/pkg/gateway/systemconfig_handler_test.go b/apps/rlark/pkg/gateway/systemconfig_handler_test.go
new file mode 100644
index 0000000..4eeed96
--- /dev/null
+++ b/apps/rlark/pkg/gateway/systemconfig_handler_test.go
@@ -0,0 +1,109 @@
+package gateway
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/logquery"
+ rlarkadmtypes "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/types"
+ corev1 "k8s.io/api/core/v1"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+)
+
+func TestValidateSSHConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ config sshConfig
+ wantErr bool
+ }{
+ {name: "empty", config: sshConfig{}},
+ {name: "hostname and port", config: sshConfig{JumpHost: "jump.example.com", JumpPort: "2222"}},
+ {name: "port without host", config: sshConfig{JumpPort: "22"}, wantErr: true},
+ {name: "host with scheme", config: sshConfig{JumpHost: "ssh://jump.example.com"}, wantErr: true},
+ {name: "host with port", config: sshConfig{JumpHost: "jump.example.com:22"}, wantErr: true},
+ {name: "invalid port", config: sshConfig{JumpHost: "jump.example.com", JumpPort: "65536"}, wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := validateSSHConfig(&test.config)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("validateSSHConfig() error = %v, wantErr %v", err, test.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateDeploymentConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ config rlarkadmtypes.DeployConfig
+ wantErr bool
+ }{
+ {name: "valid", config: rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443", Kubernetes: &rlarkadmtypes.KubernetesEnv{Kubeconfig: "/etc/kubernetes/admin.conf", ImagePullPolicy: "IfNotPresent", ContainerdSocket: "/run/containerd/containerd.sock"}}},
+ {name: "address omitted for signing fallback", config: rlarkadmtypes.DeployConfig{Kubernetes: &rlarkadmtypes.KubernetesEnv{AgentImage: "rlark:latest"}}},
+ {name: "missing kubernetes", config: rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443"}, wantErr: true},
+ {name: "invalid pull policy", config: rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443", Kubernetes: &rlarkadmtypes.KubernetesEnv{ImagePullPolicy: "Sometimes"}}, wantErr: true},
+ {name: "control plane field", config: rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443", Kubernetes: &rlarkadmtypes.KubernetesEnv{ServerImage: "server:v1"}}, wantErr: true},
+ {name: "certificate field", config: rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443", Kubernetes: &rlarkadmtypes.KubernetesEnv{}, Cert: &rlarkadmtypes.CertConfig{CACert: "unexpected"}}, wantErr: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := validateDeploymentConfig(&test.config)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("validateDeploymentConfig() error = %v, wantErr %v", err, test.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateDeploymentConfigPreservesEmptyAddress(t *testing.T) {
+ config := &rlarkadmtypes.DeployConfig{Kubernetes: &rlarkadmtypes.KubernetesEnv{AgentImage: "rlark:latest"}}
+ if err := validateDeploymentConfig(config); err != nil {
+ t.Fatalf("validateDeploymentConfig() error = %v", err)
+ }
+ if config.ControlPlaneAddress != "" {
+ t.Fatalf("ControlPlaneAddress = %q, want empty", config.ControlPlaneAddress)
+ }
+}
+
+func TestDeploymentConfigRoundTrip(t *testing.T) {
+ want := &rlarkadmtypes.DeployConfig{ControlPlaneAddress: "https://rlark.example.com:8443", SSHAddress: "client@rlark.example.com:2222", Kubernetes: &rlarkadmtypes.KubernetesEnv{Kubeconfig: "~/.kube/config", AgentImage: "rlark:v1", Image: "rlark:v1", ImagePullPolicy: "IfNotPresent", ImagePullSecrets: []string{"registry-secret"}, ContainerdSocket: "/run/containerd/containerd.sock"}}
+ secret := &corev1.Secret{Data: map[string][]byte{}}
+ if err := writeSystemConfigToSecret(secret, &systemConfig{Deployment: want}); err != nil {
+ t.Fatalf("writeSystemConfigToSecret() error = %v", err)
+ }
+ if !json.Valid(secret.Data[common.SystemConfigKeyDeployment]) {
+ t.Fatal("deployment config was not stored as JSON")
+ }
+ got := readSystemConfig(secret).Deployment
+ if got == nil || got.ControlPlaneAddress != want.ControlPlaneAddress || got.SSHAddress != want.SSHAddress || got.Kubernetes == nil || got.Kubernetes.Kubeconfig != want.Kubernetes.Kubeconfig || got.Kubernetes.AgentImage != want.Kubernetes.AgentImage || got.Kubernetes.Image != want.Kubernetes.Image || got.Kubernetes.ImagePullPolicy != want.Kubernetes.ImagePullPolicy || len(got.Kubernetes.ImagePullSecrets) != 1 || got.Kubernetes.ContainerdSocket != want.Kubernetes.ContainerdSocket {
+ t.Fatalf("deployment config = %#v, want %#v", got, want)
+ }
+}
+
+func TestDeploymentConfigJSONUsesAPIFieldNames(t *testing.T) {
+ raw := []byte(`{"controlPlaneAddress":"https://rlark.example.com:8443","sshAddress":"client@rlark.example.com:2222","kubernetes":{"kubeconfig":"~/.kube/config","agentImage":"agent:v1","imagePullPolicy":"Never","containerdSocket":"/run/k3s/containerd/containerd.sock"}}`)
+ var config rlarkadmtypes.DeployConfig
+ if err := json.Unmarshal(raw, &config); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if config.ControlPlaneAddress == "" || config.SSHAddress == "" || config.Kubernetes == nil || config.Kubernetes.AgentImage != "agent:v1" || config.Kubernetes.ImagePullPolicy != "Never" {
+ t.Fatalf("deployment config was not decoded from API field names: %#v", config)
+ }
+}
+
+func TestPreserveMaskedLogFields(t *testing.T) {
+ incoming := &logquery.Config{Backend: "sls", Config: map[string]interface{}{
+ "endpoint": "new", "accessKeyId": "****", "accessKeySecret": "****",
+ }}
+ existing := &logquery.Config{Backend: "sls", Config: map[string]interface{}{
+ "accessKeyId": "real-id", "accessKeySecret": "real-secret",
+ }}
+
+ preserveMaskedLogFields(incoming, existing)
+
+ if incoming.Config["accessKeyId"] != "real-id" || incoming.Config["accessKeySecret"] != "real-secret" {
+ t.Fatalf("masked credentials were not preserved: %#v", incoming.Config)
+ }
+}
diff --git a/apps/rlark/pkg/gateway/terminal.go b/apps/rlark/pkg/gateway/terminal.go
index cf4f843..91eff39 100644
--- a/apps/rlark/pkg/gateway/terminal.go
+++ b/apps/rlark/pkg/gateway/terminal.go
@@ -92,7 +92,7 @@ func (g *Gateway) handlePodTerminal(c *gin.Context) {
serverWs, _, err := serverDialer.DialContext(ctx, serverURL, nil)
if err != nil {
logger.Error(err, "failed to dial server terminal WebSocket")
- _ = browserWs.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("failed to connect to server: %v\r\n", err)))
+ _ = browserWs.WriteMessage(websocket.TextMessage, fmt.Appendf(nil, "failed to connect to server: %v\r\n", err))
return
}
defer func() { _ = serverWs.Close() }()
diff --git a/apps/rlark/pkg/logquery/querier.go b/apps/rlark/pkg/logquery/querier.go
index 5d172be..1d3282f 100644
--- a/apps/rlark/pkg/logquery/querier.go
+++ b/apps/rlark/pkg/logquery/querier.go
@@ -40,6 +40,11 @@ type Query struct {
// Empty means "start from the beginning of the time range". Each backend
// interprets the token in its own way; callers must treat it as opaque.
Cursor string
+
+ // Reverse controls the order of returned entries.
+ // true (default): newest first (倒序,最新日志在前)
+ // false: oldest first (正序,最旧日志在前)
+ Reverse bool
}
// Entry is a single log line.
@@ -63,6 +68,11 @@ type Result struct {
type Querier interface {
// Query runs a one-shot query against the backend.
Query(ctx context.Context, q Query) (*Result, error)
+
+ // LabelValues returns all unique values for a given label within the
+ // specified time range and optional filters. This is used to populate
+ // dropdowns (e.g. "pod", "task") for historical log browsing.
+ LabelValues(ctx context.Context, label string, from, to time.Time, filters map[string]string) ([]string, error)
}
// NewQuerier constructs a Querier for the given config. Returns an error if
@@ -86,6 +96,8 @@ func ValidateConfig(cfg *Config) error {
return fmt.Errorf("log config is nil")
}
switch cfg.Backend {
+ case "none":
+ return nil
case "sls":
return validateSLSConfig(cfg.Config)
default:
diff --git a/apps/rlark/pkg/logquery/querier_test.go b/apps/rlark/pkg/logquery/querier_test.go
new file mode 100644
index 0000000..7a97a98
--- /dev/null
+++ b/apps/rlark/pkg/logquery/querier_test.go
@@ -0,0 +1,9 @@
+package logquery
+
+import "testing"
+
+func TestValidateConfigNone(t *testing.T) {
+ if err := ValidateConfig(&Config{Backend: "none"}); err != nil {
+ t.Fatalf("ValidateConfig() error = %v", err)
+ }
+}
diff --git a/apps/rlark/pkg/logquery/sls.go b/apps/rlark/pkg/logquery/sls.go
index 86a2266..26c670a 100644
--- a/apps/rlark/pkg/logquery/sls.go
+++ b/apps/rlark/pkg/logquery/sls.go
@@ -150,7 +150,7 @@ func (q *slsQuerier) Query(ctx context.Context, query Query) (*Result, error) {
queryExp,
fetchLimit,
offset,
- true,
+ query.Reverse,
)
if err != nil {
return nil, fmt.Errorf("sls GetLogs: %w", err)
@@ -233,3 +233,61 @@ func (q *slsQuerier) Query(ctx context.Context, query Query) (*Result, error) {
return result, nil
}
+
+// LabelValues implements Querier.LabelValues for Aliyun SLS.
+// It uses SLS SQL analysis (SELECT DISTINCT) to get unique values efficiently,
+// avoiding the 100-line limit of raw log queries.
+// NOTE: Requires the target field to have analytics enabled in SLS index config.
+func (q *slsQuerier) LabelValues(ctx context.Context, label string, from, to time.Time, filters map[string]string) ([]string, error) {
+ // 构造过滤条件(WHERE 部分)
+ var conditions []string
+ for k, v := range filters {
+ if k == "" || v == "" {
+ continue
+ }
+ escaped := strings.ReplaceAll(v, `'`, `\'`)
+ conditions = append(conditions, fmt.Sprintf(`"content.%s" = '%s'`, k, escaped))
+ }
+ // 确保目标字段存在(非空)
+ conditions = append(conditions, fmt.Sprintf(`"content.%s" IS NOT NULL`, label))
+ whereClause := strings.Join(conditions, " AND ")
+
+ // 使用 SLS SQL 分析查询 DISTINCT 值
+ // 语法:* | SELECT DISTINCT "content.pod" AS value WHERE ... LIMIT 1000
+ queryExp := fmt.Sprintf(`* | SELECT DISTINCT "content.%s" AS value LIMIT 1000`, label)
+ if whereClause != "" {
+ queryExp = fmt.Sprintf(`* | SELECT DISTINCT "content.%s" AS value WHERE %s LIMIT 1000`, label, whereClause)
+ }
+
+ logs, err := q.client.GetLogs(
+ q.project,
+ q.logstore,
+ "",
+ from.Unix(),
+ to.Unix(),
+ queryExp,
+ 1000,
+ 0,
+ false,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("sls GetLogs for label values: %w", err)
+ }
+
+ valueSet := make(map[string]struct{})
+ for _, lg := range logs.Logs {
+ for k, v := range lg {
+ // SQL 查询返回的字段名是 "value"(我们在 SELECT 中起的别名)
+ if k == "value" && v != "" {
+ valueSet[v] = struct{}{}
+ }
+ }
+ }
+
+ values := make([]string, 0, len(valueSet))
+ for v := range valueSet {
+ values = append(values, v)
+ }
+
+ return values, nil
+}
diff --git a/apps/rlark/pkg/network/nodeserver/config.go b/apps/rlark/pkg/network/nodeserver/config.go
index 7097c45..52aaecd 100644
--- a/apps/rlark/pkg/network/nodeserver/config.go
+++ b/apps/rlark/pkg/network/nodeserver/config.go
@@ -4,6 +4,8 @@ import (
"fmt"
"net"
"os"
+ "path/filepath"
+ "time"
"github.com/spf13/pflag"
)
@@ -11,36 +13,76 @@ import (
// Config holds configuration options.
type Config struct {
UnixSocketAddress string
+ DrainTimeout time.Duration
}
// DefaultConfig returns the default config.
func DefaultConfig() Config {
return Config{
UnixSocketAddress: "/var/run/rlark/nodeserver.sock",
+ DrainTimeout: 30 * time.Minute,
}
}
// SetupFlags sets the upFlags.
func (c *Config) SetupFlags(fs *pflag.FlagSet) {
fs.StringVar(&c.UnixSocketAddress, "nodeserver-unix-socket", c.UnixSocketAddress, "Unix socket address for node server")
+ fs.DurationVar(&c.DrainTimeout, "nodeserver-drain-timeout", c.DrainTimeout, "Maximum time to drain active node server connections during shutdown")
}
-// Listen lists the en.
-func (c *Config) Listen() (net.Listener, error) {
- s, err := os.Stat(c.UnixSocketAddress)
+// Listen creates a unique instance socket without publishing it at the stable
+// path. Call Publish once the server's dependencies are ready.
+func (c *Config) Listen() (net.Listener, string, error) {
+ stableDir := filepath.Dir(c.UnixSocketAddress)
+ instanceFile, err := os.CreateTemp(stableDir, ".nodeserver-*.sock")
if err != nil {
- if !os.IsNotExist(err) {
- return nil, err
- }
- } else {
- if s.Mode()&os.ModeSocket == 0 {
- return nil, &os.PathError{
- Op: "listen",
- Path: c.UnixSocketAddress,
- Err: fmt.Errorf("not a socket"),
- }
- }
- _ = os.Remove(c.UnixSocketAddress)
- }
- return net.Listen("unix", c.UnixSocketAddress)
+ return nil, "", fmt.Errorf("reserve instance socket path: %w", err)
+ }
+ instanceAddress := instanceFile.Name()
+ if err := instanceFile.Close(); err != nil {
+ _ = os.Remove(instanceAddress)
+ return nil, "", fmt.Errorf("close reserved instance socket: %w", err)
+ }
+ if err := os.Remove(instanceAddress); err != nil {
+ return nil, "", fmt.Errorf("prepare instance socket: %w", err)
+ }
+ l, err := net.Listen("unix", instanceAddress)
+ if err != nil {
+ return nil, "", err
+ }
+ return l, instanceAddress, nil
+}
+
+// Publish atomically switches the stable socket path to instanceAddress.
+func (c *Config) Publish(instanceAddress string) error {
+ stableDir := filepath.Dir(c.UnixSocketAddress)
+ instanceDir := filepath.Dir(instanceAddress)
+ if stableDir != instanceDir {
+ return fmt.Errorf("stable and instance Unix sockets must be in the same directory")
+ }
+ tempLink, err := os.CreateTemp(stableDir, ".nodeserver-socket-*")
+ if err != nil {
+ return fmt.Errorf("create temporary socket link: %w", err)
+ }
+ tempPath := tempLink.Name()
+ if err := tempLink.Close(); err != nil {
+ _ = os.Remove(tempPath)
+ return fmt.Errorf("close temporary socket link: %w", err)
+ }
+ if err := os.Remove(tempPath); err != nil {
+ return fmt.Errorf("prepare temporary socket link: %w", err)
+ }
+ defer func() { _ = os.Remove(tempPath) }()
+
+ if err := os.Symlink(filepath.Base(instanceAddress), tempPath); err != nil {
+ return fmt.Errorf("create temporary socket link: %w", err)
+ }
+ if err := os.Rename(tempPath, c.UnixSocketAddress); err != nil {
+ return fmt.Errorf("publish instance socket: %w", err)
+ }
+ return nil
+}
+
+func (c *Config) CleanupSocket(instanceAddress string) {
+ _ = os.Remove(instanceAddress)
}
diff --git a/apps/rlark/pkg/network/nodeserver/hosts_test.go b/apps/rlark/pkg/network/nodeserver/hosts_test.go
new file mode 100644
index 0000000..3c837bf
--- /dev/null
+++ b/apps/rlark/pkg/network/nodeserver/hosts_test.go
@@ -0,0 +1,80 @@
+package nodeserver
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync"
+ "testing"
+ "time"
+)
+
+type testPodCred struct{}
+
+func (testPodCred) IP() string { return "10.0.0.1" }
+func (testPodCred) IPPrefixLength() int { return 24 }
+
+func TestWatchHostsReturnsWhenVersionChanges(t *testing.T) {
+ var mu sync.RWMutex
+ hosts := map[string]string{"pod-a": "10.0.0.1"}
+ s := NewNodeServer(
+ DefaultConfig(),
+ func(context.Context, int32) (testPodCred, error) { return testPodCred{}, nil },
+ nil,
+ func(context.Context, testPodCred) (map[string]string, error) {
+ mu.RLock()
+ defer mu.RUnlock()
+ copy := make(map[string]string, len(hosts))
+ for k, v := range hosts {
+ copy[k] = v
+ }
+ return copy, nil
+ },
+ func(testPodCred) (string, error) { return "pod", nil },
+ func(string) (testPodCred, error) { return testPodCred{}, nil },
+ )
+
+ server := httptest.NewServer(s.localServiceRouter())
+ defer server.Close()
+ initialVersion := hostsVersion(hosts)
+
+ result := make(chan *http.Response, 1)
+ go func() {
+ resp, err := http.Get(server.URL + "/watch_hosts?version=" + url.QueryEscape(initialVersion))
+ if err != nil {
+ result <- nil
+ return
+ }
+ result <- resp
+ }()
+
+ time.Sleep(100 * time.Millisecond)
+ mu.Lock()
+ hosts = map[string]string{"pod-a": "10.0.0.2"}
+ mu.Unlock()
+
+ select {
+ case resp := <-result:
+ if resp == nil {
+ t.Fatal("watch request failed")
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ var got map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got["pod-a"] != "10.0.0.2" {
+ t.Fatalf("hosts = %v", got)
+ }
+ if resp.Header.Get("ETag") == initialVersion {
+ t.Fatal("ETag did not change")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("watch did not return after hosts changed")
+ }
+}
diff --git a/apps/rlark/pkg/network/nodeserver/server.go b/apps/rlark/pkg/network/nodeserver/server.go
index 6406575..6bd19da 100644
--- a/apps/rlark/pkg/network/nodeserver/server.go
+++ b/apps/rlark/pkg/network/nodeserver/server.go
@@ -2,14 +2,17 @@ package nodeserver
import (
"context"
+ "crypto/sha256"
+ "encoding/json"
"fmt"
- "io"
"net"
"net/http"
"net/url"
+ "sync"
"time"
"github.com/gin-gonic/gin"
+ "github.com/go-logr/logr"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
@@ -38,6 +41,14 @@ type NodeServer[C PodCred] struct {
marshalCred func(C) (string, error)
unmarshalCred func(string) (C, error)
localServiceDialer utils.Dial
+
+ connectionsMu sync.Mutex
+ connections map[*utils.WrapConn]net.Conn
+ connectionsWG sync.WaitGroup
+}
+
+type Lifecycle interface {
+ SetReady(bool)
}
// NewNodeServer creates a new NodeServer.
@@ -53,16 +64,14 @@ func NewNodeServer[C PodCred](
getHosts: getHosts,
marshalCred: marshalCred,
unmarshalCred: unmarshalCred,
+ connections: make(map[*utils.WrapConn]net.Conn),
}
}
func (s *NodeServer[C]) startLocalService(ctx context.Context) error {
l, d := utils.NetPipeWithBuffer(65536)
s.localServiceDialer = d
- r := gin.New()
- r.Use(gin.Recovery())
- r.GET("/get_ip", s.handleGetIP)
- r.GET("/get_hosts", s.handleGetHosts)
+ r := s.localServiceRouter()
srv := http.Server{Handler: r}
go func() {
@@ -71,27 +80,53 @@ func (s *NodeServer[C]) startLocalService(ctx context.Context) error {
return nil
}
-// Run runs the component.
-func (s *NodeServer[C]) Run(ctx context.Context) error {
+func (s *NodeServer[C]) localServiceRouter() http.Handler {
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.GET("/get_ip", s.handleGetIP)
+ r.GET("/get_hosts", s.handleGetHosts)
+ r.GET("/watch_hosts", s.handleWatchHosts)
+ return r
+}
+
+// Run starts the instance listener, publishes the stable socket entry, and
+// marks the lifecycle ready only after all dependencies have initialized.
+func (s *NodeServer[C]) Run(ctx context.Context, lifecycle Lifecycle) error {
logger := log.FromContext(ctx)
if err := s.startLocalService(ctx); err != nil {
return fmt.Errorf("start local service: %w", err)
}
- l, err := s.config.Listen()
+ l, instanceAddress, err := s.config.Listen()
if err != nil {
return err
}
- defer func() { _ = l.Close() }()
+ defer func() {
+ lifecycle.SetReady(false)
+ _ = l.Close()
+ s.config.CleanupSocket(instanceAddress)
+ }()
+ if err := s.config.Publish(instanceAddress); err != nil {
+ return err
+ }
+ lifecycle.SetReady(true)
+ go func() {
+ <-ctx.Done()
+ lifecycle.SetReady(false)
+ _ = l.Close()
+ }()
- logger.Info("Node server listening", "address", s.config.UnixSocketAddress)
+ logger.Info("Node server listening", "address", instanceAddress, "stableAddress", s.config.UnixSocketAddress)
for {
select {
case <-ctx.Done():
- return nil
+ return s.drainConnections(logger)
default:
conn, err := l.Accept()
if err != nil {
+ if ctx.Err() != nil {
+ return s.drainConnections(logger)
+ }
return err
}
pid, err := GetPeerProcess(conn)
@@ -107,9 +142,76 @@ func (s *NodeServer[C]) Run(ctx context.Context) error {
continue
}
metrics.IncConnections()
- go s.handleConnection(ctx, utils.NewWrapConn(conn), cred)
+ wrappedConn := utils.NewWrapConn(conn)
+ s.trackConnection(wrappedConn)
+ go func() {
+ defer s.untrackConnection(wrappedConn)
+ s.handleConnection(context.Background(), wrappedConn, cred)
+ }()
+ }
+ }
+}
+
+func (s *NodeServer[C]) trackConnection(conn *utils.WrapConn) {
+ s.connectionsMu.Lock()
+ s.connections[conn] = nil
+ s.connectionsWG.Add(1)
+ s.connectionsMu.Unlock()
+}
+
+func (s *NodeServer[C]) setUpstreamConnection(conn *utils.WrapConn, upstream net.Conn) {
+ s.connectionsMu.Lock()
+ if _, ok := s.connections[conn]; ok {
+ s.connections[conn] = upstream
+ }
+ s.connectionsMu.Unlock()
+}
+
+func (s *NodeServer[C]) untrackConnection(conn *utils.WrapConn) {
+ s.connectionsMu.Lock()
+ delete(s.connections, conn)
+ s.connectionsMu.Unlock()
+ s.connectionsWG.Done()
+}
+
+func (s *NodeServer[C]) drainConnections(logger logr.Logger) error {
+ drained := make(chan struct{})
+ go func() {
+ s.connectionsWG.Wait()
+ close(drained)
+ }()
+
+ if s.config.DrainTimeout <= 0 {
+ <-drained
+ return nil
+ }
+
+ timer := time.NewTimer(s.config.DrainTimeout)
+ defer timer.Stop()
+ select {
+ case <-drained:
+ return nil
+ case <-timer.C:
+ logger.Info("Node server drain timed out", "timeout", s.config.DrainTimeout)
+ s.closeConnections()
+ <-drained
+ return nil
+ }
+}
+
+func (s *NodeServer[C]) closeConnections() {
+ s.connectionsMu.Lock()
+ connections := make([]net.Conn, 0, len(s.connections)*2)
+ for conn, upstream := range s.connections {
+ connections = append(connections, conn)
+ if upstream != nil {
+ connections = append(connections, upstream)
}
}
+ s.connectionsMu.Unlock()
+ for _, conn := range connections {
+ _ = conn.Close()
+ }
}
// handleConnection 处理来自本地进程的连接请求,读取目标地址并通过 dialer 连接到目标。
@@ -173,32 +275,15 @@ func (s *NodeServer[C]) handleConnection(ctx context.Context, conn *utils.WrapCo
return
}
}
+ s.setUpstreamConnection(conn, conn2)
- // 记录结束方向和原因,用于定位"谁在断连接"。
- type copyResult struct {
- direction string
- err error
+ err1, err2 := utils.RelayConnections(conn, conn2, "sidecar", "upstream")
+ if err1 != nil {
+ logger.Error(err1, "Error relaying connection", "host", host, "port", port)
}
- resultCh := make(chan copyResult, 2)
- go func() {
- _, err := io.Copy(conn2, conn) // sidecar → 上游
- resultCh <- copyResult{direction: "sidecar->upstream", err: err}
- }()
- go func() {
- _, err := io.Copy(conn, conn2) // 上游 → sidecar
- resultCh <- copyResult{direction: "upstream->sidecar", err: err}
- }()
-
- for range 2 {
- result := <-resultCh
- logger.Info("Forwarding connection closing",
- "host", host, "port", port,
- "closedBy", result.direction,
- "err", result.err,
- "errType", fmt.Sprintf("%T", result.err),
- )
+ if err2 != nil {
+ logger.Error(err2, "Error relaying connection", "host", host, "port", port)
}
- close(resultCh)
}
func (s *NodeServer[C]) handleGetIP(ctx *gin.Context) {
@@ -234,3 +319,46 @@ func (s *NodeServer[C]) handleGetHosts(ctx *gin.Context) {
}
ctx.JSON(http.StatusOK, hosts)
}
+
+func (s *NodeServer[C]) handleWatchHosts(ctx *gin.Context) {
+ cred, err := s.unmarshalCred(ctx.Request.RemoteAddr)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ version := ctx.Query("version")
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+ timeout := time.NewTimer(25 * time.Second)
+ defer timeout.Stop()
+
+ for {
+ hosts, err := s.getHosts(ctx.Request.Context(), cred)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ currentVersion := hostsVersion(hosts)
+ if currentVersion != version {
+ ctx.Header("ETag", currentVersion)
+ ctx.JSON(http.StatusOK, hosts)
+ return
+ }
+
+ select {
+ case <-ctx.Request.Context().Done():
+ return
+ case <-timeout.C:
+ ctx.Status(http.StatusNoContent)
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
+func hostsVersion(hosts map[string]string) string {
+ data, _ := json.Marshal(hosts)
+ sum := sha256.Sum256(data)
+ return fmt.Sprintf("%x", sum)
+}
diff --git a/apps/rlark/pkg/network/nodeserver/server_test.go b/apps/rlark/pkg/network/nodeserver/server_test.go
new file mode 100644
index 0000000..0311994
--- /dev/null
+++ b/apps/rlark/pkg/network/nodeserver/server_test.go
@@ -0,0 +1,295 @@
+package nodeserver
+
+import (
+ "context"
+ "net"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
+)
+
+type testLifecycle struct {
+ ready chan bool
+}
+
+func (l *testLifecycle) SetReady(ready bool) {
+ select {
+ case l.ready <- ready:
+ default:
+ }
+}
+
+func TestDrainConnectionsWaitsForActiveConnection(t *testing.T) {
+ s := newTestNodeServer(500 * time.Millisecond)
+ serverConn, peerConn := net.Pipe()
+ wrappedConn := utils.NewWrapConn(serverConn)
+ s.trackConnection(wrappedConn)
+
+ done := make(chan error, 1)
+ go func() {
+ done <- s.drainConnections(log.GetLogger())
+ }()
+
+ select {
+ case <-done:
+ t.Fatal("drain returned while connection was active")
+ case <-time.After(20 * time.Millisecond):
+ }
+
+ s.untrackConnection(wrappedConn)
+ _ = serverConn.Close()
+ _ = peerConn.Close()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("drain did not return after connection closed")
+ }
+}
+
+func TestDrainConnectionsClosesConnectionAfterTimeout(t *testing.T) {
+ s := newTestNodeServer(20 * time.Millisecond)
+ serverConn, peerConn := net.Pipe()
+ wrappedConn := utils.NewWrapConn(serverConn)
+ s.trackConnection(wrappedConn)
+
+ connectionDone := make(chan struct{})
+ go func() {
+ defer close(connectionDone)
+ defer s.untrackConnection(wrappedConn)
+ buffer := make([]byte, 1)
+ _, _ = serverConn.Read(buffer)
+ }()
+
+ if err := s.drainConnections(log.GetLogger()); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-connectionDone:
+ case <-time.After(time.Second):
+ t.Fatal("timed-out drain did not close active connection")
+ }
+ _ = peerConn.Close()
+}
+
+func TestRunStopsListeningAndDrainsConnection(t *testing.T) {
+ config := DefaultConfig()
+ config.UnixSocketAddress = filepath.Join(t.TempDir(), "nodeserver.sock")
+ config.DrainTimeout = time.Second
+ s := NewNodeServer(
+ config,
+ func(context.Context, int32) (testPodCred, error) { return testPodCred{}, nil },
+ func(context.Context, testPodCred, string, url.Values) (utils.Dial, error) {
+ return func(context.Context) (net.Conn, error) {
+ server, peer := net.Pipe()
+ go func() {
+ defer func() { _ = peer.Close() }()
+ buffer := make([]byte, 1024)
+ for {
+ n, err := peer.Read(buffer)
+ if err != nil {
+ return
+ }
+ if _, err := peer.Write(buffer[:n]); err != nil {
+ return
+ }
+ }
+ }()
+ return server, nil
+ }, nil
+ },
+ nil,
+ func(testPodCred) (string, error) { return "pod", nil },
+ func(string) (testPodCred, error) { return testPodCred{}, nil },
+ )
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ lifecycle := &testLifecycle{ready: make(chan bool, 4)}
+ go func() { done <- s.Run(ctx, lifecycle) }()
+ waitForReady(t, lifecycle)
+
+ conn := dialUnixEventually(t, config.UnixSocketAddress)
+ if _, err := conn.Write([]byte("tcp://10.0.0.2:80\n")); err != nil {
+ t.Fatal(err)
+ }
+ waitForActiveConnections(t, s, 1)
+ cancel()
+
+ select {
+ case <-done:
+ t.Fatal("server returned before active connection drained")
+ case <-time.After(20 * time.Millisecond):
+ }
+ if _, err := net.DialTimeout("unix", config.UnixSocketAddress, 20*time.Millisecond); err == nil {
+ t.Fatal("server accepted a new connection while draining")
+ }
+
+ _ = conn.Close()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("server did not finish draining")
+ }
+}
+
+func TestListenAtomicallySwitchesStableSocket(t *testing.T) {
+ config := DefaultConfig()
+ config.UnixSocketAddress = filepath.Join(t.TempDir(), "nodeserver.sock")
+
+ oldListener, oldAddress, err := config.Listen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ _ = oldListener.Close()
+ config.CleanupSocket(oldAddress)
+ }()
+ if _, err := os.Lstat(config.UnixSocketAddress); !os.IsNotExist(err) {
+ t.Fatalf("stable socket published before readiness: %v", err)
+ }
+ if err := config.Publish(oldAddress); err != nil {
+ t.Fatal(err)
+ }
+ if target := readSocketLink(t, config.UnixSocketAddress); target != filepath.Base(oldAddress) {
+ t.Fatalf("stable socket target = %q, want %q", target, filepath.Base(oldAddress))
+ }
+
+ newListener, newAddress, err := config.Listen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ _ = newListener.Close()
+ config.CleanupSocket(newAddress)
+ }()
+ if target := readSocketLink(t, config.UnixSocketAddress); target != filepath.Base(oldAddress) {
+ t.Fatalf("listen changed stable socket target to %q", target)
+ }
+ if err := config.Publish(newAddress); err != nil {
+ t.Fatal(err)
+ }
+ if target := readSocketLink(t, config.UnixSocketAddress); target != filepath.Base(newAddress) {
+ t.Fatalf("stable socket target = %q, want %q", target, filepath.Base(newAddress))
+ }
+
+ oldAccepted := make(chan net.Conn, 1)
+ go func() {
+ conn, _ := oldListener.Accept()
+ oldAccepted <- conn
+ }()
+ oldConn, err := net.Dial("unix", oldAddress)
+ if err != nil {
+ t.Fatalf("dial old instance after switch: %v", err)
+ }
+ defer func() { _ = oldConn.Close() }()
+ if conn := <-oldAccepted; conn != nil {
+ defer func() { _ = conn.Close() }()
+ }
+
+ newAccepted := make(chan net.Conn, 1)
+ go func() {
+ conn, _ := newListener.Accept()
+ newAccepted <- conn
+ }()
+ newConn, err := net.Dial("unix", config.UnixSocketAddress)
+ if err != nil {
+ t.Fatalf("dial stable socket after switch: %v", err)
+ }
+ defer func() { _ = newConn.Close() }()
+ if conn := <-newAccepted; conn != nil {
+ defer func() { _ = conn.Close() }()
+ }
+
+ config.CleanupSocket(oldAddress)
+ if target := readSocketLink(t, config.UnixSocketAddress); target != filepath.Base(newAddress) {
+ t.Fatalf("old cleanup changed stable socket target to %q", target)
+ }
+}
+
+func TestCleanupSocketLeavesStableSocketForAtomicReplacement(t *testing.T) {
+ config := DefaultConfig()
+ config.UnixSocketAddress = filepath.Join(t.TempDir(), "nodeserver.sock")
+ listener, instanceAddress, err := config.Listen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := config.Publish(instanceAddress); err != nil {
+ t.Fatal(err)
+ }
+ _ = listener.Close()
+
+ config.CleanupSocket(instanceAddress)
+ if target := readSocketLink(t, config.UnixSocketAddress); target != filepath.Base(instanceAddress) {
+ t.Fatalf("stable socket target = %q, want %q", target, filepath.Base(instanceAddress))
+ }
+ if _, err := os.Lstat(instanceAddress); !os.IsNotExist(err) {
+ t.Fatalf("instance socket still exists after cleanup: %v", err)
+ }
+}
+
+func newTestNodeServer(drainTimeout time.Duration) *NodeServer[testPodCred] {
+ config := DefaultConfig()
+ config.DrainTimeout = drainTimeout
+ return NewNodeServer[testPodCred](config, nil, nil, nil, nil, nil)
+}
+
+func dialUnixEventually(t *testing.T, address string) net.Conn {
+ t.Helper()
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ conn, err := net.DialTimeout("unix", address, 20*time.Millisecond)
+ if err == nil {
+ return conn
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("node server did not listen on %s", address)
+ return nil
+}
+
+func waitForActiveConnections(t *testing.T, s *NodeServer[testPodCred], want int) {
+ t.Helper()
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ s.connectionsMu.Lock()
+ got := len(s.connections)
+ s.connectionsMu.Unlock()
+ if got == want {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("active connections did not reach %d", want)
+}
+
+func readSocketLink(t *testing.T, address string) string {
+ t.Helper()
+ target, err := os.Readlink(address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return target
+}
+
+func waitForReady(t *testing.T, lifecycle *testLifecycle) {
+ t.Helper()
+ select {
+ case ready := <-lifecycle.ready:
+ if !ready {
+ t.Fatal("node server became not ready before readiness")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("node server did not become ready")
+ }
+}
diff --git a/apps/rlark/pkg/network/sidecar/hosts.go b/apps/rlark/pkg/network/sidecar/hosts.go
index b65c618..5899a2b 100644
--- a/apps/rlark/pkg/network/sidecar/hosts.go
+++ b/apps/rlark/pkg/network/sidecar/hosts.go
@@ -2,9 +2,11 @@ package sidecar
import (
"context"
+ "crypto/sha256"
"encoding/json"
"fmt"
"net/http"
+ "net/url"
"os"
"sort"
"strings"
@@ -27,6 +29,15 @@ type hostsSyncer struct {
transport http.RoundTripper
hostsFile string
interval time.Duration
+ version string
+ hosts map[string]string
+}
+
+type hostsWatchResult struct {
+ supported bool
+ hosts map[string]string
+ version string
+ err error
}
// newHostsSyncer creates a new hostsSyncer.
@@ -47,14 +58,47 @@ func (h *hostsSyncer) Run(ctx context.Context) error {
logger.Info("Initial hosts sync failed", "err", err)
}
- ticker := time.NewTicker(h.interval)
- defer ticker.Stop()
-
+ repairTicker := time.NewTicker(time.Second)
+ defer repairTicker.Stop()
+ pollTicker := time.NewTicker(h.interval)
+ defer pollTicker.Stop()
+ watchResults := make(chan hostsWatchResult, 1)
+ watchEnabled := true
+ watching := false
for {
+ if watchEnabled && !watching {
+ watching = true
+ go func(version string) {
+ watchResults <- h.watchOnce(ctx, version)
+ }(h.version)
+ }
+
select {
- case <-ticker.C:
- if err := h.syncOnce(ctx); err != nil {
- logger.Info("Hosts sync failed", "err", err)
+ case result := <-watchResults:
+ watching = false
+ watchEnabled = result.supported
+ if result.err != nil {
+ logger.Info("Hosts watch failed", "err", result.err)
+ select {
+ case <-time.After(time.Second):
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ if result.hosts != nil {
+ if err := h.applyHosts(result.hosts, result.version); err != nil {
+ logger.Info("Hosts sync failed", "err", err)
+ }
+ }
+ case <-repairTicker.C:
+ if err := h.repairHostsFile(); err != nil {
+ logger.Info("Hosts file repair failed", "err", err)
+ }
+ case <-pollTicker.C:
+ if !watchEnabled {
+ if err := h.syncOnce(ctx); err != nil {
+ logger.Info("Hosts sync failed", "err", err)
+ }
}
case <-ctx.Done():
return nil
@@ -67,13 +111,13 @@ func (h *hostsSyncer) Run(ctx context.Context) error {
func (h *hostsSyncer) syncOnce(ctx context.Context) error {
logger := log.FromContext(ctx)
- hosts, err := h.fetchHosts(ctx)
+ hosts, version, err := h.fetchHosts(ctx, "/get_hosts")
if err != nil {
metrics.IncHostsSync("error")
return fmt.Errorf("fetch hosts: %w", err)
}
- if err := h.updateHostsFile(hosts); err != nil {
+ if err := h.applyHosts(hosts, version); err != nil {
metrics.IncHostsSync("error")
return fmt.Errorf("update hosts file: %w", err)
}
@@ -85,10 +129,10 @@ func (h *hostsSyncer) syncOnce(ctx context.Context) error {
// fetchHosts calls the NodeServer /get_hosts endpoint, which returns a JSON
// object mapping hostname to IP.
-func (h *hostsSyncer) fetchHosts(ctx context.Context) (map[string]string, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/get_hosts", nil)
+func (h *hostsSyncer) fetchHosts(ctx context.Context, path string) (map[string]string, string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost"+path, nil)
if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
+ return nil, "", fmt.Errorf("create request: %w", err)
}
client := &http.Client{
Transport: h.transport,
@@ -96,19 +140,79 @@ func (h *hostsSyncer) fetchHosts(ctx context.Context) (map[string]string, error)
}
resp, err := client.Do(req)
if err != nil {
- return nil, fmt.Errorf("http get: %w", err)
+ return nil, "", fmt.Errorf("http get: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status: %s", resp.Status)
+ return nil, "", fmt.Errorf("unexpected status: %s", resp.Status)
}
var hosts map[string]string
if err := json.NewDecoder(resp.Body).Decode(&hosts); err != nil {
- return nil, fmt.Errorf("decode response: %w", err)
+ return nil, "", fmt.Errorf("decode response: %w", err)
}
- return hosts, nil
+ return hosts, resp.Header.Get("ETag"), nil
+}
+
+func (h *hostsSyncer) watchOnce(ctx context.Context, version string) hostsWatchResult {
+ path := "/watch_hosts?version=" + url.QueryEscape(version)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost"+path, nil)
+ if err != nil {
+ return hostsWatchResult{supported: true, err: fmt.Errorf("create watch request: %w", err)}
+ }
+ client := &http.Client{Transport: h.transport, Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return hostsWatchResult{supported: true, err: err}
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ switch resp.StatusCode {
+ case http.StatusNoContent:
+ return hostsWatchResult{supported: true}
+ case http.StatusNotFound, http.StatusMethodNotAllowed:
+ return hostsWatchResult{}
+ case http.StatusOK:
+ var hosts map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&hosts); err != nil {
+ return hostsWatchResult{supported: true, err: fmt.Errorf("decode watch response: %w", err)}
+ }
+ return hostsWatchResult{supported: true, hosts: hosts, version: resp.Header.Get("ETag")}
+ default:
+ return hostsWatchResult{supported: true, err: fmt.Errorf("unexpected watch status: %s", resp.Status)}
+ }
+}
+
+func (h *hostsSyncer) applyHosts(hosts map[string]string, version string) error {
+ if err := h.updateHostsFile(hosts); err != nil {
+ return err
+ }
+ if version == "" {
+ version = hostsVersion(hosts)
+ }
+ h.version = version
+ h.hosts = hosts
+ return nil
+}
+
+func (h *hostsSyncer) repairHostsFile() error {
+ if len(h.hosts) == 0 {
+ return nil
+ }
+ content, err := os.ReadFile(h.hostsFile)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(string(content), buildManagedSection(h.hosts)) {
+ return nil
+ }
+ return h.updateHostsFile(h.hosts)
+}
+
+func hostsVersion(hosts map[string]string) string {
+ data, _ := json.Marshal(hosts)
+ return fmt.Sprintf("%x", sha256.Sum256(data))
}
// updateHostsFile replaces the managed section (between the BEGIN/END markers)
@@ -119,11 +223,10 @@ func (h *hostsSyncer) updateHostsFile(hosts map[string]string) error {
content, err := os.ReadFile(h.hostsFile)
if err != nil {
- if !os.IsNotExist(err) {
- return fmt.Errorf("read hosts file: %w", err)
- }
- // File doesn't exist; create it with just our section.
- return os.WriteFile(h.hostsFile, []byte(section), 0644)
+ return fmt.Errorf("read hosts file: %w", err)
+ }
+ if !validBaseHosts(string(content)) {
+ return fmt.Errorf("hosts file is empty or missing localhost; retrying without update")
}
newContent, err := replaceManagedSection(string(content), section)
@@ -139,6 +242,21 @@ func (h *hostsSyncer) updateHostsFile(hosts map[string]string) error {
return os.WriteFile(h.hostsFile, []byte(newContent), 0644)
}
+func validBaseHosts(content string) bool {
+ for _, line := range strings.Split(content, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
+ continue
+ }
+ for _, host := range fields[1:] {
+ if host == "localhost" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
// buildManagedSection renders the managed block (including markers) from a
// hostname-to-IP map. Entries are sorted by hostname for deterministic output.
func buildManagedSection(hosts map[string]string) string {
diff --git a/apps/rlark/pkg/network/sidecar/hosts_test.go b/apps/rlark/pkg/network/sidecar/hosts_test.go
index b0818b4..359f894 100644
--- a/apps/rlark/pkg/network/sidecar/hosts_test.go
+++ b/apps/rlark/pkg/network/sidecar/hosts_test.go
@@ -1,6 +1,10 @@
package sidecar
import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -28,6 +32,89 @@ func TestBuildManagedSection(t *testing.T) {
}
}
+func TestWatchOnceUpdatesHosts(t *testing.T) {
+ hosts := map[string]string{"pod-a.domain": "10.0.0.2"}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/watch_hosts" {
+ t.Fatalf("path = %q", r.URL.Path)
+ }
+ w.Header().Set("ETag", hostsVersion(hosts))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"pod-a.domain":"10.0.0.2"}`))
+ }))
+ defer server.Close()
+
+ hostsFile := filepath.Join(t.TempDir(), "hosts")
+ if err := os.WriteFile(hostsFile, []byte("127.0.0.1\tlocalhost\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ hs := newHostsSyncer(rewriteHostTransport{base: http.DefaultTransport, host: server.URL}, hostsFile, 0)
+ result := hs.watchOnce(context.Background(), "")
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ if !result.supported {
+ t.Fatal("watch should be supported")
+ }
+ if err := hs.applyHosts(result.hosts, result.version); err != nil {
+ t.Fatal(err)
+ }
+ content, err := os.ReadFile(hostsFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(content), "10.0.0.2\tpod-a.domain") {
+ t.Fatalf("hosts file = %q", content)
+ }
+}
+
+func TestWatchOnceFallsBackForOldServer(t *testing.T) {
+ server := httptest.NewServer(http.NotFoundHandler())
+ defer server.Close()
+ hs := newHostsSyncer(rewriteHostTransport{base: http.DefaultTransport, host: server.URL}, "", 0)
+
+ result := hs.watchOnce(context.Background(), "")
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ if result.supported {
+ t.Fatal("404 watch endpoint should be treated as unsupported")
+ }
+}
+
+func TestRepairHostsFileRestoresExternalOverwrite(t *testing.T) {
+ hostsFile := filepath.Join(t.TempDir(), "hosts")
+ if err := os.WriteFile(hostsFile, []byte("127.0.0.1\tlocalhost\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ hs := newHostsSyncer(nil, hostsFile, 0)
+ hs.hosts = map[string]string{"pod-a.domain": "10.0.0.2"}
+
+ if err := hs.repairHostsFile(); err != nil {
+ t.Fatal(err)
+ }
+ content, err := os.ReadFile(hostsFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(content), "10.0.0.2\tpod-a.domain") {
+ t.Fatal("managed hosts section was not restored")
+ }
+}
+
+type rewriteHostTransport struct {
+ base http.RoundTripper
+ host string
+}
+
+func (t rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ clone := req.Clone(req.Context())
+ target, _ := url.Parse(t.host)
+ clone.URL.Scheme = target.Scheme
+ clone.URL.Host = target.Host
+ return t.base.RoundTrip(clone)
+}
+
func TestBuildManagedSection_Empty(t *testing.T) {
got := buildManagedSection(map[string]string{})
expected := hostsBeginMarker + "\n" + hostsEndMarker + "\n"
@@ -147,33 +234,48 @@ func TestReplaceManagedSection_MissingEndMarker(t *testing.T) {
}
}
-func TestUpdateHostsFile_NoExistingFile(t *testing.T) {
- dir := t.TempDir()
- hostsFile := filepath.Join(dir, "hosts")
+func TestUpdateHostsFileRejectsInvalidBaseFile(t *testing.T) {
+ hosts := map[string]string{"pod-a.domain": "10.0.0.1"}
+ for _, content := range []string{"", "10.0.0.9\tpod-only\n"} {
+ hostsFile := filepath.Join(t.TempDir(), "hosts")
+ if err := os.WriteFile(hostsFile, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+ hs := newHostsSyncer(nil, hostsFile, 0)
+
+ if err := hs.updateHostsFile(hosts); err == nil {
+ t.Fatalf("expected invalid base hosts %q to be rejected", content)
+ }
+ got, err := os.ReadFile(hostsFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != content {
+ t.Fatalf("invalid hosts file was modified: got %q, want %q", got, content)
+ }
+ }
+}
+func TestApplyHostsDoesNotAdvanceStateWhenBaseFileIsInvalid(t *testing.T) {
+ hostsFile := filepath.Join(t.TempDir(), "hosts")
+ if err := os.WriteFile(hostsFile, nil, 0644); err != nil {
+ t.Fatal(err)
+ }
hs := newHostsSyncer(nil, hostsFile, 0)
+ hosts := map[string]string{"pod-a.domain": "10.0.0.1"}
- hosts := map[string]string{
- "pod-a.domain1.domain": "10.0.0.1",
- "pod-b.domain1.domain": "10.0.0.2",
- }
- if err := hs.updateHostsFile(hosts); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ if err := hs.applyHosts(hosts, "new-version"); err == nil {
+ t.Fatal("expected invalid base hosts to reject update")
}
-
- content, err := os.ReadFile(hostsFile)
- if err != nil {
- t.Fatalf("read hosts file: %v", err)
+ if hs.version != "" || hs.hosts != nil {
+ t.Fatalf("state advanced after failed update: version=%q hosts=%v", hs.version, hs.hosts)
}
- if !strings.Contains(string(content), hostsBeginMarker) {
- t.Fatalf("begin marker should be present, got:\n%s", string(content))
- }
- if !strings.Contains(string(content), hostsEndMarker) {
- t.Fatalf("end marker should be present, got:\n%s", string(content))
+ if err := os.WriteFile(hostsFile, []byte("127.0.0.1\tlocalhost\n"), 0644); err != nil {
+ t.Fatal(err)
}
- if !strings.Contains(string(content), "10.0.0.1\tpod-a.domain1.domain") {
- t.Fatalf("entry should be present, got:\n%s", string(content))
+ if err := hs.applyHosts(hosts, "new-version"); err != nil {
+ t.Fatalf("retry after base hosts recovered: %v", err)
}
}
diff --git a/apps/rlark/pkg/network/sidecar/server.go b/apps/rlark/pkg/network/sidecar/server.go
index 70565aa..6f7e9cb 100644
--- a/apps/rlark/pkg/network/sidecar/server.go
+++ b/apps/rlark/pkg/network/sidecar/server.go
@@ -105,9 +105,8 @@ func (s *Sidecar) Run(ctx context.Context) error {
proxy := tun.NewProxy()
proxyErr := make(chan error, 1)
go func() {
- if err := proxy.Serve(proxyListener); err != nil {
- proxyErr <- fmt.Errorf("proxy serve: %w", err)
- }
+ err := proxy.Serve(proxyListener)
+ proxyErr <- fmt.Errorf("proxy serve: %w", err)
}()
// ─── 3. 启动 TUN client(出站) ───
diff --git a/apps/rlark/pkg/network/tun/netstack.go b/apps/rlark/pkg/network/tun/netstack.go
index a56de99..d1b8d5b 100644
--- a/apps/rlark/pkg/network/tun/netstack.go
+++ b/apps/rlark/pkg/network/tun/netstack.go
@@ -5,7 +5,6 @@ import (
"fmt"
"net"
"net/url"
- "sync"
"time"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
@@ -24,7 +23,7 @@ import (
// netstack 管理一个 gVisor 用户态 TCP/IP 协议栈实例。
//
// 该协议栈作为虚拟机和远端 Proxy 之间的中间层:
-// - 接收来自 TUN 设备(经 net.Pipe)的 IP 包
+// - 直接接收来自 TUN 设备的 IP 包
// - 在协议栈内完成 TCP/UDP/ICMP 协议解析
// - 通过 dialer 回调建立到 Proxy 的 TCP 连接,转发原始流量
// - Proxy 发回的响应经由协议栈重组为 IP 包写回 TUN 设备
@@ -47,6 +46,12 @@ type netstack struct {
writeToTUN func([]byte) error
}
+type tunDevice interface {
+ Read([]byte) (int, error)
+ Write([]byte) (int, error)
+ Close() error
+}
+
func newNetstack(ip net.IP, mtu int, dialProxy utils.Dial, queryParams map[string]string) *netstack {
ns := &netstack{
ip: ip,
@@ -71,7 +76,7 @@ func (ns *netstack) ipaddr() [4]byte {
return ip
}
-// handleTunnel 为一个 TCP 隧道连接创建独立的 gVisor 协议栈实例。
+// run 为一个 TUN 设备创建独立的 gVisor 协议栈实例。
//
// 每个隧道连接(对应一个 TUN 设备)拥有独立的协议栈,包含:
// - IPv4 网络层
@@ -79,10 +84,10 @@ func (ns *netstack) ipaddr() [4]byte {
// - channel endpoint 作为链路层接口
// - 完整的协议转发器(Forwarder)
//
-// 数据传输在一个双向转发循环中完成:隧道 → gVisor(handleRecv)和
-// gVisor → 隧道(handleSend),通过 sync.WaitGroup 同步退出。
-func (ns *netstack) handleTunnel(tunnelConn net.Conn) error {
- defer func() { _ = tunnelConn.Close() }()
+// 数据传输在两个直接转发循环中完成:TUN → gVisor 和 gVisor → TUN。
+func (ns *netstack) run(ctx context.Context, iface tunDevice) error {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
// ─── 1. 创建 gVisor 协议栈 ───
s := stack.New(stack.Options{
@@ -95,6 +100,7 @@ func (ns *netstack) handleTunnel(tunnelConn net.Conn) error {
icmp.NewProtocol4,
},
})
+ defer s.Destroy()
// ─── 2. 创建通道链路层端点 ───
// 这是 gVisor 和外部世界(TCP 隧道)之间的桥梁。
@@ -141,32 +147,37 @@ func (ns *netstack) handleTunnel(tunnelConn net.Conn) error {
s.SetTransportProtocolHandler(udp.ProtocolNumber, ns.getUDPHandler(s))
s.SetTransportProtocolHandler(icmp.ProtocolNumber4, ns.getICMPHandler(s, ep))
- // ─── 7. 启动双向数据传输 ───
- var wg sync.WaitGroup
- wg.Add(2)
- // 隧道 → gVisor:从 TCP 隧道读取 IP 包,注入 gVisor 协议栈
- go ns.handleRecv(tunnelConn, ep, &wg)
- // gVisor → 隧道:从 gVisor 协议栈读取 IP 包,通过隧道发回客户端
- go ns.handleSend(ep, tunnelConn, &wg)
+ errCh := make(chan error, 2)
+ go func() { errCh <- ns.handleRecv(iface, ep) }()
+ go func() { errCh <- ns.handleSend(ctx, ep, iface) }()
- // ─── 8. 等待退出信号 ───
- wg.Wait()
- return nil
+ var err error
+ completed := 0
+ select {
+ case <-ctx.Done():
+ err = ctx.Err()
+ case err = <-errCh:
+ completed = 1
+ }
+ cancel()
+ _ = iface.Close()
+ ep.Close()
+ for completed < 2 {
+ <-errCh
+ completed++
+ }
+ return err
}
-// handleRecv 从 TCP 隧道读取帧封装的 IP 包并注入到 gVisor 协议栈。
-//
-// 这是远端 → 本地虚拟机的方向:远端 Proxy 返回的数据经由 TCP 隧道,
-// 在此函数中被还原为 IP 包并注入 gVisor 栈,最终由虚拟机接收。
-func (ns *netstack) handleRecv(tunnelConn net.Conn, ep *channel.Endpoint, wg *sync.WaitGroup) {
- logger := log.GetLogger()
- defer wg.Done()
+// handleRecv 从 TUN 读取 IP 包并直接注入 gVisor 协议栈。
+func (ns *netstack) handleRecv(iface tunDevice, ep *channel.Endpoint) error {
+ buf := make([]byte, ns.mtu)
for {
- data, err := RecvPacket(tunnelConn)
+ n, err := iface.Read(buf)
if err != nil {
- logger.Error(nil, "Failed to receive packet from tunnel", "err", err)
- return
+ return err
}
+ data := buf[:n]
// 最小 IPv4 头长度为 20 字节,不足则丢弃
if len(data) < header.IPv4MinimumSize {
continue
@@ -180,6 +191,7 @@ func (ns *netstack) handleRecv(tunnelConn net.Conn, ep *channel.Endpoint, wg *sy
Payload: buffer.MakeWithData(data),
})
ep.InjectInbound(ipv4.ProtocolNumber, pkt)
+ pkt.DecRef()
tunMetrics.IncPackets("rx")
default:
// ignore unsupported versions
@@ -187,31 +199,28 @@ func (ns *netstack) handleRecv(tunnelConn net.Conn, ep *channel.Endpoint, wg *sy
}
}
-// handleSend 从 gVisor 协议栈的通道链路层读取 IP 包并通过 TCP 隧道发回。
-//
-// 这是本地虚拟机 → 远端的方向:虚拟机发出的 IP 包经 gVisor 协议栈处理,
-// 未匹配地址的包被路由到此函数,帧封装后通过 TCP 隧道发往远端 Proxy。
-func (ns *netstack) handleSend(ep *channel.Endpoint, tunnelConn net.Conn, wg *sync.WaitGroup) {
- logger := log.GetLogger()
- defer wg.Done()
+// handleSend 从 gVisor 通道链路层读取 IP 包并直接写回 TUN。
+func (ns *netstack) handleSend(ctx context.Context, ep *channel.Endpoint, iface tunDevice) error {
for {
- // 从通道链路层读取出去的 IP 包
- pkt := ep.Read()
+ pkt := ep.ReadContext(ctx)
if pkt == nil {
- continue
+ return ctx.Err()
}
// 收集所有 buffer 中的数据
pktBuf := pkt.ToBuffer()
data := pktBuf.Flatten()
if len(data) == 0 {
+ pktBuf.Release()
+ pkt.DecRef()
continue
}
- // 通过隧道发回客户端
- if err := SendPacket(tunnelConn, data); err != nil {
- logger.Error(nil, "Failed to send packet to tunnel", "err", err)
- return
+ _, err := iface.Write(data)
+ pktBuf.Release()
+ pkt.DecRef()
+ if err != nil {
+ return err
}
tunMetrics.IncPackets("tx")
}
diff --git a/apps/rlark/pkg/network/tun/netstack_icmp.go b/apps/rlark/pkg/network/tun/netstack_icmp.go
index 73bde5c..01fca95 100644
--- a/apps/rlark/pkg/network/tun/netstack_icmp.go
+++ b/apps/rlark/pkg/network/tun/netstack_icmp.go
@@ -153,5 +153,6 @@ func (ns *netstack) handleICMPEcho(ep *channel.Endpoint, srcAddr, dstAddr tcpip.
Payload: buffer.MakeWithData(ipBuf),
})
ep.InjectInbound(ipv4.ProtocolNumber, injectPkt)
+ injectPkt.DecRef()
}
}
diff --git a/apps/rlark/pkg/network/tun/netstack_tcp.go b/apps/rlark/pkg/network/tun/netstack_tcp.go
index ea4d915..6cac00a 100644
--- a/apps/rlark/pkg/network/tun/netstack_tcp.go
+++ b/apps/rlark/pkg/network/tun/netstack_tcp.go
@@ -2,12 +2,11 @@ package tun
import (
"context"
- "io"
"net"
- "sync"
"time"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/header"
@@ -73,26 +72,13 @@ func (ns *netstack) getTCPHandler(s *stack.Stack) func(id stack.TransportEndpoin
// handleTCPConnection 在 gVisor TCP 连接和 Proxy TCP 连接之间双向转发数据。
func (ns *netstack) handleTCPConnection(local, remote net.Conn) {
logger := log.GetLogger()
- var wg sync.WaitGroup
- wg.Add(2)
-
- // local(gVisor) → remote(Proxy)
- go func() {
- defer wg.Done()
- if _, err := io.Copy(remote, local); err != nil {
- logger.Error(nil, "Error copying from gVisor to Proxy", "err", err)
- }
- }()
-
- // remote(Proxy) → local(gVisor)
- go func() {
- defer wg.Done()
- if _, err := io.Copy(local, remote); err != nil {
- logger.Error(nil, "Error copying from Proxy to gVisor", "err", err)
- }
- }()
-
- wg.Wait()
+ err1, err2 := utils.RelayConnections(local, remote, "local", "remote")
+ if err1 != nil {
+ logger.Error(err1, "Error handling TCP connection")
+ }
+ if err2 != nil {
+ logger.Error(err2, "Error handling TCP connection")
+ }
}
// setSocketOptions 为 TCP endpoint 设置 Socket 选项。
diff --git a/apps/rlark/pkg/network/tun/netstack_test.go b/apps/rlark/pkg/network/tun/netstack_test.go
new file mode 100644
index 0000000..18e3bfa
--- /dev/null
+++ b/apps/rlark/pkg/network/tun/netstack_test.go
@@ -0,0 +1,74 @@
+package tun
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "gvisor.dev/gvisor/pkg/buffer"
+ "gvisor.dev/gvisor/pkg/tcpip/link/channel"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+)
+
+type fakeTunDevice struct {
+ mu sync.Mutex
+ writes [][]byte
+ err error
+}
+
+func (f *fakeTunDevice) Read([]byte) (int, error) { return 0, errors.New("closed") }
+func (f *fakeTunDevice) Close() error { return nil }
+func (f *fakeTunDevice) Write(data []byte) (int, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.writes = append(f.writes, append([]byte(nil), data...))
+ return len(data), f.err
+}
+
+func TestHandleSendWritesPacketAndStopsOnCancel(t *testing.T) {
+ ep := channel.New(1, 1500, "")
+ ctx, cancel := context.WithCancel(context.Background())
+ device := &fakeTunDevice{}
+ ns := &netstack{}
+ done := make(chan error, 1)
+ go func() { done <- ns.handleSend(ctx, ep, device) }()
+
+ pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
+ Payload: buffer.MakeWithData([]byte{1, 2, 3, 4}),
+ })
+ var packets stack.PacketBufferList
+ packets.PushBack(pkt)
+ if n, err := ep.WritePackets(packets); err != nil || n != 1 {
+ t.Fatalf("WritePackets() = (%d, %v), want (1, nil)", n, err)
+ }
+ pkt.DecRef()
+
+ deadline := time.Now().Add(time.Second)
+ for {
+ device.mu.Lock()
+ written := len(device.writes)
+ device.mu.Unlock()
+ if written == 1 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("packet was not written to TUN")
+ }
+ time.Sleep(time.Millisecond)
+ }
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("handleSend did not stop after cancellation")
+ }
+
+ device.mu.Lock()
+ defer device.mu.Unlock()
+ if got := device.writes[0]; len(got) != 4 || got[0] != 1 || got[3] != 4 {
+ t.Fatalf("unexpected TUN packet: %v", got)
+ }
+}
diff --git a/apps/rlark/pkg/network/tun/proxy.go b/apps/rlark/pkg/network/tun/proxy.go
index 8e34f87..759e3a9 100644
--- a/apps/rlark/pkg/network/tun/proxy.go
+++ b/apps/rlark/pkg/network/tun/proxy.go
@@ -1,7 +1,6 @@
package tun
import (
- "io"
"net"
"gvisor.dev/gvisor/pkg/tcpip/header"
@@ -81,11 +80,8 @@ func (p *Proxy) handleConnection(conn *utils.WrapConn) {
// handleTCP 在客户端 TCP 连接和目标 TCP 地址之间做双向数据转发。
//
-// 使用两个 io.Copy 实现全双工转发:
-// - 一个 goroutine 负责 client → target
-// - 主 goroutine 负责 target → client
-//
-// 当任意方向拷贝结束,函数返回,defer 关闭的 target 连接会终止另一侧的 goroutine。
+// 两个方向并发转发;任一方向结束后,为两端设置读取截止时间,等待另一方向
+// 完成。两个方向均结束后返回,并由 defer 关闭目标连接。
func (p *Proxy) handleTCP(conn *utils.WrapConn, host, port string) {
logger := log.GetLogger()
tunConn, err := net.Dial("tcp", net.JoinHostPort(host, port))
@@ -95,10 +91,7 @@ func (p *Proxy) handleTCP(conn *utils.WrapConn, host, port string) {
}
defer func() { _ = tunConn.Close() }()
- go func() {
- _, _ = io.Copy(tunConn, conn)
- }()
- _, _ = io.Copy(conn, tunConn)
+ _, _ = utils.RelayConnections(tunConn, conn, "target", "client")
}
// handleUDP 在客户端 TCP 连接和目标 UDP 地址之间做双向帧转发。
diff --git a/apps/rlark/pkg/network/tun/tun.go b/apps/rlark/pkg/network/tun/tun.go
index d9d04a6..0f77b38 100644
--- a/apps/rlark/pkg/network/tun/tun.go
+++ b/apps/rlark/pkg/network/tun/tun.go
@@ -4,11 +4,9 @@ import (
"context"
"fmt"
"net"
- "sync"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/utils"
- "github.com/songgao/water"
"github.com/vishvananda/netlink"
)
@@ -16,10 +14,8 @@ import (
//
// 工作流程:
// 1. 创建 TUN 设备并配置 IP/MTU/路由
-// 2. 通过 net.Pipe() 与 netstack 建立本地连接
-// 3. 双方向转发:
-// TUN → net.Pipe → gVisor 协议栈(收自物理网络的 IP 包)
-// gVisor 协议栈 → net.Pipe → TUN(发往物理网络的 IP 包)
+// 2. 将 TUN 数据包直接注入 gVisor channel endpoint
+// 3. 将 gVisor 输出包直接写回 TUN
// 4. gVisor 协议栈将虚拟机的 IP 流量通过 tcpDialer/udpDialer/icmpDialer 转发到远端 Proxy
type tunClient struct {
// name 是 TUN 设备名称(如 "tun0"),空字符串则由系统自动分配。
@@ -51,9 +47,7 @@ func NewTunClient(name string, ip net.IP, prefixLength int, mtu int, dialProxy u
//
// 1. 创建 TUN 设备
// 2. 配置 IP/路由/MTU
-// 3. 通过 net.Pipe() 连接 gVisor netstack
-// 4. 启动双向转发 goroutine(TUN ↔ gVisor)
-// 5. 等待退出信号(SIGINT/SIGTERM)或转发结束
+// 3. 启动 gVisor netstack 并直接桥接 TUN 数据包
func (tc *tunClient) Run(ctx context.Context) error {
logger := log.FromContext(ctx)
// ─── 1. 创建 TUN 设备 ───
@@ -72,54 +66,19 @@ func (tc *tunClient) Run(ctx context.Context) error {
return fmt.Errorf("setup TUN device: %w", err)
}
- // ─── 3. 通过 net.Pipe() 连接到 netstack ───
- // 使用内存管道模拟 TUN 设备和 gVisor 栈之间的链路层
- c1, c2 := net.Pipe()
ns := newNetstack(tc.ip, tc.mtu, tc.dialProxy, tc.queryParams)
// 设置 TUN 写入回调:ICMP Echo Reply 直接写入 TUN 设备(绕过 gVisor)
ns.writeToTUN = func(data []byte) error {
_, err := iface.Write(data)
return err
}
- go func() {
- err := ns.handleTunnel(c1)
- if err != nil {
- logger.Error(nil, "Failed to handle tunnel connection", "err", err)
- }
- }()
-
- // ─── 4. 启动双向转发 ───
- // TUN → gVisor:从 TUN 读 IP 包 → SendPacket 写入管道
- // gVisor → TUN:RecvPacket 从管道读 → 写入 TUN
- var wg sync.WaitGroup
- wg.Add(2)
- go tc.handleRead(iface, c2, &wg)
- go tc.handleWrite(c2, iface, &wg)
-
- // ─── 5. 等待退出信号 ───
- select {
- case <-ctx.Done():
- logger.Info("Context cancelled, shutting down...")
- _ = c2.Close()
- _ = iface.Close()
- case <-waitDone(&wg):
- // 自然退出
+ if err := ns.run(ctx, iface); err != nil && ctx.Err() == nil {
+ return fmt.Errorf("run netstack: %w", err)
}
- wg.Wait()
logger.Info("Client shutdown complete")
return nil
}
-// waitDone 返回一个 channel,当 WaitGroup 计数归零时关闭。
-func waitDone(wg *sync.WaitGroup) <-chan struct{} {
- ch := make(chan struct{})
- go func() {
- wg.Wait()
- close(ch)
- }()
- return ch
-}
-
// setupTUN 通过 netlink 配置 TUN 设备的 IP 地址、MTU 并启用设备。
func (tc *tunClient) setupTUN(name string) error {
link, err := netlink.LinkByName(name)
@@ -149,52 +108,3 @@ func (tc *tunClient) setupTUN(name string) error {
}
return nil
}
-
-// handleRead 从 TUN 设备读取 IP 包并通过管道发送给 gVisor 协议栈。
-//
-// 这是物理网络 → 虚拟网络的方向。
-func (tc *tunClient) handleRead(iface *water.Interface, tunnelConn net.Conn, wg *sync.WaitGroup) {
- logger := log.GetLogger()
- defer wg.Done()
- buf := make([]byte, tc.mtu)
- for {
- n, err := iface.Read(buf)
- if err != nil {
- logger.Error(nil, "Failed to read from TUN device", "err", err)
- return
- }
- if n == 0 {
- continue
- }
-
- // 通过 TCP 隧道发送 IP 包
- if err := SendPacket(tunnelConn, buf[:n]); err != nil {
- logger.Error(nil, "Failed to send IP packet", "err", err)
- return
- }
- }
-}
-
-// handleWrite 从 gVisor 协议栈接收 IP 包(通过管道)并写入 TUN 设备。
-//
-// 这是虚拟网络 → 物理网络的方向。
-func (tc *tunClient) handleWrite(tunnelConn net.Conn, iface *water.Interface, wg *sync.WaitGroup) {
- logger := log.GetLogger()
- defer wg.Done()
- for {
- data, err := RecvPacket(tunnelConn)
- if err != nil {
- logger.Error(nil, "Failed to receive IP packet", "err", err)
- return
- }
- if data == nil {
- continue
- }
-
- // 写入 TUN 设备
- if _, err := iface.Write(data); err != nil {
- logger.Error(nil, "Failed to write to TUN device", "err", err)
- return
- }
- }
-}
diff --git a/apps/rlark/pkg/remotedialer/buffer_test.go b/apps/rlark/pkg/remotedialer/buffer_test.go
new file mode 100644
index 0000000..230f111
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/buffer_test.go
@@ -0,0 +1,154 @@
+package remotedialer
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExceedBuffer(t *testing.T) {
+ ctx := t.Context()
+
+ producerAddress, err := newTestProducer(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ serverAddress, server, err := newTestServer(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := newTestClient(ctx, "ws://"+serverAddress); err != nil {
+ t.Fatal(err)
+ }
+ // onConnect fires as soon as the client-side websocket handshake completes;
+ // the server-side session may still be registering. Wait for it to be
+ // visible before issuing dials.
+ if err := waitForServerSession(server, "client", 5*time.Second); err != nil {
+ t.Fatal(err)
+ }
+
+ client := http.Client{
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, proto, address string) (net.Conn, error) {
+ return server.Dialer("client")(ctx, proto, address)
+ },
+ },
+ }
+
+ producerURL := "http://" + producerAddress
+
+ // Drain both responses concurrently. smux applies session-wide flow control,
+ // so an undrained stream must not be left blocking while another is fully
+ // consumed; reading in parallel reflects correct multiplexed usage.
+ type readResult struct {
+ n int
+ err error
+ }
+ drain := func(url string) <-chan readResult {
+ ch := make(chan readResult, 1)
+ go func() {
+ resp, err := client.Get(url)
+ if err != nil {
+ ch <- readResult{err: err}
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ ch <- readResult{n: len(body), err: err}
+ }()
+ return ch
+ }
+
+ c1 := drain(producerURL)
+ c2 := drain(producerURL)
+
+ r1 := <-c1
+ if r1.err != nil {
+ t.Fatal(r1.err)
+ }
+ r2 := <-c2
+ if r2.err != nil {
+ t.Fatal(r2.err)
+ }
+
+ assert.Equal(t, 4096*4096, r1.n)
+ assert.Equal(t, 4096*4096, r2.n)
+}
+
+func newTestServer(ctx context.Context) (string, *Server, error) {
+ auth := func(req *http.Request) (clientKey string, authed bool, err error) {
+ return "client", true, nil
+ }
+
+ server := New(auth, DefaultErrorWriter)
+ address, err := newServer(ctx, server)
+ return address, server, err
+}
+
+func newTestClient(ctx context.Context, url string) error {
+ result := make(chan error, 2)
+ go func() {
+ err := ConnectToProxy(ctx, url, nil, func(proto, address string) bool {
+ return true
+ }, nil, func(ctx context.Context, session *Session) error {
+ result <- nil
+ return nil
+ })
+ result <- err
+ }()
+ return <-result
+}
+
+// waitForServerSession polls until the server registers a session for
+// clientKey or the timeout expires. Bridges the gap between the client-side
+// onConnect callback (which fires on handshake) and the server-side session
+// bookkeeping.
+func waitForServerSession(server *Server, clientKey string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if server.HasSession(clientKey) {
+ return nil
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ return fmt.Errorf("server never registered session for %q within %s", clientKey, timeout)
+}
+
+func newServer(ctx context.Context, handler http.Handler) (string, error) {
+ server := http.Server{
+ BaseContext: func(_ net.Listener) context.Context {
+ return ctx
+ },
+ Handler: handler,
+ }
+ listener, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return "", err
+ }
+ go func() {
+ <-ctx.Done()
+ _ = listener.Close()
+ _ = server.Shutdown(context.Background())
+ }()
+ go func() { _ = server.Serve(listener) }()
+ return listener.Addr().String(), nil
+}
+
+func newTestProducer(ctx context.Context) (string, error) {
+ buffer := make([]byte, 4096)
+ return newServer(ctx, http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
+ for i := 0; i < 4096; i++ {
+ if _, err := resp.Write(buffer); err != nil {
+ panic(err)
+ }
+ }
+ }))
+}
diff --git a/apps/rlark/pkg/remotedialer/client.go b/apps/rlark/pkg/remotedialer/client.go
new file mode 100644
index 0000000..4ea0506
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/client.go
@@ -0,0 +1,99 @@
+package remotedialer
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/sirupsen/logrus"
+)
+
+// ConnectAuthorizer reports whether a remote request may connect to the given
+// protocol and address.
+type ConnectAuthorizer func(proto, address string) bool
+
+// ClientConnect connects to a remote-dialer server once. On a non-cancellation
+// error it logs the error and waits five seconds before returning, allowing a
+// caller's retry loop to avoid reconnecting immediately.
+func ClientConnect(ctx context.Context, wsURL string, headers http.Header, dialer *websocket.Dialer,
+ auth ConnectAuthorizer, onConnect func(context.Context, *Session) error) error {
+ if err := ConnectToProxy(ctx, wsURL, headers, auth, dialer, onConnect); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ logrus.WithError(err).Error("Remotedialer proxy error")
+ time.Sleep(time.Duration(5) * time.Second)
+ }
+ return err
+ }
+ return nil
+}
+
+// ConnectToProxy connects to a remote-dialer WebSocket server and serves the
+// session until it ends. Local connections requested by the server use a
+// default net.Dialer.
+func ConnectToProxy(rootCtx context.Context, proxyURL string, headers http.Header, auth ConnectAuthorizer, dialer *websocket.Dialer, onConnect func(context.Context, *Session) error) error {
+ return ConnectToProxyWithDialer(rootCtx, proxyURL, headers, auth, dialer, nil, onConnect)
+}
+
+// ConnectToProxyWithDialer connects to a remote-dialer WebSocket server and
+// serves the session until it ends. localDialer handles local connections
+// requested by the server; a nil localDialer uses a default net.Dialer.
+func ConnectToProxyWithDialer(rootCtx context.Context, proxyURL string, headers http.Header, auth ConnectAuthorizer, dialer *websocket.Dialer, localDialer Dialer, onConnect func(context.Context, *Session) error) error {
+ logrus.WithField("url", proxyURL).Info("Connecting to proxy")
+
+ if dialer == nil {
+ dialer = &websocket.Dialer{Proxy: http.ProxyFromEnvironment, HandshakeTimeout: HandshakeTimeOut}
+ }
+ ws, resp, err := dialer.DialContext(rootCtx, proxyURL, headers)
+ if err != nil {
+ if resp == nil {
+ if !errors.Is(err, context.Canceled) {
+ logrus.WithError(err).Errorf("Failed to connect to proxy. Empty dialer response")
+ }
+ } else {
+ rb, err2 := io.ReadAll(resp.Body)
+ if err2 != nil {
+ logrus.WithError(err).Errorf("Failed to connect to proxy. Response status: %v - %v. Couldn't read response body (err: %v)", resp.StatusCode, resp.Status, err2)
+ } else {
+ logrus.WithError(err).Errorf("Failed to connect to proxy. Response status: %v - %v. Response body: %s", resp.StatusCode, resp.Status, rb)
+ }
+ }
+ return err
+ }
+ defer func() { _ = ws.Close() }()
+
+ result := make(chan error, 2)
+
+ ctx, cancel := context.WithCancel(rootCtx)
+ defer cancel()
+ ctx = context.WithValue(ctx, ContextKeyCaller, fmt.Sprintf("ConnectToProxy: url: %s", proxyURL))
+
+ session := NewClientSessionWithDialer(auth, ws, localDialer)
+ defer session.Close()
+
+ if onConnect != nil {
+ go func() {
+ if err := onConnect(ctx, session); err != nil {
+ result <- err
+ }
+ }()
+ }
+
+ go func() {
+ _, err = session.Serve(ctx)
+ result <- err
+ }()
+
+ logrus.WithField("url", proxyURL).Info("Connected to proxy")
+
+ select {
+ case <-ctx.Done():
+ logrus.WithField("url", proxyURL).WithField("err", ctx.Err()).Info("Proxy done")
+ return nil
+ case err := <-result:
+ return err
+ }
+}
diff --git a/apps/rlark/pkg/remotedialer/client_dialer.go b/apps/rlark/pkg/remotedialer/client_dialer.go
new file mode 100644
index 0000000..dc75498
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/client_dialer.go
@@ -0,0 +1,37 @@
+package remotedialer
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
+)
+
+// clientDial dials the requested target on behalf of the remote peer and pipes
+// data between the smux stream and the dialed connection. Flow control and
+// stream teardown are handled by smux.
+func clientDial(ctx context.Context, dialer Dialer, stream net.Conn, proto, address string) {
+ defer func() { _ = stream.Close() }()
+
+ var (
+ netConn net.Conn
+ err error
+ )
+
+ dialCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))
+ if dialer == nil {
+ d := net.Dialer{}
+ netConn, err = d.DialContext(dialCtx, proto, address)
+ } else {
+ netConn, err = dialer(dialCtx, proto, address)
+ }
+ cancel()
+
+ if err != nil {
+ return
+ }
+ defer func() { _ = netConn.Close() }()
+
+ _, _ = utils.RelayConnections(stream, netConn, "stream", "target")
+}
diff --git a/apps/rlark/pkg/remotedialer/dialer.go b/apps/rlark/pkg/remotedialer/dialer.go
new file mode 100644
index 0000000..d69439b
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/dialer.go
@@ -0,0 +1,39 @@
+package remotedialer
+
+import (
+ "context"
+ "net"
+)
+
+// Dialer opens a network connection. It has the same calling convention as
+// net.Dialer.DialContext.
+type Dialer func(ctx context.Context, network, address string) (net.Conn, error)
+
+// HasSession reports whether clientKey is reachable through a direct client
+// session or a connected peer.
+func (s *Server) HasSession(clientKey string) bool {
+ _, err := s.sessions.getDialer(clientKey)
+ return err == nil
+}
+
+// Dialer returns a Dialer that resolves an active route to clientKey for each
+// call and opens the connection through that route.
+func (s *Server) Dialer(clientKey string) Dialer {
+ return func(ctx context.Context, network, address string) (net.Conn, error) {
+ d, err := s.sessions.getDialer(clientKey)
+ if err != nil {
+ return nil, err
+ }
+
+ return d(ctx, network, address)
+ }
+}
+
+// GetDialer returns a Dialer for only available sessions to the given clientKey.
+func (s *Server) GetDialer(clientKey string) (Dialer, error) {
+ d, err := s.sessions.getDialer(clientKey)
+ if err != nil {
+ return nil, err
+ }
+ return d, nil
+}
diff --git a/apps/rlark/pkg/remotedialer/doc.go b/apps/rlark/pkg/remotedialer/doc.go
new file mode 100644
index 0000000..80f1b7f
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/doc.go
@@ -0,0 +1,9 @@
+// Package remotedialer provides bidirectional network dialing over persistent
+// WebSocket connections.
+//
+// A client establishes a Session with a Server. The server can then use a
+// Dialer associated with that client to open logical connections through the
+// session. Sessions use smux to multiplex those logical connections over one
+// WebSocket transport. Servers may also connect as peers and advertise clients
+// reachable through another server.
+package remotedialer
diff --git a/apps/rlark/pkg/remotedialer/peer.go b/apps/rlark/pkg/remotedialer/peer.go
new file mode 100644
index 0000000..5cf8051
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/peer.go
@@ -0,0 +1,129 @@
+package remotedialer
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/sirupsen/logrus"
+)
+
+var (
+ // Token is the HTTP header carrying a peer authentication token.
+ Token = "X-API-Tunnel-Token"
+ // ID is the HTTP header carrying a peer identifier.
+ ID = "X-API-Tunnel-ID"
+)
+
+// AddPeer configures and starts a persistent connection to another server.
+// An existing peer with the same ID is replaced when its configuration differs.
+// The call has no effect unless PeerID and PeerToken are configured.
+func (s *Server) AddPeer(url, id, token string) {
+ if s.PeerID == "" || s.PeerToken == "" {
+ return
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ peer := peer{
+ url: url,
+ id: id,
+ token: token,
+ cancel: cancel,
+ }
+
+ logrus.Infof("Adding peer %s, %s", url, id)
+
+ s.peerLock.Lock()
+ defer s.peerLock.Unlock()
+
+ if p, ok := s.peers[id]; ok {
+ if p.equals(peer) {
+ return
+ }
+ p.cancel()
+ }
+
+ s.peers[id] = peer
+ go peer.start(ctx, s)
+}
+
+// RemovePeer stops and removes the peer identified by id.
+func (s *Server) RemovePeer(id string) {
+ s.peerLock.Lock()
+ defer s.peerLock.Unlock()
+
+ if p, ok := s.peers[id]; ok {
+ logrus.Infof("Removing peer %s", id)
+ p.cancel()
+ }
+ delete(s.peers, id)
+}
+
+type peer struct {
+ url, id, token string
+ cancel func()
+}
+
+func (p peer) equals(other peer) bool {
+ return p.url == other.url &&
+ p.id == other.id &&
+ p.token == other.token
+}
+
+func (p *peer) start(ctx context.Context, s *Server) {
+ headers := http.Header{
+ ID: {s.PeerID},
+ Token: {s.PeerToken},
+ }
+
+ dialer := &websocket.Dialer{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ HandshakeTimeout: HandshakeTimeOut,
+ }
+ ctx = context.WithValue(ctx, ContextKeyCaller, fmt.Sprintf("Peer url:%s, id:%s", p.url, p.id))
+
+outer:
+ for {
+ select {
+ case <-ctx.Done():
+ break outer
+ default:
+ }
+
+ ws, _, err := dialer.Dial(p.url, headers)
+ if err != nil {
+ logrus.Errorf("Failed to connect to peer %s [local ID=%s]: %v", p.url, s.PeerID, err)
+ time.Sleep(5 * time.Second)
+ continue
+ }
+
+ session := NewClientSession(func(string, string) bool { return true }, ws)
+ session.dialer = func(ctx context.Context, network, address string) (net.Conn, error) {
+ parts := strings.SplitN(network, "::", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid clientKey/proto: %s", network)
+ }
+ d := s.Dialer(parts[0])
+ return d(ctx, parts[1], address)
+ }
+
+ s.sessions.addListener(session)
+ _, err = session.Serve(ctx)
+ s.sessions.removeListener(session)
+ session.Close()
+
+ if err != nil {
+ logrus.Errorf("Failed to serve peer connection %s: %v", p.id, err)
+ }
+
+ _ = ws.Close()
+ time.Sleep(5 * time.Second)
+ }
+}
diff --git a/apps/rlark/pkg/remotedialer/server.go b/apps/rlark/pkg/remotedialer/server.go
new file mode 100644
index 0000000..c232949
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/server.go
@@ -0,0 +1,141 @@
+package remotedialer
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/pkg/errors"
+ "github.com/sirupsen/logrus"
+)
+
+var (
+ // ErrFailedAuth indicates that a connection request was not authorized.
+ ErrFailedAuth = errors.New("failed authentication")
+)
+
+// Authorizer authenticates an incoming WebSocket request and returns the key
+// used to identify the connected client.
+type Authorizer func(req *http.Request) (clientKey string, authed bool, err error)
+
+// ErrorWriter writes an HTTP error response before a WebSocket upgrade.
+type ErrorWriter func(rw http.ResponseWriter, req *http.Request, code int, err error)
+
+// DefaultErrorWriter writes the status code followed by the error message.
+func DefaultErrorWriter(rw http.ResponseWriter, req *http.Request, code int, err error) {
+ rw.WriteHeader(code)
+ _, _ = rw.Write([]byte(err.Error()))
+}
+
+// Server accepts remote-dialer clients and routes dial requests to their
+// active sessions. A Server also implements http.Handler.
+type Server struct {
+ // PeerID and PeerToken are credentials presented when connecting to peers.
+ PeerID string
+ PeerToken string
+ // ClientConnectAuthorizer controls which destinations connected clients may dial.
+ ClientConnectAuthorizer ConnectAuthorizer
+ authorizer Authorizer
+ errorWriter ErrorWriter
+ sessions *sessionManager
+ peers map[string]peer
+ peerLock sync.Mutex
+}
+
+// New constructs a Server using auth for incoming client authentication and
+// errorWriter for HTTP and WebSocket upgrade failures.
+func New(auth Authorizer, errorWriter ErrorWriter) *Server {
+ return &Server{
+ peers: map[string]peer{},
+ authorizer: auth,
+ errorWriter: errorWriter,
+ sessions: newSessionManager(),
+ }
+}
+
+// ServeHTTP authenticates and upgrades an incoming request, then serves its
+// remote-dialer session until the request is canceled or the connection closes.
+func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+ clientKey, authed, peer, err := s.auth(req)
+ if err != nil {
+ s.errorWriter(rw, req, 400, err)
+ return
+ }
+ if !authed {
+ s.errorWriter(rw, req, 401, ErrFailedAuth)
+ return
+ }
+
+ logrus.Infof("Handling backend connection request [%s]", clientKey)
+
+ upgrader := websocket.Upgrader{
+ HandshakeTimeout: 5 * time.Second,
+ CheckOrigin: func(r *http.Request) bool { return true },
+ Error: s.errorWriter,
+ }
+
+ wsConn, err := upgrader.Upgrade(rw, req, nil)
+ if err != nil {
+ s.errorWriter(rw, req, 400, errors.Wrapf(err, "Error during upgrade for host [%v]", clientKey))
+ return
+ }
+
+ session := s.sessions.add(clientKey, wsConn, peer)
+ session.auth = s.ClientConnectAuthorizer
+ defer s.sessions.remove(session)
+
+ code, err := session.Serve(req.Context())
+ if err != nil {
+ // Hijacked so we can't write to the client
+ stage, duration := session.Diagnostics()
+ logrus.WithFields(logrus.Fields{
+ "clientKey": clientKey,
+ "remote": req.RemoteAddr,
+ "duration": duration,
+ "stage": stage,
+ "code": code,
+ }).WithError(err).Info("remotedialer session ended")
+ }
+}
+
+// AddSession registers a WebSocket connection for clientKey. If peer is true,
+// the session is treated as a peer server rather than a directly connected client.
+func (s *Server) AddSession(clientKey string, wsConn *websocket.Conn, peer bool) *Session {
+ session := s.sessions.add(clientKey, wsConn, peer)
+ session.auth = s.ClientConnectAuthorizer
+ return session
+}
+
+// RemoveSession unregisters and closes session.
+func (s *Server) RemoveSession(session *Session) {
+ s.sessions.remove(session)
+}
+
+// ListClients returns the keys of directly connected clients.
+func (s *Server) ListClients() []string {
+ return s.sessions.listClients()
+}
+
+// ListPeers returns the keys of connected peer servers.
+func (s *Server) ListPeers() []string {
+ return s.sessions.listPeers()
+}
+
+func (s *Server) auth(req *http.Request) (clientKey string, authed, peer bool, err error) {
+ id := req.Header.Get(ID)
+ token := req.Header.Get(Token)
+ if id != "" && token != "" {
+ // peer authentication
+ s.peerLock.Lock()
+ p, ok := s.peers[id]
+ s.peerLock.Unlock()
+
+ if ok && p.token == token {
+ return id, true, true, nil
+ }
+ }
+
+ id, authed, err = s.authorizer(req)
+ return id, authed, false, err
+}
diff --git a/apps/rlark/pkg/remotedialer/session.go b/apps/rlark/pkg/remotedialer/session.go
new file mode 100644
index 0000000..de402a7
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session.go
@@ -0,0 +1,438 @@
+package remotedialer
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/sirupsen/logrus"
+ "github.com/xtaci/smux"
+)
+
+// Session multiplexes logical connections over a single websocket transport
+// using smux. Every logical connection is a smux stream; the first stream of a
+// session is reserved as a control channel used to synchronize peer routing
+// information (AddClient/RemoveClient).
+type Session struct {
+ sync.RWMutex
+
+ clientKey string
+ sessionKey int64
+ conn WSConn
+ mux *smux.Session
+ initErr error
+ stage string
+ startedAt time.Time
+
+ // control is the reserved control stream used to exchange peering commands.
+ control net.Conn
+
+ remoteClientKeys map[string]map[int]bool
+
+ auth ConnectAuthorizer
+ dialer Dialer
+ client bool
+}
+
+// ContextKey is the context key type used for remote-dialer diagnostic values.
+type ContextKey struct{}
+
+// ContextKeyCaller identifies a context value describing the caller that
+// established a session.
+var ContextKeyCaller = ContextKey{}
+
+// ValueFromContext returns the caller description stored under ContextKeyCaller,
+// or an empty string if the value is absent or not a string.
+func ValueFromContext(ctx context.Context) string {
+ v := ctx.Value(ContextKeyCaller)
+ if v == nil {
+ return ""
+ }
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return ""
+}
+
+// PrintTunnelData enables debug logging for tunnel routing changes. It defaults
+// to true when CATTLE_TUNNEL_DATA_DEBUG is set to "true".
+var PrintTunnelData bool
+
+func init() {
+ if os.Getenv("CATTLE_TUNNEL_DATA_DEBUG") == "true" {
+ PrintTunnelData = true
+ }
+}
+
+// smuxConfig returns the smux configuration shared by client and server.
+// smux natively handles keepalive, flow control (back pressure) and stream
+// lifecycle, replacing the previous hand-rolled ping/pause/resume/sync logic.
+func smuxConfig() *smux.Config {
+ config := smux.DefaultConfig()
+ // Version 2 is required: with v1, Stream.WriteTo (used by io.Copy) does not
+ // emit window updates to the peer, which deadlocks a full-duplex tunnel once
+ // the peer's send window is exhausted. v2 sends updates from WriteTo and
+ // also provides fair per-stream scheduling.
+ config.Version = 2
+ config.KeepAliveInterval = PingWriteInterval
+ config.KeepAliveTimeout = PingWaitDuration
+ config.MaxReceiveBuffer = MaxBuffer
+ config.MaxStreamBuffer = MaxStreamBuffer
+ return config
+}
+
+// NewClientSession creates a client-side session using the default network dialer.
+func NewClientSession(auth ConnectAuthorizer, conn *websocket.Conn) *Session {
+ return NewClientSessionWithDialer(auth, conn, nil)
+}
+
+// NewClientSessionWithDialer creates a client-side session. auth authorizes
+// server-requested connections, and dialer opens them; nil uses net.Dialer.
+func NewClientSessionWithDialer(auth ConnectAuthorizer, conn *websocket.Conn, dialer Dialer) *Session {
+ s := &Session{
+ clientKey: "client",
+ conn: NewWSConn(conn),
+ auth: auth,
+ client: true,
+ dialer: dialer,
+ remoteClientKeys: map[string]map[int]bool{},
+ stage: "websocket-upgraded",
+ startedAt: time.Now(),
+ }
+ if err := s.initMux(); err != nil {
+ s.initErr = err
+ s.stage = "initialization-failed"
+ _ = s.conn.Close()
+ logrus.WithError(err).Warn("failed to initialize smux client session")
+ }
+ return s
+}
+
+// NewServerSession creates a server-side session for clientKey. conn may be nil
+// for callers that only use session bookkeeping.
+func NewServerSession(sessionKey int64, clientKey string, conn WSConn) *Session {
+ s := &Session{
+ clientKey: clientKey,
+ sessionKey: sessionKey,
+ conn: conn,
+ remoteClientKeys: map[string]map[int]bool{},
+ stage: "websocket-upgraded",
+ startedAt: time.Now(),
+ }
+ // conn may be nil in unit tests that only exercise session bookkeeping.
+ if conn != nil {
+ if err := s.initMux(); err != nil {
+ s.initErr = err
+ s.stage = "initialization-failed"
+ _ = s.conn.Close()
+ logrus.WithError(err).Warn("failed to initialize smux server session")
+ }
+ }
+ return s
+}
+
+// initMux constructs the smux session and the reserved control stream so the
+// Session is ready for OpenStream()/writeControl() before Serve is called.
+// Serve will only run the accept loop.
+func (s *Session) initMux() error {
+ var (
+ mux *smux.Session
+ err error
+ )
+ if s.client {
+ mux, err = smux.Client(s.conn, smuxConfig())
+ } else {
+ mux, err = smux.Server(s.conn, smuxConfig())
+ }
+ if err != nil {
+ return err
+ }
+ s.Lock()
+ s.stage = "mux-created"
+ s.Unlock()
+
+ // Establish the reserved control stream synchronously. The client opens
+ // stream 0, the server accepts it, guaranteeing symmetric wiring before
+ // either side starts issuing peering commands.
+ type result struct {
+ control net.Conn
+ err error
+ }
+ resultCh := make(chan result, 1)
+ go func() {
+ var control net.Conn
+ var controlErr error
+ if s.client {
+ control, controlErr = mux.OpenStream()
+ } else {
+ control, controlErr = mux.AcceptStream()
+ }
+ resultCh <- result{control: control, err: controlErr}
+ }()
+
+ var control net.Conn
+ select {
+ case result := <-resultCh:
+ control, err = result.control, result.err
+ case <-time.After(ControlStreamTimeout):
+ _ = mux.Close()
+ return fmt.Errorf("control stream handshake timed out after %s", ControlStreamTimeout)
+ }
+ if err != nil {
+ _ = mux.Close()
+ return fmt.Errorf("control stream handshake: %w", err)
+ }
+
+ s.Lock()
+ s.mux = mux
+ s.control = control
+ if s.client {
+ s.stage = "control-opened"
+ } else {
+ s.stage = "control-accepted"
+ }
+ s.Unlock()
+ return nil
+}
+
+// addSessionKey registers a new session key for a given client key
+func (s *Session) addSessionKey(clientKey string, sessionKey int) {
+ s.Lock()
+ defer s.Unlock()
+
+ keys := s.remoteClientKeys[clientKey]
+ if keys == nil {
+ keys = map[int]bool{}
+ s.remoteClientKeys[clientKey] = keys
+ }
+ keys[sessionKey] = true
+}
+
+// removeSessionKey removes a specific session key for a client key
+func (s *Session) removeSessionKey(clientKey string, sessionKey int) {
+ s.Lock()
+ defer s.Unlock()
+
+ keys := s.remoteClientKeys[clientKey]
+ delete(keys, sessionKey)
+ if len(keys) == 0 {
+ delete(s.remoteClientKeys, clientKey)
+ }
+}
+
+// getSessionKeys retrieves all session keys for a given client key
+func (s *Session) getSessionKeys(clientKey string) map[int]bool {
+ s.RLock()
+ defer s.RUnlock()
+ return s.remoteClientKeys[clientKey]
+}
+
+// clientKeyWithPrefix returns a remote client key that starts with prefix.
+func (s *Session) clientKeyWithPrefix(prefix string) (string, bool) {
+ s.RLock()
+ defer s.RUnlock()
+ for k, keys := range s.remoteClientKeys {
+ if strings.HasPrefix(k, prefix) && len(keys) > 0 {
+ return k, true
+ }
+ }
+ return "", false
+}
+
+// Serve accepts and handles logical connections for the lifetime of the session.
+// It returns an HTTP-like status code describing why serving stopped.
+func (s *Session) Serve(ctx context.Context) (int, error) {
+ s.RLock()
+ mux := s.mux
+ control := s.control
+ initErr := s.initErr
+ s.RUnlock()
+
+ if initErr != nil {
+ return 500, initErr
+ }
+ if mux == nil {
+ return 400, errors.New("session not initialized")
+ }
+ defer func() { _ = mux.Close() }()
+ s.Lock()
+ s.stage = "serving"
+ s.Unlock()
+
+ if control != nil {
+ go s.serveControl(control)
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return 200, ctx.Err()
+ default:
+ }
+
+ stream, err := mux.AcceptStream()
+ if err != nil {
+ if mux.IsClosed() {
+ return 200, nil
+ }
+ return 500, err
+ }
+ go s.handleStream(ctx, stream)
+ }
+}
+
+// handleStream reads the connect header from a new stream, dials the requested
+// target and pipes data in both directions. Back pressure and stream teardown
+// are handled by smux.
+func (s *Session) handleStream(ctx context.Context, stream *smux.Stream) {
+ proto, address, err := readConnectHeader(stream)
+ if err != nil {
+ logrus.WithError(err).Debug("failed to read connect header")
+ _ = stream.Close()
+ return
+ }
+
+ if s.auth == nil || !s.auth(proto, address) {
+ logrus.Debugf("connect not allowed for %s/%s", proto, address)
+ _ = stream.Close()
+ return
+ }
+
+ clientDial(ctx, s.dialer, stream, proto, address)
+}
+
+// Dial opens a logical connection to network proto and address through the
+// remote end of the session.
+func (s *Session) Dial(ctx context.Context, proto, address string) (net.Conn, error) {
+ return s.serverConnectContext(ctx, proto, address)
+}
+
+func (s *Session) serverConnectContext(ctx context.Context, proto, address string) (net.Conn, error) {
+ s.RLock()
+ mux := s.mux
+ s.RUnlock()
+ if mux == nil {
+ return nil, errors.New("session not serving")
+ }
+
+ stream, err := mux.OpenStream()
+ if err != nil {
+ return nil, err
+ }
+
+ if deadline, ok := ctx.Deadline(); ok {
+ _ = stream.SetWriteDeadline(deadline)
+ }
+
+ if err := writeConnectHeader(stream, proto, address); err != nil {
+ _ = stream.Close()
+ return nil, err
+ }
+ _ = stream.SetWriteDeadline(time.Time{})
+
+ return stream, nil
+}
+
+// Close closes the control stream, multiplexed session, and WebSocket transport.
+func (s *Session) Close() {
+ s.Lock()
+ mux := s.mux
+ control := s.control
+ s.stage = "closed"
+ s.Unlock()
+
+ if control != nil {
+ _ = control.Close()
+ }
+ if mux != nil {
+ _ = mux.Close()
+ }
+ if s.conn != nil {
+ _ = s.conn.Close()
+ }
+}
+
+// Diagnostics reports the current lifecycle stage and session age.
+func (s *Session) Diagnostics() (string, time.Duration) {
+ s.RLock()
+ defer s.RUnlock()
+ return s.stage, time.Since(s.startedAt)
+}
+
+// sessionAdded notifies the remote end that a client became reachable through this session.
+func (s *Session) sessionAdded(clientKey string, sessionKey int64) {
+ client := fmt.Sprintf("%s/%d", clientKey, sessionKey)
+ if err := s.writeControl(AddClient, client); err != nil {
+ s.Close()
+ }
+}
+
+// sessionRemoved notifies the remote end that a client is no longer reachable through this session.
+func (s *Session) sessionRemoved(clientKey string, sessionKey int64) {
+ client := fmt.Sprintf("%s/%d", clientKey, sessionKey)
+ if err := s.writeControl(RemoveClient, client); err != nil {
+ s.Close()
+ }
+}
+
+func parseAddress(address string) (string, int, error) {
+ parts := strings.SplitN(address, "/", 2)
+ if len(parts) != 2 {
+ return "", 0, errors.New("not / separated")
+ }
+ v, err := strconv.Atoi(parts[1])
+ return parts[0], v, err
+}
+
+// connect header helpers -----------------------------------------------------
+
+// The connect header is a single length-prefixed line: "/\n".
+func writeConnectHeader(w io.Writer, proto, address string) error {
+ _, err := io.WriteString(w, fmt.Sprintf("%s/%s\n", proto, address))
+ return err
+}
+
+func readConnectHeader(r io.Reader) (proto, address string, err error) {
+ line, err := readLine(r)
+ if err != nil {
+ return "", "", err
+ }
+ parts := strings.SplitN(line, "/", 2)
+ if len(parts) != 2 {
+ return "", "", fmt.Errorf("failed to parse connect header %q", line)
+ }
+ return parts[0], parts[1], nil
+}
+
+// readLine reads a single '\n' terminated line byte by byte, so no extra bytes
+// belonging to the stream payload are consumed.
+func readLine(r io.Reader) (string, error) {
+ var (
+ buf [1]byte
+ sb strings.Builder
+ )
+ for sb.Len() < 512 {
+ n, err := r.Read(buf[:])
+ if n > 0 {
+ if buf[0] == '\n' {
+ return sb.String(), nil
+ }
+ sb.WriteByte(buf[0])
+ }
+ if err != nil {
+ if err == io.EOF && sb.Len() > 0 {
+ return sb.String(), nil
+ }
+ return "", err
+ }
+ }
+ return "", errors.New("connect header too long")
+}
diff --git a/apps/rlark/pkg/remotedialer/session_manager.go b/apps/rlark/pkg/remotedialer/session_manager.go
new file mode 100644
index 0000000..37b5560
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session_manager.go
@@ -0,0 +1,182 @@
+package remotedialer
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "net"
+ "strings"
+ "sync"
+
+ "github.com/gorilla/websocket"
+)
+
+type sessionListener interface {
+ sessionAdded(clientKey string, sessionKey int64)
+ sessionRemoved(clientKey string, sessionKey int64)
+}
+
+type sessionManager struct {
+ sync.Mutex
+ clients map[string][]*Session
+ peers map[string][]*Session
+ listeners map[sessionListener]bool
+}
+
+func newSessionManager() *sessionManager {
+ return &sessionManager{
+ clients: map[string][]*Session{},
+ peers: map[string][]*Session{},
+ listeners: map[sessionListener]bool{},
+ }
+}
+
+func toDialer(s *Session, prefix string) Dialer {
+ return func(ctx context.Context, proto, address string) (net.Conn, error) {
+ if prefix == "" {
+ return s.serverConnectContext(ctx, proto, address)
+ }
+ return s.serverConnectContext(ctx, prefix+"::"+proto, address)
+ }
+}
+
+func (sm *sessionManager) removeListener(listener sessionListener) {
+ sm.Lock()
+ defer sm.Unlock()
+
+ delete(sm.listeners, listener)
+}
+
+func (sm *sessionManager) addListener(listener sessionListener) {
+ sm.Lock()
+ defer sm.Unlock()
+
+ sm.listeners[listener] = true
+
+ for k, sessions := range sm.clients {
+ for _, session := range sessions {
+ listener.sessionAdded(k, session.sessionKey)
+ }
+ }
+
+ for k, sessions := range sm.peers {
+ for _, session := range sessions {
+ listener.sessionAdded(k, session.sessionKey)
+ }
+ }
+}
+
+func (sm *sessionManager) listClients() []string {
+ sm.Lock()
+ defer sm.Unlock()
+ clients := make([]string, 0, len(sm.clients))
+ for c := range sm.clients {
+ clients = append(clients, c)
+ }
+ return clients
+}
+
+func (sm *sessionManager) listPeers() []string {
+ sm.Lock()
+ defer sm.Unlock()
+ peers := make([]string, 0, len(sm.peers))
+ for p := range sm.peers {
+ peers = append(peers, p)
+ }
+ return peers
+}
+
+func (sm *sessionManager) getDialer(clientKey string) (Dialer, error) {
+ sm.Lock()
+ defer sm.Unlock()
+ session, routeKey, err := sm.findSession(clientKey)
+ if err != nil {
+ return nil, err
+ }
+ return toDialer(session, routeKey), nil
+}
+
+func (sm *sessionManager) findSession(clientKey string) (*Session, string, error) {
+ if prefix, ok := strings.CutSuffix(clientKey, "*"); ok {
+ // Any matching direct node-agent is an equivalent fallback. Map iteration
+ // intentionally leaves selection unspecified when multiple nodes match.
+ for k, sessions := range sm.clients {
+ if strings.HasPrefix(k, prefix) && len(sessions) > 0 {
+ return sessions[0], "", nil
+ }
+ }
+ // The full matched key is required so the peer can route to that direct client.
+ for _, sessions := range sm.peers {
+ for _, session := range sessions {
+ if matchedClientKey, ok := session.clientKeyWithPrefix(prefix); ok {
+ return session, matchedClientKey, nil
+ }
+ }
+ }
+ return nil, "", fmt.Errorf("failed to find session for client with prefix %s", prefix)
+ }
+
+ sessions := sm.clients[clientKey]
+ if len(sessions) > 0 {
+ return sessions[0], "", nil
+ }
+
+ for _, sessions := range sm.peers {
+ for _, session := range sessions {
+ keys := session.getSessionKeys(clientKey)
+ if len(keys) > 0 {
+ return session, clientKey, nil
+ }
+ }
+ }
+
+ return nil, "", fmt.Errorf("failed to find session for client %s", clientKey)
+}
+
+func (sm *sessionManager) add(clientKey string, conn *websocket.Conn, peer bool) *Session {
+ sessionKey := rand.Int63()
+ session := NewServerSession(sessionKey, clientKey, NewWSConn(conn))
+
+ sm.Lock()
+ defer sm.Unlock()
+
+ if peer {
+ sm.peers[clientKey] = append(sm.peers[clientKey], session)
+ } else {
+ sm.clients[clientKey] = append(sm.clients[clientKey], session)
+ }
+
+ for l := range sm.listeners {
+ l.sessionAdded(clientKey, session.sessionKey)
+ }
+
+ return session
+}
+
+func (sm *sessionManager) remove(s *Session) {
+ sm.Lock()
+ defer sm.Unlock()
+
+ for _, store := range []map[string][]*Session{sm.clients, sm.peers} {
+ var newSessions []*Session
+
+ for _, v := range store[s.clientKey] {
+ if v.sessionKey == s.sessionKey {
+ continue
+ }
+ newSessions = append(newSessions, v)
+ }
+
+ if len(newSessions) == 0 {
+ delete(store, s.clientKey)
+ } else {
+ store[s.clientKey] = newSessions
+ }
+ }
+
+ for l := range sm.listeners {
+ l.sessionRemoved(s.clientKey, s.sessionKey)
+ }
+
+ s.Close()
+}
diff --git a/apps/rlark/pkg/remotedialer/session_manager_test.go b/apps/rlark/pkg/remotedialer/session_manager_test.go
new file mode 100644
index 0000000..99ea41a
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session_manager_test.go
@@ -0,0 +1,54 @@
+package remotedialer
+
+import "testing"
+
+func TestSessionManagerGetDialerPrefix(t *testing.T) {
+ t.Parallel()
+
+ const (
+ prefix = "agent:node-agent:"
+ clientKey = prefix + "node-a"
+ )
+
+ t.Run("direct client", func(t *testing.T) {
+ t.Parallel()
+
+ sm := newSessionManager()
+ direct := &Session{}
+ sm.clients[clientKey] = []*Session{direct}
+
+ session, routeKey, err := sm.findSession(prefix + "*")
+ if err != nil {
+ t.Fatalf("expected prefix to match direct client: %v", err)
+ }
+ if session != direct {
+ t.Fatal("selected unexpected direct session")
+ }
+ if routeKey != "" {
+ t.Fatalf("direct route key = %q, want empty", routeKey)
+ }
+ })
+
+ t.Run("peer client", func(t *testing.T) {
+ t.Parallel()
+
+ peer := &Session{
+ remoteClientKeys: map[string]map[int]bool{
+ clientKey: {1: true},
+ },
+ }
+ sm := newSessionManager()
+ sm.peers["peer"] = []*Session{peer}
+
+ session, routeKey, err := sm.findSession(prefix + "*")
+ if err != nil {
+ t.Fatalf("expected prefix to match peer client: %v", err)
+ }
+ if session != peer {
+ t.Fatal("selected unexpected peer session")
+ }
+ if routeKey != clientKey {
+ t.Fatalf("peer route key = %q, want %q", routeKey, clientKey)
+ }
+ })
+}
diff --git a/apps/rlark/pkg/remotedialer/session_serve.go b/apps/rlark/pkg/remotedialer/session_serve.go
new file mode 100644
index 0000000..9b4fa66
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session_serve.go
@@ -0,0 +1,118 @@
+package remotedialer
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net"
+ "strings"
+
+ "github.com/sirupsen/logrus"
+)
+
+// controlCommand identifies a message sent over the reserved control stream.
+type controlCommand string
+
+const (
+ controlAddClient controlCommand = "ADD"
+ controlRemoveClient controlCommand = "REMOVE"
+)
+
+const (
+ // AddClient identifies a control message advertising a reachable client.
+ AddClient = "AddClient"
+ // RemoveClient identifies a control message withdrawing a reachable client.
+ RemoveClient = "RemoveClient"
+)
+
+// writeControl sends a single peering command over the control stream.
+// The wire format is a newline delimited " " line.
+func (s *Session) writeControl(kind string, address string) error {
+ s.RLock()
+ control := s.control
+ s.RUnlock()
+ if control == nil {
+ // No peer is listening on this session (e.g. a plain client session).
+ return nil
+ }
+
+ var cmd controlCommand
+ switch kind {
+ case AddClient:
+ cmd = controlAddClient
+ case RemoveClient:
+ cmd = controlRemoveClient
+ default:
+ return fmt.Errorf("unknown control command %q", kind)
+ }
+
+ _, err := io.WriteString(control, fmt.Sprintf("%s %s\n", cmd, address))
+ return err
+}
+
+// serveControl consumes peering commands from the reserved control stream until
+// it is closed.
+func (s *Session) serveControl(control net.Conn) {
+ scanner := bufio.NewScanner(control)
+ defer func() {
+ _ = scanner.Err() // ignore errors on close
+ }()
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, " ", 2)
+ if len(parts) != 2 {
+ logrus.Warnf("malformed control command %q", line)
+ continue
+ }
+ cmd, address := controlCommand(parts[0]), parts[1]
+ switch cmd {
+ case controlAddClient:
+ if err := s.addRemoteClient(address); err != nil {
+ logrus.WithError(err).Warn("failed to add remote client")
+ }
+ case controlRemoveClient:
+ if err := s.removeRemoteClient(address); err != nil {
+ logrus.WithError(err).Warn("failed to remove remote client")
+ }
+ default:
+ logrus.Warnf("unknown control command %q", cmd)
+ }
+ }
+}
+
+// addRemoteClient registers a new remote client, making it accessible for requests
+func (s *Session) addRemoteClient(address string) error {
+ if s.remoteClientKeys == nil {
+ return nil
+ }
+
+ clientKey, sessionKey, err := parseAddress(address)
+ if err != nil {
+ return fmt.Errorf("invalid remote Session %s: %v", address, err)
+ }
+ s.addSessionKey(clientKey, sessionKey)
+
+ if PrintTunnelData {
+ logrus.Debugf("ADD REMOTE CLIENT %s, SESSION %d", address, s.sessionKey)
+ }
+
+ return nil
+}
+
+// removeRemoteClient removes a given client from a session
+func (s *Session) removeRemoteClient(address string) error {
+ clientKey, sessionKey, err := parseAddress(address)
+ if err != nil {
+ return fmt.Errorf("invalid remote Session %s: %v", address, err)
+ }
+ s.removeSessionKey(clientKey, sessionKey)
+
+ if PrintTunnelData {
+ logrus.Debugf("REMOVE REMOTE CLIENT %s, SESSION %d", address, s.sessionKey)
+ }
+
+ return nil
+}
diff --git a/apps/rlark/pkg/remotedialer/session_serve_test.go b/apps/rlark/pkg/remotedialer/session_serve_test.go
new file mode 100644
index 0000000..be2cca7
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session_serve_test.go
@@ -0,0 +1,109 @@
+package remotedialer
+
+import (
+ "bufio"
+ "fmt"
+ "math/rand"
+ "net"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestSession_writeControl verifies that peering commands are serialized to the
+// control stream in the expected wire format.
+func TestSession_writeControl(t *testing.T) {
+ t.Parallel()
+
+ local, remote := net.Pipe()
+ defer func() { _ = local.Close() }()
+ defer func() { _ = remote.Close() }()
+
+ s := NewServerSession(rand.Int63(), "", nil)
+ s.control = local
+
+ lines := make(chan string, 2)
+ go func() {
+ scanner := bufio.NewScanner(remote)
+ defer func() {
+ _ = scanner.Err() // ignore errors on close
+ }()
+ for scanner.Scan() {
+ lines <- scanner.Text()
+ }
+ close(lines)
+ }()
+
+ go func() {
+ s.sessionAdded("clientA", 42)
+ s.sessionRemoved("clientA", 42)
+ }()
+
+ want := []string{"ADD clientA/42", "REMOVE clientA/42"}
+ for _, w := range want {
+ select {
+ case got := <-lines:
+ if got != w {
+ t.Errorf("control line mismatch, got %q want %q", got, w)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatalf("timed out waiting for control line %q", w)
+ }
+ }
+}
+
+// TestSession_serveControl verifies the receiving side updates its remote client
+// mapping when control commands arrive.
+func TestSession_serveControl(t *testing.T) {
+ t.Parallel()
+
+ local, remote := net.Pipe()
+ defer func() { _ = local.Close() }()
+ defer func() { _ = remote.Close() }()
+
+ s := NewServerSession(rand.Int63(), "", nil)
+ go s.serveControl(local)
+
+ clientKey, sessionKey := "peerclient", rand.Int()
+ _, _ = fmt.Fprintf(remote, "ADD %s/%d\n", clientKey, sessionKey)
+
+ waitFor(t, func() bool {
+ return len(s.getSessionKeys(clientKey)) == 1
+ }, "remote client not added")
+
+ _, _ = fmt.Fprintf(remote, "REMOVE %s/%d\n", clientKey, sessionKey)
+ waitFor(t, func() bool {
+ return len(s.getSessionKeys(clientKey)) == 0
+ }, "remote client not removed")
+}
+
+func TestSession_writeControlNoPeer(t *testing.T) {
+ t.Parallel()
+
+ // A session with no control stream (plain client) must not error out.
+ s := &Session{remoteClientKeys: map[string]map[int]bool{}}
+ if err := s.writeControl(AddClient, "clientA/1"); err != nil {
+ t.Errorf("unexpected error writing control with no peer: %v", err)
+ }
+}
+
+func TestReadLineLimit(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := readConnectHeader(strings.NewReader(strings.Repeat("x", 1024)))
+ if err == nil {
+ t.Fatal("expected error for overly long connect header")
+ }
+}
+
+func waitFor(t *testing.T, cond func() bool, msg string) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal(msg)
+}
diff --git a/apps/rlark/pkg/remotedialer/session_test.go b/apps/rlark/pkg/remotedialer/session_test.go
new file mode 100644
index 0000000..1b18901
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/session_test.go
@@ -0,0 +1,99 @@
+package remotedialer
+
+import (
+ "fmt"
+ "math/rand"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestSession_sessionKeys(t *testing.T) {
+ t.Parallel()
+
+ s := NewServerSession(rand.Int63(), "", nil)
+
+ clientKey, sessionKey := "testkey", rand.Int()
+ s.addSessionKey(clientKey, sessionKey)
+ if got, want := len(s.remoteClientKeys), 1; got != want {
+ t.Errorf("incorrect number of remote client keys, got: %d, want %d", got, want)
+ }
+
+ if got, want := s.getSessionKeys(clientKey), map[int]bool{sessionKey: true}; !reflect.DeepEqual(got, want) {
+ t.Errorf("incorrect result from getSessionKeys, got: %v, want %v", got, want)
+ }
+
+ s.removeSessionKey(clientKey, sessionKey)
+ if got, want := len(s.remoteClientKeys), 0; got != want {
+ t.Errorf("incorrect number of remote client keys after removal, got: %d, want %d", got, want)
+ }
+}
+
+func TestSession_addRemoveRemoteClient(t *testing.T) {
+ t.Parallel()
+
+ s := NewServerSession(rand.Int63(), "", nil)
+ clientKey, sessionKey := "test", rand.Int()
+
+ msgAddress := fmt.Sprintf("%s/%d", clientKey, sessionKey)
+ if err := s.addRemoteClient(msgAddress); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := s.getSessionKeys(clientKey), map[int]bool{sessionKey: true}; !reflect.DeepEqual(got, want) {
+ t.Errorf("remote client session was not added correctly, got %v, want %v", got, want)
+ }
+
+ if err := s.removeRemoteClient(msgAddress); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := s.getSessionKeys(clientKey), 0; len(got) != want {
+ t.Errorf("remote client session was not removed correctly, got %v, want len(%d)", got, want)
+ }
+}
+
+func TestConnectHeaderRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ proto, address string
+ }{
+ {"tcp", "127.0.0.1:8080"},
+ {"tcp", strings.Repeat("h", 200) + ":65500"},
+ {"unix", "/var/run/socket"},
+ }
+ for _, tt := range tests {
+ var buf strings.Builder
+ if err := writeConnectHeader(&buf, tt.proto, tt.address); err != nil {
+ t.Fatal(err)
+ }
+ proto, address, err := readConnectHeader(strings.NewReader(buf.String()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if proto != tt.proto || address != tt.address {
+ t.Errorf("round trip mismatch, got %q/%q want %q/%q", proto, address, tt.proto, tt.address)
+ }
+ }
+}
+
+func TestReadConnectHeaderDoesNotOverread(t *testing.T) {
+ t.Parallel()
+
+ payload := "tcp/127.0.0.1:80\nHELLO PAYLOAD"
+ r := strings.NewReader(payload)
+ proto, address, err := readConnectHeader(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if proto != "tcp" || address != "127.0.0.1:80" {
+ t.Fatalf("unexpected header %q/%q", proto, address)
+ }
+
+ rest := make([]byte, len("HELLO PAYLOAD"))
+ if _, err := r.Read(rest); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := string(rest), "HELLO PAYLOAD"; got != want {
+ t.Errorf("payload consumed incorrectly, got %q want %q", got, want)
+ }
+}
diff --git a/apps/rlark/pkg/remotedialer/stress_test.go b/apps/rlark/pkg/remotedialer/stress_test.go
new file mode 100644
index 0000000..60c1e6b
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/stress_test.go
@@ -0,0 +1,156 @@
+package remotedialer
+
+import (
+ "context"
+ "crypto/rand"
+ "net"
+ "net/http"
+ "sync"
+ "testing"
+ "time"
+)
+
+// TestFullLinkDuplexStress exercises the complete transport stack (HTTP-style
+// dialer -> smux tunnel over websocket -> pipe -> backend TCP) under a
+// full-duplex load with delayed reads. It is the regression guard for the
+// concurrency-repro scenario that previously deadlocked with smux v1 and
+// surfaced echo-backend self-deadlocks with a naive io.Copy(conn,conn).
+//
+// The test is skipped in -short mode because it moves ~256MiB of data.
+func TestFullLinkDuplexStress(t *testing.T) {
+ if testing.Short() {
+ t.Skip("stress test; skipped in -short mode")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
+ defer cancel()
+
+ backend := startDuplexSafeEcho(t)
+ defer func() { _ = backend.Close() }()
+
+ auth := func(req *http.Request) (string, bool, error) { return "client", true, nil }
+ server := New(auth, DefaultErrorWriter)
+ tunnelAddr, err := newServer(ctx, server)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := newTestClient(ctx, "ws://"+tunnelAddr); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, func() bool { return server.HasSession("client") }, "tunnel session never established")
+
+ const (
+ conns = 8
+ size = 32 << 20
+ chunk = 64 << 10
+ startReadDelay = 200 * time.Millisecond
+ readDelay = time.Millisecond
+ )
+
+ var wg sync.WaitGroup
+ errCh := make(chan error, conns)
+
+ for i := 0; i < conns; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ conn, err := server.Dialer("client")(ctx, "tcp", backend.Addr().String())
+ if err != nil {
+ errCh <- err
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ writeDone := make(chan error, 1)
+ go func() {
+ buf := make([]byte, chunk)
+ _, _ = rand.Read(buf)
+ var written int64
+ for written < size {
+ n := int64(len(buf))
+ if size-written < n {
+ n = size - written
+ }
+ m, err := conn.Write(buf[:n])
+ written += int64(m)
+ if err != nil {
+ writeDone <- err
+ return
+ }
+ }
+ writeDone <- nil
+ }()
+
+ time.Sleep(startReadDelay)
+ buf := make([]byte, chunk)
+ var read int64
+ for read < size {
+ n, err := conn.Read(buf)
+ read += int64(n)
+ if err != nil {
+ errCh <- err
+ return
+ }
+ time.Sleep(readDelay)
+ }
+ if err := <-writeDone; err != nil {
+ errCh <- err
+ }
+ }()
+ }
+
+ wg.Wait()
+ close(errCh)
+ for e := range errCh {
+ if e != nil {
+ t.Fatalf("stream failed: %v", e)
+ }
+ }
+}
+
+// startDuplexSafeEcho starts a TCP echo backend that decouples reads and
+// writes with separate goroutines. A single io.Copy(conn, conn) can self
+// deadlock under a full-duplex load once both send buffers are full, because
+// the same goroutine handles both directions.
+func startDuplexSafeEcho(t *testing.T) net.Listener {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) {
+ defer func() { _ = c.Close() }()
+ pending := make(chan []byte, 1024)
+ go func() {
+ for b := range pending {
+ if _, err := c.Write(b); err != nil {
+ return
+ }
+ }
+ }()
+ buf := make([]byte, 64<<10)
+ for {
+ n, err := c.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ pending <- chunk
+ }
+ if err != nil {
+ close(pending)
+ return
+ }
+ }
+ }(c)
+ }
+ }()
+ return ln
+}
diff --git a/apps/rlark/pkg/remotedialer/tests/concurrency-repro/main.go b/apps/rlark/pkg/remotedialer/tests/concurrency-repro/main.go
new file mode 100644
index 0000000..6944544
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/tests/concurrency-repro/main.go
@@ -0,0 +1,264 @@
+// Run Test: go run ./apps/rlark/pkg/remotedialer/tests/concurrency-repro \
+// -connections 8 -size 33554432 -start-read-delay 200ms -read-delay 1ms -timeout 20s -stall-threshold 1s
+
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
+)
+
+const clientID = "load-client"
+
+type config struct {
+ connections int
+ size int64
+ chunkSize int
+ readDelay time.Duration
+ startRead time.Duration
+ stall time.Duration
+ timeout time.Duration
+}
+
+type result struct {
+ id int
+ written int64
+ read int64
+ first time.Duration
+ maxGap time.Duration
+ duration time.Duration
+ err error
+}
+
+func main() {
+ var cfg config
+ flag.IntVar(&cfg.connections, "connections", 8, "number of concurrent full-duplex connections")
+ flag.Int64Var(&cfg.size, "size", 64<<20, "bytes sent and echoed per connection")
+ flag.IntVar(&cfg.chunkSize, "chunk-size", 64<<10, "application read and write size")
+ flag.DurationVar(&cfg.startRead, "start-read-delay", 500*time.Millisecond, "delay before reading echoed data")
+ flag.DurationVar(&cfg.readDelay, "read-delay", time.Millisecond, "delay after each read")
+ flag.DurationVar(&cfg.stall, "stall-threshold", 2*time.Second, "report read or write gaps above this duration")
+ flag.DurationVar(&cfg.timeout, "timeout", 2*time.Minute, "timeout per connection")
+ flag.Parse()
+
+ if cfg.connections < 1 || cfg.size < 1 || cfg.chunkSize < 1 {
+ log.Fatal("connections, size, and chunk-size must be positive")
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ backendAddr, closeBackend := startEchoServer(ctx)
+ defer closeBackend()
+ tunnel := remotedialer.New(func(*http.Request) (string, bool, error) {
+ return clientID, true, nil
+ }, remotedialer.DefaultErrorWriter)
+ tunnelAddr, closeTunnel := startHTTPServer(ctx, tunnel)
+ defer closeTunnel()
+
+ connected := make(chan struct{})
+ clientErr := make(chan error, 1)
+ go func() {
+ clientErr <- remotedialer.ConnectToProxy(ctx, "ws://"+tunnelAddr, nil,
+ func(string, string) bool { return true }, nil,
+ func(context.Context, *remotedialer.Session) error {
+ close(connected)
+ return nil
+ })
+ }()
+ select {
+ case <-connected:
+ case err := <-clientErr:
+ log.Fatalf("connect tunnel client: %v", err)
+ case <-time.After(10 * time.Second):
+ log.Fatal("timed out waiting for tunnel client")
+ }
+
+ // onConnect fires on the client as soon as the websocket handshake
+ // completes, which may be a hair before the server-side session is
+ // registered. Wait for the tunnel server to actually have a session for us
+ // before we start opening streams.
+ sessionDeadline := time.Now().Add(5 * time.Second)
+ for !tunnel.HasSession(clientID) {
+ if time.Now().After(sessionDeadline) {
+ log.Fatal("tunnel server never registered the client session")
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ results := make(chan result, cfg.connections)
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ for id := 1; id <= cfg.connections; id++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ <-start
+ results <- runConnection(ctx, tunnel.Dialer(clientID), backendAddr, id, cfg)
+ }(id)
+ }
+ log.Printf("starting connections=%d duplex-bytes=%d MiB start-read-delay=%s read-delay=%s", cfg.connections, cfg.size>>20, cfg.startRead, cfg.readDelay)
+ close(start)
+ wg.Wait()
+ close(results)
+
+ failed := false
+ for r := range results {
+ log.Printf("connection=%02d written=%d read=%d first-byte=%s max-gap=%s duration=%s err=%v", r.id, r.written, r.read, r.first.Round(time.Millisecond), r.maxGap.Round(time.Millisecond), r.duration.Round(time.Millisecond), r.err)
+ if r.err != nil || r.written != cfg.size || r.read != cfg.size {
+ failed = true
+ }
+ if r.maxGap >= cfg.stall {
+ log.Printf("STALL connection=%02d gap=%s threshold=%s", r.id, r.maxGap.Round(time.Millisecond), cfg.stall)
+ }
+ }
+ if failed {
+ os.Exit(1)
+ }
+}
+
+func runConnection(parent context.Context, dial remotedialer.Dialer, address string, id int, cfg config) result {
+ started := time.Now()
+ r := result{id: id}
+ ctx, cancel := context.WithTimeout(parent, cfg.timeout)
+ defer cancel()
+ conn, err := dial(ctx, "tcp", address)
+ if err != nil {
+ r.err = err
+ return r
+ }
+ defer func() { _ = conn.Close() }()
+ _ = conn.SetDeadline(time.Now().Add(cfg.timeout))
+
+ writeDone := make(chan error, 1)
+ go func() {
+ buf := make([]byte, cfg.chunkSize)
+ for r.written < cfg.size {
+ n := min(int64(len(buf)), cfg.size-r.written)
+ written, err := conn.Write(buf[:n])
+ r.written += int64(written)
+ if err != nil {
+ writeDone <- fmt.Errorf("write after %d bytes: %w", r.written, err)
+ return
+ }
+ }
+ writeDone <- nil
+ }()
+
+ time.Sleep(cfg.startRead)
+ buf := make([]byte, cfg.chunkSize)
+ last := started
+ for r.read < cfg.size {
+ n, err := conn.Read(buf)
+ now := time.Now()
+ if n > 0 {
+ if r.read == 0 {
+ r.first = now.Sub(started)
+ }
+ if gap := now.Sub(last); gap > r.maxGap {
+ r.maxGap = gap
+ }
+ last = now
+ r.read += int64(n)
+ if cfg.readDelay > 0 {
+ time.Sleep(cfg.readDelay)
+ }
+ }
+ if err != nil {
+ if gap := now.Sub(last); gap > r.maxGap {
+ r.maxGap = gap
+ }
+ r.err = fmt.Errorf("read after %d bytes: %w", r.read, err)
+ break
+ }
+ }
+ if err := <-writeDone; r.err == nil {
+ r.err = err
+ }
+ r.duration = time.Since(started)
+ return r
+}
+
+func startEchoServer(ctx context.Context) (string, func()) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ log.Fatal(err)
+ }
+ go func() {
+ <-ctx.Done()
+ _ = listener.Close()
+ }()
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go echoConn(conn)
+ }
+ }()
+ return listener.Addr().String(), func() { _ = listener.Close() }
+}
+
+// echoConn echoes data back on conn using separate read and write goroutines.
+//
+// A naive io.Copy(conn, conn) uses a single goroutine that reads a chunk and
+// then writes it back before reading again. Under a full-duplex load, where the
+// peer floods both directions before draining either, the write side blocks on
+// a full send buffer and the same goroutine can no longer read, wedging the
+// connection. Decoupling reads from writes avoids that self-deadlock.
+func echoConn(conn net.Conn) {
+ defer func() { _ = conn.Close() }()
+
+ pending := make(chan []byte, 1024)
+ go func() {
+ for b := range pending {
+ if _, err := conn.Write(b); err != nil {
+ return
+ }
+ }
+ }()
+
+ buf := make([]byte, 64<<10)
+ for {
+ n, err := conn.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ pending <- chunk
+ }
+ if err != nil {
+ close(pending)
+ return
+ }
+ }
+}
+
+func startHTTPServer(ctx context.Context, handler http.Handler) (string, func()) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ log.Fatal(err)
+ }
+ server := &http.Server{Handler: handler}
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Printf("http server: %v", err)
+ }
+ }()
+ go func() {
+ <-ctx.Done()
+ _ = server.Close()
+ }()
+ return listener.Addr().String(), func() { _ = server.Close() }
+}
diff --git a/apps/rlark/pkg/remotedialer/tests/conformance/main.go b/apps/rlark/pkg/remotedialer/tests/conformance/main.go
new file mode 100644
index 0000000..e63be18
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/tests/conformance/main.go
@@ -0,0 +1,450 @@
+// Run Test: go run ./apps/rlark/pkg/remotedialer/tests/conformance
+
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
+)
+
+const (
+ clientKey = "conformance-client"
+ peerAToken = "peer-a-token"
+ peerBToken = "peer-b-token"
+ waitDeadline = 10 * time.Second
+)
+
+func main() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ failures := 0
+ runCase := func(name string, fn func(context.Context) error) {
+ fmt.Printf("== %s\n", name)
+ start := time.Now()
+ err := fn(ctx)
+ dur := time.Since(start).Round(time.Millisecond)
+ if err != nil {
+ failures++
+ fmt.Printf(" FAIL (%s): %v\n", dur, err)
+ return
+ }
+ fmt.Printf(" ok (%s)\n", dur)
+ }
+
+ runCase("direct proxy: single connection round-trip", scenarioDirectRoundTrip)
+ runCase("direct proxy: concurrent connections", scenarioConcurrent)
+ runCase("direct proxy: dial unreachable backend returns error", scenarioBackendError)
+ runCase("peer forwarding: cross-server dial", scenarioPeerForwarding)
+ runCase("teardown: dialer fails after client disconnects", scenarioTeardown)
+
+ if failures > 0 {
+ fmt.Printf("\n%d case(s) failed\n", failures)
+ os.Exit(1)
+ }
+ fmt.Println("\nall cases passed")
+}
+
+// ---------------------------------------------------------------------------
+// Scenarios
+// ---------------------------------------------------------------------------
+
+func scenarioDirectRoundTrip(ctx context.Context) error {
+ env, err := startSingleServerEnv(ctx)
+ if err != nil {
+ return err
+ }
+ defer env.close()
+
+ return roundTrip(ctx, env.server.Dialer(clientKey), env.backend.Addr().String(), 64<<10)
+}
+
+func scenarioConcurrent(ctx context.Context) error {
+ env, err := startSingleServerEnv(ctx)
+ if err != nil {
+ return err
+ }
+ defer env.close()
+
+ const conns = 8
+ const size = 256 << 10
+ errs := make(chan error, conns)
+ var wg sync.WaitGroup
+ for i := 0; i < conns; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ errs <- roundTrip(ctx, env.server.Dialer(clientKey), env.backend.Addr().String(), size)
+ }()
+ }
+ wg.Wait()
+ close(errs)
+ for e := range errs {
+ if e != nil {
+ return e
+ }
+ }
+ return nil
+}
+
+func scenarioBackendError(ctx context.Context) error {
+ env, err := startSingleServerEnv(ctx)
+ if err != nil {
+ return err
+ }
+ defer env.close()
+
+ dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ // Port 1 on loopback is essentially never bound; the client-side dial
+ // should fail and the tunnel must surface that failure to the caller.
+ conn, err := env.server.Dialer(clientKey)(dialCtx, "tcp", "127.0.0.1:1")
+ if err == nil {
+ _ = conn.Close()
+ // smux may accept the stream and close it when the client-side dial
+ // fails; a subsequent read should return an error.
+ buf := make([]byte, 1)
+ _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ if _, rerr := conn.Read(buf); rerr == nil {
+ return fmt.Errorf("expected dial or read to fail for unreachable backend, got success")
+ }
+ }
+ return nil
+}
+
+func scenarioPeerForwarding(ctx context.Context) error {
+ env, err := startPeerEnv(ctx)
+ if err != nil {
+ return err
+ }
+ defer env.close()
+
+ // The client only connects to serverA. Reaching the backend via serverB
+ // exercises the peer routing path: serverB.Dialer -> peer session ->
+ // serverA -> client -> backend.
+ if err := waitFor(waitDeadline, func() bool {
+ return env.serverA.HasSession(clientKey) && env.serverB.HasSession(clientKey)
+ }); err != nil {
+ return fmt.Errorf("peer routing table did not converge: %w", err)
+ }
+
+ // Verify both paths independently.
+ if err := roundTrip(ctx, env.serverA.Dialer(clientKey), env.backend.Addr().String(), 64<<10); err != nil {
+ return fmt.Errorf("serverA -> client: %w", err)
+ }
+ if err := roundTrip(ctx, env.serverB.Dialer(clientKey), env.backend.Addr().String(), 64<<10); err != nil {
+ return fmt.Errorf("serverB -> client (via peer): %w", err)
+ }
+ return nil
+}
+
+func scenarioTeardown(ctx context.Context) error {
+ env, err := startSingleServerEnv(ctx)
+ if err != nil {
+ return err
+ }
+ // Bring down the client explicitly.
+ env.stopClient()
+
+ if err := waitFor(waitDeadline, func() bool { return !env.server.HasSession(clientKey) }); err != nil {
+ env.close()
+ return fmt.Errorf("session was not removed after client disconnect: %w", err)
+ }
+
+ dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ _, err = env.server.Dialer(clientKey)(dialCtx, "tcp", env.backend.Addr().String())
+ env.close()
+ if err == nil {
+ return fmt.Errorf("expected dial to fail after client disconnect")
+ }
+ if !strings.Contains(err.Error(), "failed to find session") {
+ return fmt.Errorf("unexpected teardown error: %v", err)
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Environments
+// ---------------------------------------------------------------------------
+
+type singleServerEnv struct {
+ server *remotedialer.Server
+ backend net.Listener
+ closeFns []func()
+ clientStop context.CancelFunc
+}
+
+func (e *singleServerEnv) stopClient() {
+ if e.clientStop != nil {
+ e.clientStop()
+ e.clientStop = nil
+ }
+}
+
+func (e *singleServerEnv) close() {
+ e.stopClient()
+ for _, v := range slices.Backward(e.closeFns) {
+ v()
+ }
+}
+
+func startSingleServerEnv(ctx context.Context) (*singleServerEnv, error) {
+ backend, err := startEchoBackend()
+ if err != nil {
+ return nil, err
+ }
+
+ server, srvAddr, srvClose, err := startTunnelServer(func(req *http.Request) (string, bool, error) {
+ return req.Header.Get("X-Client-Key"), req.Header.Get("X-Client-Key") != "", nil
+ })
+ if err != nil {
+ _ = backend.Close()
+ return nil, err
+ }
+
+ clientCtx, clientCancel := context.WithCancel(ctx)
+ clientReady, err := startTunnelClient(clientCtx, "ws://"+srvAddr, clientKey)
+ if err != nil {
+ clientCancel()
+ srvClose()
+ _ = backend.Close()
+ return nil, err
+ }
+ <-clientReady
+
+ if err := waitFor(waitDeadline, func() bool { return server.HasSession(clientKey) }); err != nil {
+ clientCancel()
+ srvClose()
+ _ = backend.Close()
+ return nil, fmt.Errorf("session not registered: %w", err)
+ }
+
+ return &singleServerEnv{
+ server: server,
+ backend: backend,
+ clientStop: clientCancel,
+ closeFns: []func(){srvClose, func() { _ = backend.Close() }},
+ }, nil
+}
+
+type peerEnv struct {
+ serverA, serverB *remotedialer.Server
+ backend net.Listener
+ closeFns []func()
+ clientStop context.CancelFunc
+}
+
+func (e *peerEnv) close() {
+ if e.clientStop != nil {
+ e.clientStop()
+ }
+ for _, v := range slices.Backward(e.closeFns) {
+ v()
+ }
+}
+
+func startPeerEnv(ctx context.Context) (*peerEnv, error) {
+ backend, err := startEchoBackend()
+ if err != nil {
+ return nil, err
+ }
+
+ auth := func(req *http.Request) (string, bool, error) {
+ return req.Header.Get("X-Client-Key"), req.Header.Get("X-Client-Key") != "", nil
+ }
+ serverA, addrA, closeA, err := startTunnelServer(auth)
+ if err != nil {
+ _ = backend.Close()
+ return nil, err
+ }
+ serverA.PeerID = "peer-A"
+ serverA.PeerToken = peerAToken
+
+ serverB, addrB, closeB, err := startTunnelServer(auth)
+ if err != nil {
+ closeA()
+ _ = backend.Close()
+ return nil, err
+ }
+ serverB.PeerID = "peer-B"
+ serverB.PeerToken = peerBToken
+
+ // Peers authenticate reciprocally with each other's ID/token.
+ serverA.AddPeer("ws://"+addrB, serverB.PeerID, serverB.PeerToken)
+ serverB.AddPeer("ws://"+addrA, serverA.PeerID, serverA.PeerToken)
+
+ clientCtx, clientCancel := context.WithCancel(ctx)
+ clientReady, err := startTunnelClient(clientCtx, "ws://"+addrA, clientKey)
+ if err != nil {
+ clientCancel()
+ closeB()
+ closeA()
+ _ = backend.Close()
+ return nil, err
+ }
+ <-clientReady
+
+ return &peerEnv{
+ serverA: serverA,
+ serverB: serverB,
+ backend: backend,
+ clientStop: clientCancel,
+ closeFns: []func(){closeB, closeA, func() { _ = backend.Close() }},
+ }, nil
+}
+
+// ---------------------------------------------------------------------------
+// Building blocks
+// ---------------------------------------------------------------------------
+
+// startEchoBackend starts a TCP listener that echoes bytes back on every
+// connection. Reads and writes are decoupled so a full-duplex sender cannot
+// self-deadlock a single io.Copy(conn, conn) goroutine.
+func startEchoBackend() (net.Listener, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go echoConn(c)
+ }
+ }()
+ return ln, nil
+}
+
+func echoConn(c net.Conn) {
+ defer func() { _ = c.Close() }()
+ pending := make(chan []byte, 128)
+ go func() {
+ for b := range pending {
+ if _, err := c.Write(b); err != nil {
+ return
+ }
+ }
+ }()
+ buf := make([]byte, 32<<10)
+ for {
+ n, err := c.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ pending <- chunk
+ }
+ if err != nil {
+ close(pending)
+ return
+ }
+ }
+}
+
+// startTunnelServer starts a remotedialer.Server bound to a random localhost
+// port and returns the server, its address, and a closer.
+func startTunnelServer(auth remotedialer.Authorizer) (*remotedialer.Server, string, func(), error) {
+ handler := remotedialer.New(auth, remotedialer.DefaultErrorWriter)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, "", nil, err
+ }
+ srv := &http.Server{Handler: handler}
+ go func() { _ = srv.Serve(ln) }()
+ return handler, ln.Addr().String(), func() { _ = srv.Close() }, nil
+}
+
+// startTunnelClient connects to url and reports readiness on the returned
+// channel once the client-side onConnect callback has fired.
+func startTunnelClient(ctx context.Context, url, key string) (<-chan struct{}, error) {
+ ready := make(chan struct{})
+ headers := http.Header{"X-Client-Key": []string{key}}
+ go func() {
+ err := remotedialer.ConnectToProxy(ctx, url, headers,
+ func(string, string) bool { return true }, nil,
+ func(context.Context, *remotedialer.Session) error {
+ closeOnce(ready)
+ return nil
+ })
+ if err != nil && !errors.Is(err, context.Canceled) {
+ log.Printf("tunnel client %s: %v", url, err)
+ }
+ }()
+ select {
+ case <-ready:
+ return ready, nil
+ case <-time.After(waitDeadline):
+ return nil, fmt.Errorf("timed out waiting for tunnel client to connect to %s", url)
+ }
+}
+
+// roundTrip opens one tunneled connection, sends a random payload of `size`
+// bytes and verifies the echoed response matches byte for byte.
+func roundTrip(ctx context.Context, dial remotedialer.Dialer, addr string, size int) error {
+ dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ conn, err := dial(dialCtx, "tcp", addr)
+ if err != nil {
+ return fmt.Errorf("dial: %w", err)
+ }
+ defer func() { _ = conn.Close() }()
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ payload := make([]byte, size)
+ if _, err := rand.Read(payload); err != nil {
+ return err
+ }
+
+ writeErr := make(chan error, 1)
+ go func() {
+ _, err := conn.Write(payload)
+ writeErr <- err
+ }()
+
+ got := make([]byte, size)
+ if _, err := io.ReadFull(conn, got); err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+ if err := <-writeErr; err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ for i := range payload {
+ if payload[i] != got[i] {
+ return fmt.Errorf("payload mismatch at byte %d", i)
+ }
+ }
+ return nil
+}
+
+func waitFor(timeout time.Duration, cond func() bool) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return nil
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return fmt.Errorf("condition not met within %s", timeout)
+}
+
+func closeOnce(ch chan struct{}) {
+ defer func() { _ = recover() }()
+ close(ch)
+}
diff --git a/apps/rlark/pkg/remotedialer/tests/disconnect-repro/main.go b/apps/rlark/pkg/remotedialer/tests/disconnect-repro/main.go
new file mode 100644
index 0000000..80f3424
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/tests/disconnect-repro/main.go
@@ -0,0 +1,210 @@
+// Run from apps/rlark:
+//
+// go run ./pkg/remotedialer/tests/disconnect-repro -mode hard -timeout 20s
+// go run ./pkg/remotedialer/tests/disconnect-repro -mode idle -timeout 20s -duration 45s
+//
+// hard simulates a middlebox with a fixed connection lifetime and should
+// produce "connection reset by peer" on both tunnel endpoints. idle resets only
+// when no TCP payload crosses the proxy; smux's five-second NOP should keep it
+// alive when timeout is 20 seconds.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
+)
+
+const clientID = "disconnect-repro"
+
+type resetProxy struct {
+ mode string
+ timeout time.Duration
+ target string
+}
+
+func main() {
+ mode := flag.String("mode", "hard", "disconnect mode: hard or idle")
+ timeout := flag.Duration("timeout", 20*time.Second, "fixed lifetime or TCP payload idle timeout")
+ duration := flag.Duration("duration", 45*time.Second, "maximum reproduction duration")
+ flag.Parse()
+
+ if (*mode != "hard" && *mode != "idle") || *timeout <= 0 || *duration <= 0 {
+ log.Fatal("mode must be hard or idle; timeout and duration must be positive")
+ }
+
+ rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ ctx, cancel := context.WithTimeout(rootCtx, *duration)
+ defer cancel()
+
+ tunnel := remotedialer.New(func(*http.Request) (string, bool, error) {
+ return clientID, true, nil
+ }, remotedialer.DefaultErrorWriter)
+ serverAddr, closeServer, err := startHTTPServer(tunnel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer closeServer()
+
+ proxyAddr, closeProxy, err := startResetProxy(ctx, resetProxy{
+ mode: *mode, timeout: *timeout, target: serverAddr,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer closeProxy()
+
+ started := time.Now()
+ log.Printf("starting mode=%s timeout=%s duration=%s path=client -> %s -> %s", *mode, *timeout, *duration, proxyAddr, serverAddr)
+ err = remotedialer.ConnectToProxy(ctx, "ws://"+proxyAddr, nil,
+ func(string, string) bool { return true }, nil,
+ func(context.Context, *remotedialer.Session) error {
+ log.Printf("tunnel connected after %s; leaving it idle", time.Since(started).Round(time.Millisecond))
+ return nil
+ })
+ elapsed := time.Since(started).Round(time.Millisecond)
+
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) && err == nil {
+ log.Printf("tunnel remained connected for %s", elapsed)
+ if *mode == "idle" {
+ log.Printf("PASS: smux traffic prevented the %s TCP payload idle timeout", *timeout)
+ }
+ return
+ }
+ if err != nil {
+ log.Printf("tunnel disconnected after %s: %v", elapsed, err)
+ if *mode == "hard" {
+ log.Printf("PASS: reproduced forced middlebox reset")
+ }
+ return
+ }
+ log.Printf("tunnel stopped after %s: %v", elapsed, ctx.Err())
+}
+
+func startHTTPServer(handler http.Handler) (string, func(), error) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return "", nil, err
+ }
+ server := &http.Server{Handler: handler}
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Printf("tunnel server: %v", err)
+ }
+ }()
+ return listener.Addr().String(), func() { _ = server.Close() }, nil
+}
+
+func startResetProxy(ctx context.Context, proxy resetProxy) (string, func(), error) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return "", nil, err
+ }
+ go func() {
+ for {
+ client, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go proxy.handle(ctx, client)
+ }
+ }()
+ return listener.Addr().String(), func() { _ = listener.Close() }, nil
+}
+
+func (p resetProxy) handle(ctx context.Context, client net.Conn) {
+ server, err := net.Dial("tcp", p.target)
+ if err != nil {
+ log.Printf("proxy dial server: %v", err)
+ _ = client.Close()
+ return
+ }
+
+ started := time.Now()
+ var lastActivity atomic.Int64
+ lastActivity.Store(started.UnixNano())
+ errCh := make(chan error, 2)
+ go proxyCopy(server, client, &lastActivity, errCh)
+ go proxyCopy(client, server, &lastActivity, errCh)
+
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ closePair(client, server, false)
+ return
+ case err := <-errCh:
+ closePair(client, server, false)
+ if err != nil && !errors.Is(err, net.ErrClosed) {
+ log.Printf("proxy copy stopped after %s: %v", time.Since(started).Round(time.Millisecond), err)
+ }
+ return
+ case <-ticker.C:
+ elapsed := time.Since(started)
+ idle := time.Since(time.Unix(0, lastActivity.Load()))
+ if p.mode == "hard" && elapsed >= p.timeout || p.mode == "idle" && idle >= p.timeout {
+ log.Printf("proxy injecting TCP RST mode=%s connected=%s payload-idle=%s", p.mode, elapsed.Round(time.Millisecond), idle.Round(time.Millisecond))
+ closePair(client, server, true)
+ return
+ }
+ }
+ }
+}
+
+func proxyCopy(dst, src net.Conn, activity *atomic.Int64, result chan<- error) {
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := src.Read(buf)
+ if n > 0 {
+ activity.Store(time.Now().UnixNano())
+ if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
+ result <- writeErr
+ return
+ }
+ }
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ result <- nil
+ } else {
+ result <- err
+ }
+ return
+ }
+ }
+}
+
+func closePair(a, b net.Conn, reset bool) {
+ var wg sync.WaitGroup
+ for _, conn := range []net.Conn{a, b} {
+ wg.Add(1)
+ go func(conn net.Conn) {
+ defer wg.Done()
+ if reset {
+ if tcp, ok := conn.(*net.TCPConn); ok {
+ if err := tcp.SetLinger(0); err != nil {
+ log.Printf("set SO_LINGER on %s: %v", conn.LocalAddr(), err)
+ }
+ }
+ }
+ if err := conn.Close(); err != nil {
+ log.Printf("close %s: %v", fmt.Sprint(conn.LocalAddr()), err)
+ }
+ }(conn)
+ }
+ wg.Wait()
+}
diff --git a/apps/rlark/pkg/remotedialer/types.go b/apps/rlark/pkg/remotedialer/types.go
new file mode 100644
index 0000000..d5ec8a0
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/types.go
@@ -0,0 +1,24 @@
+package remotedialer
+
+import "time"
+
+const (
+ // PingWriteInterval is the smux keepalive interval: how often a NOP frame
+ // is emitted on an idle tunnel to prove the transport is still alive.
+ PingWriteInterval = 5 * time.Second
+ // PingWaitDuration is the smux keepalive timeout: if no traffic (including
+ // NOP frames) arrives within this window, smux tears the session down.
+ // The name is retained for API compatibility with the pre-smux era.
+ PingWaitDuration = 60 * time.Second
+ // MaxRead is the legacy maximum read size retained for API compatibility.
+ MaxRead = 8192
+ // HandshakeTimeOut is the timeout used when establishing a peer WebSocket.
+ HandshakeTimeOut = 10 * time.Second
+ // ControlStreamTimeout bounds the initial reserved stream handshake.
+ ControlStreamTimeout = 10 * time.Second
+ // MaxBuffer is the smux session-wide receive buffer size. It bounds the
+ // total amount of unread data buffered across all streams of a session.
+ MaxBuffer = 64 * 1024 * 1024
+ // MaxStreamBuffer is the per-stream receive buffer size used for flow control.
+ MaxStreamBuffer = 4 * 1024 * 1024
+)
diff --git a/apps/rlark/pkg/remotedialer/wsconn.go b/apps/rlark/pkg/remotedialer/wsconn.go
new file mode 100644
index 0000000..c435b65
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/wsconn.go
@@ -0,0 +1,113 @@
+package remotedialer
+
+import (
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// WSConn adapts a websocket connection to an io.ReadWriteCloser so it can be
+// used as the transport for a smux session. The websocket is a message-oriented
+// protocol, so reads drain the current binary frame before fetching the next
+// one, and every write is sent as a single binary frame.
+//
+// Keepalive is delegated entirely to smux: smux emits NOP frames on the
+// interval configured in smuxConfig() and tears the session down if no data
+// arrives within KeepAliveTimeout. Layering a second websocket-level ping/pong
+// keepalive on top would race with smux's timers and, more importantly, would
+// require someone to actually send websocket pings, which nothing in this
+// package does. We therefore leave the underlying websocket without a read
+// deadline and let smux's timeout govern liveness.
+type WSConn interface {
+ io.ReadWriteCloser
+ SetReadDeadline(t time.Time) error
+ SetWriteDeadline(t time.Time) error
+}
+
+type wsWrapper struct {
+ conn *websocket.Conn
+
+ readMu sync.Mutex
+ reader io.Reader
+
+ writeMu sync.Mutex
+ closeMu sync.Once
+}
+
+// NewWSConn wraps a gorilla websocket connection as a WSConn.
+func NewWSConn(conn *websocket.Conn) WSConn {
+ enableTCPKeepAlive(conn.UnderlyingConn())
+ return &wsWrapper{conn: conn}
+}
+
+func enableTCPKeepAlive(conn net.Conn) {
+ if tcpConn, ok := conn.(*net.TCPConn); ok {
+ _ = tcpConn.SetKeepAlive(true)
+ _ = tcpConn.SetKeepAlivePeriod(15 * time.Second)
+ }
+}
+
+func (w *wsWrapper) Read(p []byte) (int, error) {
+ w.readMu.Lock()
+ defer w.readMu.Unlock()
+
+ for {
+ if w.reader == nil {
+ msgType, reader, err := w.conn.NextReader()
+ if err != nil {
+ return 0, err
+ }
+ if msgType != websocket.BinaryMessage {
+ continue
+ }
+ w.reader = reader
+ }
+
+ n, err := w.reader.Read(p)
+ if err == io.EOF {
+ // Current frame exhausted, move on to the next one.
+ w.reader = nil
+ if n > 0 {
+ return n, nil
+ }
+ continue
+ }
+ return n, err
+ }
+}
+
+func (w *wsWrapper) Write(p []byte) (int, error) {
+ w.writeMu.Lock()
+ defer w.writeMu.Unlock()
+
+ if err := w.conn.WriteMessage(websocket.BinaryMessage, p); err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
+
+func (w *wsWrapper) Close() error {
+ var err error
+ w.closeMu.Do(func() {
+ w.writeMu.Lock()
+ _ = w.conn.WriteControl(websocket.CloseMessage,
+ websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
+ time.Now().Add(time.Second))
+ w.writeMu.Unlock()
+ err = w.conn.Close()
+ })
+ return err
+}
+
+func (w *wsWrapper) SetReadDeadline(t time.Time) error {
+ return w.conn.SetReadDeadline(t)
+}
+
+func (w *wsWrapper) SetWriteDeadline(t time.Time) error {
+ w.writeMu.Lock()
+ defer w.writeMu.Unlock()
+ return w.conn.SetWriteDeadline(t)
+}
diff --git a/apps/rlark/pkg/remotedialer/wsconn_test.go b/apps/rlark/pkg/remotedialer/wsconn_test.go
new file mode 100644
index 0000000..5917e8a
--- /dev/null
+++ b/apps/rlark/pkg/remotedialer/wsconn_test.go
@@ -0,0 +1,39 @@
+package remotedialer
+
+import (
+ "net"
+ "testing"
+)
+
+func TestEnableTCPKeepAlive(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = listener.Close() }()
+
+ accepted := make(chan net.Conn, 1)
+ go func() {
+ conn, acceptErr := listener.Accept()
+ if acceptErr == nil {
+ accepted <- conn
+ }
+ }()
+
+ conn, err := net.Dial("tcp", listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ serverConn := <-accepted
+ defer func() { _ = serverConn.Close() }()
+
+ // The helper must accept both TCP connections and unrelated net.Conn
+ // implementations without panicking or changing the transport API.
+ enableTCPKeepAlive(conn)
+ local, remote := net.Pipe()
+ defer func() { _ = local.Close() }()
+ defer func() { _ = remote.Close() }()
+ enableTCPKeepAlive(local)
+}
diff --git a/apps/rlark/pkg/rlarkadm/component/component.go b/apps/rlark/pkg/rlarkadm/component/component.go
index 12d7047..e3fe9f3 100644
--- a/apps/rlark/pkg/rlarkadm/component/component.go
+++ b/apps/rlark/pkg/rlarkadm/component/component.go
@@ -54,8 +54,30 @@ var commonEnvs = []corev1.EnvVar{
// Component describes a deployable component.
type Component = types.Component
-func commonArgs() []string {
- return []string{"--kubeconfig", constants.KCPKubeconfigPath}
+func commonArgs(cfg *types.DeployConfig) []string {
+ if cfg.UsesKubernetesManagementAPI() {
+ return []string{"--in-cluster", "--kube-namespace=" + constants.Namespace}
+ }
+ return []string{"--kubeconfig", constants.KCPKubeconfigPath, "--kube-namespace=default"}
+}
+
+func managementAPIVolume(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
+ if cfg.UsesKubernetesManagementAPI() {
+ return nil, nil
+ }
+ return kubeconfigVolume()
+}
+
+func managementAPIRBAC(cfg *types.DeployConfig) []rbacv1.PolicyRule {
+ if !cfg.UsesKubernetesManagementAPI() {
+ return nil
+ }
+ return []rbacv1.PolicyRule{
+ {APIGroups: []string{"rlinf.io"}, Resources: []string{"*"}, Verbs: []string{"*"}},
+ {APIGroups: []string{""}, Resources: []string{"configmaps", "events", "namespaces", "secrets", "serviceaccounts"}, Verbs: []string{"*"}},
+ {APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"roles", "rolebindings", "clusterroles", "clusterrolebindings"}, Verbs: []string{"*"}},
+ {APIGroups: []string{"coordination.k8s.io"}, Resources: []string{"leases"}, Verbs: []string{"*"}},
+ }
}
func dbConfigVolume() ([]corev1.Volume, []corev1.VolumeMount) {
@@ -101,7 +123,7 @@ func kcpDataVolume(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMou
}},
[]corev1.VolumeMount{{
Name: "kcp-data",
- MountPath: constants.KCPEtcdDataDir,
+ MountPath: constants.KCPDataDir,
}}
case types.StorageHostPath:
return []corev1.Volume{{
@@ -115,12 +137,12 @@ func kcpDataVolume(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMou
}},
[]corev1.VolumeMount{{
Name: "kcp-data",
- MountPath: constants.KCPEtcdDataDir,
+ MountPath: constants.KCPDataDir,
}}
default:
return nil, []corev1.VolumeMount{{
Name: "kcp-data",
- MountPath: constants.KCPEtcdDataDir,
+ MountPath: constants.KCPDataDir,
}}
}
}
@@ -311,6 +333,9 @@ func resolveComponentReplicas(cfg *types.DeployConfig, name string) int32 {
if cfg.Kubernetes == nil {
return 1
}
+ if name == constants.ComponentKCP {
+ return 1
+ }
cc := resolveComponentConfig(cfg, name)
if cc.Replicas != 0 {
return cc.Replicas
@@ -376,17 +401,54 @@ func kubeconfigVolume() ([]corev1.Volume, []corev1.VolumeMount) {
}}
}
-func postgresqlDataVolume() ([]corev1.Volume, []corev1.VolumeMount) {
- return []corev1.Volume{{
+func postgresqlDataVolume(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
+ storage := resolveComponentStorage(cfg, constants.ComponentPostgresql)
+ mounts := []corev1.VolumeMount{{
+ Name: "pg-data",
+ MountPath: constants.PostgresqlDataDir,
+ }}
+ switch storage.Type {
+ case "", types.StorageEmptyDir:
+ return []corev1.Volume{{
Name: "pg-data",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
- }},
- []corev1.VolumeMount{{
- Name: "pg-data",
- MountPath: constants.PostgresqlDataDir,
- }}
+ }}, mounts
+ case types.StorageHostPath:
+ return []corev1.Volume{{
+ Name: "pg-data",
+ VolumeSource: corev1.VolumeSource{
+ HostPath: &corev1.HostPathVolumeSource{
+ Path: storage.HostPath,
+ Type: &[]corev1.HostPathType{corev1.HostPathDirectoryOrCreate}[0],
+ },
+ },
+ }}, mounts
+ default:
+ return nil, mounts
+ }
+}
+
+func postgresqlVolumeClaim(cfg *types.DeployConfig) []corev1.PersistentVolumeClaim {
+ storage := resolveComponentStorage(cfg, constants.ComponentPostgresql)
+ if storage.Type != types.StoragePVC {
+ return nil
+ }
+ size := storage.Size
+ if size == "" {
+ size = "30Gi"
+ }
+ return []corev1.PersistentVolumeClaim{{
+ ObjectMeta: metav1.ObjectMeta{Name: "pg-data"},
+ Spec: corev1.PersistentVolumeClaimSpec{
+ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
+ StorageClassName: stringPtr(storage.StorageClass),
+ Resources: corev1.VolumeResourceRequirements{
+ Requests: corev1.ResourceList{corev1.ResourceStorage: mustParseQuantity(size)},
+ },
+ },
+ }}
}
func postgresqlInitVolume() ([]corev1.Volume, []corev1.VolumeMount) {
@@ -409,8 +471,10 @@ func postgresqlInitVolume() ([]corev1.Volume, []corev1.VolumeMount) {
var components = []types.Component{
{
Name: constants.ComponentGateway, Port: 8090, Plane: types.PlaneControl, NeedsService: true,
- Dependencies: []string{constants.ComponentKCP, constants.ComponentServer},
- HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentGateway}),
+ ServiceAccount: constants.ComponentGateway,
+ RBACRulesFn: managementAPIRBAC,
+ Dependencies: []string{constants.ComponentKCP, constants.ComponentServer},
+ HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentGateway}),
ImageFn: func(cfg *types.DeployConfig) string {
return imageByMode(cfg, func(k *types.KubernetesEnv) string { return k.GatewayImage }, func(d *types.DockerEnv) string { return d.GatewayImage })
},
@@ -426,10 +490,10 @@ var components = []types.Component{
if cfg.DB != nil {
args = append(args, "--db-config="+constants.DBConfigPath)
}
- return append(args, commonArgs()...)
+ return append(args, commonArgs(cfg)...)
},
VolumeFn: func(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
- vols, mounts := kubeconfigVolume()
+ vols, mounts := managementAPIVolume(cfg)
if cfg.DB != nil {
dv, dm := dbConfigVolume()
vols = append(vols, dv...)
@@ -440,9 +504,11 @@ var components = []types.Component{
},
{
Name: constants.ComponentControllerManager, Port: 8081, Plane: types.PlaneControl,
- MetricsPort: 8080,
- Dependencies: []string{constants.ComponentKCP},
- HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentControllerManager}),
+ ServiceAccount: constants.ComponentControllerManager,
+ RBACRulesFn: managementAPIRBAC,
+ MetricsPort: 8080,
+ Dependencies: []string{constants.ComponentKCP},
+ HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentControllerManager}),
ImageFn: func(cfg *types.DeployConfig) string {
return imageByMode(cfg, func(k *types.KubernetesEnv) string { return k.ControllerManagerImage }, func(d *types.DockerEnv) string { return d.ControllerManagerImage })
},
@@ -457,15 +523,15 @@ var components = []types.Component{
args := []string{
"--metrics-bind-address=:8080",
"--health-probe-bind-address=:8081",
- "--leader-elect=" + leaderElectFlag(cfg, constants.ComponentControllerManager),
+ "--leader-election=" + leaderElectFlag(cfg, constants.ComponentControllerManager),
}
if cfg.DB != nil {
args = append(args, "--db-config="+constants.DBConfigPath)
}
- return append(args, commonArgs()...)
+ return append(args, commonArgs(cfg)...)
},
VolumeFn: func(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
- vols, mounts := kubeconfigVolume()
+ vols, mounts := managementAPIVolume(cfg)
if cfg.DB != nil {
dv, dm := dbConfigVolume()
vols = append(vols, dv...)
@@ -476,9 +542,11 @@ var components = []types.Component{
},
{
Name: constants.ComponentServer, Port: 8443, Plane: types.PlaneControl, NeedsService: true,
- MetricsPort: 8888,
- Dependencies: []string{constants.ComponentKCP},
- HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentServer}),
+ ServiceAccount: constants.ComponentServer,
+ RBACRulesFn: managementAPIRBAC,
+ MetricsPort: 8888,
+ Dependencies: []string{constants.ComponentKCP},
+ HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentServer}),
ImageFn: func(cfg *types.DeployConfig) string {
return imageByMode(cfg, func(k *types.KubernetesEnv) string { return k.ServerImage }, func(d *types.DockerEnv) string { return d.ServerImage })
},
@@ -500,7 +568,7 @@ var components = []types.Component{
if cfg.DB != nil {
args = append(args, "--db-config="+constants.DBConfigPath)
}
- return append(args, commonArgs()...)
+ return append(args, commonArgs(cfg)...)
},
ExtraSvcPortsFn: func(cfg *types.DeployConfig) []corev1.ServicePort {
return []corev1.ServicePort{
@@ -512,7 +580,7 @@ var components = []types.Component{
}
},
VolumeFn: func(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
- vols, mounts := kubeconfigVolume()
+ vols, mounts := managementAPIVolume(cfg)
if cfg.DB != nil {
dv, dm := dbConfigVolume()
vols = append(vols, dv...)
@@ -571,7 +639,23 @@ var components = []types.Component{
{
Name: constants.ComponentAgentNode, Port: 8081, Plane: types.PlaneData, WorkloadKind: "DaemonSet",
HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentAgentNode}),
- ServiceAccount: "rlark-agent",
+ ServiceAccount: constants.ComponentAgentNode,
+ ProbeFn: func(cfg *types.DeployConfig) (*corev1.Probe, *corev1.Probe) {
+ readiness := &corev1.Probe{
+ ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{
+ Path: "/readyz",
+ Port: intstr.FromInt32(8081),
+ }},
+ PeriodSeconds: 5,
+ TimeoutSeconds: 3,
+ FailureThreshold: 3,
+ }
+ return nil, readiness
+ },
+ RBACRules: []rbacv1.PolicyRule{
+ {APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}},
+ {APIGroups: []string{""}, Resources: []string{"events"}, Verbs: []string{"list", "watch"}},
+ },
ImageFn: func(cfg *types.DeployConfig) string {
return imageByMode(cfg, func(k *types.KubernetesEnv) string { return k.AgentImage }, func(d *types.DockerEnv) string { return d.AgentImage })
},
@@ -591,7 +675,7 @@ var components = []types.Component{
"--ca-cert=" + constants.CertDir + "/ca.crt",
"--leader-election=false",
"--mode=node",
- "--rlark-server-ssh-address=client@" + cfg.ControlPlaneAddress + ":" + strconv.Itoa(constants.ServerSSHPort),
+ "--rlark-server-ssh-address=" + cfg.SSHServerAddress(),
// Enable node-level image pre-pulling (containerd/docker). The
// node-agent mounts the container runtime socket below.
"--image-pull-enabled=true",
@@ -659,7 +743,7 @@ var components = []types.Component{
ParallelPodMgmt: true,
WorkloadKind: "StatefulSet",
MetricsPort: constants.EtcdMetricsPort,
- EnabledFn: func(cfg *types.DeployConfig) bool { return etcdEnabled(cfg) },
+ EnabledFn: func(cfg *types.DeployConfig) bool { return !cfg.UsesKubernetesManagementAPI() && etcdEnabled(cfg) },
HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentEtcd}),
ImageFn: func(cfg *types.DeployConfig) string {
return imageByMode(cfg, func(k *types.KubernetesEnv) string { return k.EtcdImage }, func(d *types.DockerEnv) string { return d.EtcdImage })
@@ -698,20 +782,12 @@ var components = []types.Component{
"--data-dir=" + constants.EtcdDataDir,
}
- if etcdReplicas(cfg) == 1 {
- args = append(args,
- "--initial-advertise-peer-urls=http://$(POD_IP):"+peerPort,
- "--advertise-client-urls=http://$(POD_IP):"+clientPort,
- "--initial-cluster=$(POD_NAME)=http://$(POD_IP):"+peerPort,
- )
- } else {
- dnsHost := "$(POD_NAME)." + constants.ComponentEtcd + "." + constants.Namespace + ".svc"
- args = append(args,
- "--initial-advertise-peer-urls=http://"+dnsHost+":"+peerPort,
- "--advertise-client-urls=http://"+dnsHost+":"+clientPort,
- "--initial-cluster="+etcdInitialClusterDNS(cfg),
- )
- }
+ dnsHost := "$(POD_NAME)." + constants.ComponentEtcd + "." + constants.Namespace + ".svc"
+ args = append(args,
+ "--initial-advertise-peer-urls=http://"+dnsHost+":"+peerPort,
+ "--advertise-client-urls=http://"+dnsHost+":"+clientPort,
+ "--initial-cluster="+etcdInitialClusterDNS(cfg),
+ )
return args
},
@@ -752,6 +828,7 @@ var components = []types.Component{
},
{
Name: constants.ComponentKCP, Port: 6443, Plane: types.PlaneControl, NeedsService: true,
+ EnabledFn: func(cfg *types.DeployConfig) bool { return !cfg.UsesKubernetesManagementAPI() },
MetricsPort: 8080,
Dependencies: []string{constants.ComponentEtcd},
HealthCheckFn: health.ModeHealthCheck(types.Component{Name: constants.ComponentKCP}),
@@ -829,10 +906,11 @@ var components = []types.Component{
}
},
VolumeFn: func(cfg *types.DeployConfig) ([]corev1.Volume, []corev1.VolumeMount) {
- dv, dm := postgresqlDataVolume()
+ dv, dm := postgresqlDataVolume(cfg)
iv, im := postgresqlInitVolume()
return append(dv, iv...), append(dm, im...)
},
+ VolumeClaimFn: postgresqlVolumeClaim,
},
{
Name: constants.ComponentUI, Port: 80, Plane: types.PlaneControl, NeedsService: true,
@@ -886,8 +964,12 @@ func ComponentsForPlane(cfg *types.DeployConfig) []types.Component {
if c.EnabledFn != nil && !c.EnabledFn(cfg) {
continue
}
- result = append(result, *c)
- topos = append(topos, c)
+ resolved := *c
+ if resolved.Name == constants.ComponentKCP && cfg.Kubernetes != nil && !etcdConfigured(cfg) {
+ resolved.WorkloadKind = "StatefulSet"
+ }
+ result = append(result, resolved)
+ topos = append(topos, &result[len(result)-1])
}
sorted, err := utils.TopologicalSort(topos)
@@ -904,6 +986,17 @@ func ComponentsForPlane(cfg *types.DeployConfig) []types.Component {
}
// Deployment returns a Deployment for the component.
+func imagePullSecrets(cfg *types.DeployConfig) []corev1.LocalObjectReference {
+ if cfg.Kubernetes == nil {
+ return nil
+ }
+ secrets := make([]corev1.LocalObjectReference, 0, len(cfg.Kubernetes.ImagePullSecrets))
+ for _, name := range cfg.Kubernetes.ImagePullSecrets {
+ secrets = append(secrets, corev1.LocalObjectReference{Name: name})
+ }
+ return secrets
+}
+
func Deployment(cfg *types.DeployConfig, c *types.Component) *appsv1.Deployment {
labels := map[string]string{"app": c.Name}
@@ -919,11 +1012,12 @@ func Deployment(cfg *types.DeployConfig, c *types.Component) *appsv1.Deployment
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
- ServiceAccountName: ServiceAccountName(c),
+ ServiceAccountName: ServiceAccountName(cfg, c),
+ ImagePullSecrets: imagePullSecrets(cfg),
Containers: []corev1.Container{{
Name: c.Name,
Image: c.ImageFn(cfg),
- ImagePullPolicy: corev1.PullAlways,
+ ImagePullPolicy: cfg.ImagePullPolicy(),
Ports: []corev1.ContainerPort{{
ContainerPort: c.Port,
}},
@@ -990,6 +1084,8 @@ func Deployment(cfg *types.DeployConfig, c *types.Component) *appsv1.Deployment
}
}
+ dep.Spec.Template.Spec.NodeSelector = resolveComponentNodeSelector(cfg, c.Name)
+
return dep
}
@@ -1008,11 +1104,12 @@ func DaemonSet(cfg *types.DeployConfig, c *types.Component) *appsv1.DaemonSet {
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
- ServiceAccountName: ServiceAccountName(c),
+ ServiceAccountName: ServiceAccountName(cfg, c),
+ ImagePullSecrets: imagePullSecrets(cfg),
Containers: []corev1.Container{{
Name: c.Name,
Image: c.ImageFn(cfg),
- ImagePullPolicy: corev1.PullAlways,
+ ImagePullPolicy: cfg.ImagePullPolicy(),
Ports: []corev1.ContainerPort{{
ContainerPort: c.Port,
}},
@@ -1056,6 +1153,16 @@ func DaemonSet(cfg *types.DeployConfig, c *types.Component) *appsv1.DaemonSet {
ds.Spec.Template.Spec.Containers[0].VolumeMounts = append(ds.Spec.Template.Spec.Containers[0].VolumeMounts, mounts...)
}
+ if c.ProbeFn != nil {
+ liveness, readiness := c.ProbeFn(cfg)
+ if liveness != nil {
+ ds.Spec.Template.Spec.Containers[0].LivenessProbe = liveness
+ }
+ if readiness != nil {
+ ds.Spec.Template.Spec.Containers[0].ReadinessProbe = readiness
+ }
+ }
+
return ds
}
@@ -1088,11 +1195,12 @@ func StatefulSet(cfg *types.DeployConfig, c *types.Component) *appsv1.StatefulSe
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
- ServiceAccountName: ServiceAccountName(c),
+ ServiceAccountName: ServiceAccountName(cfg, c),
+ ImagePullSecrets: imagePullSecrets(cfg),
Containers: []corev1.Container{{
Name: c.Name,
Image: c.ImageFn(cfg),
- ImagePullPolicy: corev1.PullAlways,
+ ImagePullPolicy: cfg.ImagePullPolicy(),
Ports: ports,
Args: c.ArgsFn(cfg),
}},
@@ -1158,16 +1266,24 @@ func StatefulSet(cfg *types.DeployConfig, c *types.Component) *appsv1.StatefulSe
}
// ServiceAccountName returns the service account name.
-func ServiceAccountName(c *types.Component) string {
- if c.ServiceAccount != "" {
+func ServiceAccountName(cfg *types.DeployConfig, c *types.Component) string {
+ rules := c.RBACRules
+ if c.RBACRulesFn != nil {
+ rules = c.RBACRulesFn(cfg)
+ }
+ if c.ServiceAccount != "" && len(rules) > 0 {
return c.ServiceAccount
}
return "default"
}
// RBAC returns RBAC resources for the component.
-func RBAC(c *types.Component) (*corev1.ServiceAccount, *rbacv1.ClusterRole, *rbacv1.ClusterRoleBinding) {
- if len(c.RBACRules) == 0 {
+func RBAC(cfg *types.DeployConfig, c *types.Component) (*corev1.ServiceAccount, *rbacv1.ClusterRole, *rbacv1.ClusterRoleBinding) {
+ rules := c.RBACRules
+ if c.RBACRulesFn != nil {
+ rules = c.RBACRulesFn(cfg)
+ }
+ if len(rules) == 0 {
return nil, nil, nil
}
sa := &corev1.ServiceAccount{
@@ -1175,7 +1291,7 @@ func RBAC(c *types.Component) (*corev1.ServiceAccount, *rbacv1.ClusterRole, *rba
}
cr := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: c.ServiceAccount},
- Rules: c.RBACRules,
+ Rules: rules,
}
crb := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: c.ServiceAccount},
diff --git a/apps/rlark/pkg/rlarkadm/component/component_test.go b/apps/rlark/pkg/rlarkadm/component/component_test.go
new file mode 100644
index 0000000..12afc66
--- /dev/null
+++ b/apps/rlark/pkg/rlarkadm/component/component_test.go
@@ -0,0 +1,534 @@
+package component
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/constants"
+ "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/types"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/resource"
+)
+
+func TestKCPWorkloadStrategy(t *testing.T) {
+ tests := []struct {
+ name string
+ etcd *types.EtcdConfig
+ kcpReplicas int32
+ wantKind string
+ wantReplicas int32
+ wantClaim bool
+ wantDataMount bool
+ }{
+ {
+ name: "embedded storage uses single replica statefulset",
+ kcpReplicas: 1,
+ wantKind: "StatefulSet",
+ wantReplicas: 1,
+ wantClaim: true,
+ wantDataMount: true,
+ },
+ {
+ name: "deployed etcd uses single replica deployment",
+ etcd: &types.EtcdConfig{},
+ kcpReplicas: 1,
+ wantKind: "",
+ wantReplicas: 1,
+ },
+ {
+ name: "external etcd uses single replica deployment",
+ etcd: &types.EtcdConfig{Address: "https://etcd.example.com:2379"},
+ kcpReplicas: 1,
+ wantKind: "",
+ wantReplicas: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ Kubernetes: &types.KubernetesEnv{
+ Etcd: tt.etcd,
+ KCP: &types.ComponentConfig{
+ Replicas: tt.kcpReplicas,
+ Storage: &types.StorageConfig{
+ Type: types.StoragePVC,
+ Size: "20Gi",
+ },
+ },
+ },
+ }
+
+ var kcp *types.Component
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name == constants.ComponentKCP {
+ component := c
+ kcp = &component
+ break
+ }
+ }
+ if kcp == nil {
+ t.Fatal("kcp component not found")
+ }
+ if kcp.WorkloadKind != tt.wantKind {
+ t.Fatalf("WorkloadKind = %q, want %q", kcp.WorkloadKind, tt.wantKind)
+ }
+
+ if tt.wantKind == "StatefulSet" {
+ sts := StatefulSet(cfg, kcp)
+ if got := *sts.Spec.Replicas; got != tt.wantReplicas {
+ t.Fatalf("replicas = %d, want %d", got, tt.wantReplicas)
+ }
+ if got := len(sts.Spec.VolumeClaimTemplates) > 0; got != tt.wantClaim {
+ t.Fatalf("has volume claim = %v, want %v", got, tt.wantClaim)
+ }
+ hasDataMount := len(sts.Spec.Template.Spec.Containers[0].VolumeMounts) > 0
+ if hasDataMount != tt.wantDataMount {
+ t.Fatalf("has data mount = %v, want %v", hasDataMount, tt.wantDataMount)
+ }
+ if hasDataMount {
+ mount := sts.Spec.Template.Spec.Containers[0].VolumeMounts[0]
+ if mount.Name != "kcp-data" || mount.MountPath != constants.KCPDataDir {
+ t.Fatalf("data mount = %s:%s, want kcp-data:%s", mount.Name, mount.MountPath, constants.KCPDataDir)
+ }
+ }
+ return
+ }
+
+ dep := Deployment(cfg, kcp)
+ if got := *dep.Spec.Replicas; got != tt.wantReplicas {
+ t.Fatalf("replicas = %d, want %d", got, tt.wantReplicas)
+ }
+ if len(dep.Spec.Template.Spec.Volumes) != 0 {
+ t.Fatalf("deployment has %d data volumes, want none", len(dep.Spec.Template.Spec.Volumes))
+ }
+ })
+ }
+}
+
+func TestGlobalReplicasDoNotScaleKCP(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ Kubernetes: &types.KubernetesEnv{
+ Replicas: 3,
+ Etcd: &types.EtcdConfig{Replicas: 3},
+ },
+ }
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name == constants.ComponentKCP {
+ if got := *Deployment(cfg, &c).Spec.Replicas; got != 1 {
+ t.Fatalf("kcp replicas = %d, want 1", got)
+ }
+ return
+ }
+ }
+ t.Fatal("kcp component not found")
+}
+
+func TestKubernetesManagementAPIComponents(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ Kubernetes: &types.KubernetesEnv{
+ ManagementAPI: "kubernetes",
+ GatewayImage: "gateway:test",
+ ControllerManagerImage: "controller:test",
+ ServerImage: "server:test",
+ },
+ }
+
+ components := ComponentsForPlane(cfg)
+ for _, c := range components {
+ if c.Name == constants.ComponentKCP || c.Name == constants.ComponentEtcd {
+ t.Fatalf("unexpected component %q in kubernetes management API mode", c.Name)
+ }
+ switch c.Name {
+ case constants.ComponentGateway, constants.ComponentControllerManager, constants.ComponentServer:
+ dep := Deployment(cfg, &c)
+ container := dep.Spec.Template.Spec.Containers[0]
+ if !slices.Contains(container.Args, "--in-cluster") {
+ t.Fatalf("%s args do not contain --in-cluster: %#v", c.Name, container.Args)
+ }
+ if slices.Contains(container.Args, "--kubeconfig") {
+ t.Fatalf("%s still uses --kubeconfig: %#v", c.Name, container.Args)
+ }
+ if !slices.Contains(container.Args, "--kube-namespace="+constants.Namespace) {
+ t.Fatalf("%s does not use deployment namespace: %#v", c.Name, container.Args)
+ }
+ if len(container.VolumeMounts) != 0 {
+ t.Fatalf("%s has unexpected kubeconfig mounts: %#v", c.Name, container.VolumeMounts)
+ }
+ if dep.Spec.Template.Spec.ServiceAccountName != c.Name {
+ t.Fatalf("%s service account = %q, want %q", c.Name, dep.Spec.Template.Spec.ServiceAccountName, c.Name)
+ }
+ if sa, role, binding := RBAC(cfg, &c); sa == nil || role == nil || binding == nil {
+ t.Fatalf("%s management API RBAC is incomplete", c.Name)
+ }
+ }
+ }
+}
+
+func TestManagementAPIRBACAllowsEventRecording(t *testing.T) {
+ cfg := &types.DeployConfig{Kubernetes: &types.KubernetesEnv{ManagementAPI: "kubernetes"}}
+ for _, rule := range managementAPIRBAC(cfg) {
+ if slices.Contains(rule.APIGroups, "") && slices.Contains(rule.Resources, "events") {
+ if !slices.Contains(rule.Verbs, "*") {
+ t.Fatalf("events rule verbs = %v, want all verbs", rule.Verbs)
+ }
+ return
+ }
+ }
+ t.Fatal("events RBAC rule not found")
+}
+
+func TestManagementAPIRBACAllowsControlPlaneResources(t *testing.T) {
+ cfg := &types.DeployConfig{Kubernetes: &types.KubernetesEnv{ManagementAPI: "kubernetes"}}
+ want := map[string][]string{
+ "": {"configmaps", "events", "namespaces", "secrets", "serviceaccounts"},
+ "rbac.authorization.k8s.io": {"roles", "rolebindings", "clusterroles", "clusterrolebindings"},
+ "coordination.k8s.io": {"leases"},
+ }
+ for group, resources := range want {
+ for _, resource := range resources {
+ found := false
+ for _, rule := range managementAPIRBAC(cfg) {
+ if slices.Contains(rule.APIGroups, group) && slices.Contains(rule.Resources, resource) && slices.Contains(rule.Verbs, "*") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing all-verbs RBAC rule for %s/%s", group, resource)
+ }
+ }
+ }
+}
+
+func TestNodeAgentUsesDedicatedRBAC(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneData,
+ Kubernetes: &types.KubernetesEnv{},
+ }
+
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name != constants.ComponentAgentNode {
+ continue
+ }
+
+ ds := DaemonSet(cfg, &c)
+ if got := ds.Spec.Template.Spec.ServiceAccountName; got != constants.ComponentAgentNode {
+ t.Fatalf("node-agent service account = %q, want %q", got, constants.ComponentAgentNode)
+ }
+ container := ds.Spec.Template.Spec.Containers[0]
+ if container.LivenessProbe != nil {
+ t.Fatalf("node-agent liveness probe = %#v, want nil", container.LivenessProbe)
+ }
+ if container.ReadinessProbe == nil || container.ReadinessProbe.HTTPGet == nil || container.ReadinessProbe.HTTPGet.Path != "/readyz" {
+ t.Fatalf("node-agent readiness probe = %#v", container.ReadinessProbe)
+ }
+
+ sa, role, binding := RBAC(cfg, &c)
+ if sa == nil || role == nil || binding == nil {
+ t.Fatal("node-agent RBAC is incomplete")
+ }
+ if sa.Name != constants.ComponentAgentNode || role.Name != constants.ComponentAgentNode || binding.Name != constants.ComponentAgentNode {
+ t.Fatalf("node-agent RBAC names = %q, %q, %q, want %q", sa.Name, role.Name, binding.Name, constants.ComponentAgentNode)
+ }
+
+ want := map[string][]string{
+ "nodes": {"get"},
+ "events": {"list", "watch"},
+ }
+ if len(role.Rules) != len(want) {
+ t.Fatalf("node-agent RBAC rules = %#v, want %d rules", role.Rules, len(want))
+ }
+ for resource, verbs := range want {
+ found := false
+ for _, rule := range role.Rules {
+ if slices.Equal(rule.APIGroups, []string{""}) && slices.Equal(rule.Resources, []string{resource}) && slices.Equal(rule.Verbs, verbs) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing node-agent RBAC rule for %s with verbs %v", resource, verbs)
+ }
+ }
+ return
+ }
+
+ t.Fatal("node-agent component not found")
+}
+
+func TestKubernetesWorkloadsUseImagePullSecrets(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Kubernetes: &types.KubernetesEnv{
+ ImagePullSecrets: []string{"registry-one", "registry-two"},
+ },
+ }
+ c := &types.Component{
+ Name: "test",
+ ImageFn: func(*types.DeployConfig) string { return "test:latest" },
+ ArgsFn: func(*types.DeployConfig) []string { return nil },
+ }
+
+ for name, refs := range map[string][]corev1.LocalObjectReference{
+ "deployment": Deployment(cfg, c).Spec.Template.Spec.ImagePullSecrets,
+ "daemonset": DaemonSet(cfg, c).Spec.Template.Spec.ImagePullSecrets,
+ "statefulset": StatefulSet(cfg, c).Spec.Template.Spec.ImagePullSecrets,
+ } {
+ names := make([]string, 0, len(refs))
+ for _, ref := range refs {
+ names = append(names, ref.Name)
+ }
+ if !slices.Equal(names, cfg.Kubernetes.ImagePullSecrets) {
+ t.Errorf("%s imagePullSecrets = %v, want %v", name, names, cfg.Kubernetes.ImagePullSecrets)
+ }
+ }
+}
+
+func TestControllerManagerLeaderElectionArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ managementAPI string
+ wantNamespace string
+ }{
+ {name: "kcp", wantNamespace: "default"},
+ {name: "kubernetes", managementAPI: "kubernetes", wantNamespace: constants.Namespace},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ Kubernetes: &types.KubernetesEnv{
+ ManagementAPI: tt.managementAPI,
+ ControllerManagerImage: "controller:test",
+ Replicas: 2,
+ },
+ }
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name != constants.ComponentControllerManager {
+ continue
+ }
+ args := Deployment(cfg, &c).Spec.Template.Spec.Containers[0].Args
+ if !slices.Contains(args, "--leader-election=true") {
+ t.Fatalf("args do not enable leader election: %#v", args)
+ }
+ if !slices.Contains(args, "--kube-namespace="+tt.wantNamespace) {
+ t.Fatalf("args do not set leader election namespace %q: %#v", tt.wantNamespace, args)
+ }
+ return
+ }
+ t.Fatal("controller manager component not found")
+ })
+ }
+}
+
+func TestEtcdStatefulSetStorageAndStableIdentity(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ Kubernetes: &types.KubernetesEnv{
+ EtcdImage: "etcd:test",
+ Etcd: &types.EtcdConfig{
+ Replicas: 3,
+ Storage: &types.StorageConfig{
+ Type: types.StoragePVC,
+ Size: "8Gi",
+ },
+ },
+ },
+ }
+
+ var etcd *types.Component
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name == constants.ComponentEtcd {
+ component := c
+ etcd = &component
+ break
+ }
+ }
+ if etcd == nil {
+ t.Fatal("etcd component not found")
+ }
+
+ sts := StatefulSet(cfg, etcd)
+ if sts.Spec.ServiceName != constants.ComponentEtcd {
+ t.Fatalf("serviceName = %q, want %q", sts.Spec.ServiceName, constants.ComponentEtcd)
+ }
+ if sts.Spec.PodManagementPolicy != appsv1.ParallelPodManagement {
+ t.Fatalf("podManagementPolicy = %q, want %q", sts.Spec.PodManagementPolicy, appsv1.ParallelPodManagement)
+ }
+ if len(sts.Spec.VolumeClaimTemplates) != 1 || sts.Spec.VolumeClaimTemplates[0].Name != "etcd-data" {
+ t.Fatalf("volumeClaimTemplates = %#v, want etcd-data claim", sts.Spec.VolumeClaimTemplates)
+ }
+ mounts := sts.Spec.Template.Spec.Containers[0].VolumeMounts
+ if len(mounts) != 1 || mounts[0].Name != "etcd-data" || mounts[0].MountPath != constants.EtcdDataDir {
+ t.Fatalf("volume mounts = %#v, want etcd-data:%s", mounts, constants.EtcdDataDir)
+ }
+
+ args := sts.Spec.Template.Spec.Containers[0].Args
+ if !slices.Contains(args, "--initial-advertise-peer-urls=http://$(POD_NAME).etcd.rlark-system.svc:2380") {
+ t.Fatalf("args do not advertise stable peer DNS: %#v", args)
+ }
+ if !slices.Contains(args, "--initial-cluster=etcd-0=http://etcd-0.etcd.rlark-system.svc:2380,etcd-1=http://etcd-1.etcd.rlark-system.svc:2380,etcd-2=http://etcd-2.etcd.rlark-system.svc:2380") {
+ t.Fatalf("args do not contain expected initial cluster: %#v", args)
+ }
+
+ svc := Service(cfg, etcd)
+ if svc.Spec.ClusterIP != "None" || !svc.Spec.PublishNotReadyAddresses {
+ t.Fatalf("etcd service is not headless with publishNotReadyAddresses: %#v", svc.Spec)
+ }
+}
+
+func TestPostgresqlStorage(t *testing.T) {
+ tests := []struct {
+ name string
+ globalStorage *types.StorageConfig
+ componentStorage *types.StorageConfig
+ wantEmptyDir bool
+ wantHostPath string
+ wantPVC bool
+ wantSize resource.Quantity
+ wantStorageClass string
+ wantNodeSelector map[string]string
+ }{
+ {
+ name: "default uses emptyDir",
+ wantEmptyDir: true,
+ },
+ {
+ name: "global hostPath",
+ globalStorage: &types.StorageConfig{
+ Type: types.StorageHostPath,
+ HostPath: "/data/postgresql",
+ NodeSelector: map[string]string{"storage": "local"},
+ },
+ wantHostPath: "/data/postgresql",
+ wantNodeSelector: map[string]string{"storage": "local"},
+ },
+ {
+ name: "global pvc",
+ globalStorage: &types.StorageConfig{
+ Type: types.StoragePVC,
+ StorageClass: "fast",
+ Size: "40Gi",
+ },
+ wantPVC: true,
+ wantSize: resource.MustParse("40Gi"),
+ wantStorageClass: "fast",
+ },
+ {
+ name: "component pvc overrides global storage",
+ globalStorage: &types.StorageConfig{
+ Type: types.StorageHostPath,
+ HostPath: "/global",
+ },
+ componentStorage: &types.StorageConfig{
+ Type: types.StoragePVC,
+ NodeSelector: map[string]string{"database": "true"},
+ },
+ wantPVC: true,
+ wantSize: resource.MustParse("30Gi"),
+ wantNodeSelector: map[string]string{"database": "true"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &types.DeployConfig{
+ Plane: types.PlaneControl,
+ DB: &types.DBConfig{},
+ Kubernetes: &types.KubernetesEnv{
+ Storage: tt.globalStorage,
+ Postgresql: &types.ComponentConfig{
+ Storage: tt.componentStorage,
+ },
+ },
+ }
+
+ var postgresql *types.Component
+ for _, c := range ComponentsForPlane(cfg) {
+ if c.Name == constants.ComponentPostgresql {
+ component := c
+ postgresql = &component
+ break
+ }
+ }
+ if postgresql == nil {
+ t.Fatal("postgresql component not found")
+ }
+
+ dep := Deployment(cfg, postgresql)
+ if !mapsEqual(dep.Spec.Template.Spec.NodeSelector, tt.wantNodeSelector) {
+ t.Fatalf("node selector = %#v, want %#v", dep.Spec.Template.Spec.NodeSelector, tt.wantNodeSelector)
+ }
+ dataVolume, found := volumeByName(dep.Spec.Template.Spec.Volumes, "pg-data")
+ if !found {
+ t.Fatal("pg-data volume not found")
+ }
+ if got := dataVolume.EmptyDir != nil; got != tt.wantEmptyDir {
+ t.Fatalf("pg-data emptyDir = %v, want %v", got, tt.wantEmptyDir)
+ }
+ if tt.wantHostPath != "" {
+ if dataVolume.HostPath == nil || dataVolume.HostPath.Path != tt.wantHostPath || dataVolume.HostPath.Type == nil || *dataVolume.HostPath.Type != corev1.HostPathDirectoryOrCreate {
+ t.Fatalf("pg-data hostPath = %#v, want %q with DirectoryOrCreate", dataVolume.HostPath, tt.wantHostPath)
+ }
+ }
+ if got := dataVolume.PersistentVolumeClaim != nil; got != tt.wantPVC {
+ t.Fatalf("pg-data uses PVC = %v, want %v", got, tt.wantPVC)
+ }
+
+ claims := postgresql.VolumeClaimFn(cfg)
+ if got := len(claims) == 1; got != tt.wantPVC {
+ t.Fatalf("has PVC = %v, want %v", got, tt.wantPVC)
+ }
+ if tt.wantPVC {
+ claim := claims[0]
+ if claim.Name != "pg-data" || claim.Spec.Resources.Requests.Storage().Cmp(tt.wantSize) != 0 {
+ t.Fatalf("claim = %#v, want pg-data with size %s", claim, tt.wantSize.String())
+ }
+ if tt.wantStorageClass == "" {
+ if claim.Spec.StorageClassName != nil {
+ t.Fatalf("storage class = %q, want cluster default", *claim.Spec.StorageClassName)
+ }
+ } else if claim.Spec.StorageClassName == nil || *claim.Spec.StorageClassName != tt.wantStorageClass {
+ t.Fatalf("storage class = %#v, want %q", claim.Spec.StorageClassName, tt.wantStorageClass)
+ }
+ }
+
+ mounts := dep.Spec.Template.Spec.Containers[0].VolumeMounts
+ if !slices.ContainsFunc(mounts, func(m corev1.VolumeMount) bool {
+ return m.Name == "pg-data" && m.MountPath == constants.PostgresqlDataDir
+ }) {
+ t.Fatalf("postgresql data mount not found: %#v", mounts)
+ }
+ if !slices.ContainsFunc(mounts, func(m corev1.VolumeMount) bool {
+ return m.Name == "pg-init" && m.ReadOnly
+ }) {
+ t.Fatalf("postgresql init mount not found: %#v", mounts)
+ }
+ })
+ }
+}
+
+func volumeByName(volumes []corev1.Volume, name string) (corev1.Volume, bool) {
+ for _, volume := range volumes {
+ if volume.Name == name {
+ return volume, true
+ }
+ }
+ return corev1.Volume{}, false
+}
+
+func mapsEqual(got, want map[string]string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for key, value := range want {
+ if got[key] != value {
+ return false
+ }
+ }
+ return true
+}
diff --git a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install.go b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install.go
index 237d1e2..1d6c385 100644
--- a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install.go
+++ b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install.go
@@ -5,7 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
- "encoding/json"
+ encodingjson "encoding/json"
"fmt"
"os/exec"
"strings"
@@ -21,15 +21,19 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/types"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
+ apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+ apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
+ "sigs.k8s.io/yaml"
)
// Installer installs RLark components.
type Installer struct {
- summary *types.InstallSummary
+ summary *types.InstallSummary
+ kubeconfig string
}
// Install installs the components.
@@ -39,6 +43,7 @@ func (d *Installer) Install(cfg *types.DeployConfig, certBundle *cert.Bundle) er
if kubeconfig == "" {
kubeconfig = clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename()
}
+ d.kubeconfig = kubeconfig
restConfig, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
return fmt.Errorf("build kubeconfig: %w", err)
@@ -52,6 +57,18 @@ func (d *Installer) Install(cfg *types.DeployConfig, certBundle *cert.Bundle) er
if err := ensureNamespace(ctx, clientset); err != nil {
return err
}
+ if cfg.UsesKubernetesManagementAPI() {
+ apiExtensions, err := apiextensionsclient.NewForConfig(restConfig)
+ if err != nil {
+ return fmt.Errorf("create apiextensions clientset: %w", err)
+ }
+ if err := installCRDsToKubernetes(ctx, apiExtensions); err != nil {
+ return err
+ }
+ if err := ensureUIAuthSecretInKubernetes(ctx, clientset, constants.Namespace); err != nil {
+ return err
+ }
+ }
if certBundle != nil {
if err := createCertSecret(ctx, clientset, cfg, certBundle); err != nil {
@@ -72,16 +89,9 @@ func (d *Installer) Install(cfg *types.DeployConfig, certBundle *cert.Bundle) er
for _, c := range component.ComponentsForPlane(cfg) {
c.HealthCheckFn = health.K8sWorkloadHealthCheck(clientset, c)
if c.Name == constants.ComponentKCP {
- c.PostDeployFn = extractKCPKubeconfigFn(ctx, clientset)
+ c.PostDeployFn = extractKCPKubeconfigFn(ctx, clientset, kubeconfig)
}
-
- // 如果组件已存在且健康,跳过部署(CRD apply 在循环结束后统一执行)
- if c.HealthCheckFn != nil && c.HealthCheckFn(cfg) == nil {
- logger.Info("component already healthy, skipping", "name", c.Name)
- continue
- }
-
- if err := ensureRBAC(ctx, clientset, &c); err != nil {
+ if err := ensureRBAC(ctx, clientset, cfg, &c); err != nil {
return err
}
@@ -110,11 +120,11 @@ func (d *Installer) Install(cfg *types.DeployConfig, certBundle *cert.Bundle) er
}
// 始终 apply CRD,无论 KCP 是否新部署
- if cfg.Plane == types.PlaneControl {
- if err := createUIAuthSecretInKCP(ctx, clientset); err != nil {
+ if cfg.Plane == types.PlaneControl && !cfg.UsesKubernetesManagementAPI() {
+ if err := createUIAuthSecretInKCP(ctx, clientset, kubeconfig); err != nil {
return err
}
- if err := applyKCP(ctx, clientset); err != nil {
+ if err := applyKCP(ctx, clientset, kubeconfig); err != nil {
return err
}
}
@@ -162,7 +172,14 @@ func (d *Installer) buildSummary(ctx context.Context, clientset *kubernetes.Clie
}
if cfg.Plane == types.PlaneControl {
- if adminPW, userPW, err := readUIAuthFromKCP(ctx, clientset); err == nil {
+ var adminPW, userPW string
+ var err error
+ if cfg.UsesKubernetesManagementAPI() {
+ adminPW, userPW, err = readUIAuthFromKubernetes(ctx, clientset, constants.Namespace)
+ } else {
+ adminPW, userPW, err = readUIAuthFromKCP(ctx, clientset, d.kubeconfig)
+ }
+ if err == nil {
summary.AdminPassword = adminPW
summary.UserPassword = userPW
}
@@ -171,6 +188,105 @@ func (d *Installer) buildSummary(ctx context.Context, clientset *kubernetes.Clie
return summary
}
+func installCRDsToKubernetes(ctx context.Context, client apiextensionsclient.Interface) error {
+ entries, err := config.CRDFiles.ReadDir("crd/bases")
+ if err != nil {
+ return fmt.Errorf("read embedded CRD files: %w", err)
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ content, err := config.CRDFiles.ReadFile("crd/bases/" + entry.Name())
+ if err != nil {
+ return fmt.Errorf("read embedded CRD %s: %w", entry.Name(), err)
+ }
+ crd := &apiextensionsv1.CustomResourceDefinition{}
+ if err := yaml.Unmarshal(content, crd); err != nil {
+ return fmt.Errorf("decode embedded CRD %s: %w", entry.Name(), err)
+ }
+ crds := client.ApiextensionsV1().CustomResourceDefinitions()
+ existing, err := crds.Get(ctx, crd.Name, metav1.GetOptions{})
+ if errors.IsNotFound(err) {
+ if _, err := crds.Create(ctx, crd, metav1.CreateOptions{}); err != nil {
+ return fmt.Errorf("create CRD %s: %w", crd.Name, err)
+ }
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("get CRD %s: %w", crd.Name, err)
+ }
+ crd.ResourceVersion = existing.ResourceVersion
+ if _, err := crds.Update(ctx, crd, metav1.UpdateOptions{}); err != nil {
+ return fmt.Errorf("update CRD %s: %w", crd.Name, err)
+ }
+ }
+ return nil
+}
+
+func ensureUIAuthSecretInKubernetes(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+ if secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{}); err == nil {
+ if len(secret.Data[common.UIAuthJWTSigningKey]) >= 32 {
+ return nil
+ }
+ signingKey, err := generateSecret(32)
+ if err != nil {
+ return fmt.Errorf("generate JWT signing key: %w", err)
+ }
+ if secret.Data == nil {
+ secret.Data = make(map[string][]byte)
+ }
+ secret.Data[common.UIAuthJWTSigningKey] = signingKey
+ if _, err := clientset.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
+ return fmt.Errorf("update ui auth secret: %w", err)
+ }
+ return nil
+ } else if !errors.IsNotFound(err) {
+ return fmt.Errorf("get ui auth secret: %w", err)
+ }
+ adminPassword, err := generatePassword(16)
+ if err != nil {
+ return fmt.Errorf("generate admin password: %w", err)
+ }
+ userPassword, err := generatePassword(16)
+ if err != nil {
+ return fmt.Errorf("generate user password: %w", err)
+ }
+ signingKey, err := generateSecret(32)
+ if err != nil {
+ return fmt.Errorf("generate JWT signing key: %w", err)
+ }
+ _, err = clientset.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: common.UIAuthSecretName, Namespace: namespace},
+ Type: corev1.SecretTypeOpaque,
+ Data: map[string][]byte{
+ common.UIAuthAdminPasswordKey: []byte(adminPassword),
+ common.UIAuthUserPasswordKey: []byte(userPassword),
+ common.UIAuthJWTSigningKey: signingKey,
+ },
+ }, metav1.CreateOptions{})
+ if err != nil && !errors.IsAlreadyExists(err) {
+ return fmt.Errorf("create ui auth secret: %w", err)
+ }
+ return nil
+}
+
+func readUIAuthFromKubernetes(ctx context.Context, clientset kubernetes.Interface, namespace string) (string, string, error) {
+ secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
+ if err != nil {
+ return "", "", fmt.Errorf("get ui auth secret: %w", err)
+ }
+ adminPassword, ok := secret.Data[common.UIAuthAdminPasswordKey]
+ if !ok {
+ return "", "", fmt.Errorf("admin-password not found in secret")
+ }
+ userPassword, ok := secret.Data[common.UIAuthUserPasswordKey]
+ if !ok {
+ return "", "", fmt.Errorf("user-password not found in secret")
+ }
+ return string(adminPassword), string(userPassword), nil
+}
+
func ensureNamespace(ctx context.Context, clientset *kubernetes.Clientset) error {
_, err := clientset.CoreV1().Namespaces().Get(ctx, constants.Namespace, metav1.GetOptions{})
if err == nil {
@@ -188,9 +304,9 @@ func ensureNamespace(ctx context.Context, clientset *kubernetes.Clientset) error
return nil
}
-func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.Component) error {
+func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, cfg *types.DeployConfig, c *types.Component) error {
logger := log.FromContext(ctx)
- sa, cr, crb := component.RBAC(c)
+ sa, cr, crb := component.RBAC(cfg, c)
if sa == nil {
return nil
}
@@ -199,6 +315,11 @@ func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.C
if !errors.IsAlreadyExists(err) {
return fmt.Errorf("create serviceaccount %s: %w", sa.Name, err)
}
+ existing, getErr := clientset.CoreV1().ServiceAccounts(constants.Namespace).Get(ctx, sa.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get serviceaccount %s: %w", sa.Name, getErr)
+ }
+ sa.ResourceVersion = existing.ResourceVersion
if _, err := clientset.CoreV1().ServiceAccounts(constants.Namespace).Update(ctx, sa, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("update serviceaccount %s: %w", sa.Name, err)
}
@@ -206,6 +327,11 @@ func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.C
if _, err := clientset.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}); err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.RbacV1().ClusterRoles().Get(ctx, cr.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get clusterrole %s: %w", cr.Name, getErr)
+ }
+ cr.ResourceVersion = existing.ResourceVersion
if _, err := clientset.RbacV1().ClusterRoles().Update(ctx, cr, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("update clusterrole %s: %w", cr.Name, err)
}
@@ -216,6 +342,11 @@ func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.C
if _, err := clientset.RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}); err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.RbacV1().ClusterRoleBindings().Get(ctx, crb.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get clusterrolebinding %s: %w", crb.Name, getErr)
+ }
+ crb.ResourceVersion = existing.ResourceVersion
if _, err := clientset.RbacV1().ClusterRoleBindings().Update(ctx, crb, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("update clusterrolebinding %s: %w", crb.Name, err)
}
@@ -230,17 +361,17 @@ func ensureRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.C
// extractKCPKubeconfigFn returns a PostDeployFn that extracts admin.kubeconfig
// from the KCP pod and creates a ConfigMap for other components to mount.
-func extractKCPKubeconfigFn(ctx context.Context, clientset *kubernetes.Clientset) func(cfg *types.DeployConfig) error {
+func extractKCPKubeconfigFn(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) func(cfg *types.DeployConfig) error {
return func(cfg *types.DeployConfig) error {
- return extractAndApplyKCP(ctx, clientset)
+ return extractAndApplyKCP(ctx, clientset, kubeconfig)
}
}
-func applyKCP(ctx context.Context, clientset *kubernetes.Clientset) error {
- return extractAndApplyKCP(ctx, clientset)
+func applyKCP(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) error {
+ return extractAndApplyKCP(ctx, clientset, kubeconfig)
}
-func extractAndApplyKCP(ctx context.Context, clientset *kubernetes.Clientset) error {
+func extractAndApplyKCP(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) error {
logger := log.GetLogger()
pods, err := clientset.CoreV1().Pods(constants.Namespace).List(ctx, metav1.ListOptions{
LabelSelector: "app=" + constants.ComponentKCP,
@@ -252,13 +383,16 @@ func extractAndApplyKCP(ctx context.Context, clientset *kubernetes.Clientset) er
return fmt.Errorf("no kcp pods found")
}
+ // TODO: Although kcp supports multiple replicas, the generated kubeconfig and
+ // CA currently bind access to one selected Pod. Make access replica-aware so
+ // clients can safely use every kcp instance.
podName := pods.Items[0].Name
// Wait for admin.kubeconfig to be available, then extract via kubectl exec
deadline := time.Now().Add(60 * time.Second)
var kubeconfigData string
for {
- cmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", podName, "--", "cat", constants.KCPDataDir+"/admin.kubeconfig")
+ cmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", podName, "--", "cat", constants.KCPDataDir+"/admin.kubeconfig")
var buf bytes.Buffer
cmd.Stdout = &buf
err := cmd.Run()
@@ -290,7 +424,7 @@ func extractAndApplyKCP(ctx context.Context, clientset *kubernetes.Clientset) er
logger.Info("extracted admin.kubeconfig to ConfigMap")
- if err := installCRDs(podName); err != nil {
+ if err := installCRDs(podName, kubeconfig); err != nil {
return fmt.Errorf("install CRDs to kcp: %w", err)
}
@@ -299,7 +433,7 @@ func extractAndApplyKCP(ctx context.Context, clientset *kubernetes.Clientset) er
// installCRDs applies embedded CRD manifests into the KCP pod via kubectl exec,
// since the local machine cannot reach the KCP API directly.
-func installCRDs(podName string) error {
+func installCRDs(podName, kubeconfig string) error {
logger := log.GetLogger()
entries, err := config.CRDFiles.ReadDir("crd/bases")
@@ -324,7 +458,7 @@ func installCRDs(podName string) error {
kc := constants.KCPDataDir + "/admin.kubeconfig"
var lastErr error
for attempt := 0; attempt < 5; attempt++ {
- createCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", "-i", podName, "--",
+ createCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", "-i", podName, "--",
"kubectl", "--kubeconfig", kc, "create", "--validate=false", "-f", "-")
createCmd.Stdin = strings.NewReader(string(content))
var createErr bytes.Buffer
@@ -333,7 +467,7 @@ func installCRDs(podName string) error {
lastErr = nil
break
}
- replaceCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", "-i", podName, "--",
+ replaceCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", "-i", podName, "--",
"kubectl", "--kubeconfig", kc, "replace", "--validate=false", "-f", "-")
replaceCmd.Stdin = strings.NewReader(string(content))
var replaceErr bytes.Buffer
@@ -343,13 +477,13 @@ func installCRDs(podName string) error {
break
}
if strings.Contains(replaceErr.String(), "field is immutable") {
- deleteCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", podName, "--",
+ deleteCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", podName, "--",
"kubectl", "--kubeconfig", kc, "delete", "--ignore-not-found", "-f", "-")
deleteCmd.Stdin = strings.NewReader(string(content))
var deleteErr bytes.Buffer
deleteCmd.Stderr = &deleteErr
if err := deleteCmd.Run(); err == nil {
- recreateCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", "-i", podName, "--",
+ recreateCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", "-i", podName, "--",
"kubectl", "--kubeconfig", kc, "create", "--validate=false", "-f", "-")
recreateCmd.Stdin = strings.NewReader(string(content))
var recreateErr bytes.Buffer
@@ -394,9 +528,17 @@ func generatePassword(length int) (string, error) {
return string(out), nil
}
+func generateSecret(length int) ([]byte, error) {
+ secret := make([]byte, length)
+ if _, err := rand.Read(secret); err != nil {
+ return nil, err
+ }
+ return secret, nil
+}
+
// createUIAuthSecretInKCP creates the rlark-ui-auth secret in KCP (not local K8s)
// by running kubectl inside the KCP pod.
-func createUIAuthSecretInKCP(ctx context.Context, clientset *kubernetes.Clientset) error {
+func createUIAuthSecretInKCP(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) error {
logger := log.FromContext(ctx)
pods, err := clientset.CoreV1().Pods(constants.Namespace).List(ctx, metav1.ListOptions{
@@ -412,9 +554,29 @@ func createUIAuthSecretInKCP(ctx context.Context, clientset *kubernetes.Clientse
kc := constants.KCPDataDir + "/admin.kubeconfig"
// Check if secret already exists in KCP.
- checkCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", podName, "--",
+ checkCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", podName, "--",
"kubectl", "--kubeconfig", kc, "get", "secret", common.UIAuthSecretName, "-n", "default")
if err := checkCmd.Run(); err == nil {
+ adminPassword, userPassword, signingKey, err := readUIAuthSecretFromKCP(ctx, clientset, kubeconfig)
+ if err != nil {
+ return fmt.Errorf("existing ui auth secret in kcp is invalid: %w", err)
+ }
+ if len(signingKey) < 32 {
+ signingKey, err = generateSecret(32)
+ if err != nil {
+ return fmt.Errorf("generate JWT signing key: %w", err)
+ }
+ manifest, err := uiAuthSecretManifest(adminPassword, userPassword, signingKey)
+ if err != nil {
+ return fmt.Errorf("marshal ui auth secret: %w", err)
+ }
+ applyCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", "-i", podName, "--",
+ "kubectl", "--kubeconfig", kc, "apply", "--validate=false", "-f", "-")
+ applyCmd.Stdin = bytes.NewReader(manifest)
+ if err := applyCmd.Run(); err != nil {
+ return fmt.Errorf("update ui auth secret in kcp: %w", err)
+ }
+ }
logger.Info("ui auth secret already exists in KCP, skipping", "name", common.UIAuthSecretName)
return nil
}
@@ -427,22 +589,19 @@ func createUIAuthSecretInKCP(ctx context.Context, clientset *kubernetes.Clientse
if err != nil {
return fmt.Errorf("generate user password: %w", err)
}
+ signingKey, err := generateSecret(32)
+ if err != nil {
+ return fmt.Errorf("generate JWT signing key: %w", err)
+ }
- manifest := fmt.Sprintf(`
-apiVersion: v1
-kind: Secret
-metadata:
- name: %s
- namespace: default
-type: Opaque
-stringData:
- admin-password: %s
- user-password: %s
-`, common.UIAuthSecretName, adminPassword, userPassword)
+ manifest, err := uiAuthSecretManifest(adminPassword, userPassword, signingKey)
+ if err != nil {
+ return fmt.Errorf("marshal ui auth secret: %w", err)
+ }
- applyCmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", "-i", podName, "--",
+ applyCmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", "-i", podName, "--",
"kubectl", "--kubeconfig", kc, "apply", "--validate=false", "-f", "-")
- applyCmd.Stdin = strings.NewReader(manifest)
+ applyCmd.Stdin = bytes.NewReader(manifest)
var errBuf bytes.Buffer
applyCmd.Stderr = &errBuf
if err := applyCmd.Run(); err != nil {
@@ -453,56 +612,90 @@ stringData:
return nil
}
+func uiAuthSecretManifest(adminPassword, userPassword string, signingKey []byte) ([]byte, error) {
+ return yaml.Marshal(&corev1.Secret{
+ TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
+ ObjectMeta: metav1.ObjectMeta{
+ Name: common.UIAuthSecretName,
+ Namespace: "default",
+ },
+ Type: corev1.SecretTypeOpaque,
+ Data: map[string][]byte{
+ common.UIAuthAdminPasswordKey: []byte(adminPassword),
+ common.UIAuthUserPasswordKey: []byte(userPassword),
+ common.UIAuthJWTSigningKey: signingKey,
+ },
+ })
+}
+
// readUIAuthFromKCP reads the ui auth secret from KCP via kubectl exec.
-func readUIAuthFromKCP(ctx context.Context, clientset *kubernetes.Clientset) (adminPW, userPW string, err error) {
+func readUIAuthFromKCP(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) (adminPW, userPW string, err error) {
+ adminPW, userPW, _, err = readUIAuthSecretFromKCP(ctx, clientset, kubeconfig)
+ return adminPW, userPW, err
+}
+
+func readUIAuthSecretFromKCP(ctx context.Context, clientset *kubernetes.Clientset, kubeconfig string) (adminPW, userPW string, signingKey []byte, err error) {
pods, err := clientset.CoreV1().Pods(constants.Namespace).List(ctx, metav1.ListOptions{
LabelSelector: "app=" + constants.ComponentKCP,
})
if err != nil {
- return "", "", fmt.Errorf("list kcp pods: %w", err)
+ return "", "", nil, fmt.Errorf("list kcp pods: %w", err)
}
if len(pods.Items) == 0 {
- return "", "", fmt.Errorf("no kcp pods found")
+ return "", "", nil, fmt.Errorf("no kcp pods found")
}
podName := pods.Items[0].Name
kc := constants.KCPDataDir + "/admin.kubeconfig"
- cmd := exec.Command("kubectl", "-n", constants.Namespace, "exec", podName, "--",
+ cmd := kubectlCommand(kubeconfig, "-n", constants.Namespace, "exec", podName, "--",
"kubectl", "--kubeconfig", kc, "get", "secret", common.UIAuthSecretName, "-n", "default",
"-o", "json")
var jsonOut bytes.Buffer
cmd.Stdout = &jsonOut
if err := cmd.Run(); err != nil {
- return "", "", fmt.Errorf("read ui auth secret from kcp: %w", err)
+ return "", "", nil, fmt.Errorf("read ui auth secret from kcp: %w", err)
}
- return parseAuthSecretJSON(jsonOut.Bytes())
+ return parseAuthSecretJSONWithSigningKey(jsonOut.Bytes())
}
-func parseAuthSecretJSON(data []byte) (adminPW, userPW string, err error) {
+func kubectlCommand(kubeconfig string, args ...string) *exec.Cmd {
+ if kubeconfig != "" {
+ args = append([]string{"--kubeconfig", kubeconfig}, args...)
+ }
+ return exec.Command("kubectl", args...)
+}
+
+func parseAuthSecretJSONWithSigningKey(data []byte) (adminPW, userPW string, signingKey []byte, err error) {
var s struct {
Data map[string]string `json:"data"`
}
- if err := json.Unmarshal(data, &s); err != nil {
- return "", "", err
+ if err := encodingjson.Unmarshal(data, &s); err != nil {
+ return "", "", nil, err
}
adminRaw, ok := s.Data["admin-password"]
if !ok {
- return "", "", fmt.Errorf("admin-password not found in secret")
+ return "", "", nil, fmt.Errorf("admin-password not found in secret")
}
userRaw, ok := s.Data["user-password"]
if !ok {
- return "", "", fmt.Errorf("user-password not found in secret")
+ return "", "", nil, fmt.Errorf("user-password not found in secret")
}
adminDec, err := base64.StdEncoding.DecodeString(adminRaw)
if err != nil {
- return "", "", fmt.Errorf("decode admin-password: %w", err)
+ return "", "", nil, fmt.Errorf("decode admin-password: %w", err)
}
userDec, err := base64.StdEncoding.DecodeString(userRaw)
if err != nil {
- return "", "", fmt.Errorf("decode user-password: %w", err)
+ return "", "", nil, fmt.Errorf("decode user-password: %w", err)
+ }
+ if signingRaw, ok := s.Data[common.UIAuthJWTSigningKey]; ok {
+ signingKey, err = base64.StdEncoding.DecodeString(signingRaw)
+ if err != nil {
+ return "", "", nil, fmt.Errorf("decode %s: %w", common.UIAuthJWTSigningKey, err)
+ }
}
- return string(adminDec), string(userDec), nil
+ return string(adminDec), string(userDec), signingKey, nil
}
func createDBConfigMap(ctx context.Context, clientset *kubernetes.Clientset, cfg *types.DeployConfig) error {
@@ -541,6 +734,11 @@ func createCertSecret(ctx context.Context, clientset *kubernetes.Clientset, cfg
_, err := clientset.CoreV1().Secrets(constants.Namespace).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.CoreV1().Secrets(constants.Namespace).Get(ctx, name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get cert secret %s: %w", name, getErr)
+ }
+ secret.ResourceVersion = existing.ResourceVersion
_, err = clientset.CoreV1().Secrets(constants.Namespace).Update(ctx, secret, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update cert secret %s: %w", name, err)
@@ -556,6 +754,11 @@ func createDeployment(ctx context.Context, clientset *kubernetes.Clientset, dep
_, err := clientset.AppsV1().Deployments(constants.Namespace).Create(ctx, dep, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.AppsV1().Deployments(constants.Namespace).Get(ctx, dep.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get deployment %s: %w", dep.Name, getErr)
+ }
+ dep.ResourceVersion = existing.ResourceVersion
_, err = clientset.AppsV1().Deployments(constants.Namespace).Update(ctx, dep, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update deployment %s: %w", dep.Name, err)
@@ -587,6 +790,11 @@ func createStatefulSet(ctx context.Context, clientset *kubernetes.Clientset, sts
_, err := clientset.AppsV1().StatefulSets(constants.Namespace).Create(ctx, sts, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.AppsV1().StatefulSets(constants.Namespace).Get(ctx, sts.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get statefulset %s: %w", sts.Name, getErr)
+ }
+ sts.ResourceVersion = existing.ResourceVersion
_, err = clientset.AppsV1().StatefulSets(constants.Namespace).Update(ctx, sts, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update statefulset %s: %w", sts.Name, err)
@@ -602,6 +810,11 @@ func createDaemonSet(ctx context.Context, clientset *kubernetes.Clientset, ds *a
_, err := clientset.AppsV1().DaemonSets(constants.Namespace).Create(ctx, ds, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.AppsV1().DaemonSets(constants.Namespace).Get(ctx, ds.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get daemonset %s: %w", ds.Name, getErr)
+ }
+ ds.ResourceVersion = existing.ResourceVersion
_, err = clientset.AppsV1().DaemonSets(constants.Namespace).Update(ctx, ds, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update daemonset %s: %w", ds.Name, err)
@@ -617,6 +830,16 @@ func createService(ctx context.Context, clientset *kubernetes.Clientset, svc *co
_, err := clientset.CoreV1().Services(constants.Namespace).Create(ctx, svc, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.CoreV1().Services(constants.Namespace).Get(ctx, svc.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get service %s: %w", svc.Name, getErr)
+ }
+ svc.ResourceVersion = existing.ResourceVersion
+ svc.Spec.ClusterIP = existing.Spec.ClusterIP
+ svc.Spec.ClusterIPs = existing.Spec.ClusterIPs
+ svc.Spec.IPFamilies = existing.Spec.IPFamilies
+ svc.Spec.IPFamilyPolicy = existing.Spec.IPFamilyPolicy
+ svc.Spec.HealthCheckNodePort = existing.Spec.HealthCheckNodePort
_, err = clientset.CoreV1().Services(constants.Namespace).Update(ctx, svc, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update service %s: %w", svc.Name, err)
@@ -632,6 +855,11 @@ func createOrUpdateConfigMap(ctx context.Context, clientset *kubernetes.Clientse
_, err := clientset.CoreV1().ConfigMaps(constants.Namespace).Create(ctx, cm, metav1.CreateOptions{})
if err != nil {
if errors.IsAlreadyExists(err) {
+ existing, getErr := clientset.CoreV1().ConfigMaps(constants.Namespace).Get(ctx, cm.Name, metav1.GetOptions{})
+ if getErr != nil {
+ return fmt.Errorf("get configmap %s: %w", cm.Name, getErr)
+ }
+ cm.ResourceVersion = existing.ResourceVersion
_, err = clientset.CoreV1().ConfigMaps(constants.Namespace).Update(ctx, cm, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("update configmap %s: %w", cm.Name, err)
diff --git a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install_test.go b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install_test.go
new file mode 100644
index 0000000..3fe0c31
--- /dev/null
+++ b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/install_test.go
@@ -0,0 +1,92 @@
+package kubernetes
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/constants"
+ corev1 "k8s.io/api/core/v1"
+ apiextensionsfake "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kubernetesfake "k8s.io/client-go/kubernetes/fake"
+ "sigs.k8s.io/yaml"
+)
+
+func TestKubectlCommandUsesKubeconfig(t *testing.T) {
+ cmd := kubectlCommand("/tmp/test-kubeconfig", "get", "pods")
+ want := []string{"kubectl", "--kubeconfig", "/tmp/test-kubeconfig", "get", "pods"}
+ if !slices.Equal(cmd.Args, want) {
+ t.Fatalf("command args = %v, want %v", cmd.Args, want)
+ }
+}
+
+func TestUIAuthSecretManifestPreservesSpecialCharacters(t *testing.T) {
+ adminPassword := "#admin: password"
+ userPassword := "user#password"
+ signingKey := []byte("01234567890123456789012345678901")
+ manifest, err := uiAuthSecretManifest(adminPassword, userPassword, signingKey)
+ if err != nil {
+ t.Fatalf("uiAuthSecretManifest() error = %v", err)
+ }
+
+ var secret corev1.Secret
+ if err := yaml.Unmarshal(manifest, &secret); err != nil {
+ t.Fatalf("unmarshal manifest: %v", err)
+ }
+ if string(secret.Data[common.UIAuthAdminPasswordKey]) != adminPassword || string(secret.Data[common.UIAuthUserPasswordKey]) != userPassword {
+ t.Fatalf("passwords were not preserved: %#v", secret.Data)
+ }
+ if string(secret.Data[common.UIAuthJWTSigningKey]) != string(signingKey) {
+ t.Fatal("JWT signing key was not preserved")
+ }
+}
+
+func TestKubectlCommandUsesDefaultLoadingRules(t *testing.T) {
+ cmd := kubectlCommand("", "get", "pods")
+ want := []string{"kubectl", "get", "pods"}
+ if !slices.Equal(cmd.Args, want) {
+ t.Fatalf("command args = %v, want %v", cmd.Args, want)
+ }
+}
+
+func TestInstallCRDsToKubernetes(t *testing.T) {
+ client := apiextensionsfake.NewSimpleClientset()
+ if err := installCRDsToKubernetes(context.Background(), client); err != nil {
+ t.Fatalf("installCRDsToKubernetes() error = %v", err)
+ }
+ crds, err := client.ApiextensionsV1().CustomResourceDefinitions().List(context.Background(), metav1.ListOptions{})
+ if err != nil {
+ t.Fatalf("list CRDs: %v", err)
+ }
+ if len(crds.Items) == 0 {
+ t.Fatal("no CRDs installed")
+ }
+}
+
+func TestEnsureUIAuthSecretInKubernetes(t *testing.T) {
+ client := kubernetesfake.NewSimpleClientset()
+ ctx := context.Background()
+ if err := ensureUIAuthSecretInKubernetes(ctx, client, constants.Namespace); err != nil {
+ t.Fatalf("ensureUIAuthSecretInKubernetes() error = %v", err)
+ }
+ secret, err := client.CoreV1().Secrets(constants.Namespace).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("get UI auth secret: %v", err)
+ }
+ if len(secret.Data["admin-password"]) != 16 || len(secret.Data["user-password"]) != 16 {
+ t.Fatalf("unexpected generated passwords: %#v", secret.Data)
+ }
+ admin := append([]byte(nil), secret.Data["admin-password"]...)
+ if err := ensureUIAuthSecretInKubernetes(ctx, client, constants.Namespace); err != nil {
+ t.Fatalf("second ensureUIAuthSecretInKubernetes() error = %v", err)
+ }
+ secret, err = client.CoreV1().Secrets(constants.Namespace).Get(ctx, common.UIAuthSecretName, metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("get preserved UI auth secret: %v", err)
+ }
+ if string(secret.Data["admin-password"]) != string(admin) {
+ t.Fatal("existing UI auth secret was changed")
+ }
+}
diff --git a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall.go b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall.go
index 309bae9..154925d 100644
--- a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall.go
+++ b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall.go
@@ -3,7 +3,9 @@ package kubernetes
import (
"context"
"fmt"
+ "slices"
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/component"
"github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/constants"
@@ -42,7 +44,7 @@ func (d *Installer) Uninstall(cfg *types.DeployConfig, purge bool) error {
return err
}
}
- if err := deleteRBAC(ctx, clientset, &c); err != nil {
+ if err := deleteRBAC(ctx, clientset, cfg, &c); err != nil {
return err
}
logger.Info("component removed", "name", c.Name)
@@ -64,6 +66,11 @@ func (d *Installer) Uninstall(cfg *types.DeployConfig, purge bool) error {
}
if purge {
+ if cfg.UsesKubernetesManagementAPI() {
+ if err := removeManagementSecretFinalizers(ctx, clientset, constants.Namespace); err != nil {
+ return err
+ }
+ }
if err := clientset.CoreV1().Namespaces().Delete(ctx, constants.Namespace, metav1.DeleteOptions{}); err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("delete namespace %s: %w", constants.Namespace, err)
}
@@ -74,6 +81,40 @@ func (d *Installer) Uninstall(cfg *types.DeployConfig, purge bool) error {
return nil
}
+var protectedManagementSecrets = map[string][]string{
+ "rlark-tls": {"rlark.io/tls-secret-protection"},
+ common.TLSCASecretName: {"rlark.io/ca-secret-protection"},
+ "rlark-client-ca": {"rlark.io/ca-secret-protection"},
+ common.AdminCertSecretName: {"rlark.io/admin-cert-secret-protection"},
+}
+
+func removeManagementSecretFinalizers(ctx context.Context, clientset kubernetes.Interface, namespace string) error {
+ secrets := clientset.CoreV1().Secrets(namespace)
+ for name, ownedFinalizers := range protectedManagementSecrets {
+ secret, err := secrets.Get(ctx, name, metav1.GetOptions{})
+ if errors.IsNotFound(err) {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("get protected secret %s/%s: %w", namespace, name, err)
+ }
+ finalizers := secret.Finalizers[:0]
+ for _, finalizer := range secret.Finalizers {
+ if !slices.Contains(ownedFinalizers, finalizer) {
+ finalizers = append(finalizers, finalizer)
+ }
+ }
+ if len(finalizers) == len(secret.Finalizers) {
+ continue
+ }
+ secret.Finalizers = finalizers
+ if _, err := secrets.Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
+ return fmt.Errorf("remove finalizers from secret %s/%s: %w", namespace, name, err)
+ }
+ }
+ return nil
+}
+
func deleteWorkload(ctx context.Context, clientset *kubernetes.Clientset, c *types.Component) error {
switch c.WorkloadKind {
case "DaemonSet":
@@ -133,8 +174,8 @@ func deleteSecret(ctx context.Context, clientset *kubernetes.Clientset, name str
return nil
}
-func deleteRBAC(ctx context.Context, clientset *kubernetes.Clientset, c *types.Component) error {
- sa, cr, crb := component.RBAC(c)
+func deleteRBAC(ctx context.Context, clientset *kubernetes.Clientset, cfg *types.DeployConfig, c *types.Component) error {
+ sa, cr, crb := component.RBAC(cfg, c)
if sa == nil {
return nil
}
diff --git a/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall_test.go b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall_test.go
new file mode 100644
index 0000000..5934887
--- /dev/null
+++ b/apps/rlark/pkg/rlarkadm/deployer/kubernetes/uninstall_test.go
@@ -0,0 +1,31 @@
+package kubernetes
+
+import (
+ "context"
+ "testing"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/common"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+func TestRemoveManagementSecretFinalizers(t *testing.T) {
+ client := fake.NewSimpleClientset(
+ &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
+ Name: common.TLSCASecretName,
+ Namespace: "rlark-system",
+ Finalizers: []string{"rlark.io/ca-secret-protection", "example.com/keep"},
+ }},
+ )
+ if err := removeManagementSecretFinalizers(context.Background(), client, "rlark-system"); err != nil {
+ t.Fatalf("removeManagementSecretFinalizers() error = %v", err)
+ }
+ secret, err := client.CoreV1().Secrets("rlark-system").Get(context.Background(), common.TLSCASecretName, metav1.GetOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(secret.Finalizers) != 1 || secret.Finalizers[0] != "example.com/keep" {
+ t.Fatalf("finalizers = %#v, want unrelated finalizer preserved", secret.Finalizers)
+ }
+}
diff --git a/apps/rlark/pkg/rlarkadm/types/summary.go b/apps/rlark/pkg/rlarkadm/types/summary.go
index 041fe25..ecb7fe9 100644
--- a/apps/rlark/pkg/rlarkadm/types/summary.go
+++ b/apps/rlark/pkg/rlarkadm/types/summary.go
@@ -70,8 +70,12 @@ func (s *InstallSummary) Print() {
if s.AdminPassword != "" {
b.WriteString("\n")
b.WriteString("Credentials:\n")
- b.WriteString(" Admin: admin / " + s.AdminPassword + "\n")
- b.WriteString(" User: user / " + s.UserPassword + "\n")
+ b.WriteString(" Admin: admin / ")
+ b.WriteString(s.AdminPassword)
+ b.WriteString("\n")
+ b.WriteString(" User: user / ")
+ b.WriteString(s.UserPassword)
+ b.WriteString("\n")
}
}
diff --git a/apps/rlark/pkg/rlarkadm/types/types.go b/apps/rlark/pkg/rlarkadm/types/types.go
index 0257824..8baba10 100644
--- a/apps/rlark/pkg/rlarkadm/types/types.go
+++ b/apps/rlark/pkg/rlarkadm/types/types.go
@@ -2,11 +2,16 @@ package types
import (
"fmt"
+ "net"
+ "net/url"
"os"
+ "strings"
"go.yaml.in/yaml/v2"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/rlarkadm/constants"
)
// Plane identifies a deployment plane.
@@ -30,40 +35,50 @@ const (
// DeployConfig holds configuration options.
type DeployConfig struct {
- APIVersion string `yaml:"apiVersion"`
- Kind string `yaml:"kind"`
- Plane Plane `yaml:"plane"`
- ControlPlaneAddress string `yaml:"control-plane-address,omitempty"`
- DB *DBConfig `yaml:"db,omitempty"`
- Kubernetes *KubernetesEnv `yaml:"kubernetes,omitempty"`
- Docker *DockerEnv `yaml:"docker,omitempty"`
- Raw *RawEnv `yaml:"raw,omitempty"`
- Cert *CertConfig `yaml:"cert,omitempty"`
- InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify,omitempty"`
+ APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion"`
+ Kind string `json:"kind,omitempty" yaml:"kind"`
+ Plane Plane `json:"plane,omitempty" yaml:"plane"`
+ ControlPlaneAddress string `json:"controlPlaneAddress,omitempty" yaml:"control-plane-address,omitempty"`
+ // SSHAddress is the control-plane Server SSH address used for cross-cluster
+ // networking, in the form "user@host:port" (e.g. "client@rlark-server:2222").
+ // When empty it is auto-derived from ControlPlaneAddress.
+ SSHAddress string `json:"sshAddress,omitempty" yaml:"ssh-address,omitempty"`
+ DB *DBConfig `json:"db,omitempty" yaml:"db,omitempty"`
+ Kubernetes *KubernetesEnv `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"`
+ Docker *DockerEnv `json:"docker,omitempty" yaml:"docker,omitempty"`
+ Raw *RawEnv `json:"raw,omitempty" yaml:"raw,omitempty"`
+ Cert *CertConfig `json:"cert,omitempty" yaml:"cert,omitempty"`
+ InsecureSkipTLSVerify bool `json:"insecureSkipTlsVerify,omitempty" yaml:"insecure-skip-tls-verify,omitempty"`
}
// KubernetesEnv holds environment configuration.
type KubernetesEnv struct {
- Kubeconfig string `yaml:"kubeconfig,omitempty"`
- GatewayImage string `yaml:"gateway-image"`
- ControllerManagerImage string `yaml:"controller-manager-image"`
- ServerImage string `yaml:"server-image"`
- AgentImage string `yaml:"agent-image"`
- Image string `yaml:"image,omitempty"`
- KCPImage string `yaml:"kcp-image,omitempty"`
- EtcdImage string `yaml:"etcd-image,omitempty"`
- PostgresqlImage string `yaml:"postgresql-image,omitempty"`
- UIImage string `yaml:"ui-image,omitempty"`
- Replicas int32 `yaml:"replicas,omitempty"`
- Storage *StorageConfig `yaml:"storage,omitempty"`
- KCP *ComponentConfig `yaml:"kcp,omitempty"`
- Etcd *EtcdConfig `yaml:"etcd,omitempty"`
- Postgresql *ComponentConfig `yaml:"postgresql,omitempty"`
+ ManagementAPI string `json:"managementApi,omitempty" yaml:"management-api,omitempty"`
+ Kubeconfig string `json:"kubeconfig,omitempty" yaml:"kubeconfig,omitempty"`
+ GatewayImage string `json:"gatewayImage,omitempty" yaml:"gateway-image"`
+ ControllerManagerImage string `json:"controllerManagerImage,omitempty" yaml:"controller-manager-image"`
+ ServerImage string `json:"serverImage,omitempty" yaml:"server-image"`
+ AgentImage string `json:"agentImage,omitempty" yaml:"agent-image"`
+ Image string `json:"image,omitempty" yaml:"image,omitempty"`
+ KCPImage string `json:"kcpImage,omitempty" yaml:"kcp-image,omitempty"`
+ EtcdImage string `json:"etcdImage,omitempty" yaml:"etcd-image,omitempty"`
+ PostgresqlImage string `json:"postgresqlImage,omitempty" yaml:"postgresql-image,omitempty"`
+ UIImage string `json:"uiImage,omitempty" yaml:"ui-image,omitempty"`
+ // ImagePullPolicy is the pull policy applied to all control/data plane
+ // component containers. One of Always, IfNotPresent, Never. Defaults to
+ // Always when empty.
+ ImagePullPolicy string `json:"imagePullPolicy,omitempty" yaml:"image-pull-policy,omitempty"`
+ ImagePullSecrets []string `json:"imagePullSecrets,omitempty" yaml:"image-pull-secrets,omitempty"`
+ Replicas int32 `json:"replicas,omitempty" yaml:"replicas,omitempty"`
+ Storage *StorageConfig `json:"storage,omitempty" yaml:"storage,omitempty"`
+ KCP *ComponentConfig `json:"kcp,omitempty" yaml:"kcp,omitempty"`
+ Etcd *EtcdConfig `json:"etcd,omitempty" yaml:"etcd,omitempty"`
+ Postgresql *ComponentConfig `json:"postgresql,omitempty" yaml:"postgresql,omitempty"`
// ContainerdSocket is the host path to the containerd socket used by the
// node-agent for image pre-pull progress monitoring. Defaults to
// /run/containerd/containerd.sock when empty. Set this for non-standard
// runtimes such as k3s (/run/k3s/containerd/containerd.sock).
- ContainerdSocket string `yaml:"containerd-socket,omitempty"`
+ ContainerdSocket string `json:"containerdSocket,omitempty" yaml:"containerd-socket,omitempty"`
}
// ComponentConfig holds configuration options.
@@ -99,6 +114,9 @@ type DockerEnv struct {
EtcdImage string `yaml:"etcd-image,omitempty"`
PostgresqlImage string `yaml:"postgresql-image,omitempty"`
UIImage string `yaml:"ui-image,omitempty"`
+ // ImagePullPolicy is the pull policy applied to all component containers.
+ // One of Always, IfNotPresent, Never. Defaults to Always when empty.
+ ImagePullPolicy string `yaml:"image-pull-policy,omitempty"`
}
// RawEnv holds environment configuration.
@@ -140,6 +158,7 @@ type Component struct {
WorkloadKind string
ServiceAccount string
RBACRules []rbacv1.PolicyRule
+ RBACRulesFn func(cfg *DeployConfig) []rbacv1.PolicyRule
Dependencies []string
MetricsPort int32
EnabledFn func(cfg *DeployConfig) bool
@@ -215,6 +234,29 @@ func (c *DeployConfig) Validate() error {
return fmt.Errorf("exactly one of kubernetes/docker/raw must be specified")
}
+ var pullPolicy string
+ if c.Kubernetes != nil {
+ pullPolicy = c.Kubernetes.ImagePullPolicy
+ if c.Kubernetes.KCP != nil && c.Kubernetes.KCP.Replicas > 1 {
+ return fmt.Errorf("kubernetes.kcp.replicas must be 1")
+ }
+ switch c.Kubernetes.ManagementAPI {
+ case "", "kcp", "kubernetes":
+ default:
+ return fmt.Errorf("kubernetes.management-api must be %q or %q, got %q", "kcp", "kubernetes", c.Kubernetes.ManagementAPI)
+ }
+ if c.Kubernetes.ManagementAPI == "kubernetes" && c.Plane != PlaneControl {
+ return fmt.Errorf("kubernetes.management-api %q is only supported for the control plane", "kubernetes")
+ }
+ } else if c.Docker != nil {
+ pullPolicy = c.Docker.ImagePullPolicy
+ }
+ switch corev1.PullPolicy(pullPolicy) {
+ case "", corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever:
+ default:
+ return fmt.Errorf("image-pull-policy must be one of Always, IfNotPresent, Never, got %q", pullPolicy)
+ }
+
if c.Plane == PlaneData {
if c.Cert == nil {
return fmt.Errorf("cert is required for data plane")
@@ -237,6 +279,12 @@ func (c *DeployConfig) Validate() error {
return nil
}
+// UsesKubernetesManagementAPI reports whether control-plane state is stored in
+// the target Kubernetes API instead of a separately deployed kcp instance.
+func (c *DeployConfig) UsesKubernetesManagementAPI() bool {
+ return c.Kubernetes != nil && c.Kubernetes.ManagementAPI == "kubernetes"
+}
+
// EnvMode returns the environment mode.
func (c *DeployConfig) EnvMode() string {
if c.Kubernetes != nil {
@@ -247,3 +295,64 @@ func (c *DeployConfig) EnvMode() string {
}
return "Raw"
}
+
+// defaultSSHUser is the user embedded in the auto-derived Server SSH address.
+const defaultSSHUser = "client"
+
+// SSHServerAddress resolves the control-plane Server SSH address in the form
+// "user@host:port". When SSHAddress is explicitly set it is returned as-is;
+// otherwise it is derived from ControlPlaneAddress by extracting its host and
+// combining it with the default SSH user and Server SSH port. An empty string
+// is returned when neither is available.
+func (c *DeployConfig) SSHServerAddress() string {
+ if c.SSHAddress != "" {
+ return c.SSHAddress
+ }
+ host := hostFromAddress(c.ControlPlaneAddress)
+ if host == "" {
+ return ""
+ }
+ return fmt.Sprintf("%s@%s:%d", defaultSSHUser, host, constants.ServerSSHPort)
+}
+
+// hostFromAddress extracts the bare host from a control-plane address that may
+// be a full URL (e.g. "https://rlark-server:8443"), a "host:port" pair, or a
+// bare host. Any scheme and port are stripped.
+func hostFromAddress(addr string) string {
+ addr = strings.TrimSpace(addr)
+ if addr == "" {
+ return ""
+ }
+ // Full URL with scheme, e.g. https://host:8443.
+ if strings.Contains(addr, "://") {
+ if u, err := url.Parse(addr); err == nil && u.Hostname() != "" {
+ return u.Hostname()
+ }
+ }
+ // host:port without scheme.
+ if host, _, err := net.SplitHostPort(addr); err == nil {
+ return host
+ }
+ // Bare host.
+ return addr
+}
+
+// ImagePullPolicy resolves the configured image pull policy for component
+// containers, defaulting to corev1.PullAlways when unset. Only the Kubernetes
+// and Docker environment modes carry the setting; other modes return the
+// default.
+func (c *DeployConfig) ImagePullPolicy() corev1.PullPolicy {
+ var raw string
+ switch {
+ case c.Kubernetes != nil:
+ raw = c.Kubernetes.ImagePullPolicy
+ case c.Docker != nil:
+ raw = c.Docker.ImagePullPolicy
+ }
+ switch corev1.PullPolicy(raw) {
+ case corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever:
+ return corev1.PullPolicy(raw)
+ default:
+ return corev1.PullAlways
+ }
+}
diff --git a/apps/rlark/pkg/rlarkadm/types/types_test.go b/apps/rlark/pkg/rlarkadm/types/types_test.go
index 3bfba03..6470a11 100644
--- a/apps/rlark/pkg/rlarkadm/types/types_test.go
+++ b/apps/rlark/pkg/rlarkadm/types/types_test.go
@@ -81,3 +81,114 @@ func TestDeployConfigValidateRequiresDataPlaneCertificates(t *testing.T) {
})
}
}
+
+func TestDeployConfigSSHServerAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ sshAddress string
+ controlPlane string
+ want string
+ }{
+ {
+ name: "explicit ssh-address takes precedence",
+ sshAddress: "operator@ssh.example.com:2200",
+ // control-plane-address is ignored when ssh-address is set.
+ controlPlane: "https://rlark-server:8443",
+ want: "operator@ssh.example.com:2200",
+ },
+ {
+ name: "derive from https url with port",
+ controlPlane: "https://rlark-server.rlark-system.svc:8443",
+ want: "client@rlark-server.rlark-system.svc:2222",
+ },
+ {
+ name: "derive from https url without port",
+ controlPlane: "https://rlark.example.com",
+ want: "client@rlark.example.com:2222",
+ },
+ {
+ name: "derive from host:port without scheme",
+ controlPlane: "rlark-server:8443",
+ want: "client@rlark-server:2222",
+ },
+ {
+ name: "derive from bare host",
+ controlPlane: "rlark-server",
+ want: "client@rlark-server:2222",
+ },
+ {
+ name: "empty when nothing configured",
+ controlPlane: "",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := DeployConfig{
+ ControlPlaneAddress: tt.controlPlane,
+ SSHAddress: tt.sshAddress,
+ }
+ if got := cfg.SSHServerAddress(); got != tt.want {
+ t.Fatalf("SSHServerAddress() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDeployConfigManagementAPI(t *testing.T) {
+ tests := []struct {
+ name string
+ plane Plane
+ managementAPI string
+ wantNative bool
+ wantErr string
+ }{
+ {name: "default kcp", plane: PlaneControl},
+ {name: "explicit kcp", plane: PlaneControl, managementAPI: "kcp"},
+ {name: "current kubernetes", plane: PlaneControl, managementAPI: "kubernetes", wantNative: true},
+ {name: "unknown", plane: PlaneControl, managementAPI: "other", wantErr: "kubernetes.management-api must be"},
+ {name: "data plane", plane: PlaneData, managementAPI: "kubernetes", wantErr: "is only supported for the control plane"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := DeployConfig{
+ APIVersion: "rlark.io/v1alpha1",
+ Kind: "DeployConfig",
+ Plane: tt.plane,
+ Kubernetes: &KubernetesEnv{ManagementAPI: tt.managementAPI},
+ }
+ if tt.plane == PlaneData {
+ cfg.ControlPlaneAddress = "https://rlark.example.com"
+ cfg.Cert = &CertConfig{CACert: "ca", AgentCert: "cert", AgentKey: "key"}
+ }
+ err := cfg.Validate()
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("Validate() error = %v, want containing %q", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("Validate() error = %v", err)
+ }
+ if got := cfg.UsesKubernetesManagementAPI(); got != tt.wantNative {
+ t.Fatalf("UsesKubernetesManagementAPI() = %v, want %v", got, tt.wantNative)
+ }
+ })
+ }
+}
+
+func TestDeployConfigRejectsMultipleKCPReplicas(t *testing.T) {
+ cfg := DeployConfig{
+ APIVersion: "rlark.io/v1alpha1",
+ Kind: "DeployConfig",
+ Plane: PlaneControl,
+ Kubernetes: &KubernetesEnv{
+ KCP: &ComponentConfig{Replicas: 2},
+ },
+ }
+ if err := cfg.Validate(); err == nil || err.Error() != "kubernetes.kcp.replicas must be 1" {
+ t.Fatalf("Validate() error = %v, want kcp replica limit", err)
+ }
+}
diff --git a/apps/rlark/pkg/server/agent.go b/apps/rlark/pkg/server/agent.go
index f97778a..cebac4d 100644
--- a/apps/rlark/pkg/server/agent.go
+++ b/apps/rlark/pkg/server/agent.go
@@ -3,16 +3,15 @@ package server
import (
"context"
"fmt"
- "time"
v1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/leaderelection"
- "k8s.io/client-go/tools/leaderelection/resourcelock"
"github.com/rlinf/rlark/apps/rlark/pkg/apis"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
)
func (s *Server) registerAgent(ctx context.Context, agentID string) error {
@@ -59,6 +58,8 @@ func (s *Server) registerAgent(ctx context.Context, agentID string) error {
{APIGroups: []string{"rlinf.io"}, Resources: []string{"nodes", "tasks", "pods", "addons"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}},
{APIGroups: []string{"rlinf.io"}, Resources: []string{"nodes/status", "tasks/status", "pods/status", "addons/status"}, Verbs: []string{"get", "update", "patch"}},
{APIGroups: []string{"rlinf.io"}, Resources: []string{"domainpeers"}, Verbs: []string{"get", "list", "watch"}},
+ {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "update", "patch"}},
+ {APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}},
{APIGroups: []string{"coordination.k8s.io"}, Resources: []string{"leases"}, Verbs: []string{"get", "create", "update", "patch"}},
}
roleName := saName
@@ -160,31 +161,20 @@ func (s *Server) startAgentBroadcaster(ctx context.Context, agentID, role, connI
namespace := apis.RLarkAgentNamespacePrefix + agentID
id := "heartbeat"
- rl, err := resourcelock.New(
- resourcelock.LeasesResourceLock,
- namespace, id,
- s.kubeClient.CoreV1(),
- s.kubeClient.CoordinationV1(),
- resourcelock.ResourceLockConfig{
- Identity: connID,
+ leConfig := configs.DefaultLeaderElectionConfig()
+ leConfig.Key = namespace + "/" + id
+ leConfig.Identity = connID
+ electionConfig, err := leConfig.Build(s.kubeClient, namespace, leaderelection.LeaderCallbacks{
+ OnStartedLeading: func(ctx context.Context) {
+ <-ctx.Done()
},
- )
+ OnStoppedLeading: func() {},
+ OnNewLeader: func(identity string) {},
+ })
if err != nil {
- return fmt.Errorf("create lock: %w", err)
+ return fmt.Errorf("build leader election config: %w", err)
}
- le, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
- Lock: rl,
- LeaseDuration: time.Second * 30,
- RenewDeadline: time.Second * 10,
- RetryPeriod: time.Second * 5,
- Callbacks: leaderelection.LeaderCallbacks{
- OnStartedLeading: func(ctx context.Context) {
- <-ctx.Done()
- },
- OnStoppedLeading: func() {},
- OnNewLeader: func(identity string) {},
- },
- })
+ le, err := leaderelection.NewLeaderElector(electionConfig)
if err != nil {
return fmt.Errorf("create leader elector: %w", err)
}
diff --git a/apps/rlark/pkg/server/agent_test.go b/apps/rlark/pkg/server/agent_test.go
new file mode 100644
index 0000000..f8b85c4
--- /dev/null
+++ b/apps/rlark/pkg/server/agent_test.go
@@ -0,0 +1,48 @@
+package server
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ rbacv1 "k8s.io/api/rbac/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/fake"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/apis"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegisterAgentGrantsDeliverySecretAndStatusPermissions(t *testing.T) {
+ ctx := context.Background()
+ client := fake.NewSimpleClientset()
+ server := &Server{kubeClient: client}
+ require.NoError(t, server.registerAgent(ctx, "cluster-a"))
+
+ namespace := apis.RLarkAgentNamespacePrefix + "cluster-a"
+ role, err := client.RbacV1().Roles(namespace).Get(ctx, apis.RLarkAgentServiceAccountName, metav1.GetOptions{})
+ require.NoError(t, err)
+ assert.True(t, ruleAllows(role.Rules, "", "secrets", "get", "list", "watch", "update", "patch"))
+ assert.True(t, ruleAllows(role.Rules, "", "configmaps", "get", "list", "watch", "create", "update", "patch", "delete"))
+
+ require.NoError(t, server.registerAgent(ctx, "cluster-a"))
+ role, err = client.RbacV1().Roles(namespace).Get(ctx, apis.RLarkAgentServiceAccountName, metav1.GetOptions{})
+ require.NoError(t, err)
+ assert.True(t, ruleAllows(role.Rules, "", "secrets", "patch"))
+}
+
+func ruleAllows(rules []rbacv1.PolicyRule, group, resource string, verbs ...string) bool {
+ for _, rule := range rules {
+ if !slices.Contains(rule.APIGroups, group) || !slices.Contains(rule.Resources, resource) {
+ continue
+ }
+ for _, verb := range verbs {
+ if !slices.Contains(rule.Verbs, verb) {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
diff --git a/apps/rlark/pkg/server/client.go b/apps/rlark/pkg/server/client.go
index be9e3fe..40c135a 100644
--- a/apps/rlark/pkg/server/client.go
+++ b/apps/rlark/pkg/server/client.go
@@ -18,10 +18,10 @@ import (
"time"
"github.com/gorilla/websocket"
- "github.com/rancher/remotedialer"
"github.com/rlinf/rlark/apps/rlark/pkg/auth/cert"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
"github.com/rlinf/rlark/apps/rlark/pkg/configs"
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
diff --git a/apps/rlark/pkg/server/config.go b/apps/rlark/pkg/server/config.go
index 0fc31c9..abc6a6b 100644
--- a/apps/rlark/pkg/server/config.go
+++ b/apps/rlark/pkg/server/config.go
@@ -14,6 +14,11 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/configs"
)
+const (
+ defaultClientQPS = 50
+ defaultClientBurst = 100
+)
+
// Config holds the server configuration parameters.
type Config struct {
// HTTPS Port to listen on.
@@ -163,5 +168,11 @@ func (c *ClientConfig) BuildKubeAPIConfig() (*api.Config, error) {
// BuildRestConfig builds the restConfig.
func (c *ClientConfig) BuildRestConfig() (*rest.Config, error) {
- return clientcmd.BuildConfigFromKubeconfigGetter("", c.BuildKubeAPIConfig)
+ config, err := clientcmd.BuildConfigFromKubeconfigGetter("", c.BuildKubeAPIConfig)
+ if err != nil {
+ return nil, err
+ }
+ config.QPS = defaultClientQPS
+ config.Burst = defaultClientBurst
+ return config, nil
}
diff --git a/apps/rlark/pkg/server/config_test.go b/apps/rlark/pkg/server/config_test.go
new file mode 100644
index 0000000..1159e18
--- /dev/null
+++ b/apps/rlark/pkg/server/config_test.go
@@ -0,0 +1,22 @@
+package server
+
+import "testing"
+
+func TestClientConfigBuildRestConfigSetsRateLimits(t *testing.T) {
+ config := ClientConfig{
+ ServerAddress: "https://rlark.example.com",
+ ServerNamespace: "data-cluster",
+ InsecureSkipTLSVerify: true,
+ }
+
+ restConfig, err := config.BuildRestConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restConfig.QPS != defaultClientQPS {
+ t.Fatalf("QPS = %v, want %v", restConfig.QPS, defaultClientQPS)
+ }
+ if restConfig.Burst != defaultClientBurst {
+ t.Fatalf("Burst = %d, want %d", restConfig.Burst, defaultClientBurst)
+ }
+}
diff --git a/apps/rlark/pkg/server/handle_proxy.go b/apps/rlark/pkg/server/handle_proxy.go
index e2a320e..5e286d9 100644
--- a/apps/rlark/pkg/server/handle_proxy.go
+++ b/apps/rlark/pkg/server/handle_proxy.go
@@ -13,13 +13,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
- "github.com/rancher/remotedialer"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/rlinf/rlark/api/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/apis"
"github.com/rlinf/rlark/apps/rlark/pkg/auth"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
"github.com/rlinf/rlark/apps/rlark/pkg/server/reverseproxy"
)
@@ -84,27 +84,30 @@ func (s *Server) GetDial(ctx context.Context, dialType, address string, certMeta
}
// getAgentDialer returns a remotedialer.Dialer for the specified agentID and nodeName.
-// If nodeName is provided, it will prioritize the dialer for that specific node.
+// If nodeName is provided it tries the node-specific agent key first, then falls back
+// to the generic agent key. Candidate keys are evaluated lazily when the returned
+// Dialer is called, so sessions that become available between calls are usable.
func (s *Server) getAgentDialer(ctx context.Context, agentID, nodeName string) remotedialer.Dialer {
- _ = ctx
- candidateClientKeys := []string{}
+ candidateKeys := make([]string, 0, 2)
if nodeName != "" {
- candidateClientKeys = append(candidateClientKeys, agentID+":node-agent:"+nodeName)
+ candidateKeys = append(candidateKeys, agentID+":node-agent:"+nodeName)
}
- candidateClientKeys = append(candidateClientKeys, agentID)
+ candidateKeys = append(candidateKeys, agentID)
+ candidateKeys = append(candidateKeys, agentID+":node-agent:*")
return func(ctx context.Context, network, addr string) (net.Conn, error) {
- for _, clientKey := range candidateClientKeys {
- d := s.dialerFactory.GetDialer(ctx, clientKey)
- conn, err := d(ctx, network, addr)
- if err == nil {
- return conn, nil
+ for _, clientKey := range candidateKeys {
+ d, err := s.dialerFactory.GetDialer(ctx, clientKey)
+ if err != nil {
+ continue
}
- if !strings.Contains(err.Error(), "failed to find Session") {
+ conn, err := d(ctx, network, addr)
+ if err != nil {
return nil, err
}
+ return conn, nil
}
- return nil, fmt.Errorf("no available dialer found for candidate client keys: %v", candidateClientKeys)
+ return nil, fmt.Errorf("no active session for candidates %v", candidateKeys)
}
}
@@ -220,6 +223,7 @@ func (s *Server) handlePeerConnectProxy(ctx *gin.Context) {
Path: "/api/connect",
}
proxy := httputil.ReverseProxy{
+ //nolint:staticcheck // Ignore staticcheck warnings for the Director function in the reverse proxy
Director: func(req *http.Request) {
req.URL = url
req.Host = url.Host
diff --git a/apps/rlark/pkg/server/handle_terminal.go b/apps/rlark/pkg/server/handle_terminal.go
index 603fc98..e25fe0a 100644
--- a/apps/rlark/pkg/server/handle_terminal.go
+++ b/apps/rlark/pkg/server/handle_terminal.go
@@ -54,7 +54,7 @@ func (s *Server) handleTerminalProxy(c *gin.Context) {
agentWs, _, err := agentWsDialer.Dial(agentURL, nil)
if err != nil {
logger.Error(err, "failed to dial agent terminal WebSocket")
- _ = browserWs.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("failed to connect to agent: %v\r\n", err)))
+ _ = browserWs.WriteMessage(websocket.TextMessage, fmt.Appendf(nil, "failed to connect to agent: %v\r\n", err))
return
}
defer func() { _ = agentWs.Close() }()
diff --git a/apps/rlark/pkg/server/leader_election_test.go b/apps/rlark/pkg/server/leader_election_test.go
new file mode 100644
index 0000000..d21a313
--- /dev/null
+++ b/apps/rlark/pkg/server/leader_election_test.go
@@ -0,0 +1,69 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+func testLeaderElectionConfig() configs.LeaderElectionConfig {
+ return configs.LeaderElectionConfig{
+ Key: "test-lock",
+ Identity: "test-instance",
+ LeaseDuration: 500 * time.Millisecond,
+ RenewDeadline: 300 * time.Millisecond,
+ RetryPeriod: 100 * time.Millisecond,
+ }
+}
+
+func TestRunLeaderInitialization(t *testing.T) {
+ client := fake.NewSimpleClientset()
+ called := false
+ if err := runLeaderInitialization(context.Background(), client, "default", testLeaderElectionConfig(), func(context.Context) error {
+ called = true
+ return nil
+ }); err != nil {
+ t.Fatalf("runLeaderInitialization() error = %v", err)
+ }
+ if !called {
+ t.Fatal("initializer was not called")
+ }
+ lease, err := client.CoordinationV1().Leases("default").Get(context.Background(), "test-lock", metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("get leader lease: %v", err)
+ }
+ if lease.Spec.HolderIdentity == nil || *lease.Spec.HolderIdentity != "test-instance" {
+ t.Fatalf("holder identity = %v, want test-instance", lease.Spec.HolderIdentity)
+ }
+}
+
+func TestRunLeaderInitializationReturnsInitializerError(t *testing.T) {
+ want := errors.New("initialize failed")
+ err := runLeaderInitialization(context.Background(), fake.NewSimpleClientset(), "default", testLeaderElectionConfig(), func(context.Context) error {
+ return want
+ })
+ if !errors.Is(err, want) {
+ t.Fatalf("runLeaderInitialization() error = %v, want %v", err, want)
+ }
+}
+
+func TestRunLeaderInitializationCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ called := false
+ err := runLeaderInitialization(ctx, fake.NewSimpleClientset(), "default", testLeaderElectionConfig(), func(context.Context) error {
+ called = true
+ return nil
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("runLeaderInitialization() error = %v, want context.Canceled", err)
+ }
+ if called {
+ t.Fatal("initializer was called after context cancellation")
+ }
+}
diff --git a/apps/rlark/pkg/server/peer_manager.go b/apps/rlark/pkg/server/peer_manager.go
index 19ec9c8..43eba7f 100644
--- a/apps/rlark/pkg/server/peer_manager.go
+++ b/apps/rlark/pkg/server/peer_manager.go
@@ -11,12 +11,12 @@ import (
"time"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
coordinationv1 "k8s.io/api/coordination/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/tools/leaderelection"
- "k8s.io/client-go/tools/leaderelection/resourcelock"
"github.com/rlinf/rlark/apps/rlark/pkg/auth/cert"
)
@@ -67,34 +67,21 @@ func (s *Server) runBroadcaster(ctx context.Context) error {
id := ServerPeerPrefix + common.Hostname("node")
ip := common.PodIP("localhost") + "/" + s.dialerFactory.GetPeerID() + "/" + s.dialerFactory.GetPeerToken()
- rl, err := resourcelock.New(
- resourcelock.LeasesResourceLock,
- s.config.KubeClientConfig.DefaultNamespace(),
- id,
- s.kubeClient.CoreV1(),
- s.kubeClient.CoordinationV1(),
- resourcelock.ResourceLockConfig{
- Identity: ip,
+ leConfig := configs.DefaultLeaderElectionConfig()
+ leConfig.Key = id
+ leConfig.Identity = ip
+ electionConfig, err := leConfig.Build(s.kubeClient, s.config.KubeClientConfig.DefaultNamespace(), leaderelection.LeaderCallbacks{
+ OnStartedLeading: func(ctx context.Context) {
+ s.peerBroadcasted.Store(true)
+ <-ctx.Done()
},
- )
+ OnStoppedLeading: func() {},
+ OnNewLeader: func(identity string) {},
+ })
if err != nil {
- return fmt.Errorf("create resource lock: %w", err)
+ return fmt.Errorf("build leader election config: %w", err)
}
-
- le, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
- Lock: rl,
- LeaseDuration: time.Second * 30,
- RenewDeadline: time.Second * 10,
- RetryPeriod: time.Second * 5,
- Callbacks: leaderelection.LeaderCallbacks{
- OnStartedLeading: func(ctx context.Context) {
- s.peerBroadcasted = true
- <-ctx.Done()
- },
- OnStoppedLeading: func() {},
- OnNewLeader: func(identity string) {},
- },
- })
+ le, err := leaderelection.NewLeaderElector(electionConfig)
if err != nil {
return fmt.Errorf("create leader elector: %w", err)
}
diff --git a/apps/rlark/pkg/server/reverseproxy/dialer_factory.go b/apps/rlark/pkg/server/reverseproxy/dialer_factory.go
index a17e4ef..adc623d 100644
--- a/apps/rlark/pkg/server/reverseproxy/dialer_factory.go
+++ b/apps/rlark/pkg/server/reverseproxy/dialer_factory.go
@@ -7,9 +7,9 @@ import (
"sync"
"github.com/google/uuid"
- "github.com/rancher/remotedialer"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/remotedialer"
)
// Variables used by the package.
@@ -19,45 +19,29 @@ var (
PeerTokenHeader = remotedialer.Token
)
-type client struct {
- key string
- connCnt int
- errCnt int
-}
-
-func (c *client) onConnect() error {
- if c.connCnt > 0 {
- // 当有相同 clientKey 的连接存在时,暂时不接受新的连接,让代理的连接尽可能均衡地分布在不同的 server 实例上
- // 如果连续出现多个连接,说明可能连接已经较为均衡了,因此允许多个连接存在
- if c.errCnt < 5 {
- c.errCnt++
- return fmt.Errorf("client %s already connected", c.key)
- }
- }
- c.connCnt++
- c.errCnt = 0
- return nil
-}
+const maxRejections = 5
-func (c *client) onDisconnect() {
- if c.connCnt > 0 {
- c.connCnt--
- c.errCnt = 0
- }
+type clientState struct {
+ connections int
+ rejections int
}
-// DialerFactory creates instances.
+// DialerFactory wraps a remotedialer.Server and adds a soft load-balancing
+// heuristic: when a clientKey already has an active session the factory
+// rejects the first maxRejections reconnection attempts so that the agent's
+// retries may land on a different Server instance. After the threshold it
+// falls back to allowing multiple sessions for the same key.
type DialerFactory struct {
dialerServer *remotedialer.Server
- clients map[string]*client
- mutex sync.Mutex
+ clients map[string]*clientState
+ mu sync.Mutex
}
// NewDialerFactory creates a new DialerFactory.
func NewDialerFactory() *DialerFactory {
f := &DialerFactory{
- clients: make(map[string]*client),
+ clients: make(map[string]*clientState),
}
f.dialerServer = remotedialer.New(f.auth, remotedialer.DefaultErrorWriter)
f.dialerServer.PeerID = uuid.NewString()
@@ -73,34 +57,42 @@ func (f *DialerFactory) auth(req *http.Request) (string, bool, error) {
return clientKey, true, nil
}
-func (f *DialerFactory) addClient(clientKey string) error {
- f.mutex.Lock()
- defer f.mutex.Unlock()
-
- c, ok := f.clients[clientKey]
- if !ok {
- c = &client{
- key: clientKey,
- }
- f.clients[clientKey] = c
+// tryAccept implements a soft load-balancing heuristic: when a clientKey
+// already has an active session the factory rejects the first maxRejections
+// reconnection attempts, so that agent retries may land on a different
+// Server instance and connections are distributed across replicas.
+// After the threshold it falls back to allowing multiple sessions for the
+// same key.
+func (f *DialerFactory) tryAccept(clientKey string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ state := f.clients[clientKey]
+ if state == nil {
+ state = &clientState{}
+ f.clients[clientKey] = state
}
- err := c.onConnect()
- if c.connCnt == 0 {
- delete(f.clients, clientKey)
+ if state.connections > 0 && state.rejections < maxRejections {
+ state.rejections++
+ return fmt.Errorf("client %s already connected", clientKey)
}
- return err
+ state.connections++
+ state.rejections = 0
+ return nil
}
-func (f *DialerFactory) removeClient(clientKey string) {
- f.mutex.Lock()
- defer f.mutex.Unlock()
+func (f *DialerFactory) disconnect(clientKey string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
- c, ok := f.clients[clientKey]
- if ok {
- c.onDisconnect()
- if c.connCnt == 0 {
- delete(f.clients, clientKey)
- }
+ state := f.clients[clientKey]
+ if state == nil {
+ return
+ }
+ state.connections--
+ state.rejections = 0
+ if state.connections == 0 {
+ delete(f.clients, clientKey)
}
}
@@ -118,20 +110,21 @@ func (f *DialerFactory) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
- if err := f.addClient(clientKey); err != nil {
+ if err := f.tryAccept(clientKey); err != nil {
remotedialer.DefaultErrorWriter(rw, req, http.StatusInternalServerError, err)
return
}
- defer f.removeClient(clientKey)
+ defer f.disconnect(clientKey)
logger.Info("Client connected", "clientKey", clientKey)
f.dialerServer.ServeHTTP(rw, req)
}
-// GetDialer returns the dialer.
-func (f *DialerFactory) GetDialer(ctx context.Context, clientKey string) remotedialer.Dialer {
- return f.dialerServer.Dialer(clientKey)
+// GetDialer returns the dialer for the given clientKey, or an error if no
+// active session exists.
+func (f *DialerFactory) GetDialer(ctx context.Context, clientKey string) (remotedialer.Dialer, error) {
+ return f.dialerServer.GetDialer(clientKey)
}
// GetPeerID returns the peerID.
diff --git a/apps/rlark/pkg/server/reverseproxy/dialer_factory_test.go b/apps/rlark/pkg/server/reverseproxy/dialer_factory_test.go
new file mode 100644
index 0000000..56b9ba5
--- /dev/null
+++ b/apps/rlark/pkg/server/reverseproxy/dialer_factory_test.go
@@ -0,0 +1,61 @@
+package reverseproxy
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestDialerFactoryTryAcceptTracksPendingConnections(t *testing.T) {
+ t.Parallel()
+
+ f := NewDialerFactory()
+ const clientKey = "client"
+
+ if err := f.tryAccept(clientKey); err != nil {
+ t.Fatalf("first connection was rejected: %v", err)
+ }
+ for i := 0; i < maxRejections; i++ {
+ if err := f.tryAccept(clientKey); err == nil {
+ t.Fatalf("connection %d was accepted before rejection threshold", i+2)
+ }
+ }
+ if err := f.tryAccept(clientKey); err != nil {
+ t.Fatalf("fallback connection was rejected: %v", err)
+ }
+
+ f.disconnect(clientKey)
+ f.disconnect(clientKey)
+ if _, ok := f.clients[clientKey]; ok {
+ t.Fatal("client state remained after all connections disconnected")
+ }
+}
+
+func TestDialerFactoryTryAcceptConcurrent(t *testing.T) {
+ t.Parallel()
+
+ f := NewDialerFactory()
+ const attempts = 32
+
+ var wg sync.WaitGroup
+ results := make(chan bool, attempts)
+ for i := 0; i < attempts; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ results <- f.tryAccept("client") == nil
+ }()
+ }
+ wg.Wait()
+ close(results)
+
+ accepted := 0
+ for ok := range results {
+ if ok {
+ accepted++
+ }
+ }
+ want := 1 + (attempts-1)/(maxRejections+1)
+ if accepted != want {
+ t.Fatalf("accepted %d concurrent connections, want %d", accepted, want)
+ }
+}
diff --git a/apps/rlark/pkg/server/reverseproxy/pipe.go b/apps/rlark/pkg/server/reverseproxy/pipe.go
deleted file mode 100644
index a55e7a4..0000000
--- a/apps/rlark/pkg/server/reverseproxy/pipe.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package reverseproxy
-
-import (
- "io"
- "sync"
- "time"
-
- "github.com/rlinf/rlark/apps/rlark/pkg/log"
- "github.com/xjasonlyu/tun2socks/v2/buffer"
-)
-
-const (
- // tcpWaitTimeout implements a TCP half-close timeout.
- tcpWaitTimeout = 60 * time.Second
-)
-
-// PipeConnections pipes bidirectional data between origin and remote connections.
-func PipeConnections(origin, remote io.ReadWriteCloser) {
- wg := sync.WaitGroup{}
- wg.Add(2)
-
- go unidirectionalStream(remote, origin, "origin->remote", &wg)
- go unidirectionalStream(origin, remote, "remote->origin", &wg)
-
- wg.Wait()
-}
-
-func unidirectionalStream(dst, src io.ReadWriteCloser, dir string, wg *sync.WaitGroup) {
- logger := log.GetLogger()
- defer wg.Done()
- buf := buffer.Get(buffer.RelayBufferSize)
- if _, err := io.CopyBuffer(dst, src, buf); err != nil {
- logger.V(1).Info("[TCP] copy data", "dir", dir, "err", err)
- }
- if err := buffer.Put(buf); err != nil {
- logger.V(1).Info("[TCP] put buffer", "dir", dir, "err", err)
- }
- // Do the upload/download side TCP half-close.
- if cr, ok := src.(interface{ CloseRead() error }); ok {
- if err := cr.CloseRead(); err != nil {
- logger.V(1).Info("[TCP] close read", "dir", dir, "err", err)
- }
- }
- if cw, ok := dst.(interface{ CloseWrite() error }); ok {
- if err := cw.CloseWrite(); err != nil {
- logger.V(1).Info("[TCP] close write", "dir", dir, "err", err)
- }
- }
- // Set TCP half-close timeout.
- if srd, ok := dst.(interface{ SetReadDeadline(time.Time) error }); ok {
- if err := srd.SetReadDeadline(time.Now().Add(tcpWaitTimeout)); err != nil {
- logger.V(1).Info("[TCP] set read deadline", "dir", dir, "err", err)
- }
- }
-}
diff --git a/apps/rlark/pkg/server/server.go b/apps/rlark/pkg/server/server.go
index 8b4c1bb..ff619a5 100644
--- a/apps/rlark/pkg/server/server.go
+++ b/apps/rlark/pkg/server/server.go
@@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
+ "sync/atomic"
"time"
gocache "github.com/patrickmn/go-cache"
@@ -16,13 +17,13 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/leaderelection"
- "k8s.io/client-go/tools/leaderelection/resourcelock"
"github.com/rlinf/rlark/api/kubeclients/clientset/versioned"
"github.com/rlinf/rlark/api/kubeclients/informers/externalversions"
listerv1alpha1 "github.com/rlinf/rlark/api/kubeclients/listers/rlark.io/v1alpha1"
"github.com/rlinf/rlark/apps/rlark/pkg/auth/cert"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/configs"
"github.com/rlinf/rlark/apps/rlark/pkg/db"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
"github.com/rlinf/rlark/apps/rlark/pkg/server/caches"
@@ -60,7 +61,7 @@ type Server struct {
defaultPeerTransport http.RoundTripper
// health scope variables
- peerBroadcasted bool // 第一次广播完成后,才认为服务已经准备好
+ peerBroadcasted atomic.Bool // 第一次广播完成后,才认为服务已经准备好
}
// NewServer creates a new Server instance with the provided configuration.
@@ -130,45 +131,13 @@ func (s *Server) init(ctx context.Context) error {
// 3. 通过 Kubernetes Lease 获取操作权,进行数据初始化
id := fmt.Sprintf("%s-%d", common.Hostname("node"), os.Getpid())
- rl, err := resourcelock.New(
- resourcelock.LeasesResourceLock,
- s.config.KubeClientConfig.DefaultNamespace(),
- "rlark-server-init-lock",
- s.kubeClient.CoreV1(),
- s.kubeClient.CoordinationV1(),
- resourcelock.ResourceLockConfig{
- Identity: id,
- },
- )
- if err != nil {
- return fmt.Errorf("create resource lock: %w", err)
- }
- initServerDataErrorCh := make(chan error)
- defer close(initServerDataErrorCh)
- le, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
- Lock: rl,
- LeaseDuration: time.Second * 5,
- RenewDeadline: time.Second * 2,
- RetryPeriod: time.Second * 1,
- Callbacks: leaderelection.LeaderCallbacks{
- OnStartedLeading: func(ctx context.Context) {
- initServerDataErrorCh <- s.initServerData(ctx)
- <-ctx.Done()
- },
- OnStoppedLeading: func() {},
- OnNewLeader: func(identity string) {},
- },
- })
- if err != nil {
- return fmt.Errorf("create leader elector: %w", err)
- }
-
- leCtx, leCancel := context.WithCancel(ctx)
- go le.Run(leCtx)
-
- err = <-initServerDataErrorCh
- leCancel()
- if err != nil {
+ leConfig := configs.DefaultLeaderElectionConfig()
+ leConfig.Key = "rlark-server-init-lock"
+ leConfig.Identity = id
+ leConfig.LeaseDuration = 5 * time.Second
+ leConfig.RenewDeadline = 2 * time.Second
+ leConfig.RetryPeriod = time.Second
+ if err := runLeaderInitialization(ctx, s.kubeClient, s.config.KubeClientConfig.DefaultNamespace(), leConfig, s.initServerData); err != nil {
return err
}
@@ -180,6 +149,34 @@ func (s *Server) init(ctx context.Context) error {
return nil
}
+func runLeaderInitialization(ctx context.Context, client kubernetes.Interface, defaultNamespace string, config configs.LeaderElectionConfig, initialize func(context.Context) error) error {
+ result := make(chan error, 1)
+ electionConfig, err := config.Build(client, defaultNamespace, leaderelection.LeaderCallbacks{
+ OnStartedLeading: func(ctx context.Context) {
+ result <- initialize(ctx)
+ <-ctx.Done()
+ },
+ OnStoppedLeading: func() {},
+ OnNewLeader: func(identity string) {},
+ })
+ if err != nil {
+ return fmt.Errorf("build leader election config: %w", err)
+ }
+ elector, err := leaderelection.NewLeaderElector(electionConfig)
+ if err != nil {
+ return fmt.Errorf("create leader elector: %w", err)
+ }
+ electionCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ go elector.Run(electionCtx)
+ select {
+ case err := <-result:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
func (s *Server) initKubeClient(ctx context.Context) error {
var err error
s.restConfig, err = s.config.KubeClientConfig.BuildRestConfig()
diff --git a/apps/rlark/pkg/server/ssh_server.go b/apps/rlark/pkg/server/ssh_server.go
index d5c27c7..97db08e 100644
--- a/apps/rlark/pkg/server/ssh_server.go
+++ b/apps/rlark/pkg/server/ssh_server.go
@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/rlinf/rlark/apps/rlark/pkg/common"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
gossh "golang.org/x/crypto/ssh"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -18,7 +19,6 @@ import (
"github.com/rlinf/rlark/apps/rlark/pkg/apis"
"github.com/rlinf/rlark/apps/rlark/pkg/auth/cert"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
- "github.com/rlinf/rlark/apps/rlark/pkg/server/reverseproxy"
)
func (s *Server) runSSHServer(ctx context.Context) error {
@@ -230,7 +230,7 @@ func (s *Server) handleSSHChannel(srv *ssh.Server, conn *gossh.ServerConn, newCh
return
}
defer func() { _ = c.Close() }()
- reverseproxy.PipeConnections(ch, c)
+ utils.RelayStreams(ch, c)
}
func (s *Server) authenticateUserKey(username string, key gossh.PublicKey) (string, error) {
@@ -260,7 +260,7 @@ func (s *Server) authenticateUserKeyFromSecret(username string, key gossh.Public
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- secret, err := s.kubeClient.CoreV1().Secrets(common.SecretNamespace).Get(ctx, common.SSHUserKeySecretName, metav1.GetOptions{})
+ secret, err := s.kubeClient.CoreV1().Secrets(s.config.KubeClientConfig.DefaultNamespace()).Get(ctx, common.SSHUserKeySecretName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
return "", nil
diff --git a/apps/rlark/pkg/server/unsafe_http_server.go b/apps/rlark/pkg/server/unsafe_http_server.go
index e6b8e59..72d9d6d 100644
--- a/apps/rlark/pkg/server/unsafe_http_server.go
+++ b/apps/rlark/pkg/server/unsafe_http_server.go
@@ -50,7 +50,7 @@ func (s *Server) runUnsafeHTTPServer(ctx context.Context) error {
}
func (s *Server) handleHealthCheck(ctx *gin.Context) {
- if s.peerBroadcasted {
+ if s.peerBroadcasted.Load() {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
} else {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"status": "not ready"})
diff --git a/apps/rlark/pkg/server/unsafe_http_server_test.go b/apps/rlark/pkg/server/unsafe_http_server_test.go
new file mode 100644
index 0000000..1463267
--- /dev/null
+++ b/apps/rlark/pkg/server/unsafe_http_server_test.go
@@ -0,0 +1,29 @@
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestHandleHealthCheckUsesPeerBroadcastState(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ server := &Server{}
+
+ checkStatus := func(want int) {
+ t.Helper()
+ response := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(response)
+ ctx.Request = httptest.NewRequest(http.MethodGet, "/readyz", nil)
+ server.handleHealthCheck(ctx)
+ if response.Code != want {
+ t.Fatalf("status = %d, want %d", response.Code, want)
+ }
+ }
+
+ checkStatus(http.StatusServiceUnavailable)
+ server.peerBroadcasted.Store(true)
+ checkStatus(http.StatusOK)
+}
diff --git a/apps/rlark/pkg/sshd/server.go b/apps/rlark/pkg/sshd/server.go
index 6e8598d..e7db638 100644
--- a/apps/rlark/pkg/sshd/server.go
+++ b/apps/rlark/pkg/sshd/server.go
@@ -6,7 +6,7 @@ package sshd
import (
"crypto/ed25519"
- "crypto/rand"
+ "crypto/sha256"
"encoding/binary"
"errors"
"fmt"
@@ -22,6 +22,7 @@ import (
gossh "golang.org/x/crypto/ssh"
"github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/rlinf/rlark/apps/rlark/pkg/utils"
)
// ptyRequestMsg maps to the SSH "pty-req" channel request payload (RFC 4254 §6.2).
@@ -169,13 +170,7 @@ func (s *Server) serveDirectTCP(ch gossh.Channel, reqs <-chan *gossh.Request, ex
}
defer func() { _ = conn.Close() }()
- done := make(chan struct{})
- go func() {
- _, _ = io.Copy(conn, ch)
- close(done)
- }()
- _, _ = io.Copy(ch, conn)
- <-done
+ utils.RelayStreams(ch, conn)
}
// keepAlive sends periodic global keepalive requests to detect dead
@@ -355,13 +350,19 @@ func (s *Server) runSFTP(ch gossh.Channel) {
}
}
-// generateHostKey creates a fresh ed25519 key pair on each startup. The key is
-// ephemeral and not persisted — clients should not pin the host key.
+// generateHostKey creates a deterministic ed25519 host key derived from the
+// pod hostname plus a hardcoded salt. The same pod (same hostname) always gets
+// the same key across restarts, so users don't need to clear their known_hosts
+// after a pod restart.
func generateHostKey() (gossh.Signer, error) {
- _, priv, err := ed25519.GenerateKey(rand.Reader)
+ hostname, err := os.Hostname()
if err != nil {
- return nil, fmt.Errorf("generate ed25519 key: %w", err)
+ return nil, fmt.Errorf("get hostname: %w", err)
}
+
+ hostKeySalt := "rlark-sshd-hostkey-salt-v1-7a3f8b2e1c9d4e6f"
+ seed := sha256.Sum256([]byte(hostKeySalt + ":" + hostname))
+ priv := ed25519.NewKeyFromSeed(seed[:])
signer, err := gossh.NewSignerFromKey(priv)
if err != nil {
return nil, fmt.Errorf("create signer: %w", err)
diff --git a/apps/rlark/pkg/utils/children.go b/apps/rlark/pkg/utils/children.go
new file mode 100644
index 0000000..36f8277
--- /dev/null
+++ b/apps/rlark/pkg/utils/children.go
@@ -0,0 +1,87 @@
+package utils
+
+import (
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+)
+
+const (
+ ParentUIDAnnotation = "rlark.io/parent-uid"
+ ChildTemplateAnnotation = "rlark.io/child-template"
+)
+
+// ClassifyChild accepts explicit ownership and the label-only format emitted by
+// older controllers. Any contradictory metadata is a conflict.
+func ClassifyChild(obj metav1.Object, parentUID types.UID, parentAPIVersion, parentKind, parentLabel, parentName, templateName string) (owned, legacy bool, err error) {
+ annotations := obj.GetAnnotations()
+ uid, hasUID := annotations[ParentUIDAnnotation]
+ template, hasTemplate := annotations[ChildTemplateAnnotation]
+ owner := metav1.GetControllerOf(obj)
+
+ ownerMatches := owner != nil && owner.UID == parentUID && owner.APIVersion == parentAPIVersion &&
+ owner.Kind == parentKind && owner.Name == parentName
+ if ownerMatches && !hasUID && !hasTemplate {
+ return true, true, nil
+ }
+
+ if hasUID || hasTemplate || owner != nil {
+ if !hasUID || uid != string(parentUID) || !hasTemplate || template != templateName ||
+ !ownerMatches {
+ return false, false, fmt.Errorf("child %s has conflicting ownership metadata", obj.GetName())
+ }
+ return true, false, nil
+ }
+
+ if obj.GetLabels()[parentLabel] == parentName {
+ return true, true, nil
+ }
+ return false, false, fmt.Errorf("child %s already exists and is not owned by %s", obj.GetName(), parentName)
+}
+
+// ClassifyRemovedChild accepts authoritative ownership directly. Label-only
+// legacy children additionally require template identity from the old parent
+// status/spec snapshot; child names are not assumed to be reversible.
+func ClassifyRemovedChild(obj metav1.Object, parentUID types.UID, parentAPIVersion, parentKind, parentLabel, parentName string, oldTemplates []string) (owned, legacy bool) {
+ annotations := obj.GetAnnotations()
+ uid, hasUID := annotations[ParentUIDAnnotation]
+ _, hasTemplate := annotations[ChildTemplateAnnotation]
+ owner := metav1.GetControllerOf(obj)
+ ownerMatches := owner != nil && owner.UID == parentUID && owner.APIVersion == parentAPIVersion &&
+ owner.Kind == parentKind && owner.Name == parentName
+
+ if owner != nil && !ownerMatches || hasUID && uid != string(parentUID) {
+ return false, false
+ }
+ if ownerMatches || hasUID {
+ return true, !hasUID || !hasTemplate
+ }
+ if hasTemplate {
+ return false, false
+ }
+ if obj.GetLabels()[parentLabel] != parentName {
+ return false, false
+ }
+ for _, candidate := range oldTemplates {
+ if ChildName(parentName, candidate) == obj.GetName() {
+ return true, true
+ }
+ }
+ return false, false
+}
+
+func MergeAnnotations(existing, owned map[string]string) map[string]string {
+ merged := make(map[string]string, len(existing)+len(owned))
+ for key, value := range existing {
+ merged[key] = value
+ }
+ for key, value := range owned {
+ if value == "" {
+ delete(merged, key)
+ } else {
+ merged[key] = value
+ }
+ }
+ return merged
+}
diff --git a/apps/rlark/pkg/utils/children_test.go b/apps/rlark/pkg/utils/children_test.go
new file mode 100644
index 0000000..fc0feb6
--- /dev/null
+++ b/apps/rlark/pkg/utils/children_test.go
@@ -0,0 +1,33 @@
+package utils
+
+import (
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestClassifyRemovedChildRequiresAuthoritativeIdentity(t *testing.T) {
+ controller := true
+ tests := []struct {
+ name string
+ meta metav1.ObjectMeta
+ old []string
+ wantOwned bool
+ wantLegacy bool
+ }{
+ {name: "matching owner only", meta: metav1.ObjectMeta{Name: "arbitrary", OwnerReferences: []metav1.OwnerReference{{APIVersion: "rlinf.io/v1alpha1", Kind: "Workflow", Name: "wf", UID: "uid", Controller: &controller}}}, wantOwned: true, wantLegacy: true},
+ {name: "matching parent uid", meta: metav1.ObjectMeta{Name: "arbitrary", Annotations: map[string]string{ParentUIDAnnotation: "uid"}}, wantOwned: true, wantLegacy: true},
+ {name: "old status proves label only", meta: metav1.ObjectMeta{Name: ChildName("wf", "old"), Labels: map[string]string{"parent": "wf"}}, old: []string{"old"}, wantOwned: true, wantLegacy: true},
+ {name: "prefix does not prove label only", meta: metav1.ObjectMeta{Name: "wf-unrelated", Labels: map[string]string{"parent": "wf"}}},
+ {name: "conflicting owner", meta: metav1.ObjectMeta{Name: ChildName("wf", "old"), Labels: map[string]string{"parent": "wf"}, OwnerReferences: []metav1.OwnerReference{{APIVersion: "rlinf.io/v1alpha1", Kind: "Workflow", Name: "wf", UID: "other", Controller: &controller}}}, old: []string{"old"}},
+ {name: "conflicting uid", meta: metav1.ObjectMeta{Name: ChildName("wf", "old"), Labels: map[string]string{"parent": "wf"}, Annotations: map[string]string{ParentUIDAnnotation: "other"}}, old: []string{"old"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ owned, legacy := ClassifyRemovedChild(&tt.meta, "uid", "rlinf.io/v1alpha1", "Workflow", "parent", "wf", tt.old)
+ if owned != tt.wantOwned || legacy != tt.wantLegacy {
+ t.Fatalf("ClassifyRemovedChild() = %v, %v, want %v, %v", owned, legacy, tt.wantOwned, tt.wantLegacy)
+ }
+ })
+ }
+}
diff --git a/apps/rlark/pkg/utils/pipe.go b/apps/rlark/pkg/utils/pipe.go
new file mode 100644
index 0000000..871edc4
--- /dev/null
+++ b/apps/rlark/pkg/utils/pipe.go
@@ -0,0 +1,112 @@
+package utils
+
+import (
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/go-logr/logr"
+ "github.com/rlinf/rlark/apps/rlark/pkg/log"
+ "github.com/xjasonlyu/tun2socks/v2/buffer"
+)
+
+const (
+ // StreamDrainTimeout is the read deadline applied while waiting for the
+ // opposite copy direction to finish after one direction has completed.
+ StreamDrainTimeout = 60 * time.Second
+)
+
+// CopyStream copies data from src to dst using a pooled relay buffer.
+func CopyStream(dst, src io.ReadWriteCloser) error {
+ var ret error
+ buf := buffer.Get(buffer.RelayBufferSize)
+ if buf == nil {
+ _, ret = io.Copy(dst, src)
+ } else {
+ defer func() { _ = buffer.Put(buf) }()
+ _, ret = io.CopyBuffer(dst, src, buf)
+ }
+ return ret
+}
+
+func halfCloseStream(dst, src io.ReadWriteCloser, direction string, logger *logr.Logger) {
+ if cr, ok := src.(interface{ CloseRead() error }); ok {
+ if err := cr.CloseRead(); err != nil && logger != nil {
+ logger.V(1).Info("[PIPE] close read", "dir", direction, "err", err)
+ }
+ }
+ if cw, ok := dst.(interface{ CloseWrite() error }); ok {
+ if err := cw.CloseWrite(); err != nil && logger != nil {
+ logger.V(1).Info("[PIPE] close write", "dir", direction, "err", err)
+ }
+ }
+}
+
+func setStreamDrainDeadline(dst io.ReadWriteCloser, direction string, logger *logr.Logger) {
+ if srd, ok := dst.(interface{ SetReadDeadline(time.Time) error }); ok {
+ if err := srd.SetReadDeadline(time.Now().Add(StreamDrainTimeout)); err != nil && logger != nil {
+ logger.V(1).Info("[PIPE] set the other side read deadline", "dir", direction, "err", err)
+ }
+ }
+}
+
+// RelayStreams copies data bidirectionally between two streams until both copy
+// directions finish. When one direction finishes, it attempts to half-close
+// that direction and sets a drain deadline if the destination supports read
+// deadlines. Optional names identify the streams in diagnostic logs.
+func RelayStreams(stream1, stream2 io.ReadWriteCloser, names ...string) {
+ wg := sync.WaitGroup{}
+ wg.Add(2)
+
+ name1, name2 := "origin", "remote"
+ if len(names) >= 2 {
+ name1, name2 = names[0], names[1]
+ }
+
+ logger := log.GetLogger()
+ go relayStream(stream2, stream1, "from_"+name1+"_to_"+name2, &wg, &logger)
+ go relayStream(stream1, stream2, "from_"+name2+"_to_"+name1, &wg, &logger)
+ wg.Wait()
+}
+
+func relayStream(dst, src io.ReadWriteCloser, direction string, wg *sync.WaitGroup, logger *logr.Logger) {
+ defer wg.Done()
+ if err := CopyStream(dst, src); err != nil && logger != nil {
+ logger.V(1).Info("[PIPE] stream copy error", "dir", direction, "err", err)
+ }
+ halfCloseStream(dst, src, direction, logger)
+ setStreamDrainDeadline(dst, direction, logger)
+}
+
+// RelayConnections copies data bidirectionally between two network connections.
+// After either direction finishes, it sets read deadlines on both connections
+// and waits for the opposite direction without half-closing either connection.
+// Returned errors correspond to conn1 to conn2 and conn2 to conn1,
+// respectively. Optional names identify the connections in returned errors.
+func RelayConnections(conn1, conn2 net.Conn, names ...string) (error, error) {
+ name1, name2 := "origin", "remote"
+ if len(names) >= 2 {
+ name1, name2 = names[0], names[1]
+ }
+ errors := make([]error, 2)
+ sigch := make(chan struct{}, 2)
+ go func() {
+ if err := CopyStream(conn2, conn1); err != nil && err != io.EOF {
+ errors[0] = fmt.Errorf("copy from %s to %s: %w", name1, name2, err)
+ }
+ sigch <- struct{}{}
+ }()
+ go func() {
+ if err := CopyStream(conn1, conn2); err != nil && err != io.EOF {
+ errors[1] = fmt.Errorf("copy from %s to %s: %w", name2, name1, err)
+ }
+ sigch <- struct{}{}
+ }()
+ <-sigch
+ _ = conn1.SetReadDeadline(time.Now().Add(StreamDrainTimeout))
+ _ = conn2.SetReadDeadline(time.Now().Add(StreamDrainTimeout))
+ <-sigch
+ return errors[0], errors[1]
+}
diff --git a/go.work.sum b/go.work.sum
index 280c00d..fd96d63 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -247,8 +247,6 @@ github.com/Microsoft/hcsshim v0.9.12 h1:0Wgl1fRF4WmBuqP6EnHk2w3m7CCCumD/KUumZxp7
github.com/Microsoft/hcsshim v0.9.12/go.mod h1:qAiPvMgZoM0wpkVg6qMdSEu+1VtI6/qHOOPkTGt8ftQ=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
-github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d h1:wvStE9wLpws31NiWUx+38wny1msZ/tm+eL5xmm4Y7So=
-github.com/Netflix/go-env v0.0.0-20220526054621-78278af1949d/go.mod h1:9XMFaCeRyW7fC9XJOWQ+NdAv8VLG7ys7l3x4ozEGLUQ=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
@@ -268,28 +266,6 @@ github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HR
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
-github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 h1:iC9YFYKDGEy3n/FtqJnOkZsene9olVspKmkX5A2YBEo=
-github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
-github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.4 h1:7Q2FEyqxeZeIkwYMwRC3uphxV4i7O2eV4ETe21d6lS4=
-github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.4/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
-github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 h1:NqugFkGxx1TXSh/pBcU00Y6bljgDPaFdh5MUSeJ7e50=
-github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
-github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
-github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
-github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
-github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
-github.com/alibabacloud-go/sts-20150401/v2 v2.0.1 h1:CevZp0VdG7Q+1J3qwNj+JL7ztKxsL27+tknbdTK9Y6M=
-github.com/alibabacloud-go/sts-20150401/v2 v2.0.1/go.mod h1:8wJW1xC4mVcdRXzOvWJYfCCxmvFzZ0VB9iilVjBeWBc=
-github.com/alibabacloud-go/tea v1.1.19 h1:Xroq0M+pr0mC834Djj3Fl4ZA8+GGoA0i7aWse1vmgf4=
-github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
-github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
-github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
-github.com/alibabacloud-go/tea-utils/v2 v2.0.1 h1:K6kwgo+UiYx+/kr6CO0PN5ACZDzE3nnn9d77215AkTs=
-github.com/alibabacloud-go/tea-utils/v2 v2.0.1/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
-github.com/alibabacloud-go/tea-xml v1.1.2 h1:oLxa7JUXm2EDFzMg+7oRsYc+kutgCVwm+bZlhhmvW5M=
-github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
-github.com/aliyun/credentials-go v1.1.2 h1:qU1vwGIBb3UJ8BwunHDRFtAhS6jnQLnde/yk0+Ih2GY=
-github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/apache/thrift v0.13.0 h1:5hryIiq9gtn+MiLVn0wP37kb/uTeRZgN08WoCsAhIhI=
@@ -320,8 +296,6 @@ github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdm
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4=
github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM=
-github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
-github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec h1:EdRZT3IeKQmfCSrgo8SZ8V3MEnskuJP0wCYNpe+aiXo=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
@@ -435,7 +409,6 @@ github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQr
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
-github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -508,8 +481,6 @@ github.com/hudl/fargo v1.3.0 h1:0U6+BtN6LhaYuTnIJq4Wyq5cpn6O2kWrxAtcqBmYY6w=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d h1:/WZQPMZNsjZ7IlCpsLGdQBINg5bxKQ1K1sh6awxLtkA=
github.com/intel/goresctrl v0.3.0 h1:K2D3GOzihV7xSBedGxONSlaw/un1LZgWsc9IfqipN4c=
github.com/intel/goresctrl v0.3.0/go.mod h1:fdz3mD85cmP9sHD8JUlrNWAxvwM86CrbmVXltEKd7zk=
-github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
-github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
@@ -597,7 +568,6 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk=
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5 h1:58+kh9C6jJVXYjt8IE48G2eWl6BjwU5Gj0gqY84fy78=
-github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/open-policy-agent/opa v0.42.2 h1:qocVAKyjrqMjCqsU02S/gHyLr4AQQ9xMtuV1kKnnyhM=
github.com/open-policy-agent/opa v0.42.2/go.mod h1:MrmoTi/BsKWT58kXlVayBb+rYVeaMwuBm3nYAN3923s=
@@ -662,8 +632,6 @@ github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtse
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
-github.com/tjfoc/gmsm v1.3.2 h1:7JVkAn5bvUJ7HtU08iW6UiD+UTmJTIToHCfeFzkcCxM=
-github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8=
@@ -755,8 +723,6 @@ gopkg.in/cheggaaa/pb.v1 v1.0.25 h1:Ev7yu1/f6+d+b3pi5vPdRPc6nNtP1umSfcWiEfRqv6I=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs=
-gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI=
-gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
diff --git a/hack/coverage-report.mjs b/hack/coverage-report.mjs
new file mode 100644
index 0000000..dd52ca2
--- /dev/null
+++ b/hack/coverage-report.mjs
@@ -0,0 +1,73 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const coverage = new Map();
+
+function add(file, line, covered) {
+ const relative = path.relative(root, path.resolve(root, file)).replaceAll(path.sep, "/");
+ if (relative.startsWith("../")) return;
+ const lines = coverage.get(relative) ?? new Map();
+ lines.set(line, (lines.get(line) ?? 0) + (covered ? 1 : 0));
+ coverage.set(relative, lines);
+}
+
+function readV8(directory) {
+ if (!fs.existsSync(directory)) return;
+ for (const name of fs.readdirSync(directory).filter((file) => file.endsWith(".json"))) {
+ const report = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
+ for (const script of report.result ?? []) {
+ if (!script.url.startsWith("file:")) continue;
+ const file = fileURLToPath(script.url);
+ if (!file.includes(`${path.sep}apps${path.sep}rlark-ui${path.sep}dist${path.sep}test${path.sep}`)) continue;
+ const source = fs.readFileSync(file, "utf8");
+ const offsets = [0];
+ for (let index = 0; index < source.length; index++) if (source[index] === "\n") offsets.push(index + 1);
+ const lineAt = (offset) => {
+ let low = 0;
+ let high = offsets.length;
+ while (low + 1 < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (offsets[middle] <= offset) low = middle;
+ else high = middle;
+ }
+ return low + 1;
+ };
+ const output = path.relative(path.join(root, "apps/rlark-ui/dist/test"), file).replace(/\.js$/, ".ts");
+ const target = `apps/rlark-ui/src/utils/${output}`;
+ const lineHits = new Map();
+ for (const fn of script.functions ?? []) {
+ for (const range of fn.ranges ?? []) {
+ for (let line = lineAt(range.startOffset); line <= lineAt(Math.max(range.startOffset, range.endOffset - 1)); line++) {
+ const current = lineHits.get(line);
+ lineHits.set(line, current === undefined ? range.count : Math.min(current, range.count));
+ }
+ }
+ }
+ for (const [line, count] of lineHits) add(target, line, count > 0);
+ }
+ }
+}
+
+function xml(value) {
+ return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
+}
+
+readV8(path.join(root, "apps/rlark-ui/coverage/v8"));
+
+let total = 0;
+let hit = 0;
+const classes = [];
+for (const [file, lines] of [...coverage].sort()) {
+ for (const count of lines.values()) {
+ total++;
+ if (count > 0) hit++;
+ }
+ const entries = [...lines].sort((a, b) => a[0] - b[0]);
+ classes.push(` ${entries.map(([line, count]) => ` `).join("")} `);
+}
+const rate = total ? hit / total : 0;
+fs.mkdirSync(path.join(root, "coverage"), { recursive: true });
+fs.writeFileSync(path.join(root, "coverage/cobertura.xml"), `. \n${classes.join("\n")}\n \n`);
+console.log(`TOTAL COVERAGE: ${(rate * 100).toFixed(2)}% (${hit}/${total} lines)`);
diff --git a/hack/go-coverage-report.py b/hack/go-coverage-report.py
new file mode 100644
index 0000000..ad47176
--- /dev/null
+++ b/hack/go-coverage-report.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+
+import glob
+import os
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+
+POSITION = re.compile(r"^(.+):(\d+)\.\d+,(\d+)\.\d+ \d+ (\d+)$")
+
+
+def source_path(profile, filename):
+ module = os.path.basename(profile).removesuffix(".out").replace("apps-", "apps/", 1)
+ filename = filename.removeprefix("github.com/rlinf/rlark/")
+ if filename != module and not filename.startswith(f"{module}/"):
+ filename = f"{module}/{filename}"
+ return filename
+
+
+def main():
+ profiles = glob.glob("coverage/go/*.out")
+ if not profiles:
+ sys.exit("no Go coverage profiles found")
+
+ covered = {}
+ for profile in profiles:
+ with open(profile, encoding="utf-8") as source:
+ for row in source:
+ row = row.strip()
+ if not row or row.startswith("mode:"):
+ continue
+ match = POSITION.match(row)
+ if not match:
+ sys.exit(f"invalid coverage row in {profile}: {row}")
+ filename = source_path(profile, match.group(1))
+ lines = covered.setdefault(filename, {})
+ hit = int(match.group(4)) > 0
+ for number in range(int(match.group(2)), int(match.group(3)) + 1):
+ lines[number] = lines.get(number, False) or hit
+
+ total = sum(len(lines) for lines in covered.values())
+ hits = sum(sum(lines.values()) for lines in covered.values())
+ rate = hits / total if total else 0
+ report = ET.Element(
+ "coverage",
+ {
+ "line-rate": str(rate),
+ "lines-covered": str(hits),
+ "lines-valid": str(total),
+ "version": "rlark",
+ },
+ )
+ ET.SubElement(ET.SubElement(report, "sources"), "source").text = "."
+ package = ET.SubElement(
+ ET.SubElement(report, "packages"),
+ "package",
+ {"name": "rlark", "line-rate": str(rate)},
+ )
+ classes = ET.SubElement(package, "classes")
+ for filename, lines in sorted(covered.items()):
+ class_rate = sum(lines.values()) / len(lines) if lines else 0
+ class_element = ET.SubElement(
+ classes,
+ "class",
+ {"name": filename, "filename": filename, "line-rate": str(class_rate)},
+ )
+ ET.SubElement(class_element, "methods")
+ line_elements = ET.SubElement(class_element, "lines")
+ for number, hit in sorted(lines.items()):
+ ET.SubElement(line_elements, "line", {"number": str(number), "hits": str(int(hit))})
+
+ os.makedirs("coverage", exist_ok=True)
+ ET.ElementTree(report).write("coverage/cobertura.xml", encoding="utf-8", xml_declaration=True)
+ print(f"TOTAL COVERAGE: {rate * 100:.2f}% ({hits}/{total} lines)")
+
+
+if __name__ == "__main__":
+ main()