第一次合并develop-fech
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
type Visibility,
|
||||
type WorkspaceDirectory,
|
||||
} from "../../services/api";
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import SchedulePage from "../schedules/SchedulePage";
|
||||
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
|
||||
@@ -593,6 +593,19 @@ function AuthenticatedModelPlatformApp() {
|
||||
const submitCreate = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
|
||||
const requestedName = form.name.trim();
|
||||
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
|
||||
? requestedName
|
||||
: `${requestedName}${suffix}`;
|
||||
const duplicate = scripts.some((script) =>
|
||||
script.script_type === form.scriptType
|
||||
&& script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
|
||||
);
|
||||
if (duplicate) {
|
||||
setToast({ tone: "error", message: `${normalizedName} 已存在,请更换名称` });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.createScript(form);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ScheduleRunSummary,
|
||||
} from "../../services/api";
|
||||
|
||||
import { useApi } from "~/context/AuthContext";
|
||||
import { useApi } from "../../context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import "../../styles/schedule.css";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { AuthProvider } from "~/context/AuthContext";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [];
|
||||
|
||||
@@ -453,76 +453,83 @@ 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.
|
||||
// The current backend authorizes Jupyter through the session cookie and
|
||||
// deliberately has no persisted file-lock or access-ticket endpoints.
|
||||
// Keep the editor's session-shaped UI contract locally while opening the
|
||||
// existing authenticated Jupyter proxy directly.
|
||||
|
||||
export async function acquireFileLock(
|
||||
workspaceId: string,
|
||||
script: ScriptItem,
|
||||
): Promise<ActiveEditSession> {
|
||||
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");
|
||||
}
|
||||
const now = Date.now();
|
||||
return {
|
||||
...session,
|
||||
edit_session_id: createUuid().replaceAll("-", ""),
|
||||
workspace_id: workspaceId,
|
||||
storage_object_id: script.current_object_id,
|
||||
user_id: script.owner_user_id,
|
||||
session_status: "active",
|
||||
lease_seconds: 3600,
|
||||
heartbeat_interval_seconds: 300,
|
||||
expires_at: new Date(now + 3600_000).toISOString(),
|
||||
runtime_id: workspaceId,
|
||||
jupyter_session_id: "unlocked-session",
|
||||
relative_path: script.relative_path,
|
||||
lock_token: "unlocked-session",
|
||||
script_id: script.script_id,
|
||||
script_name: script.script_name,
|
||||
jupyter_path: session.relative_path ?? script.jupyter_path,
|
||||
lock_token: session.lock_token,
|
||||
jupyter_path: script.jupyter_path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function heartbeatFileLock(
|
||||
workspaceId: string,
|
||||
_workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
return {
|
||||
...session,
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function releaseFileLock(
|
||||
workspaceId: string,
|
||||
_workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
return { ...session, session_status: "closed" };
|
||||
}
|
||||
|
||||
export function releaseFileLockOnUnload(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
_workspaceId: string,
|
||||
_session: ActiveEditSession,
|
||||
): void {
|
||||
void fetch(
|
||||
`/api/v1/file-locks/${session.edit_session_id}?workspace_id=${
|
||||
encodeURIComponent(workspaceId)
|
||||
}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
// No backend lock is created in compatibility mode.
|
||||
}
|
||||
|
||||
async function waitForJupyterReady(jupyterUrl: string): Promise<void> {
|
||||
const retryableStatuses = new Set([502, 503, 504]);
|
||||
let lastStatus = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const response = await fetch(jupyterUrl, {
|
||||
credentials: "same-origin",
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok) return;
|
||||
|
||||
lastStatus = response.status;
|
||||
if (!retryableStatuses.has(response.status)) {
|
||||
throw new ApiRequestError(
|
||||
`Jupyter 打开失败(HTTP ${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 400));
|
||||
}
|
||||
|
||||
throw new ApiRequestError(
|
||||
`Jupyter 服务启动超时${lastStatus ? `(HTTP ${lastStatus})` : ""}`,
|
||||
lastStatus || 504,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,17 +537,25 @@ export async function createJupyterAccessTicket(
|
||||
workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<JupyterAccessTicket> {
|
||||
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("/");
|
||||
if (!encodedPath) {
|
||||
throw new Error("脚本缺少 Jupyter 存储路径");
|
||||
}
|
||||
|
||||
const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`;
|
||||
await waitForJupyterReady(jupyterUrl);
|
||||
return {
|
||||
edit_session_id: session.edit_session_id,
|
||||
jupyter_url: jupyterUrl,
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listScriptVersions(
|
||||
|
||||
Reference in New Issue
Block a user