merge: integrate feat/auth into develop
This commit is contained in:
@@ -25,3 +25,86 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
.auth-loading,
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-loading {
|
||||
gap: 12px;
|
||||
color: #61758a;
|
||||
}
|
||||
|
||||
.auth-loading > span {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #bfd5e9;
|
||||
border-top-color: #1677ff;
|
||||
border-radius: 50%;
|
||||
animation: auth-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes auth-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.login-page {
|
||||
padding: 48px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 15%, rgba(22, 119, 255, 0.12), transparent 34%),
|
||||
linear-gradient(145deg, #eef5fb, #f8fafc 55%, #edf3f8);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
padding: 42px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 22px 60px rgba(37, 63, 88, 0.14);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
color: white;
|
||||
background: linear-gradient(145deg, #1177e8, #25a1f2);
|
||||
}
|
||||
|
||||
.login-kicker {
|
||||
margin: 26px 0 8px;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.login-card h1 { margin: 0; font-size: 27px; }
|
||||
.login-description { margin: 10px 0 28px; color: #728398; }
|
||||
.login-card form { display: grid; gap: 18px; }
|
||||
.login-card label { display: grid; gap: 8px; color: #465b70; font-size: 13px; }
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #cad8e5;
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
color: #18334f;
|
||||
background: white;
|
||||
}
|
||||
.login-card input:focus { border-color: #1677ff; box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); }
|
||||
.login-card button {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: white;
|
||||
background: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-card button:disabled { opacity: 0.65; cursor: wait; }
|
||||
.login-error { margin: -4px 0 0; color: #d4380d; font-size: 13px; }
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import * as rawApi from "../services/api";
|
||||
import type { WorkspaceBoundApi } from "../services/api";
|
||||
|
||||
export type AuthUser = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
status: string;
|
||||
role_code: string | null;
|
||||
};
|
||||
|
||||
export type AuthWorkspace = {
|
||||
workspace_id: string;
|
||||
workspace_code: string;
|
||||
workspace_name: string;
|
||||
role_code: string;
|
||||
role_name: string;
|
||||
};
|
||||
|
||||
type AuthSession = {
|
||||
user: AuthUser;
|
||||
workspaces: AuthWorkspace[];
|
||||
default_workspace_id: string | null;
|
||||
};
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
workspaces: AuthWorkspace[];
|
||||
currentWorkspace: AuthWorkspace | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
setCurrentWorkspace: (workspaceId: string) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
const workspaceStorageKey = "model-platform-current-workspace";
|
||||
|
||||
async function authRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({})) as
|
||||
| ApiEnvelope<T>
|
||||
| { detail?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"detail" in payload && typeof payload.detail === "string"
|
||||
? payload.detail
|
||||
: `请求失败(HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return (payload as ApiEnvelope<T>).data;
|
||||
}
|
||||
|
||||
function selectWorkspace(
|
||||
session: AuthSession,
|
||||
preferredId?: string | null,
|
||||
): AuthWorkspace | null {
|
||||
return session.workspaces.find((item) => item.workspace_id === preferredId)
|
||||
?? session.workspaces.find(
|
||||
(item) => item.workspace_id === session.default_workspace_id,
|
||||
)
|
||||
?? session.workspaces[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<AuthWorkspace[]>([]);
|
||||
const [currentWorkspace, setWorkspace] = useState<AuthWorkspace | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applySession = useCallback((session: AuthSession) => {
|
||||
const storedId = typeof window === "undefined"
|
||||
? null
|
||||
: window.localStorage.getItem(workspaceStorageKey);
|
||||
const workspace = selectWorkspace(session, storedId);
|
||||
setUser(session.user);
|
||||
setWorkspaces(session.workspaces);
|
||||
setWorkspace(workspace);
|
||||
if (workspace && typeof window !== "undefined") {
|
||||
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void authRequest<AuthSession>("/api/v1/auth/me")
|
||||
.then((session) => {
|
||||
if (!cancelled) applySession(session);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
setWorkspace(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user && location.pathname !== "/login") {
|
||||
navigate("/login", { replace: true });
|
||||
} else if (user && location.pathname === "/login") {
|
||||
navigate("/workbench", { replace: true });
|
||||
}
|
||||
}, [loading, location.pathname, navigate, user]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const session = await authRequest<AuthSession>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
applySession(session);
|
||||
navigate("/workbench", { replace: true });
|
||||
}, [applySession, navigate]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authRequest<{ logged_out: boolean }>("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
} finally {
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
setWorkspace(null);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const setCurrentWorkspace = useCallback((workspaceId: string) => {
|
||||
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
|
||||
if (!workspace) return;
|
||||
setWorkspace(workspace);
|
||||
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
|
||||
}, [workspaces]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => ({
|
||||
user,
|
||||
workspaces,
|
||||
currentWorkspace,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
setCurrentWorkspace,
|
||||
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="auth-loading" role="status">
|
||||
<span />
|
||||
正在验证登录状态…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useApi(): WorkspaceBoundApi {
|
||||
const { currentWorkspace } = useAuth();
|
||||
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||
|
||||
return useMemo<WorkspaceBoundApi>(() => ({
|
||||
listScripts: () => rawApi.listScripts(workspaceId),
|
||||
createScript: (input) => rawApi.createScript(workspaceId, input),
|
||||
uploadScript: (file, parentPath, visibility) =>
|
||||
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
|
||||
updateScript: (scriptId, input) =>
|
||||
rawApi.updateScript(workspaceId, scriptId, input),
|
||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId),
|
||||
createWorkspaceDirectory: (directoryName, parentPath) =>
|
||||
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
|
||||
deleteWorkspaceDirectory: (path) =>
|
||||
rawApi.deleteWorkspaceDirectory(workspaceId, path),
|
||||
acquireFileLock: (script) => rawApi.acquireFileLock(workspaceId, script),
|
||||
heartbeatFileLock: (session) => rawApi.heartbeatFileLock(workspaceId, session),
|
||||
releaseFileLock: (session) => rawApi.releaseFileLock(workspaceId, session),
|
||||
releaseFileLockOnUnload: (session) =>
|
||||
rawApi.releaseFileLockOnUnload(workspaceId, session),
|
||||
createJupyterAccessTicket: (session) =>
|
||||
rawApi.createJupyterAccessTicket(workspaceId, session),
|
||||
listScriptVersions: (scriptId) => rawApi.listScriptVersions(workspaceId, scriptId),
|
||||
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
|
||||
listSchedules: () => rawApi.listSchedules(workspaceId),
|
||||
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
|
||||
createSchedule: (input) => rawApi.createSchedule(workspaceId, input),
|
||||
updateSchedule: (scheduleId, input) =>
|
||||
rawApi.updateSchedule(workspaceId, scheduleId, input),
|
||||
deleteSchedule: (scheduleId, workflowVersion) =>
|
||||
rawApi.deleteSchedule(workspaceId, scheduleId, workflowVersion),
|
||||
listScheduleArtifacts: () => rawApi.listScheduleArtifacts(workspaceId),
|
||||
hideScheduleArtifact: (versionsId) =>
|
||||
rawApi.hideScheduleArtifact(workspaceId, versionsId),
|
||||
listEmployees: () => rawApi.listEmployees(workspaceId),
|
||||
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
|
||||
updateEmployee: (userId, input) =>
|
||||
rawApi.updateEmployee(workspaceId, userId, input),
|
||||
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
|
||||
createScheduleNode: (scheduleId, input) =>
|
||||
rawApi.createScheduleNode(workspaceId, scheduleId, input),
|
||||
updateScheduleNode: (scheduleId, nodeId, input) =>
|
||||
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
|
||||
deleteScheduleNode: (scheduleId, nodeId, workflowVersion) =>
|
||||
rawApi.deleteScheduleNode(workspaceId, scheduleId, nodeId, workflowVersion),
|
||||
createScheduleEdge: (scheduleId, input) =>
|
||||
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
|
||||
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
|
||||
rawApi.deleteScheduleEdge(workspaceId, scheduleId, edgeId, workflowVersion),
|
||||
validateSchedule: (scheduleId) => rawApi.validateSchedule(workspaceId, scheduleId),
|
||||
previewCron: (input) => rawApi.previewCron(workspaceId, input),
|
||||
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
|
||||
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
|
||||
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
|
||||
}), [workspaceId]);
|
||||
}
|
||||
@@ -2,13 +2,9 @@ import { type FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
ApiRequestError,
|
||||
createEmployee,
|
||||
deleteEmployee,
|
||||
demoContext,
|
||||
listEmployees,
|
||||
updateEmployee,
|
||||
type Employee,
|
||||
} from "../../services/api";
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import "../../styles/admin.css";
|
||||
import "../../styles/dashboard.css";
|
||||
@@ -28,13 +24,17 @@ export function DashboardPage({
|
||||
online: boolean;
|
||||
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
||||
}) {
|
||||
const { user, currentWorkspace } = useAuth();
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<div className="dashboard-hero">
|
||||
<div>
|
||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||
<h2>下午好,{demoContext.userName}</h2>
|
||||
<p>当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。</p>
|
||||
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
||||
<p>
|
||||
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
||||
,可以继续构建脚本或配置调度。
|
||||
</p>
|
||||
</div>
|
||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||
</div>
|
||||
@@ -112,18 +112,20 @@ export function SystemAdminPage({
|
||||
onNotify: (notice: Notice) => void;
|
||||
onConnectionChange: (online: boolean) => void;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { user, currentWorkspace } = useAuth();
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState<Employee | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const canManage = demoContext.roleCode === "admin";
|
||||
const canManage = user?.role_code === "admin";
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setEmployees(await listEmployees());
|
||||
setEmployees(await api.listEmployees());
|
||||
onConnectionChange(true);
|
||||
} catch (error) {
|
||||
onConnectionChange(false);
|
||||
@@ -164,7 +166,7 @@ export function SystemAdminPage({
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateEmployee(editing.user_id, {
|
||||
const updated = await api.updateEmployee(editing.user_id, {
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
role_code: form.role_code,
|
||||
@@ -174,7 +176,7 @@ export function SystemAdminPage({
|
||||
(item) => item.user_id === updated.user_id ? updated : item,
|
||||
));
|
||||
} else {
|
||||
const created = await createEmployee({
|
||||
const created = await api.createEmployee({
|
||||
username: form.username.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
@@ -197,7 +199,7 @@ export function SystemAdminPage({
|
||||
const remove = async (employee: Employee): Promise<void> => {
|
||||
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
|
||||
try {
|
||||
await deleteEmployee(employee.user_id);
|
||||
await api.deleteEmployee(employee.user_id);
|
||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
||||
onNotify({ tone: "success", message: "员工已删除" });
|
||||
} catch (error) {
|
||||
@@ -211,7 +213,11 @@ export function SystemAdminPage({
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<header className="admin-page__header">
|
||||
<div><span>系统管理</span><h2>员工管理</h2><p>{demoContext.workspaceName} · {employees.length} 名员工</p></div>
|
||||
<div>
|
||||
<span>系统管理</span>
|
||||
<h2>员工管理</h2>
|
||||
<p>{currentWorkspace?.workspace_name ?? "(未选择 Workspace)"} · {employees.length} 名员工</p>
|
||||
</div>
|
||||
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
||||
<Icon name="plus" size={15} />添加员工
|
||||
</button>
|
||||
|
||||
@@ -10,24 +10,6 @@ import {
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import {
|
||||
acquireFileLock,
|
||||
createWorkspaceDirectory,
|
||||
createScript,
|
||||
createJupyterAccessTicket,
|
||||
deleteScript,
|
||||
deleteWorkspaceDirectory,
|
||||
demoContext,
|
||||
demoUsers,
|
||||
demoWorkspaces,
|
||||
heartbeatFileLock,
|
||||
listScripts,
|
||||
listScriptVersions,
|
||||
listWorkspaceDirectories,
|
||||
publishScriptVersion,
|
||||
releaseFileLock,
|
||||
releaseFileLockOnUnload,
|
||||
setDemoContext,
|
||||
uploadScript,
|
||||
type ActiveEditSession,
|
||||
type ScriptItem,
|
||||
type ScriptType,
|
||||
@@ -35,6 +17,7 @@ import {
|
||||
type Visibility,
|
||||
type WorkspaceDirectory,
|
||||
} from "../../services/api";
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import SchedulePage from "../schedules/SchedulePage";
|
||||
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
|
||||
@@ -123,9 +106,27 @@ function mergeDirectories(
|
||||
).values()];
|
||||
}
|
||||
export default function ModelPlatformApp() {
|
||||
const { currentWorkspace } = useAuth();
|
||||
if (!currentWorkspace) {
|
||||
return (
|
||||
<div className="app-shell" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<span style={{ fontSize: 18 }}>加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <AuthenticatedModelPlatformApp />;
|
||||
}
|
||||
|
||||
function AuthenticatedModelPlatformApp() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activePage = pageFromPath(location.pathname);
|
||||
const auth = useAuth();
|
||||
const { user, workspaces, setCurrentWorkspace, logout } = auth;
|
||||
const currentWorkspace = auth.currentWorkspace!;
|
||||
const api = useApi();
|
||||
const [scripts, setScripts] = useState<ScriptItem[]>([]);
|
||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -144,7 +145,6 @@ export default function ModelPlatformApp() {
|
||||
}>({ open: false, parentPath: "", name: "", busy: false });
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [uploadParentPath, setUploadParentPath] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -176,8 +176,8 @@ export default function ModelPlatformApp() {
|
||||
setRefreshing(silent);
|
||||
try {
|
||||
const [items, folderItems] = await Promise.all([
|
||||
listScripts(),
|
||||
listWorkspaceDirectories(),
|
||||
api.listScripts(),
|
||||
api.listWorkspaceDirectories(),
|
||||
]);
|
||||
setScripts(items);
|
||||
setDirectories(folderItems);
|
||||
@@ -248,7 +248,7 @@ export default function ModelPlatformApp() {
|
||||
}
|
||||
let ignore = false;
|
||||
setVersionsLoading(true);
|
||||
void listScriptVersions(selectedId)
|
||||
void api.listScriptVersions(selectedId)
|
||||
.then((items) => {
|
||||
if (!ignore) setVersions(items);
|
||||
})
|
||||
@@ -282,7 +282,7 @@ export default function ModelPlatformApp() {
|
||||
return;
|
||||
}
|
||||
heartbeatRunning = true;
|
||||
void heartbeatFileLock(current)
|
||||
void api.heartbeatFileLock(current)
|
||||
.then((updated) => {
|
||||
setEditSession((active) => active
|
||||
&& active.edit_session_id === updated.edit_session_id
|
||||
@@ -319,7 +319,7 @@ export default function ModelPlatformApp() {
|
||||
if (!current || current.edit_session_id !== editSession.edit_session_id) {
|
||||
return;
|
||||
}
|
||||
void createJupyterAccessTicket(current)
|
||||
void api.createJupyterAccessTicket(current)
|
||||
.then((ticket) => {
|
||||
setEditSession((active) => active
|
||||
&& active.edit_session_id === ticket.edit_session_id
|
||||
@@ -342,7 +342,7 @@ export default function ModelPlatformApp() {
|
||||
if (!editSession) return;
|
||||
const handleUnload = () => {
|
||||
const current = editSessionRef.current;
|
||||
if (current) releaseFileLockOnUnload(current);
|
||||
if (current) api.releaseFileLockOnUnload(current);
|
||||
};
|
||||
window.addEventListener("beforeunload", handleUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||
@@ -356,24 +356,17 @@ export default function ModelPlatformApp() {
|
||||
);
|
||||
}, [keyword, scripts]);
|
||||
|
||||
const memberScriptGroups = [...demoUsers]
|
||||
.sort((left, right) => (
|
||||
Number(right.userId === demoContext.userId)
|
||||
- Number(left.userId === demoContext.userId)
|
||||
))
|
||||
.map((user) => {
|
||||
const memberScripts = filteredScripts.filter(
|
||||
(item) => item.owner_user_id === user.userId,
|
||||
const memberScriptGroups = (() => {
|
||||
const currentUserScripts = filteredScripts.filter(
|
||||
(item) => item.owner_user_id === user?.user_id,
|
||||
);
|
||||
const inferred = inferredDirectories(memberScripts);
|
||||
return {
|
||||
user,
|
||||
scripts: memberScripts,
|
||||
directories: user.userId === demoContext.userId
|
||||
? mergeDirectories(directories, inferred)
|
||||
: inferred,
|
||||
};
|
||||
});
|
||||
const inferred = inferredDirectories(currentUserScripts);
|
||||
return [{
|
||||
user: user,
|
||||
scripts: currentUserScripts,
|
||||
directories: mergeDirectories(directories, inferred),
|
||||
}];
|
||||
})();
|
||||
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
|
||||
|
||||
const selectScript = (scriptId: string | null) => {
|
||||
@@ -413,7 +406,7 @@ export default function ModelPlatformApp() {
|
||||
let newlyAcquired = false;
|
||||
try {
|
||||
if (active && active.script_id !== script.script_id) {
|
||||
await releaseFileLock(active);
|
||||
await api.releaseFileLock(active);
|
||||
setEmbeddedJupyterUrl(null);
|
||||
setEditSession(null);
|
||||
editSessionRef.current = null;
|
||||
@@ -422,20 +415,20 @@ export default function ModelPlatformApp() {
|
||||
if (!requestIsCurrent()) return;
|
||||
|
||||
if (!active) {
|
||||
active = await acquireFileLock(script);
|
||||
active = await api.acquireFileLock(script);
|
||||
newlyAcquired = true;
|
||||
}
|
||||
if (!requestIsCurrent()) {
|
||||
if (active) {
|
||||
await releaseFileLock(active);
|
||||
await api.releaseFileLock(active);
|
||||
clearSessionIfActive(active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await createJupyterAccessTicket(active);
|
||||
const ticket = await api.createJupyterAccessTicket(active);
|
||||
if (!requestIsCurrent()) {
|
||||
await releaseFileLock(active);
|
||||
await api.releaseFileLock(active);
|
||||
clearSessionIfActive(active);
|
||||
return;
|
||||
}
|
||||
@@ -456,7 +449,7 @@ export default function ModelPlatformApp() {
|
||||
} catch (error) {
|
||||
if (newlyAcquired && active) {
|
||||
try {
|
||||
await releaseFileLock(active);
|
||||
await api.releaseFileLock(active);
|
||||
} catch {
|
||||
// The database lease is the final safety net if compensation cannot reach Runtime.
|
||||
}
|
||||
@@ -513,7 +506,7 @@ export default function ModelPlatformApp() {
|
||||
}
|
||||
setEditBusy(true);
|
||||
try {
|
||||
await releaseFileLock(active);
|
||||
await api.releaseFileLock(active);
|
||||
setEmbeddedJupyterUrl(null);
|
||||
setEditSession(null);
|
||||
editSessionRef.current = null;
|
||||
@@ -572,7 +565,7 @@ export default function ModelPlatformApp() {
|
||||
if (!publishTarget) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const version = await publishScriptVersion({
|
||||
const version = await api.publishScriptVersion({
|
||||
script: publishTarget,
|
||||
releaseNote,
|
||||
visibility: publishVisibility,
|
||||
@@ -602,7 +595,7 @@ export default function ModelPlatformApp() {
|
||||
if (!form.name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await createScript(form);
|
||||
const created = await api.createScript(form);
|
||||
setScripts((items) => [created, ...items]);
|
||||
selectScript(created.script_id);
|
||||
setCreateOpen(false);
|
||||
@@ -658,7 +651,7 @@ export default function ModelPlatformApp() {
|
||||
let lastCreated: ScriptItem | null = null;
|
||||
try {
|
||||
for (const file of files) {
|
||||
lastCreated = await uploadScript(file, uploadParentPath);
|
||||
lastCreated = await api.uploadScript(file, uploadParentPath);
|
||||
}
|
||||
await load(true);
|
||||
if (lastCreated) selectScript(lastCreated.script_id);
|
||||
@@ -684,7 +677,7 @@ export default function ModelPlatformApp() {
|
||||
if (!folderDialog.name.trim()) return;
|
||||
setFolderDialog((current) => ({ ...current, busy: true }));
|
||||
try {
|
||||
await createWorkspaceDirectory(
|
||||
await api.createWorkspaceDirectory(
|
||||
folderDialog.name.trim(),
|
||||
folderDialog.parentPath,
|
||||
);
|
||||
@@ -718,7 +711,7 @@ export default function ModelPlatformApp() {
|
||||
if (editSessionRef.current?.script_id === script.script_id) return;
|
||||
}
|
||||
try {
|
||||
await deleteScript(script.script_id);
|
||||
await api.deleteScript(script.script_id);
|
||||
if (selectedIdRef.current === script.script_id) selectScript(null);
|
||||
await load(true);
|
||||
setToast({
|
||||
@@ -752,7 +745,7 @@ export default function ModelPlatformApp() {
|
||||
if (editSessionRef.current?.script_id === activeScript.script_id) return;
|
||||
}
|
||||
try {
|
||||
const result = await deleteWorkspaceDirectory(path);
|
||||
const result = await api.deleteWorkspaceDirectory(path);
|
||||
const selectedScript = scripts.find(
|
||||
(item) => item.script_id === selectedIdRef.current,
|
||||
);
|
||||
@@ -851,36 +844,29 @@ export default function ModelPlatformApp() {
|
||||
{apiOnline ? "服务已连接" : "服务未连接"}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); setUserMenuOpen(false); }}>
|
||||
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); }}>
|
||||
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
|
||||
<span><small>当前 Workspace</small><strong>{demoContext.workspaceName}</strong></span>
|
||||
<span><small>当前 Workspace</small><strong>{currentWorkspace.workspace_name}</strong></span>
|
||||
<Icon name="chevron" size={15} />
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="topbar-dropdown">
|
||||
{demoWorkspaces.map((workspace) => (
|
||||
<button className={workspace.workspaceId === demoContext.workspaceId ? "is-selected" : ""} type="button" key={workspace.workspaceId} onClick={() => { setDemoContext({ workspace }); window.location.reload(); }}>
|
||||
<Icon name="workspace" size={15} /><span><strong>{workspace.workspaceName}</strong><small>{workspace.workspaceId === demoContext.workspaceId ? "当前使用" : "点击切换"}</small></span>
|
||||
{workspaces.map((workspace) => (
|
||||
<button className={workspace.workspace_id === currentWorkspace.workspace_id ? "is-selected" : ""} type="button" key={workspace.workspace_id} onClick={() => { setCurrentWorkspace(workspace.workspace_id); setWorkspaceMenuOpen(false); }}>
|
||||
<Icon name="workspace" size={15} /><span><strong>{workspace.workspace_name}</strong><small>{workspace.workspace_id === currentWorkspace.workspace_id ? "当前使用" : "点击切换"}</small></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<button className="user-menu" type="button" onClick={() => { setUserMenuOpen((value) => !value); setWorkspaceMenuOpen(false); }}>
|
||||
<span className="avatar">{demoContext.userName.slice(0, 1)}</span>
|
||||
<span className="user-menu__copy"><strong>{demoContext.userName}</strong><small>{demoContext.roleName}</small></span>
|
||||
<Icon name="chevron" size={15} />
|
||||
<button className="user-menu" type="button">
|
||||
<span className="avatar">{user?.display_name?.slice(0, 1) ?? "?"}</span>
|
||||
<span className="user-menu__copy"><strong>{user?.display_name ?? "未知用户"}</strong><small>{user?.role_code === "admin" ? "管理员" : "开发人员"}</small></span>
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={() => { logout(); window.location.assign("/login"); }}>
|
||||
登出
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div className="topbar-dropdown topbar-dropdown--users">
|
||||
{demoUsers.map((user) => (
|
||||
<button className={user.userId === demoContext.userId ? "is-selected" : ""} type="button" key={user.userId} onClick={() => { setDemoContext({ user }); window.location.reload(); }}>
|
||||
<span className="avatar">{user.userName.slice(0, 1)}</span><span><strong>{user.userName}</strong><small>{user.roleName} · {user.username}</small></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -949,18 +935,18 @@ export default function ModelPlatformApp() {
|
||||
<>
|
||||
{memberScriptGroups.map((group) => (
|
||||
<WorkspaceTreeGroup
|
||||
key={group.user.userId}
|
||||
title={`${group.user.userName}的文件`}
|
||||
key={group.user?.user_id ?? "anon"}
|
||||
title={`${group.user?.display_name}的文件`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectScript}
|
||||
onContextMenu={
|
||||
group.user.userId === demoContext.userId
|
||||
group.user?.user_id === user?.user_id
|
||||
? showContextMenu
|
||||
: undefined
|
||||
}
|
||||
readOnly={group.user.userId !== demoContext.userId}
|
||||
readOnly={group.user?.user_id !== user?.user_id}
|
||||
/>
|
||||
))}
|
||||
{filteredScripts.length === 0 && (
|
||||
@@ -1045,13 +1031,13 @@ export default function ModelPlatformApp() {
|
||||
</section>
|
||||
) : activePage === "schedules" ? (
|
||||
<SchedulePage
|
||||
key={`${demoContext.userId}-${demoContext.workspaceId}`}
|
||||
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
||||
onNotify={setToast}
|
||||
onConnectionChange={setApiOnline}
|
||||
/>
|
||||
) : activePage === "system" ? (
|
||||
<SystemAdminPage
|
||||
key={`${demoContext.userId}-${demoContext.workspaceId}`}
|
||||
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
||||
onNotify={setToast}
|
||||
onConnectionChange={setApiOnline}
|
||||
/>
|
||||
@@ -1458,4 +1444,4 @@ export default function ModelPlatformApp() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import Icon from "../../components/Icon";
|
||||
import type {
|
||||
ActiveEditSession,
|
||||
ScriptItem,
|
||||
StableVersion,
|
||||
} from "../../services/api";
|
||||
import { scriptIcon } from "./WorkspaceTree";
|
||||
|
||||
type ToastState = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ScriptWorkspaceProps = {
|
||||
script: ScriptItem;
|
||||
editSession: ActiveEditSession | null;
|
||||
jupyterUrl: string | null;
|
||||
editBusy: boolean;
|
||||
openError: string | null;
|
||||
latestVersion: StableVersion | null;
|
||||
versionsLoading: boolean;
|
||||
onOpenEditor: () => void;
|
||||
onEndEditing: () => void;
|
||||
onClose: () => void;
|
||||
onPublish: () => void;
|
||||
onInfo: (toast: ToastState) => void;
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
return `${(value / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function shortHash(value: string) {
|
||||
return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—";
|
||||
}
|
||||
|
||||
function confineJupyterFrame(frame: HTMLIFrameElement): void {
|
||||
try {
|
||||
const document = frame.contentDocument;
|
||||
if (!document?.documentElement) return;
|
||||
const keepInside = (): void => {
|
||||
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
|
||||
(element) => {
|
||||
const label = `${element.getAttribute("aria-label") ?? ""} ${
|
||||
element.getAttribute("title") ?? ""
|
||||
} ${element.textContent ?? ""}`.trim();
|
||||
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
|
||||
element.style.setProperty("display", "none", "important");
|
||||
}
|
||||
},
|
||||
);
|
||||
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
|
||||
if (["_blank", "_top", "_parent"].includes(link.target)) {
|
||||
link.target = "_self";
|
||||
}
|
||||
});
|
||||
};
|
||||
keepInside();
|
||||
new MutationObserver(keepInside).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const link = target?.closest?.("a") as HTMLAnchorElement | null;
|
||||
if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
|
||||
link.target = "_self";
|
||||
}
|
||||
}, true);
|
||||
} catch {
|
||||
// The iframe remains sandboxed even if its document is not yet accessible.
|
||||
}
|
||||
}
|
||||
|
||||
export function ScriptWorkspace({
|
||||
script,
|
||||
editSession,
|
||||
jupyterUrl,
|
||||
editBusy,
|
||||
openError,
|
||||
latestVersion,
|
||||
versionsLoading,
|
||||
onOpenEditor,
|
||||
onEndEditing,
|
||||
onClose,
|
||||
onPublish,
|
||||
onInfo,
|
||||
}: ScriptWorkspaceProps) {
|
||||
const isNotebook = script.script_type === "notebook";
|
||||
const isEditing = editSession?.session_status === "active";
|
||||
return (
|
||||
<>
|
||||
<div className="tabbar">
|
||||
<div className="editor-tab editor-tab--active">
|
||||
<span className={`file-icon file-icon--${script.script_type}`}>
|
||||
<Icon name={scriptIcon(script)} size={16} />
|
||||
</span>
|
||||
<span>{script.script_name}</span>
|
||||
<button type="button" aria-label="关闭标签" onClick={onClose}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="new-tab"
|
||||
type="button"
|
||||
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
|
||||
>
|
||||
<Icon name="plus" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-toolbar">
|
||||
<div className="editor-toolbar__path">
|
||||
<span className={`file-icon file-icon--${script.script_type}`}>
|
||||
<Icon name={scriptIcon(script)} size={17} />
|
||||
</span>
|
||||
<span>工作副本</span>
|
||||
<Icon name="chevron" size={13} />
|
||||
<strong>{script.script_name}</strong>
|
||||
</div>
|
||||
<div className="editor-toolbar__actions">
|
||||
{isEditing ? (
|
||||
<button
|
||||
className="end-edit-button"
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onEndEditing}
|
||||
>
|
||||
{editBusy ? "正在释放…" : "结束编辑"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInfo({
|
||||
tone: "info",
|
||||
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
|
||||
})}
|
||||
>
|
||||
保存说明
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="release-button"
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
>
|
||||
发布稳定版
|
||||
</button>
|
||||
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
|
||||
<span />
|
||||
{isEditing
|
||||
? isNotebook
|
||||
? "Demo 无锁模式 · Kernel 已连接"
|
||||
: "Demo 无锁模式 · 编辑中"
|
||||
: latestVersion
|
||||
? `最新 ${latestVersion.version_label}`
|
||||
: "工作副本已就绪"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
|
||||
{isEditing && jupyterUrl ? (
|
||||
<section className="embedded-jupyter">
|
||||
<div className="embedded-jupyter__status">
|
||||
<span>
|
||||
<i />
|
||||
Workspace Jupyter Server
|
||||
</span>
|
||||
<span>
|
||||
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
|
||||
</span>
|
||||
<code title={editSession.runtime_id}>
|
||||
Runtime {editSession.runtime_id.slice(-8)}
|
||||
</code>
|
||||
</div>
|
||||
<iframe
|
||||
key={`${editSession.edit_session_id}:${jupyterUrl}`}
|
||||
src={jupyterUrl}
|
||||
title={`${script.script_name} Jupyter 编辑器`}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
|
||||
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
|
||||
/>
|
||||
</section>
|
||||
) : isNotebook ? (
|
||||
<section
|
||||
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="editor-opening-state__icon">
|
||||
{openError
|
||||
? <Icon name="info" size={28} />
|
||||
: <span className="button-spinner button-spinner--blue" />}
|
||||
</div>
|
||||
<strong>
|
||||
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
|
||||
</strong>
|
||||
<p>
|
||||
{openError
|
||||
? openError
|
||||
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
|
||||
</p>
|
||||
{openError && (
|
||||
<button
|
||||
className="open-editor-button"
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onOpenEditor}
|
||||
>
|
||||
{editBusy
|
||||
? <span className="button-spinner button-spinner--blue" />
|
||||
: <Icon name="refresh" size={16} />}
|
||||
{editBusy ? "正在重试…" : "重试打开"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="script-overview">
|
||||
<div className="script-overview__header">
|
||||
<div>
|
||||
<span className="section-kicker">PYTHON SCRIPT</span>
|
||||
<h2>{script.script_name}</h2>
|
||||
<p>{script.relative_path}</p>
|
||||
</div>
|
||||
<button
|
||||
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
|
||||
type="button"
|
||||
disabled={editBusy}
|
||||
onClick={onOpenEditor}
|
||||
>
|
||||
{editBusy
|
||||
? <span className="button-spinner button-spinner--blue" />
|
||||
: <Icon name="external" size={17} />}
|
||||
{editBusy
|
||||
? "正在准备编辑器…"
|
||||
: isEditing
|
||||
? "继续编辑"
|
||||
: "打开编辑器"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="metadata-grid">
|
||||
<div>
|
||||
<span>脚本类型</span>
|
||||
<strong>Python</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>可见范围</span>
|
||||
<strong>
|
||||
{script.visibility === "workspace"
|
||||
? "Workspace"
|
||||
: script.visibility === "public" ? "公开" : "私有"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>文件大小</span>
|
||||
<strong>{formatBytes(script.size_bytes)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近更新</span>
|
||||
<strong>{formatTime(script.updated_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-card">
|
||||
<div className="preview-card__bar">
|
||||
<div>
|
||||
<span className="window-dot window-dot--red" />
|
||||
<span className="window-dot window-dot--yellow" />
|
||||
<span className="window-dot window-dot--green" />
|
||||
</div>
|
||||
<span>Python 预览</span>
|
||||
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
|
||||
</div>
|
||||
<PythonPreview />
|
||||
</div>
|
||||
|
||||
<div className="integrity-row">
|
||||
<span>
|
||||
<Icon name="check" size={15} />
|
||||
{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
|
||||
</span>
|
||||
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||
<span>
|
||||
稳定版本
|
||||
{versionsLoading
|
||||
? "加载中"
|
||||
: latestVersion
|
||||
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
||||
: "尚未发布"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PythonPreview() {
|
||||
return (
|
||||
<div className="python-preview">
|
||||
<div className="line-numbers">
|
||||
1<br />2<br />3<br />4<br />5<br />6<br />7<br />8<br />9
|
||||
</div>
|
||||
<pre>
|
||||
<span className="code-comment">"""模型实验开发平台构建脚本。"""</span>
|
||||
{"\n\n"}<b>def</b> <span className="code-function">main</span>() -> <b>None</b>:
|
||||
{"\n"} print(<i>"Hello, Model Platform!"</i>)
|
||||
{"\n\n\n"}<b>if</b> __name__ == <i>"__main__"</i>:
|
||||
{"\n"} main()
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { type MouseEvent as ReactMouseEvent, useState } from "react";
|
||||
|
||||
import Icon from "../../components/Icon";
|
||||
import type {
|
||||
ScriptItem,
|
||||
WorkspaceDirectory,
|
||||
} from "../../services/api";
|
||||
|
||||
export type WorkspaceTreeTarget = {
|
||||
kind: "root" | "directory" | "file";
|
||||
path: string;
|
||||
script?: ScriptItem;
|
||||
};
|
||||
|
||||
type WorkspaceTreeProps = {
|
||||
title: string;
|
||||
scripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onContextMenu?: (
|
||||
event: ReactMouseEvent,
|
||||
target: WorkspaceTreeTarget,
|
||||
) => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
|
||||
path: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function scriptIcon(item: ScriptItem) {
|
||||
return item.script_type === "notebook" ? "notebook" : "python";
|
||||
}
|
||||
|
||||
function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||
}
|
||||
|
||||
function parentOf(path: string) {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
export function WorkspaceTreeGroup({
|
||||
title,
|
||||
scripts,
|
||||
directories,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
readOnly = false,
|
||||
}: WorkspaceTreeProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="tree-group">
|
||||
<button
|
||||
className={`tree-group__title${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, { kind: "root", path: "" })
|
||||
: undefined}
|
||||
>
|
||||
<Icon name="chevron" size={14} />
|
||||
<Icon name="folder" size={17} />
|
||||
<span>{title}</span>
|
||||
<em>{scripts.length}</em>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tree-group__items">
|
||||
<WorkspaceTreeItems
|
||||
path=""
|
||||
depth={0}
|
||||
scripts={scripts}
|
||||
directories={directories}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
{scripts.length === 0 && directories.length === 0 && (
|
||||
<p className="tree-group__empty">
|
||||
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceTreeItems({
|
||||
path,
|
||||
depth,
|
||||
scripts,
|
||||
directories,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: WorkspaceTreeItemsProps) {
|
||||
const childDirectories = directories.filter(
|
||||
(item) => item.parent_path === path,
|
||||
);
|
||||
const childScripts = scripts.filter(
|
||||
(item) => parentOf(ownedScriptPath(item)) === path,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{childDirectories.map((directory) => (
|
||||
<DirectoryBranch
|
||||
key={directory.path}
|
||||
directory={directory}
|
||||
depth={depth}
|
||||
scripts={scripts}
|
||||
directories={directories}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
{childScripts.map((item) => (
|
||||
<button
|
||||
className={`script-row${
|
||||
selectedId === item.script_id ? " script-row--active" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: 20 + depth * 16 }}
|
||||
key={item.script_id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.script_id)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, {
|
||||
kind: "file",
|
||||
path: ownedScriptPath(item),
|
||||
script: item,
|
||||
})
|
||||
: undefined}
|
||||
>
|
||||
<span className={`file-icon file-icon--${item.script_type}`}>
|
||||
<Icon name={scriptIcon(item)} size={17} />
|
||||
</span>
|
||||
<span className="script-row__copy">
|
||||
<strong title={item.script_name}>{item.script_name}</strong>
|
||||
<small>{formatTime(item.updated_at)}</small>
|
||||
</span>
|
||||
{item.visibility !== "private" && (
|
||||
<span className="visibility-dot" title="Workspace 可见" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryBranch({
|
||||
directory,
|
||||
depth,
|
||||
scripts,
|
||||
directories,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: Omit<WorkspaceTreeItemsProps, "path"> & {
|
||||
directory: WorkspaceDirectory;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="directory-branch">
|
||||
<button
|
||||
className="directory-row"
|
||||
style={{ paddingLeft: 10 + depth * 16 }}
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, {
|
||||
kind: "directory",
|
||||
path: directory.path,
|
||||
})
|
||||
: undefined}
|
||||
>
|
||||
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
|
||||
<Icon name="chevron" size={13} />
|
||||
</span>
|
||||
<Icon name="folder" size={17} />
|
||||
<strong title={directory.path}>{directory.name}</strong>
|
||||
</button>
|
||||
{open && (
|
||||
<WorkspaceTreeItems
|
||||
path={directory.path}
|
||||
depth={depth + 1}
|
||||
scripts={scripts}
|
||||
directories={directories}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,22 +11,6 @@ import {
|
||||
|
||||
import {
|
||||
ApiRequestError,
|
||||
createSchedule,
|
||||
createScheduleEdge,
|
||||
createScheduleNode,
|
||||
deleteSchedule,
|
||||
deleteScheduleEdge,
|
||||
deleteScheduleNode,
|
||||
hideScheduleArtifact,
|
||||
getSchedule,
|
||||
listScheduleArtifacts,
|
||||
listScheduleRuns,
|
||||
listSchedules,
|
||||
previewCron,
|
||||
runScheduleNow,
|
||||
updateSchedule,
|
||||
updateScheduleNode,
|
||||
validateSchedule,
|
||||
type CronPreview,
|
||||
type Schedule,
|
||||
type ScheduleArtifact,
|
||||
@@ -34,6 +18,8 @@ import {
|
||||
type ScheduleNode,
|
||||
type ScheduleRunSummary,
|
||||
} from "../../services/api";
|
||||
|
||||
import { useApi } from "~/context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import "../../styles/schedule.css";
|
||||
|
||||
@@ -250,6 +236,7 @@ export default function SchedulePage({
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
|
||||
const [positionDraftCount, setPositionDraftCount] = useState(0);
|
||||
const api = useApi();
|
||||
|
||||
const selectedNode = schedule?.nodes.find(
|
||||
(item) => item.node_id === selectedNodeId,
|
||||
@@ -328,7 +315,7 @@ export default function SchedulePage({
|
||||
): Promise<void> => {
|
||||
if (showLoading) setRunsLoading(true);
|
||||
try {
|
||||
const items = await listScheduleRuns({
|
||||
const items = await api.listScheduleRuns({
|
||||
scheduleId,
|
||||
limit: 20,
|
||||
});
|
||||
@@ -349,8 +336,8 @@ export default function SchedulePage({
|
||||
preferredScheduleId?: string | null,
|
||||
): Promise<void> => {
|
||||
const [scheduleItems, artifactItems] = await Promise.all([
|
||||
listSchedules(),
|
||||
listScheduleArtifacts(),
|
||||
api.listSchedules(),
|
||||
api.listScheduleArtifacts(),
|
||||
]);
|
||||
setSchedules(scheduleItems);
|
||||
setArtifacts(artifactItems);
|
||||
@@ -362,20 +349,20 @@ export default function SchedulePage({
|
||||
setSchedule(null);
|
||||
return;
|
||||
}
|
||||
const detail = await getSchedule(targetId);
|
||||
const detail = await api.getSchedule(targetId);
|
||||
setSchedule(applyPositionDrafts(detail));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([listSchedules(), listScheduleArtifacts()])
|
||||
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
|
||||
.then(async ([scheduleItems, artifactItems]) => {
|
||||
if (cancelled) return;
|
||||
setSchedules(scheduleItems);
|
||||
setArtifacts(artifactItems);
|
||||
if (scheduleItems[0]) {
|
||||
const detail = await getSchedule(scheduleItems[0].schedule_id);
|
||||
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
|
||||
if (!cancelled) setSchedule(applyPositionDrafts(detail));
|
||||
}
|
||||
onConnectionChange(true);
|
||||
@@ -404,7 +391,7 @@ export default function SchedulePage({
|
||||
}
|
||||
let cancelled = false;
|
||||
setRunsLoading(true);
|
||||
listScheduleRuns({ scheduleId, limit: 20 })
|
||||
api.listScheduleRuns({ scheduleId, limit: 20 })
|
||||
.then((items) => {
|
||||
if (!cancelled) setRuns(items);
|
||||
})
|
||||
@@ -507,7 +494,7 @@ export default function SchedulePage({
|
||||
setLinkSourceId(null);
|
||||
setCronResult(null);
|
||||
try {
|
||||
setSchedule(await getSchedule(scheduleId));
|
||||
setSchedule(await api.getSchedule(scheduleId));
|
||||
onConnectionChange(true);
|
||||
} catch (error) {
|
||||
await handleError(error, "调度详情加载失败");
|
||||
@@ -529,7 +516,7 @@ export default function SchedulePage({
|
||||
if (busy || !scheduleName) return;
|
||||
setBusy("create-schedule");
|
||||
try {
|
||||
const created = await createSchedule({
|
||||
const created = await api.createSchedule({
|
||||
schedule_name: scheduleName,
|
||||
description: "在画布中拖入稳定版本并配置执行顺序",
|
||||
trigger_type: "manual",
|
||||
@@ -557,7 +544,7 @@ export default function SchedulePage({
|
||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
||||
setBusy("delete-schedule");
|
||||
try {
|
||||
await deleteSchedule(
|
||||
await api.deleteSchedule(
|
||||
selectedSchedule.schedule_id,
|
||||
selectedSchedule.workflow_version,
|
||||
);
|
||||
@@ -571,7 +558,7 @@ export default function SchedulePage({
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
if (remaining[0]) {
|
||||
setSchedule(await getSchedule(remaining[0].schedule_id));
|
||||
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
||||
}
|
||||
}
|
||||
onNotify({ tone: "success", message: "调度方案已删除" });
|
||||
@@ -589,7 +576,7 @@ export default function SchedulePage({
|
||||
if (!scheduleName || scheduleName === target.schedule_name) return;
|
||||
setBusy("rename-schedule");
|
||||
try {
|
||||
const updated = await updateSchedule(target.schedule_id, {
|
||||
const updated = await api.updateSchedule(target.schedule_id, {
|
||||
workflow_version: target.workflow_version,
|
||||
schedule_name: scheduleName,
|
||||
});
|
||||
@@ -620,7 +607,7 @@ export default function SchedulePage({
|
||||
) return;
|
||||
setBusy("delete-artifact");
|
||||
try {
|
||||
await hideScheduleArtifact(artifact.versions_id);
|
||||
await api.hideScheduleArtifact(artifact.versions_id);
|
||||
setArtifacts((current) => current.filter(
|
||||
(item) => item.versions_id !== artifact.versions_id,
|
||||
));
|
||||
@@ -652,13 +639,13 @@ export default function SchedulePage({
|
||||
for (const [nodeId, position] of Object.entries(
|
||||
positionDraftsRef.current,
|
||||
)) {
|
||||
updated = await updateScheduleNode(updated.schedule_id, nodeId, {
|
||||
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
|
||||
workflow_version: updated.workflow_version,
|
||||
position_x: position.position_x,
|
||||
position_y: position.position_y,
|
||||
});
|
||||
}
|
||||
updated = await updateSchedule(updated.schedule_id, {
|
||||
updated = await api.updateSchedule(updated.schedule_id, {
|
||||
workflow_version: updated.workflow_version,
|
||||
schedule_name: scheduleForm.scheduleName.trim(),
|
||||
description: scheduleForm.description.trim() || null,
|
||||
@@ -694,7 +681,7 @@ export default function SchedulePage({
|
||||
if (busy) return;
|
||||
setBusy("cron-preview");
|
||||
try {
|
||||
const result = await previewCron({
|
||||
const result = await api.previewCron({
|
||||
cron_expression: scheduleForm.cronExpression.trim(),
|
||||
timezone: scheduleForm.timezone.trim(),
|
||||
count: 5,
|
||||
@@ -727,7 +714,7 @@ export default function SchedulePage({
|
||||
}
|
||||
setBusy("run-now");
|
||||
try {
|
||||
const created = await runScheduleNow(schedule.schedule_id);
|
||||
const created = await api.runScheduleNow(schedule.schedule_id);
|
||||
setRuns((current) => [
|
||||
created,
|
||||
...current.filter((item) => item.run_id !== created.run_id),
|
||||
@@ -763,7 +750,7 @@ export default function SchedulePage({
|
||||
const nodeKey = artifactNodeKey(artifact, schedule);
|
||||
const updated = await withMutation(
|
||||
"add-node",
|
||||
() => createScheduleNode(schedule.schedule_id, {
|
||||
() => api.createScheduleNode(schedule.schedule_id, {
|
||||
workflow_version: schedule.workflow_version,
|
||||
node_key: nodeKey,
|
||||
node_name: artifact.script_name,
|
||||
@@ -898,7 +885,7 @@ export default function SchedulePage({
|
||||
setLinkSourceId(null);
|
||||
await withMutation(
|
||||
"create-edge",
|
||||
() => createScheduleEdge(schedule.schedule_id, {
|
||||
() => api.createScheduleEdge(schedule.schedule_id, {
|
||||
workflow_version: schedule.workflow_version,
|
||||
source_node_id: sourceId,
|
||||
target_node_id: targetNodeId,
|
||||
@@ -935,7 +922,7 @@ export default function SchedulePage({
|
||||
);
|
||||
await withMutation(
|
||||
"save-node",
|
||||
() => updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
||||
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
||||
workflow_version: schedule.workflow_version,
|
||||
node_name: nodeForm.nodeName.trim(),
|
||||
timeout_seconds: timeoutSeconds,
|
||||
@@ -958,7 +945,7 @@ export default function SchedulePage({
|
||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
||||
const updated = await withMutation(
|
||||
"delete-node",
|
||||
() => deleteScheduleNode(
|
||||
() => api.deleteScheduleNode(
|
||||
schedule.schedule_id,
|
||||
node.node_id,
|
||||
schedule.workflow_version,
|
||||
@@ -974,7 +961,7 @@ export default function SchedulePage({
|
||||
setContextMenu(null);
|
||||
const updated = await withMutation(
|
||||
"delete-edge",
|
||||
() => deleteScheduleEdge(
|
||||
() => api.deleteScheduleEdge(
|
||||
schedule.schedule_id,
|
||||
edge.edge_id,
|
||||
schedule.workflow_version,
|
||||
@@ -988,7 +975,7 @@ export default function SchedulePage({
|
||||
if (!schedule || busy) return;
|
||||
setBusy("validate");
|
||||
try {
|
||||
const result = await validateSchedule(schedule.schedule_id);
|
||||
const result = await api.validateSchedule(schedule.schedule_id);
|
||||
setSchedule((current) => current
|
||||
? { ...current, dag_validation: result }
|
||||
: current);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { AuthProvider } from "~/context/AuthContext";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [];
|
||||
@@ -31,7 +32,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Outlet />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { index, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
route("login", "routes/login.tsx"),
|
||||
index("routes/home.tsx"),
|
||||
route("workbench", "routes/workbench.tsx"),
|
||||
route("scripts", "routes/scripts.tsx"),
|
||||
route("schedules", "routes/schedules.tsx"),
|
||||
route("system", "routes/system.tsx"),
|
||||
route("*", "routes/platform.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Route } from "./+types/home";
|
||||
import { Welcome } from "../welcome/welcome";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "New React Router App" },
|
||||
{ name: "description", content: "Welcome to React Router!" },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import type { Route } from "./+types/login";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "登录 · 模型实验开发平台" },
|
||||
{ name: "description", content: "登录模型实验开发平台" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function LoginRoute() {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
setError("请输入用户名和密码");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "登录失败,请稍后重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-brand" aria-hidden="true">◆</div>
|
||||
<p className="login-kicker">MODEL EXPERIMENT PLATFORM</p>
|
||||
<h1 id="login-title">模型实验开发平台</h1>
|
||||
<p className="login-description">使用平台账号登录并选择你的 Workspace。</p>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="login-error" role="alert">{error}</p>}
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "正在登录…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+443
-166
@@ -92,6 +92,19 @@ export function setDemoContext(input: {
|
||||
}
|
||||
}
|
||||
|
||||
// API client for the platform backend.
|
||||
//
|
||||
// All endpoints that take a workspace context require the caller to
|
||||
// pass `workspaceId` explicitly. Components read the active workspace
|
||||
// from `useAuth().currentWorkspace` and thread it through; the cookie
|
||||
// set by `/api/v1/auth/login` is sent automatically thanks to
|
||||
// `credentials: "same-origin"`, and the backend reads it via the
|
||||
// shared `request_context` dependency.
|
||||
//
|
||||
// 401 from any endpoint means the session has expired or was never
|
||||
// established; the global `apiRequest` helper bounces the user to
|
||||
// `/login` so the platform never tries to render with a stale identity.
|
||||
|
||||
export type ScriptType = "python" | "notebook";
|
||||
export type Visibility = "private" | "workspace" | "public";
|
||||
|
||||
@@ -162,22 +175,40 @@ export class ApiRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function appendWorkspaceId(path: string, workspaceId: string): string {
|
||||
// `path` may already contain a query string. Use URLSearchParams to
|
||||
// merge cleanly either way.
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}workspace_id=${encodeURIComponent(workspaceId)}`;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
workspaceId?: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
|
||||
const response = await fetch(finalPath, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-User-ID": demoContext.userId,
|
||||
"X-Workspace-ID": demoContext.workspaceId,
|
||||
"X-Request-ID": createUuid().replaceAll("-", ""),
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Session expired / never authenticated — bounce to login. The
|
||||
// /login route itself is the only path that must remain reachable
|
||||
// while anonymous, so the redirect there is safe.
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
const here = window.location.pathname;
|
||||
if (here !== "/login") {
|
||||
window.location.assign("/login");
|
||||
}
|
||||
throw new ApiRequestError("未登录或登录已过期", 401);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| ApiEnvelope<T>
|
||||
| ApiErrorEnvelope;
|
||||
@@ -201,8 +232,8 @@ async function apiRequest<T>(
|
||||
return (payload as ApiEnvelope<T>).data;
|
||||
}
|
||||
|
||||
export async function listScripts(): Promise<ScriptItem[]> {
|
||||
return apiRequest<ScriptItem[]>("/api/v1/scripts");
|
||||
export async function listScripts(workspaceId: string): Promise<ScriptItem[]> {
|
||||
return apiRequest<ScriptItem[]>("/api/v1/scripts", {}, workspaceId);
|
||||
}
|
||||
|
||||
function initialContent(scriptType: ScriptType): string {
|
||||
@@ -258,25 +289,33 @@ function initialContent(scriptType: ScriptType): string {
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScript(input: {
|
||||
name: string;
|
||||
scriptType: ScriptType;
|
||||
visibility: Visibility;
|
||||
parentPath?: string | null;
|
||||
}): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>("/api/v1/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
script_name: input.name.trim(),
|
||||
script_type: input.scriptType,
|
||||
visibility: input.visibility,
|
||||
content: initialContent(input.scriptType),
|
||||
parent_path: input.parentPath,
|
||||
}),
|
||||
});
|
||||
export async function createScript(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
name: string;
|
||||
scriptType: ScriptType;
|
||||
visibility: Visibility;
|
||||
parentPath?: string | null;
|
||||
},
|
||||
): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>(
|
||||
"/api/v1/scripts",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
script_name: input.name.trim(),
|
||||
script_type: input.scriptType,
|
||||
visibility: input.visibility,
|
||||
content: initialContent(input.scriptType),
|
||||
parent_path: input.parentPath,
|
||||
}),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadScript(
|
||||
workspaceId: string,
|
||||
file: File,
|
||||
parentPath = "",
|
||||
visibility: Visibility = "workspace",
|
||||
@@ -293,38 +332,64 @@ export async function uploadScript(
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScript(
|
||||
workspaceId: string,
|
||||
scriptId: string,
|
||||
input: { content: string },
|
||||
): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>(
|
||||
`/api/v1/scripts/${scriptId}`,
|
||||
{ method: "PUT", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScript(
|
||||
workspaceId: string,
|
||||
scriptId: string,
|
||||
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
|
||||
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
|
||||
return apiRequest(
|
||||
`/api/v1/scripts/${scriptId}`,
|
||||
{ method: "DELETE" },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWorkspaceDirectories(): Promise<
|
||||
WorkspaceDirectory[]
|
||||
> {
|
||||
export async function listWorkspaceDirectories(
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceDirectory[]> {
|
||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||
"/api/v1/workspace-tree",
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
return data.directories;
|
||||
}
|
||||
|
||||
export async function createWorkspaceDirectory(
|
||||
workspaceId: string,
|
||||
directoryName: string,
|
||||
parentPath = "",
|
||||
): Promise<WorkspaceDirectory> {
|
||||
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
directory_name: directoryName,
|
||||
parent_path: parentPath,
|
||||
}),
|
||||
});
|
||||
return apiRequest<WorkspaceDirectory>(
|
||||
"/api/v1/workspace-directories",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
directory_name: directoryName,
|
||||
parent_path: parentPath,
|
||||
}),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceDirectory(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
): Promise<{
|
||||
path: string;
|
||||
@@ -333,9 +398,11 @@ export async function deleteWorkspaceDirectory(
|
||||
versions_preserved: boolean;
|
||||
}> {
|
||||
const parameters = new URLSearchParams({ path });
|
||||
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return apiRequest(
|
||||
`/api/v1/workspace-directories?${parameters.toString()}`,
|
||||
{ method: "DELETE" },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export type FileLockSession = {
|
||||
@@ -386,83 +453,115 @@ export type StableVersion = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Note: the file-lock and jupyter-ticket endpoints are not yet
|
||||
// implemented in the backend (see the cookie+JWT auth refactor plan).
|
||||
// They are retained here so the editor UI keeps its existing call
|
||||
// sites, but they will return 404 until the backend ships the
|
||||
// corresponding routes.
|
||||
|
||||
export async function acquireFileLock(
|
||||
workspaceId: string,
|
||||
script: ScriptItem,
|
||||
): Promise<ActiveEditSession> {
|
||||
const now = Date.now();
|
||||
const session = await apiRequest<FileLockSession>(
|
||||
`/api/v1/files/${script.current_object_id}/lock`,
|
||||
{ method: "POST" },
|
||||
workspaceId,
|
||||
);
|
||||
if (!session.lock_token) {
|
||||
throw new Error("加锁成功响应缺少 lock_token");
|
||||
}
|
||||
return {
|
||||
edit_session_id: createUuid().replaceAll("-", ""),
|
||||
workspace_id: script.workspace_id,
|
||||
storage_object_id: script.current_object_id,
|
||||
user_id: demoContext.userId,
|
||||
session_status: "active",
|
||||
lease_seconds: 3600,
|
||||
heartbeat_interval_seconds: 300,
|
||||
expires_at: new Date(now + 3600_000).toISOString(),
|
||||
runtime_id: script.workspace_id,
|
||||
jupyter_session_id: "demo-session",
|
||||
relative_path: script.relative_path,
|
||||
lock_token: "demo-unlocked-session",
|
||||
...session,
|
||||
script_id: script.script_id,
|
||||
script_name: script.script_name,
|
||||
jupyter_path: script.jupyter_path,
|
||||
jupyter_path: session.relative_path ?? script.jupyter_path,
|
||||
lock_token: session.lock_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function heartbeatFileLock(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return {
|
||||
...session,
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function releaseFileLock(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return { ...session, session_status: "closed" };
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export function releaseFileLockOnUnload(_session: ActiveEditSession): void {
|
||||
// The current Backend deliberately has no persisted file-lock API.
|
||||
export function releaseFileLockOnUnload(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): void {
|
||||
void fetch(
|
||||
`/api/v1/file-locks/${session.edit_session_id}?workspace_id=${
|
||||
encodeURIComponent(workspaceId)
|
||||
}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createJupyterAccessTicket(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<JupyterAccessTicket> {
|
||||
const result = await apiRequest<{ expires_at: number }>(
|
||||
"/api/v1/auth/demo-session",
|
||||
return apiRequest<JupyterAccessTicket>(
|
||||
"/api/v1/jupyter/access-tickets",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
edit_session_id: session.edit_session_id,
|
||||
lock_token: session.lock_token,
|
||||
}),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb")
|
||||
? "notebooks"
|
||||
: "edit";
|
||||
const encodedPath = session.jupyter_path
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
return {
|
||||
edit_session_id: session.edit_session_id,
|
||||
jupyter_url: `/jupyter/${encodeURIComponent(session.workspace_id)}/${editorRoute}/${encodedPath}`,
|
||||
expires_at: new Date(result.expires_at * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listScriptVersions(
|
||||
workspaceId: string,
|
||||
scriptId: string,
|
||||
): Promise<StableVersion[]> {
|
||||
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
|
||||
return apiRequest<StableVersion[]>(
|
||||
`/api/v1/scripts/${scriptId}/versions`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishScriptVersion(input: {
|
||||
script: ScriptItem;
|
||||
releaseNote: string;
|
||||
visibility: Visibility;
|
||||
}): Promise<StableVersion> {
|
||||
export async function publishScriptVersion(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
script: ScriptItem;
|
||||
releaseNote: string;
|
||||
visibility: Visibility;
|
||||
},
|
||||
): Promise<StableVersion> {
|
||||
return apiRequest<StableVersion>(
|
||||
`/api/v1/scripts/${input.script.script_id}/versions`,
|
||||
{
|
||||
@@ -473,6 +572,7 @@ export async function publishScriptVersion(input: {
|
||||
visibility: input.visibility,
|
||||
}),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,31 +725,43 @@ export type ScheduleRunDetail = ScheduleRunSummary & {
|
||||
node_runs: ScheduleNodeRun[];
|
||||
};
|
||||
|
||||
export async function listSchedules(): Promise<Schedule[]> {
|
||||
return apiRequest<Schedule[]>("/api/v1/schedules");
|
||||
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
|
||||
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
|
||||
}
|
||||
|
||||
export async function getSchedule(scheduleId: string): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
|
||||
export async function getSchedule(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSchedule(input: {
|
||||
schedule_name: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
}): Promise<Schedule> {
|
||||
return apiRequest<Schedule>("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export async function createSchedule(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
schedule_name: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
"/api/v1/schedules",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
@@ -663,55 +775,72 @@ export async function updateSchedule(
|
||||
failure_policy?: "stop" | "continue";
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
});
|
||||
return apiRequest(
|
||||
`/api/v1/schedules/${scheduleId}`,
|
||||
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
|
||||
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
|
||||
export async function listScheduleArtifacts(
|
||||
workspaceId: string,
|
||||
): Promise<ScheduleArtifact[]> {
|
||||
return apiRequest<ScheduleArtifact[]>(
|
||||
"/api/v1/schedule-artifacts",
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function hideScheduleArtifact(
|
||||
workspaceId: string,
|
||||
versionsId: string,
|
||||
): Promise<{
|
||||
versions_id: string;
|
||||
deleted: boolean;
|
||||
artifact_preserved: boolean;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/versions/${versionsId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return apiRequest(
|
||||
`/api/v1/versions/${versionsId}`,
|
||||
{ method: "DELETE" },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listEmployees(): Promise<Employee[]> {
|
||||
return apiRequest<Employee[]>("/api/v1/admin/employees");
|
||||
export async function listEmployees(workspaceId: string): Promise<Employee[]> {
|
||||
return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId);
|
||||
}
|
||||
|
||||
export async function createEmployee(input: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
}): Promise<Employee> {
|
||||
return apiRequest<Employee>("/api/v1/admin/employees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export async function createEmployee(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
},
|
||||
): Promise<Employee> {
|
||||
return apiRequest<Employee>(
|
||||
"/api/v1/admin/employees",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateEmployee(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
input: {
|
||||
display_name?: string;
|
||||
@@ -720,21 +849,26 @@ export async function updateEmployee(
|
||||
status?: "active" | "disabled" | "locked";
|
||||
},
|
||||
): Promise<Employee> {
|
||||
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return apiRequest<Employee>(
|
||||
`/api/v1/admin/employees/${userId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEmployee(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<{ user_id: string; deleted: boolean }> {
|
||||
return apiRequest(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return apiRequest(
|
||||
`/api/v1/admin/employees/${userId}`,
|
||||
{ method: "DELETE" },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduleNode(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
@@ -750,13 +884,15 @@ export async function createScheduleNode(
|
||||
env_refs_json?: Record<string, string>;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes`,
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduleNode(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
input: {
|
||||
@@ -774,28 +910,26 @@ export async function updateScheduleNode(
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
{ method: "PUT", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduleNode(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduleEdge(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
@@ -804,50 +938,58 @@ export async function createScheduleEdge(
|
||||
condition_expr?: string | null;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/edges`,
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduleEdge(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
edgeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateSchedule(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
): Promise<DagValidation & {
|
||||
schedule_id: string;
|
||||
workflow_version: number;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
|
||||
method: "POST",
|
||||
});
|
||||
return apiRequest(
|
||||
`/api/v1/schedules/${scheduleId}/validate`,
|
||||
{ method: "POST" },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewCron(input: {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
count?: number;
|
||||
base_time?: string;
|
||||
}): Promise<CronPreview> {
|
||||
return apiRequest<CronPreview>("/api/v1/cron/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export async function previewCron(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
count?: number;
|
||||
base_time?: string;
|
||||
},
|
||||
): Promise<CronPreview> {
|
||||
return apiRequest<CronPreview>(
|
||||
"/api/v1/cron/preview",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runScheduleNow(
|
||||
workspaceId: string,
|
||||
scheduleId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(
|
||||
@@ -859,25 +1001,160 @@ export async function runScheduleNow(
|
||||
},
|
||||
body: JSON.stringify({ reason: "manual_run" }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduleRuns(input: {
|
||||
scheduleId?: string;
|
||||
status?: ScheduleRunStatus;
|
||||
limit?: number;
|
||||
} = {}): Promise<ScheduleRunSummary[]> {
|
||||
export async function listScheduleRuns(
|
||||
workspaceId: string,
|
||||
input: {
|
||||
scheduleId?: string;
|
||||
status?: ScheduleRunStatus;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<ScheduleRunSummary[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
|
||||
if (input.status) query.set("status", input.status);
|
||||
query.set("limit", String(input.limit ?? 20));
|
||||
return apiRequest<ScheduleRunSummary[]>(
|
||||
`/api/v1/schedule-runs?${query.toString()}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduleRun(
|
||||
workspaceId: string,
|
||||
runId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
|
||||
return apiRequest<ScheduleRunDetail>(
|
||||
`/api/v1/schedule-runs/${runId}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Workspace-bound API surface.
|
||||
//
|
||||
// `useApi()` in ~/context/AuthContext returns an object where every
|
||||
// function has had its first `workspaceId` argument pre-filled. The
|
||||
// type below lets consumers import the bound type without depending
|
||||
// on the raw functions. Keep this last in the file so the type
|
||||
// references all the exports above.
|
||||
// ----------------------------------------------------------------------------
|
||||
export type WorkspaceBoundApi = {
|
||||
listScripts: () => Promise<ScriptItem[]>;
|
||||
createScript: (
|
||||
input: Parameters<typeof createScript>[1],
|
||||
) => Promise<ScriptItem>;
|
||||
uploadScript: (
|
||||
file: File,
|
||||
parentPath?: string,
|
||||
visibility?: Visibility,
|
||||
) => Promise<ScriptItem>;
|
||||
updateScript: (
|
||||
scriptId: string,
|
||||
input: Parameters<typeof updateScript>[2],
|
||||
) => Promise<ScriptItem>;
|
||||
deleteScript: (
|
||||
scriptId: string,
|
||||
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
|
||||
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>;
|
||||
createWorkspaceDirectory: (
|
||||
directoryName: string,
|
||||
parentPath?: string,
|
||||
) => Promise<WorkspaceDirectory>;
|
||||
deleteWorkspaceDirectory: (
|
||||
path: string,
|
||||
) => Promise<{
|
||||
path: string;
|
||||
status: string;
|
||||
deleted_scripts: number;
|
||||
versions_preserved: boolean;
|
||||
}>;
|
||||
acquireFileLock: (
|
||||
script: ScriptItem,
|
||||
) => Promise<ActiveEditSession>;
|
||||
heartbeatFileLock: (
|
||||
session: ActiveEditSession,
|
||||
) => Promise<FileLockSession>;
|
||||
releaseFileLock: (
|
||||
session: ActiveEditSession,
|
||||
) => Promise<FileLockSession>;
|
||||
releaseFileLockOnUnload: (session: ActiveEditSession) => void;
|
||||
createJupyterAccessTicket: (
|
||||
session: ActiveEditSession,
|
||||
) => Promise<JupyterAccessTicket>;
|
||||
listScriptVersions: (scriptId: string) => Promise<StableVersion[]>;
|
||||
publishScriptVersion: (
|
||||
input: Parameters<typeof publishScriptVersion>[1],
|
||||
) => Promise<StableVersion>;
|
||||
listSchedules: () => Promise<Schedule[]>;
|
||||
getSchedule: (scheduleId: string) => Promise<Schedule>;
|
||||
createSchedule: (
|
||||
input: Parameters<typeof createSchedule>[1],
|
||||
) => Promise<Schedule>;
|
||||
updateSchedule: (
|
||||
scheduleId: string,
|
||||
input: Parameters<typeof updateSchedule>[2],
|
||||
) => Promise<Schedule>;
|
||||
deleteSchedule: (
|
||||
scheduleId: string,
|
||||
workflowVersion: number,
|
||||
) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>;
|
||||
listScheduleArtifacts: () => Promise<ScheduleArtifact[]>;
|
||||
hideScheduleArtifact: (
|
||||
versionsId: string,
|
||||
) => Promise<{
|
||||
versions_id: string;
|
||||
deleted: boolean;
|
||||
artifact_preserved: boolean;
|
||||
}>;
|
||||
listEmployees: () => Promise<Employee[]>;
|
||||
createEmployee: (
|
||||
input: Parameters<typeof createEmployee>[1],
|
||||
) => Promise<Employee>;
|
||||
updateEmployee: (
|
||||
userId: string,
|
||||
input: Parameters<typeof updateEmployee>[2],
|
||||
) => Promise<Employee>;
|
||||
deleteEmployee: (
|
||||
userId: string,
|
||||
) => Promise<{ user_id: string; deleted: boolean }>;
|
||||
createScheduleNode: (
|
||||
scheduleId: string,
|
||||
input: Parameters<typeof createScheduleNode>[2],
|
||||
) => Promise<Schedule>;
|
||||
updateScheduleNode: (
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
input: Parameters<typeof updateScheduleNode>[3],
|
||||
) => Promise<Schedule>;
|
||||
deleteScheduleNode: (
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
workflowVersion: number,
|
||||
) => Promise<Schedule>;
|
||||
createScheduleEdge: (
|
||||
scheduleId: string,
|
||||
input: Parameters<typeof createScheduleEdge>[2],
|
||||
) => Promise<Schedule>;
|
||||
deleteScheduleEdge: (
|
||||
scheduleId: string,
|
||||
edgeId: string,
|
||||
workflowVersion: number,
|
||||
) => Promise<Schedule>;
|
||||
validateSchedule: (
|
||||
scheduleId: string,
|
||||
) => Promise<DagValidation & { schedule_id: string; workflow_version: number }>;
|
||||
previewCron: (
|
||||
input: Parameters<typeof previewCron>[1],
|
||||
) => Promise<CronPreview>;
|
||||
runScheduleNow: (scheduleId: string) => Promise<ScheduleRunDetail>;
|
||||
listScheduleRuns: (
|
||||
input?: Parameters<typeof listScheduleRuns>[1],
|
||||
) => Promise<ScheduleRunSummary[]>;
|
||||
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user