chore:调度文件拆分

This commit is contained in:
xiaozhu
2026-08-05 15:58:18 +08:00
parent 8c4d2c0517
commit 9b5de51a68
9 changed files with 771 additions and 644 deletions
@@ -0,0 +1,79 @@
import { type ScheduleArtifact } from "../../services/api";
import Icon from "../../components/common/Icon";
import { shortHash } from "./utils";
export function ArtifactList({
artifacts,
keyword,
onDrop,
onDoubleClick,
onContextMenu,
onKeywordChange,
}: {
artifacts: ScheduleArtifact[];
keyword: string;
onDrop: (artifact: ScheduleArtifact, x: number, y: number) => void;
onDoubleClick: (artifact: ScheduleArtifact) => void;
onContextMenu: (event: React.MouseEvent, artifact: ScheduleArtifact) => void;
onKeywordChange: (keyword: string) => void;
}) {
const filtered = keyword.trim()
? artifacts.filter((item) => (
item.script_name.toLowerCase().includes(keyword.trim().toLowerCase())
|| item.version_label.toLowerCase().includes(keyword.trim().toLowerCase())
))
: artifacts;
return (
<section className="schedule-panel schedule-artifacts-panel">
<div className="schedule-panel__title">
<div><strong></strong><span>{artifacts.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={keyword}
placeholder="搜索稳定版本"
onChange={(event) => onKeywordChange(event.target.value)}
/>
</label>
<p className="artifact-tip"> versions_id</p>
<div className="artifact-list">
{filtered.length ? filtered.map((artifact) => (
<div
className="artifact-card"
draggable
key={artifact.versions_id}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
"application/x-model-platform-version",
artifact.versions_id,
);
event.dataTransfer.setData("text/plain", artifact.versions_id);
}}
onContextMenu={(event) => onContextMenu(event, artifact)}
onDoubleClick={() => onDoubleClick(artifact)}
>
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
<Icon
name={artifact.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<span>
<strong>{artifact.script_name}</strong>
<small>
{artifact.version_label} · {shortHash(artifact.content_hash)}
</small>
</span>
</div>
)) : (
<p className="schedule-empty">
"构建脚本"
</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,153 @@
import { type ScheduleNode } from "../../services/api";
import Icon from "../../components/common/Icon";
import { shortHash } from "./utils";
type NodeForm = {
nodeName: string;
timeoutSeconds: string;
retryCount: string;
retryIntervalSec: string;
argumentsJson: string;
envRefsJson: string;
};
export function NodeInspector({
node,
form,
busy,
onChange,
onSave,
onDelete,
}: {
node: ScheduleNode;
form: NodeForm;
busy: boolean;
onChange: (form: NodeForm) => void;
onSave: () => void;
onDelete: () => void;
}) {
return (
<div className="schedule-inspector">
<section className="node-inspector-title">
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
<Icon
name={node.version.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<div>
<small></small>
<strong>{node.node_key}</strong>
</div>
</section>
<section>
<h3></h3>
<div className="version-readonly">
<strong>{node.version.script_name}</strong>
<span>{node.version.version_label}</span>
<small>versions_id: {node.versions_id}</small>
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
</div>
</section>
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.nodeName}
onChange={(event) => onChange({
...form,
nodeName: event.target.value,
})}
/>
</label>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
value={form.timeoutSeconds}
onChange={(event) => onChange({
...form,
timeoutSeconds: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0"
max="10"
value={form.retryCount}
onChange={(event) => onChange({
...form,
retryCount: event.target.value,
})}
/>
</label>
</div>
<label>
<span></span>
<input
type="number"
min="0"
value={form.retryIntervalSec}
onChange={(event) => onChange({
...form,
retryIntervalSec: event.target.value,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span>arguments_json</span>
<textarea
className="json-editor"
rows={5}
spellCheck={false}
value={form.argumentsJson}
onChange={(event) => onChange({
...form,
argumentsJson: event.target.value,
})}
/>
</label>
<label>
<span>env_refs_json</span>
<textarea
className="json-editor"
rows={4}
spellCheck={false}
value={form.envRefsJson}
onChange={(event) => onChange({
...form,
envRefsJson: event.target.value,
})}
/>
</label>
</section>
<div className="node-inspector-actions">
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
<button
className="inspector-danger-button"
type="button"
disabled={busy}
onClick={onDelete}
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { type ScheduleRunSummary } from "../../services/api";
import Icon from "../../components/common/Icon";
import { formatTime, formatDuration } from "./utils";
const RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
timed_out: "已超时",
};
export function RunHistory({
runs,
loading,
disabled,
onRefresh,
}: {
runs: ScheduleRunSummary[];
loading: boolean;
disabled: boolean;
onRefresh: () => void;
}) {
return (
<section className="schedule-run-history">
<header>
<div>
<strong></strong>
<span>{runs.length}</span>
</div>
<button
type="button"
aria-label="刷新运行记录"
disabled={disabled || loading}
onClick={onRefresh}
>
<Icon name="refresh" size={13} />
</button>
</header>
<div className="schedule-run-list">
{loading && runs.length === 0 ? (
<p></p>
) : runs.length === 0 ? (
<p>"立即运行"</p>
) : (
runs.map((run) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import Icon from "../../components/common/Icon";
export function ScheduleCanvasHeader({
linkSourceId,
onCancelLink,
}: {
linkSourceId: string | null;
onCancelLink: () => void;
}) {
return (
<div className="schedule-canvas-title">
<div>
<strong></strong>
<span>
线
</span>
</div>
{linkSourceId && (
<button type="button" onClick={onCancelLink}>
线
</button>
)}
</div>
);
}
@@ -0,0 +1,191 @@
import { type Schedule, type CronPreview } from "../../services/api";
import Icon from "../../components/common/Icon";
import { formatTime } from "./utils";
type ScheduleForm = {
scheduleName: string;
description: string;
triggerType: "manual" | "cron" | "api";
cronExpression: string;
timezone: string;
enabled: boolean;
maxConcurrency: string;
failurePolicy: "stop" | "continue";
};
export function ScheduleInspector({
schedule,
form,
cronResult,
busy,
onChange,
onPreview,
onSave,
}: {
schedule: Schedule | null;
form: ScheduleForm;
cronResult: CronPreview | null;
busy: boolean;
onChange: (form: ScheduleForm) => void;
onPreview: () => void;
onSave: () => void;
}) {
if (!schedule) {
return (
<div className="schedule-inspector-empty">
<Icon name="settings" size={28} />
<strong></strong>
<p>Cron </p>
</div>
);
}
return (
<div className="schedule-inspector">
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.scheduleName}
onChange={(event) => onChange({
...form,
scheduleName: event.target.value,
})}
/>
</label>
<label>
<span></span>
<textarea
rows={2}
value={form.description}
onChange={(event) => onChange({
...form,
description: event.target.value,
})}
/>
</label>
<label className="schedule-switch-row">
<span>
<b></b>
<small>DAG </small>
</span>
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => onChange({
...form,
enabled: event.target.checked,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span></span>
<select
value={form.triggerType}
onChange={(event) => onChange({
...form,
triggerType: event.target.value as typeof form.triggerType,
enabled: event.target.value === "cron" ? form.enabled : false,
})}
>
<option value="manual"></option>
<option value="cron">Cron </option>
<option value="api">API </option>
</select>
</label>
{form.triggerType === "cron" && (
<>
<label>
<span>Cron </span>
<input
value={form.cronExpression}
onChange={(event) => onChange({
...form,
cronExpression: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
value={form.timezone}
onChange={(event) => onChange({
...form,
timezone: event.target.value,
})}
/>
</label>
<button
className="inspector-secondary-button"
type="button"
disabled={busy}
onClick={onPreview}
>
5
</button>
{cronResult && (
<ol className="cron-preview-list">
{cronResult.occurrences.map((item) => (
<li key={item.utc_time}>
{new Date(item.local_time).toLocaleString("zh-CN", {
hour12: false,
})}
</li>
))}
</ol>
)}
</>
)}
</section>
<section>
<h3></h3>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
max="64"
value={form.maxConcurrency}
onChange={(event) => onChange({
...form,
maxConcurrency: event.target.value,
})}
/>
</label>
<label>
<span></span>
<select
value={form.failurePolicy}
onChange={(event) => onChange({
...form,
failurePolicy: event.target.value as "stop" | "continue",
})}
>
<option value="stop"></option>
<option value="continue"></option>
</select>
</label>
</div>
<div className="schedule-next-run">
<span></span>
<strong>{formatTime(schedule.next_run_at)}</strong>
</div>
</section>
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
</div>
);
}
@@ -0,0 +1,73 @@
import { type Schedule } from "../../services/api";
import Icon from "../../components/common/Icon";
export function ScheduleList({
schedules,
selectedScheduleId,
keyword,
loading,
onSelect,
onContextMenu,
onKeywordChange,
}: {
schedules: Schedule[];
selectedScheduleId: string | null;
keyword: string;
loading: boolean;
onSelect: (scheduleId: string) => void;
onContextMenu: (event: React.MouseEvent, schedule: Schedule | null) => void;
onKeywordChange: (keyword: string) => void;
}) {
const filtered = keyword.trim()
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword.trim().toLowerCase()))
: schedules;
return (
<section className="schedule-panel schedule-list-panel">
<div
className="schedule-panel__title"
onContextMenu={(event) => onContextMenu(event, null)}
>
<div><strong></strong><span>{schedules.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={keyword}
placeholder="搜索调度名称"
onChange={(event) => onKeywordChange(event.target.value)}
/>
</label>
<div
className="schedule-list"
onContextMenu={(event) => onContextMenu(event, null)}
>
{loading ? (
<p className="schedule-empty"></p>
) : filtered.length ? (
filtered.map((item) => (
<button
className={`schedule-card${
selectedScheduleId === item.schedule_id ? " schedule-card--active" : ""
}`}
key={item.schedule_id}
type="button"
onClick={() => onSelect(item.schedule_id)}
onContextMenu={(event) => onContextMenu(event, item)}
>
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
<span>
<strong>{item.schedule_name}</strong>
<small>
{item.node_count} · {item.enabled ? "已启用" : "未启用"}
</small>
</span>
</button>
))
) : (
<p className="schedule-empty"></p>
)}
</div>
</section>
);
}
+55 -644
View File
@@ -21,9 +21,33 @@ import {
import { useApi } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
NODE_WIDTH,
NODE_HEIGHT,
ARTIFACT_MIME,
EMPTY_SCHEDULE_FORM,
EMPTY_NODE_FORM,
} from "./constants";
import {
formatTime,
shortHash,
parseObject,
scheduleToForm,
nodeToForm,
artifactNodeKey,
edgePath,
withSuppressedError,
} from "./utils";
import { ScheduleList } from "./ScheduleList";
import { ArtifactList } from "./ArtifactList";
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
import { ScheduleInspector } from "./ScheduleInspector";
import { NodeInspector } from "./NodeInspector";
import { RunHistory } from "./RunHistory";
import "../../styles/schedule.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
@@ -78,132 +102,6 @@ type ScheduleContextMenuTarget =
| { kind: "node"; node: ScheduleNode }
| { kind: "edge"; edge: ScheduleEdge };
const EMPTY_SCHEDULE_FORM: ScheduleForm = {
scheduleName: "",
description: "",
triggerType: "manual",
cronExpression: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop",
};
const EMPTY_NODE_FORM: NodeForm = {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
};
const CANVAS_WIDTH = 1400;
const CANVAS_HEIGHT = 860;
const NODE_WIDTH = 218;
const NODE_HEIGHT = 104;
const ARTIFACT_MIME = "application/x-model-platform-version";
function formatTime(value: string | null): string {
if (!value) return "尚未执行";
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 shortHash(value: string): string {
return value ? `${value.slice(0, 7)}${value.slice(-5)}` : "—";
}
const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
timed_out: "已超时",
};
function formatDuration(value: number | null): string {
if (value === null) return "—";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
}
function parseObject(text: string, label: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(`${label}必须是合法 JSON`);
}
if (!value || Array.isArray(value) || typeof value !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return value as Record<string, unknown>;
}
function scheduleToForm(schedule: Schedule): ScheduleForm {
return {
scheduleName: schedule.schedule_name,
description: schedule.description ?? "",
triggerType: schedule.trigger_type,
cronExpression: schedule.cron_expression ?? "0 9 * * *",
timezone: schedule.timezone,
enabled: schedule.enabled,
maxConcurrency: String(schedule.max_concurrency),
failurePolicy: schedule.failure_policy,
};
}
function nodeToForm(node: ScheduleNode): NodeForm {
return {
nodeName: node.node_name,
timeoutSeconds: String(node.timeout_seconds),
retryCount: String(node.retry_count),
retryIntervalSec: String(node.retry_interval_sec),
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
};
}
function artifactNodeKey(
artifact: ScheduleArtifact,
schedule: Schedule,
): string {
const ascii = artifact.script_name
.replace(/\.[^.]+$/, "")
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^([^A-Za-z])/, "n_$1")
.replace(/^_+|_+$/g, "")
.slice(0, 48);
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
const existing = new Set(schedule.nodes.map((item) => item.node_key));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}_${index}`)) index += 1;
return `${base}_${index}`.slice(0, 64);
}
function edgePath(
source: ScheduleNode,
target: ScheduleNode,
): string {
const x1 = source.position_x + NODE_WIDTH;
const y1 = source.position_y + NODE_HEIGHT / 2;
const x2 = target.position_x;
const y2 = target.position_y + NODE_HEIGHT / 2;
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
}
export default function SchedulePage({
onNotify,
onConnectionChange,
@@ -224,10 +122,8 @@ export default function SchedulePage({
const [busy, setBusy] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [contextMenu, setContextMenu] =
useState<ScheduleContextMenu | null>(null);
const [scheduleForm, setScheduleForm] =
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
@@ -411,18 +307,14 @@ export default function SchedulePage({
};
}, [schedule?.schedule_id]);
const hasActiveRuns = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !hasActiveRuns) return;
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
const timer = window.setInterval(() => {
void refreshRuns(scheduleId);
}, 1500);
return () => window.clearInterval(timer);
}, [schedule?.schedule_id, hasActiveRuns]);
}, [schedule?.schedule_id, runs]);
const handleError = async (
error: unknown,
@@ -541,7 +433,7 @@ export default function SchedulePage({
const selectedSchedule = target ?? schedule;
if (!selectedSchedule || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除调度${selectedSchedule.schedule_name}吗?`)) return;
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
setBusy("delete-schedule");
try {
await api.deleteSchedule(
@@ -601,7 +493,7 @@ export default function SchedulePage({
setContextMenu(null);
if (
!window.confirm(
`确定将${artifact.script_name} ${artifact.version_label}移出调度列表吗?\n`
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
+ "稳定版本本身和历史运行记录不会被删除。",
)
) return;
@@ -701,7 +593,7 @@ export default function SchedulePage({
if (positionDraftCount > 0) {
onNotify({
tone: "info",
message: "还有节点位置未保存,请先点击保存配置再运行",
message: "还有节点位置未保存,请先点击'保存配置'再运行",
});
return;
}
@@ -771,15 +663,6 @@ export default function SchedulePage({
}
};
const onArtifactDragStart = (
event: DragEvent<HTMLDivElement>,
artifact: ScheduleArtifact,
): void => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(ARTIFACT_MIME, artifact.versions_id);
event.dataTransfer.setData("text/plain", artifact.versions_id);
};
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
@@ -942,7 +825,7 @@ export default function SchedulePage({
const node = target ?? selectedNode;
if (!schedule || !node || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除节点${node.node_name}吗?`)) return;
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
const updated = await withMutation(
"delete-node",
() => api.deleteScheduleNode(
@@ -1074,124 +957,30 @@ export default function SchedulePage({
<div className="schedule-workbench">
<aside className="schedule-left">
<section className="schedule-panel schedule-list-panel">
<div
className="schedule-panel__title"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
<div><strong></strong><span>{schedules.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={scheduleKeyword}
placeholder="搜索调度名称"
onChange={(event) => setScheduleKeyword(event.target.value)}
/>
</label>
<div
className="schedule-list"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
{loading ? (
<p className="schedule-empty"></p>
) : filteredSchedules.length ? (
filteredSchedules.map((item) => (
<button
className={`schedule-card${
schedule?.schedule_id === item.schedule_id
? " schedule-card--active"
: ""
}`}
key={item.schedule_id}
type="button"
onClick={() => void chooseSchedule(item.schedule_id)}
onContextMenu={(event) => openContextMenu(event, {
kind: "schedule",
schedule: item,
})}
>
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
<span>
<strong>{item.schedule_name}</strong>
<small>
{item.node_count} · {item.enabled ? "已启用" : "未启用"}
</small>
</span>
</button>
))
) : (
<p className="schedule-empty"></p>
)}
</div>
</section>
<section className="schedule-panel schedule-artifacts-panel">
<div className="schedule-panel__title">
<div><strong></strong><span>{artifacts.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={artifactKeyword}
placeholder="搜索稳定版本"
onChange={(event) => setArtifactKeyword(event.target.value)}
/>
</label>
<p className="artifact-tip"> versions_id</p>
<div className="artifact-list">
{filteredArtifacts.length ? filteredArtifacts.map((artifact) => (
<div
className="artifact-card"
draggable
key={artifact.versions_id}
onDragStart={(event) => onArtifactDragStart(event, artifact)}
onContextMenu={(event) => openContextMenu(event, {
kind: "artifact",
artifact,
})}
onDoubleClick={() => void addArtifactAt(
artifact,
90 + (schedule?.nodes.length ?? 0) * 245,
130,
)}
>
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
<Icon
name={artifact.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<span>
<strong>{artifact.script_name}</strong>
<small>
{artifact.version_label} · {shortHash(artifact.content_hash)}
</small>
</span>
</div>
)) : (
<p className="schedule-empty">
</p>
)}
</div>
</section>
<ScheduleList
schedules={filteredSchedules}
selectedScheduleId={schedule?.schedule_id ?? null}
keyword={scheduleKeyword}
loading={loading}
onSelect={chooseSchedule}
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
onKeywordChange={setScheduleKeyword}
/>
<ArtifactList
artifacts={filteredArtifacts}
keyword={artifactKeyword}
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
onKeywordChange={setArtifactKeyword}
/>
</aside>
<main className="schedule-center">
<div className="schedule-canvas-title">
<div>
<strong></strong>
<span>
线
</span>
</div>
{linkSourceId && (
<button type="button" onClick={() => setLinkSourceId(null)}>
线
</button>
)}
</div>
<ScheduleCanvasHeader
linkSourceId={linkSourceId}
onCancelLink={() => setLinkSourceId(null)}
/>
<div
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
ref={canvasRef}
@@ -1346,7 +1135,7 @@ export default function SchedulePage({
<div className="schedule-canvas-empty">
<Icon name="schedule" size={34} />
<strong></strong>
<p></p>
<p>"新建"</p>
<button type="button" onClick={openCreateScheduleDialog}>
<Icon name="plus" size={15} />
</button>
@@ -1558,381 +1347,3 @@ export default function SchedulePage({
</section>
);
}
function RunHistory({
runs,
loading,
disabled,
onRefresh,
}: {
runs: ScheduleRunSummary[];
loading: boolean;
disabled: boolean;
onRefresh: () => void;
}) {
return (
<section className="schedule-run-history">
<header>
<div>
<strong></strong>
<span>{runs.length}</span>
</div>
<button
type="button"
aria-label="刷新运行记录"
disabled={disabled || loading}
onClick={onRefresh}
>
<Icon name="refresh" size={13} />
</button>
</header>
<div className="schedule-run-list">
{loading && runs.length === 0 ? (
<p></p>
) : runs.length === 0 ? (
<p></p>
) : (
runs.map((run) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
)}
</div>
</section>
);
}
function ScheduleInspector({
schedule,
form,
cronResult,
busy,
onChange,
onPreview,
onSave,
}: {
schedule: Schedule | null;
form: ScheduleForm;
cronResult: CronPreview | null;
busy: boolean;
onChange: (form: ScheduleForm) => void;
onPreview: () => void;
onSave: () => void;
}) {
if (!schedule) {
return (
<div className="schedule-inspector-empty">
<Icon name="settings" size={28} />
<strong></strong>
<p>Cron </p>
</div>
);
}
return (
<div className="schedule-inspector">
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.scheduleName}
onChange={(event) => onChange({
...form,
scheduleName: event.target.value,
})}
/>
</label>
<label>
<span></span>
<textarea
rows={2}
value={form.description}
onChange={(event) => onChange({
...form,
description: event.target.value,
})}
/>
</label>
<label className="schedule-switch-row">
<span>
<b></b>
<small>DAG </small>
</span>
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => onChange({
...form,
enabled: event.target.checked,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span></span>
<select
value={form.triggerType}
onChange={(event) => onChange({
...form,
triggerType: event.target.value as ScheduleForm["triggerType"],
enabled: event.target.value === "cron" ? form.enabled : false,
})}
>
<option value="manual"></option>
<option value="cron">Cron </option>
<option value="api">API </option>
</select>
</label>
{form.triggerType === "cron" && (
<>
<label>
<span>Cron </span>
<input
value={form.cronExpression}
onChange={(event) => onChange({
...form,
cronExpression: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
value={form.timezone}
onChange={(event) => onChange({
...form,
timezone: event.target.value,
})}
/>
</label>
<button
className="inspector-secondary-button"
type="button"
disabled={busy}
onClick={onPreview}
>
5
</button>
{cronResult && (
<ol className="cron-preview-list">
{cronResult.occurrences.map((item) => (
<li key={item.utc_time}>
{new Date(item.local_time).toLocaleString("zh-CN", {
hour12: false,
})}
</li>
))}
</ol>
)}
</>
)}
</section>
<section>
<h3></h3>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
max="64"
value={form.maxConcurrency}
onChange={(event) => onChange({
...form,
maxConcurrency: event.target.value,
})}
/>
</label>
<label>
<span></span>
<select
value={form.failurePolicy}
onChange={(event) => onChange({
...form,
failurePolicy: event.target.value as "stop" | "continue",
})}
>
<option value="stop"></option>
<option value="continue"></option>
</select>
</label>
</div>
<div className="schedule-next-run">
<span></span>
<strong>{formatTime(schedule.next_run_at)}</strong>
</div>
</section>
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
</div>
);
}
function NodeInspector({
node,
form,
busy,
onChange,
onSave,
onDelete,
}: {
node: ScheduleNode;
form: NodeForm;
busy: boolean;
onChange: (form: NodeForm) => void;
onSave: () => void;
onDelete: () => void;
}) {
return (
<div className="schedule-inspector">
<section className="node-inspector-title">
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
<Icon
name={node.version.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<div>
<small></small>
<strong>{node.node_key}</strong>
</div>
</section>
<section>
<h3></h3>
<div className="version-readonly">
<strong>{node.version.script_name}</strong>
<span>{node.version.version_label}</span>
<small>versions_id: {node.versions_id}</small>
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
</div>
</section>
<section>
<h3></h3>
<label>
<span></span>
<input
value={form.nodeName}
onChange={(event) => onChange({
...form,
nodeName: event.target.value,
})}
/>
</label>
<div className="inspector-grid">
<label>
<span></span>
<input
type="number"
min="1"
value={form.timeoutSeconds}
onChange={(event) => onChange({
...form,
timeoutSeconds: event.target.value,
})}
/>
</label>
<label>
<span></span>
<input
type="number"
min="0"
max="10"
value={form.retryCount}
onChange={(event) => onChange({
...form,
retryCount: event.target.value,
})}
/>
</label>
</div>
<label>
<span></span>
<input
type="number"
min="0"
value={form.retryIntervalSec}
onChange={(event) => onChange({
...form,
retryIntervalSec: event.target.value,
})}
/>
</label>
</section>
<section>
<h3></h3>
<label>
<span>arguments_json</span>
<textarea
className="json-editor"
rows={5}
spellCheck={false}
value={form.argumentsJson}
onChange={(event) => onChange({
...form,
argumentsJson: event.target.value,
})}
/>
</label>
<label>
<span>env_refs_json</span>
<textarea
className="json-editor"
rows={4}
spellCheck={false}
value={form.envRefsJson}
onChange={(event) => onChange({
...form,
envRefsJson: event.target.value,
})}
/>
</label>
</section>
<div className="node-inspector-actions">
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
</button>
<button
className="inspector-danger-button"
type="button"
disabled={busy}
onClick={onDelete}
>
</button>
</div>
</div>
);
}
function withSuppressedError(action: () => Promise<void>): void {
void action().catch(() => undefined);
}
@@ -0,0 +1,36 @@
// 调度页面常量
export const CANVAS_WIDTH = 1400;
export const CANVAS_HEIGHT = 860;
export const NODE_WIDTH = 218;
export const NODE_HEIGHT = 104;
export const ARTIFACT_MIME = "application/x-model-platform-version";
export const EMPTY_SCHEDULE_FORM = {
scheduleName: "",
description: "",
triggerType: "manual" as "manual" | "cron" | "api",
cronExpression: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop" as "stop" | "continue",
};
export const EMPTY_NODE_FORM = {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
};
export const RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
timed_out: "已超时",
};
+95
View File
@@ -0,0 +1,95 @@
import { type Schedule, type ScheduleArtifact, type ScheduleNode } from "../../services/api";
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
export function formatTime(value: string | null): string {
if (!value) return "尚未执行";
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 shortHash(value: string): string {
return value ? `${value.slice(0, 7)}${value.slice(-5)}` : "—";
}
export function formatDuration(value: number | null): string {
if (value === null) return "—";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
}
export function parseObject(text: string, label: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(`${label}必须是合法 JSON`);
}
if (!value || Array.isArray(value) || typeof value !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return value as Record<string, unknown>;
}
export function scheduleToForm(schedule: Schedule) {
return {
scheduleName: schedule.schedule_name,
description: schedule.description ?? "",
triggerType: schedule.trigger_type,
cronExpression: schedule.cron_expression ?? "0 9 * * *",
timezone: schedule.timezone,
enabled: schedule.enabled,
maxConcurrency: String(schedule.max_concurrency),
failurePolicy: schedule.failure_policy,
};
}
export function nodeToForm(node: ScheduleNode) {
return {
nodeName: node.node_name,
timeoutSeconds: String(node.timeout_seconds),
retryCount: String(node.retry_count),
retryIntervalSec: String(node.retry_interval_sec),
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
};
}
export function artifactNodeKey(
artifact: ScheduleArtifact,
schedule: Schedule,
): string {
const ascii = artifact.script_name
.replace(/\.[^.]+$/, "")
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^([^A-Za-z])/, "n_$1")
.replace(/^_+|_+$/g, "")
.slice(0, 48);
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
const existing = new Set(schedule.nodes.map((item) => item.node_key));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}_${index}`)) index += 1;
return `${base}_${index}`.slice(0, 64);
}
export function edgePath(
source: ScheduleNode,
target: ScheduleNode,
): string {
const x1 = source.position_x + 218;
const y1 = source.position_y + 52;
const x2 = target.position_x;
const y2 = target.position_y + 52;
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
}
export function withSuppressedError(action: () => Promise<void>): void {
void action().catch(() => undefined);
}